Refer permissions by UUID rather than scope.

This commit is contained in:
Leo Vasanko
2026-01-24 00:06:08 +00:00
parent 5585651e57
commit 2227a9bf6f
7 changed files with 149 additions and 98 deletions
+2 -2
View File
@@ -299,13 +299,13 @@ async function performPermissionDeletion(permissionScope) {
}
function deletePermission(p) {
const userCount = permissionSummary.value[p.scope]?.userCount || 0
const userCount = permissionSummary.value[p.uuid]?.userCount || 0
// Count roles that have this permission
let roleCount = 0
for (const org of orgs.value) {
for (const role of org.roles) {
if (role.permissions.includes(p.scope)) {
if (role.permissions.includes(p.uuid)) {
roleCount++
}
}
+11 -5
View File
@@ -26,6 +26,12 @@ const sortedRoles = computed(() => {
})
})
// Get org's grantable permissions as full permission objects (with UUIDs)
const orgPermissions = computed(() => {
const uuidSet = new Set(props.selectedOrg.permissions || [])
return props.permissions.filter(p => uuidSet.has(p.uuid))
})
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
@@ -302,17 +308,17 @@ defineExpose({ focusFirstElement })
</div>
<div class="grid-head role-head add-role-head" title="Add role" @click="$emit('createRole', selectedOrg)" role="button" tabindex="0" @keydown.enter="$emit('createRole', selectedOrg)"></div>
<template v-for="pid in selectedOrg.permissions" :key="pid">
<div class="perm-name" :title="pid">{{ permissionDisplayName(pid) }}</div>
<template v-for="p in orgPermissions" :key="p.uuid">
<div class="perm-name" :title="p.scope">{{ p.display_name }}</div>
<div
v-for="r in sortedRoles"
:key="r.uuid + '-' + pid"
:key="r.uuid + '-' + p.uuid"
class="matrix-cell"
>
<input
type="checkbox"
:checked="r.permissions.includes(pid)"
@change="e => toggleRolePermission(r, pid, e.target.checked)"
:checked="r.permissions.includes(p.uuid)"
@change="e => toggleRolePermission(r, p.uuid, e.target.checked)"
/>
</div>
<div class="matrix-cell add-role-cell" />
+6 -6
View File
@@ -296,19 +296,19 @@ defineExpose({ focusFirstElement })
<span>{{ o.display_name }}</span>
</div>
<template v-for="p in sortedPermissions" :key="p.scope">
<template v-for="p in sortedPermissions" :key="p.uuid">
<div class="perm-name" :title="p.scope">
<span class="display-text">{{ p.display_name }}</span>
</div>
<div
v-for="o in sortedOrgs"
:key="o.uuid + '-' + p.scope"
:key="o.uuid + '-' + p.uuid"
class="matrix-cell"
>
<input
type="checkbox"
:checked="o.permissions.includes(p.scope)"
@change="e => $emit('toggleOrgPermission', o, p.scope, e.target.checked)"
:checked="o.permissions.includes(p.uuid)"
@change="e => $emit('toggleOrgPermission', o, p.uuid, e.target.checked)"
/>
</div>
</template>
@@ -329,7 +329,7 @@ defineExpose({ focusFirstElement })
</tr>
</thead>
<tbody>
<tr v-for="p in sortedPermissions" :key="p.scope">
<tr v-for="p in sortedPermissions" :key="p.uuid">
<td class="perm-name-cell">
<div class="perm-title">
<span class="display-text">{{ p.display_name }}</span>
@@ -340,7 +340,7 @@ defineExpose({ focusFirstElement })
</div>
</td>
<td class="perm-domain">{{ p.domain || '—' }}</td>
<td class="perm-members center">{{ permissionSummary[p.scope]?.userCount || 0 }}</td>
<td class="perm-members center">{{ permissionSummary[p.uuid]?.userCount || 0 }}</td>
<td class="perm-actions center">
<button @click="$emit('deletePermission', p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</td>
+90 -51
View File
@@ -170,14 +170,14 @@ def build_role(uuid: UUID) -> Role:
uuid=uuid,
org_uuid=r.org,
display_name=r.display_name,
permissions=list(r.permissions.keys()),
permissions=[str(pid) for pid in r.permissions.keys()],
)
def build_org(uuid: UUID, include_roles: bool = False) -> Org:
o = _db._data.orgs[uuid]
perm_scopes = [p.scope for p in _db._data.permissions.values() if uuid in p.orgs]
org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_scopes)
perm_uuids = [str(pid) for pid, p in _db._data.permissions.items() if uuid in p.orgs]
org = Org(uuid=uuid, display_name=o.display_name, permissions=perm_uuids)
if include_roles:
org.roles = [
build_role(rid) for rid, r in _db._data.roles.items() if r.org == uuid
@@ -461,24 +461,24 @@ def get_session_context(
else None
)
# Effective permissions: role's permission scopes that the org can grant
# Effective permissions: role's permissions that the org can grant
# Also filter by domain if host is provided
org_scopes = set(org.permissions)
org_perm_uuids = set(org.permissions) # Set of permission UUID strings
normalized_host = normalize_host(host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
effective_perms = []
for scope in role.permissions:
if scope not in org_scopes:
for perm_uuid_str in role.permissions:
if perm_uuid_str not in org_perm_uuids:
continue
# Find permission by scope
for pid, p in _db._data.permissions.items():
if p.scope == scope:
# Check domain restriction
if p.domain is not None and p.domain != host_without_port:
continue
effective_perms.append(build_permission(pid))
break
perm_uuid = UUID(perm_uuid_str)
if perm_uuid not in _db._data.permissions:
continue
p = _db._data.permissions[perm_uuid]
# Check domain restriction
if p.domain is not None and p.domain != host_without_port:
continue
effective_perms.append(build_permission(perm_uuid))
return SessionContext(
session=session,
@@ -527,7 +527,7 @@ def rename_permission(
) -> None:
"""Rename a permission's scope. The UUID remains the same.
Also updates all role references to use the new scope.
Since roles reference permissions by UUID, no role updates are needed.
Note: Scopes do not need to be unique (same scope with different domains is valid).
"""
# Find permission by old scope
@@ -545,21 +545,17 @@ def rename_permission(
_db._data.permissions[key].display_name = display_name
_db._data.permissions[key].domain = domain
# Update role references if scope changed
if old_scope != new_scope:
for r in _db._data.roles.values():
if old_scope in r.permissions:
del r.permissions[old_scope]
r.permissions[new_scope] = True
def delete_permission(uuid: str | UUID, actor: str = "system") -> None:
"""Delete a permission."""
"""Delete a permission and remove it from all roles."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db._data.permissions:
raise ValueError(f"Permission {uuid} not found")
with _db.transaction(actor):
# Remove this permission from all roles
for role in _db._data.roles.values():
role.permissions.pop(uuid, None)
del _db._data.permissions[uuid]
@@ -574,19 +570,26 @@ def create_organization(org: Org, actor: str = "system") -> None:
_db._data.orgs[org.uuid] = _OrgData(
display_name=org.display_name, created_at=datetime.now(timezone.utc)
)
# Grant listed permissions to this org
for scope in org.permissions:
for pid, p in _db._data.permissions.items():
if p.scope == scope:
p.orgs[org.uuid] = True
# Grant listed permissions to this org (org.permissions contains UUIDs now)
for perm_uuid_str in org.permissions:
perm_uuid = UUID(perm_uuid_str) if isinstance(perm_uuid_str, str) else perm_uuid_str
if perm_uuid in _db._data.permissions:
_db._data.permissions[perm_uuid].orgs[org.uuid] = True
# Create Administration role with org admin permission
import uuid7
admin_role_uuid = uuid7.create()
# Find the auth:org:admin permission UUID
org_admin_perm_uuid = None
for pid, p in _db._data.permissions.items():
if p.scope == "auth:org:admin":
org_admin_perm_uuid = pid
break
role_permissions = {org_admin_perm_uuid: True} if org_admin_perm_uuid else {}
_db._data.roles[admin_role_uuid] = _RoleData(
org=org.uuid,
display_name="Administration",
permissions={"auth:org:admin": True},
permissions=role_permissions,
)
@@ -624,35 +627,65 @@ def delete_organization(uuid: str | UUID, actor: str = "system") -> None:
def add_permission_to_organization(
org_uuid: str | UUID, permission_scope: str, actor: str = "system"
org_uuid: str | UUID, permission_id: str | UUID, actor: str = "system"
) -> None:
"""Grant a permission scope to an organization."""
"""Grant a permission to an organization by UUID."""
if isinstance(org_uuid, str):
org_uuid = UUID(org_uuid)
if org_uuid not in _db._data.orgs:
raise ValueError(f"Organization {org_uuid} not found")
found = False
# Convert permission_id to UUID
if isinstance(permission_id, str):
try:
permission_uuid = UUID(permission_id)
except ValueError:
# It's a scope - look up the UUID (backwards compat)
for pid, p in _db._data.permissions.items():
if p.scope == permission_id:
permission_uuid = pid
break
else:
raise ValueError(f"Permission {permission_id} not found")
else:
permission_uuid = permission_id
if permission_uuid not in _db._data.permissions:
raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction(actor):
for p in _db._data.permissions.values():
if p.scope == permission_scope:
p.orgs[org_uuid] = True
found = True
if not found:
raise ValueError(f"Permission scope {permission_scope} not found")
_db._data.permissions[permission_uuid].orgs[org_uuid] = True
def remove_permission_from_organization(
org_uuid: str | UUID, permission_scope: str, actor: str = "system"
org_uuid: str | UUID, permission_id: str | UUID, actor: str = "system"
) -> None:
"""Remove a permission scope from an organization."""
"""Remove a permission from an organization by UUID."""
if isinstance(org_uuid, str):
org_uuid = UUID(org_uuid)
if org_uuid not in _db._data.orgs:
raise ValueError(f"Organization {org_uuid} not found")
# Convert permission_id to UUID
if isinstance(permission_id, str):
try:
permission_uuid = UUID(permission_id)
except ValueError:
# It's a scope - look up the UUID (backwards compat)
for pid, p in _db._data.permissions.items():
if p.scope == permission_id:
permission_uuid = pid
break
else:
return # Permission not found, silently return
else:
permission_uuid = permission_id
if permission_uuid not in _db._data.permissions:
return # Permission not found, silently return
with _db.transaction(actor):
for p in _db._data.permissions.values():
if p.scope == permission_scope:
p.orgs.pop(org_uuid, None)
_db._data.permissions[permission_uuid].orgs.pop(org_uuid, None)
def create_role(role: Role, actor: str = "system") -> None:
@@ -665,7 +698,7 @@ def create_role(role: Role, actor: str = "system") -> None:
_db._data.roles[role.uuid] = _RoleData(
org=role.org_uuid,
display_name=role.display_name,
permissions={scope: True for scope in role.permissions},
permissions={UUID(pid): True for pid in role.permissions},
)
@@ -682,27 +715,33 @@ def update_role_name(
def add_permission_to_role(
role_uuid: str | UUID, permission_scope: str, actor: str = "system"
role_uuid: str | UUID, permission_uuid: str | UUID, actor: str = "system"
) -> None:
"""Add permission scope to role."""
"""Add permission to role by UUID."""
if isinstance(role_uuid, str):
role_uuid = UUID(role_uuid)
if isinstance(permission_uuid, str):
permission_uuid = UUID(permission_uuid)
if role_uuid not in _db._data.roles:
raise ValueError(f"Role {role_uuid} not found")
if permission_uuid not in _db._data.permissions:
raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction(actor):
_db._data.roles[role_uuid].permissions[permission_scope] = True
_db._data.roles[role_uuid].permissions[permission_uuid] = True
def remove_permission_from_role(
role_uuid: str | UUID, permission_scope: str, actor: str = "system"
role_uuid: str | UUID, permission_uuid: str | UUID, actor: str = "system"
) -> None:
"""Remove permission scope from role."""
"""Remove permission from role by UUID."""
if isinstance(role_uuid, str):
role_uuid = UUID(role_uuid)
if isinstance(permission_uuid, str):
permission_uuid = UUID(permission_uuid)
if role_uuid not in _db._data.roles:
raise ValueError(f"Role {role_uuid} not found")
with _db.transaction(actor):
_db._data.roles[role_uuid].permissions.pop(permission_scope, None)
_db._data.roles[role_uuid].permissions.pop(permission_uuid, None)
def delete_role(uuid: str | UUID, actor: str = "system") -> None:
+3 -3
View File
@@ -15,13 +15,13 @@ class Role(msgspec.Struct):
uuid: UUID
org_uuid: UUID
display_name: str
permissions: list[str] = [] # permission IDs this role grants
permissions: list[str] = [] # permission UUIDs this role grants
class Org(msgspec.Struct):
uuid: UUID
display_name: str
permissions: list[str] = [] # permission IDs this org can grant
permissions: list[str] = [] # permission UUIDs this org can grant
roles: list[Role] = [] # roles belonging to this org
@@ -100,7 +100,7 @@ class _OrgData(msgspec.Struct):
class _RoleData(msgspec.Struct):
org: UUID
display_name: str
permissions: dict[str, bool] # permission_id -> True
permissions: dict[UUID, bool] # permission_uuid -> True
class _UserData(msgspec.Struct):
+22 -14
View File
@@ -319,11 +319,11 @@ async def admin_update_role_name(
return {"status": "ok"}
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_id}")
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission(
org_uuid: UUID,
role_uuid: UUID,
permission_id: str,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
@@ -344,20 +344,22 @@ async def admin_add_role_permission(
raise HTTPException(status_code=404, detail="Role not found in organization")
# Verify permission exists and org can grant it
db.get_permission(permission_id)
perm = db.get_permission(permission_uuid)
if not perm:
raise HTTPException(status_code=404, detail="Permission not found")
org = db.get_organization(str(org_uuid))
if permission_id not in org.permissions:
if str(permission_uuid) not in org.permissions:
raise ValueError("Permission not grantable by organization")
db.add_permission_to_role(role_uuid, permission_id, actor=str(ctx.user.uuid))
db.add_permission_to_role(role_uuid, permission_uuid, actor=str(ctx.user.uuid))
return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_id}")
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission(
org_uuid: UUID,
role_uuid: UUID,
permission_id: str,
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
@@ -378,17 +380,23 @@ async def admin_remove_role_permission(
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from removing their own access
# Find auth:admin and auth:org:admin permission UUIDs
perm_uuid_str = str(permission_uuid)
perm = db.get_permission(permission_uuid)
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
if permission_id in ["auth:admin", "auth:org:admin"]:
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
# Check if removing this permission would leave no admin access
remaining_perms = set(role.permissions) - {permission_id}
if (
"auth:admin" not in remaining_perms
and "auth:org:admin" not in remaining_perms
):
remaining_perms = set(role.permissions) - {perm_uuid_str}
has_admin = False
for rp_uuid in remaining_perms:
rp = db.get_permission(rp_uuid)
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
has_admin = True
break
if not has_admin:
raise ValueError("Cannot remove your own admin permissions")
db.remove_permission_from_role(role_uuid, permission_id, actor=str(ctx.user.uuid))
db.remove_permission_from_role(role_uuid, permission_uuid, actor=str(ctx.user.uuid))
return {"status": "ok"}
+15 -17
View File
@@ -96,19 +96,19 @@ async def migrate_from_sql(
orgs={},
)
# Mapping from old permission ID to new scope
perm_id_to_scope: dict[str, str] = {}
# Mapping from old permission ID to new permission UUID
perm_id_to_uuid: dict[str, UUID] = {}
for perm in permissions:
# Skip old org-specific admin permissions (auth:org:{uuid}) - they map to auth:org:admin
match = old_org_admin_pattern.match(perm.id)
if match:
perm_id_to_scope[perm.id] = "auth:org:admin"
perm_id_to_uuid[perm.id] = org_admin_perm_uuid
continue
# Skip if this is already auth:org:admin - we created one above
if perm.id == "auth:org:admin":
perm_id_to_scope[perm.id] = "auth:org:admin"
perm_id_to_uuid[perm.id] = org_admin_perm_uuid
continue
# Regular permission - create with UUID key
@@ -118,7 +118,7 @@ async def migrate_from_sql(
display_name=perm.display_name,
orgs={},
)
perm_id_to_scope[perm.id] = perm.id # Scope same as old ID
perm_id_to_uuid[perm.id] = perm_uuid
print(
f" Migrated {len(permissions)} permissions (with {len(org_admin_uuids)} org-specific admins consolidated to auth:org:admin)"
)
@@ -130,28 +130,26 @@ async def migrate_from_sql(
json_db._data.orgs[org_key] = _OrgData(
display_name=org.display_name,
)
# Update permissions to allow this org to grant them (by scope)
# Update permissions to allow this org to grant them (by UUID)
for old_perm_id in org.permissions:
new_scope = perm_id_to_scope.get(old_perm_id, old_perm_id)
# Find permission with this scope and add org
for pid, p in json_db._data.permissions.items():
if p.scope == new_scope:
p.orgs[org_key] = True
break
perm_uuid = perm_id_to_uuid.get(old_perm_id)
if perm_uuid and perm_uuid in json_db._data.permissions:
json_db._data.permissions[perm_uuid].orgs[org_key] = True
# Ensure every org can grant auth:org:admin
json_db._data.permissions[org_admin_perm_uuid].orgs[org_key] = True
print(f" Migrated {len(orgs)} organizations")
# Migrate roles - convert old permission IDs to scopes
# Migrate roles - convert old permission IDs to UUIDs
role_count = 0
for org in orgs:
for role in org.roles:
role_key: UUID = role.uuid
# Convert old permission IDs to scopes
new_permissions = {}
# Convert old permission IDs to UUIDs
new_permissions: dict[UUID, bool] = {}
for old_perm_id in role.permissions or []:
new_scope = perm_id_to_scope.get(old_perm_id, old_perm_id)
new_permissions[new_scope] = True
perm_uuid = perm_id_to_uuid.get(old_perm_id)
if perm_uuid:
new_permissions[perm_uuid] = True
json_db._data.roles[role_key] = _RoleData(
org=role.org_uuid,
display_name=role.display_name,