Permissions refactor. Permissions have UUID and scope (previously id) and the latter no longer needs to be unique. Org admin uses a single global permission now. Domain scoped permissions. Removed from user info the admin fields, use effective_permission checks instead.

This commit is contained in:
2026-01-23 13:54:31 +00:00
parent 236d52aa55
commit 3430c7f0cf
16 changed files with 729 additions and 318 deletions
+191 -83
View File
@@ -23,6 +23,30 @@ from paskia.util.tokens import encode_session_key, session_key
app = FastAPI()
def is_global_admin(ctx) -> bool:
"""Check if user has global admin permission."""
return "auth:admin" in ctx.role.permissions
def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
"""Check if user has org admin permission.
If org_uuid is provided, checks if user is admin of that specific org.
If org_uuid is None, checks if user is admin of their own org.
"""
if "auth:org:admin" not in ctx.role.permissions:
return False
if org_uuid is None:
return True
# User must belong to the target org (via their role)
return ctx.org.uuid == org_uuid
def can_manage_org(ctx, org_uuid: UUID) -> bool:
"""Check if user can manage the specified organization."""
return is_global_admin(ctx) or is_org_admin(ctx, org_uuid)
@app.exception_handler(ValueError)
async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
return JSONResponse(status_code=400, content={"detail": str(exc)})
@@ -55,13 +79,14 @@ async def adminapp(request: Request, auth=AUTH_COOKIE):
async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
orgs = db.list_organizations()
if "auth:admin" not in ctx.role.permissions:
orgs = [o for o in orgs if f"auth:org:{o.uuid}" in ctx.role.permissions]
if not is_global_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def role_to_dict(r):
return {
@@ -110,12 +135,13 @@ async def admin_create_org(
db.create_organization(org)
# Automatically create Administration role with org admin permission
# The auth:org:admin permission is automatically created/enabled by create_organization
role_uuid = uuid4()
admin_role = RoleDC(
uuid=role_uuid,
org_uuid=org_uuid,
display_name="Administration",
permissions=[f"auth:org:{org_uuid}"],
permissions=["auth:org:admin"],
)
db.create_role(admin_role)
@@ -131,10 +157,14 @@ async def admin_update_org(
):
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
from ..db import Org as OrgDC # local import to avoid cycles
current = db.get_organization(str(org_uuid))
@@ -144,13 +174,10 @@ async def admin_update_org(
permissions = current.permissions or []
# Sanity check: prevent removing permissions that would break current user's admin access
org_admin_perm = f"auth:org:{org_uuid}"
org_admin_perm = "auth:org:admin"
# If current user is org admin (not global admin), ensure org admin perm remains
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" in ctx.role.permissions
):
if not is_global_admin(ctx) and is_org_admin(ctx, org_uuid):
if org_admin_perm not in permissions:
raise ValueError(
"Cannot remove organization admin permission from your own organization"
@@ -165,11 +192,15 @@ async def admin_update_org(
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
@@ -177,15 +208,15 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = db.list_permissions()
for perm in all_permissions:
perm_id_lower = perm.id.lower()
perm_scope_lower = perm.scope.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_id_lower
or perm_id_lower.startswith(f"{org_perm_pattern}:")
or perm_id_lower.endswith(f":{org_perm_pattern}")
or perm_id_lower == org_perm_pattern
f":{org_perm_pattern}:" in perm_scope_lower
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
or perm_scope_lower.endswith(f":{org_perm_pattern}")
or perm_scope_lower == org_perm_pattern
):
db.delete_permission(perm.id)
db.delete_permission(str(perm.uuid))
db.delete_organization(org_uuid)
return {"status": "ok"}
@@ -229,12 +260,16 @@ async def admin_create_role(
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
from ..db import Role as RoleDC
role_uuid = uuid4()
@@ -267,10 +302,14 @@ async def admin_update_role(
# Verify caller is global admin or admin of provided org
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.get_role(role_uuid)
if role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
@@ -291,7 +330,7 @@ async def admin_update_role(
# Sanity check: prevent admin from removing their own access via role update
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
has_admin_access = (
"auth:admin" in permissions or f"auth:org:{org_uuid}" in permissions
"auth:admin" in permissions or "auth:org:admin" in permissions
)
if not has_admin_access:
raise ValueError("Cannot update your own role to remove admin permissions")
@@ -315,11 +354,15 @@ async def admin_delete_role(
):
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role = db.get_role(role_uuid)
if role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
@@ -342,12 +385,16 @@ async def admin_create_user(
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
await authz.verify(
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
role_name = payload.get("role")
if not display_name or not role_name:
@@ -380,10 +427,14 @@ async def admin_update_user_role(
):
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_role = payload.get("role")
if not new_role:
raise ValueError("role is required")
@@ -403,7 +454,7 @@ async def admin_update_user_role(
if new_role_obj: # pragma: no branch - always true, role validated above
has_admin_access = (
"auth:admin" in new_role_obj.permissions
or f"auth:org:{org_uuid}" in new_role_obj.permissions
or "auth:org:admin" in new_role_obj.permissions
)
if not has_admin_access:
raise ValueError(
@@ -429,15 +480,12 @@ async def admin_create_user_registration_link(
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -480,14 +528,11 @@ async def admin_get_user_detail(
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -565,9 +610,7 @@ async def admin_get_user_detail(
"ip": entry.ip,
"user_agent": useragent.compact_user_agent(entry.user_agent),
"last_renewed": (
renewed.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
if renewed.tzinfo
else renewed.replace(tzinfo=timezone.utc)
.isoformat()
@@ -631,14 +674,11 @@ async def admin_update_user_display_name(
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -667,15 +707,12 @@ async def admin_delete_user_credential(
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -699,14 +736,11 @@ async def admin_delete_user_session(
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify(
auth,
["auth:admin", f"auth:org:{org_uuid}"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
if ( # pragma: no cover - defense in depth, authz.verify already checked
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
if not can_manage_org(ctx, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -732,24 +766,46 @@ async def admin_delete_user_session(
# -------------------- Permissions (global) --------------------
def _perm_to_dict(p):
"""Convert Permission to dict, omitting domain if None."""
d = {"uuid": str(p.uuid), "scope": p.scope, "display_name": p.display_name}
if p.domain is not None:
d["domain"] = p.domain
return d
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id or a subdomain of it."""
if domain is None:
return
from paskia.globals import global_passkey
rp_id = global_passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"):
return
raise ValueError(
f"Domain '{domain}' must be the same as or a subdomain of rp_id '{rp_id}'"
)
@app.get("/permissions")
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:*"],
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
perms = db.list_permissions()
# Global admins see all permissions
if "auth:admin" in ctx.role.permissions:
return [{"id": p.id, "display_name": p.display_name} for p in perms]
if is_global_admin(ctx):
return [_perm_to_dict(p) for p in perms]
# Org admins only see permissions their org can grant
grantable = set(ctx.org.permissions or [])
filtered_perms = [p for p in perms if p.id in grantable]
return [{"id": p.id, "display_name": p.display_name} for p in filtered_perms]
filtered_perms = [p for p in perms if p.scope in grantable]
return [_perm_to_dict(p) for p in filtered_perms]
@app.post("/permissions")
@@ -765,34 +821,67 @@ async def admin_create_permission(
match=permutil.has_all,
max_age="5m",
)
import uuid7
from ..db import Permission as PermDC
perm_id = payload.get("id")
scope = payload.get("scope") or payload.get(
"id"
) # Support both for backwards compat
display_name = payload.get("display_name")
if not perm_id or not display_name:
raise ValueError("id and display_name are required")
querysafe.assert_safe(perm_id, field="id")
db.create_permission(PermDC(id=perm_id, display_name=display_name))
domain = payload.get("domain") or None # Treat empty string as None
if not scope or not display_name:
raise ValueError("scope and display_name are required")
querysafe.assert_safe(scope, field="scope")
_validate_permission_domain(domain)
db.create_permission(
PermDC(
uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
)
)
return {"status": "ok"}
@app.put("/permission")
async def admin_update_permission(
permission_id: str,
display_name: str,
request: Request,
auth=AUTH_COOKIE,
permission_uuid: str | None = None,
permission_id: str | None = None, # Backwards compat - treated as scope
display_name: str | None = None,
scope: str | None = None,
domain: str | None = None,
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
# permission_uuid or permission_id (scope) to identify the permission
perm_identifier = permission_uuid or permission_id
if not perm_identifier:
raise ValueError("permission_uuid or permission_id required")
# Get existing permission
perm = db.get_permission(perm_identifier)
# Update fields that were provided
new_scope = scope if scope is not None else perm.scope
new_display_name = display_name if display_name is not None else perm.display_name
domain_value = domain if domain else None
if not new_display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(new_scope, field="scope")
_validate_permission_domain(domain_value)
from ..db import Permission as PermDC
if not display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(permission_id, field="permission_id")
db.update_permission(
PermDC(id=permission_id, display_name=display_name)
PermDC(
uuid=perm.uuid,
scope=new_scope,
display_name=new_display_name,
domain=domain_value,
)
)
return {"status": "ok"}
@@ -806,31 +895,43 @@ async def admin_rename_permission(
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
old_id = payload.get("old_id")
new_id = payload.get("new_id")
old_scope = payload.get("old_scope") or payload.get("old_id") # Support both
new_scope = payload.get("new_scope") or payload.get("new_id") # Support both
display_name = payload.get("display_name")
if not old_id or not new_id:
raise ValueError("old_id and new_id required")
domain = payload.get(
"domain"
) # Can be None (not provided), empty string (clear), or value
if not old_scope or not new_scope:
raise ValueError("old_scope and new_scope required")
# Sanity check: prevent renaming critical permissions
if old_id == "auth:admin":
if old_scope == "auth:admin":
raise ValueError("Cannot rename the master admin permission")
querysafe.assert_safe(old_id, field="old_id")
querysafe.assert_safe(new_id, field="new_id")
querysafe.assert_safe(old_scope, field="old_scope")
querysafe.assert_safe(new_scope, field="new_scope")
# Get existing permission to preserve values not being changed
perm = db.get_permission(old_scope)
if display_name is None:
perm = db.get_permission(old_id)
display_name = perm.display_name
# domain=None means "not provided, keep existing", domain="" means "clear it"
if domain is None:
domain_value = perm.domain
else:
domain_value = domain if domain else None
_validate_permission_domain(domain_value)
# All current backends support rename_permission
db.rename_permission(old_id, new_id, display_name)
db.rename_permission(old_scope, new_scope, display_name, domain_value)
return {"status": "ok"}
@app.delete("/permission")
async def admin_delete_permission(
permission_id: str,
request: Request,
auth=AUTH_COOKIE,
permission_uuid: str | None = None,
permission_id: str | None = None, # Backwards compat - treated as scope
):
await authz.verify(
auth,
@@ -839,11 +940,18 @@ async def admin_delete_permission(
match=permutil.has_all,
max_age="5m",
)
querysafe.assert_safe(permission_id, field="permission_id")
perm_identifier = permission_uuid or permission_id
if not perm_identifier:
raise ValueError("permission_uuid or permission_id required")
querysafe.assert_safe(perm_identifier, field="permission_id")
# Get the permission to check its scope
perm = db.get_permission(perm_identifier)
# Sanity check: prevent deleting critical permissions
if permission_id == "auth:admin":
if perm.scope == "auth:admin":
raise ValueError("Cannot delete the master admin permission")
db.delete_permission(permission_id)
db.delete_permission(str(perm.uuid))
return {"status": "ok"}
+3 -5
View File
@@ -147,7 +147,7 @@ async def forward_authentication(
)
role_permissions = set(ctx.role.permissions or [])
if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions)
role_permissions.update(permission.scope for permission in ctx.permissions)
remote_headers: dict[str, str] = {
"Remote-User": str(ctx.user.uuid),
@@ -158,13 +158,11 @@ async def forward_authentication(
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": (
ctx.session.expiry
.astimezone(timezone.utc)
ctx.session.expiry.astimezone(timezone.utc)
.isoformat()
.replace("+00:00", "Z")
if ctx.session.expiry.tzinfo
else ctx.session.expiry
.replace(tzinfo=timezone.utc)
else ctx.session.expiry.replace(tzinfo=timezone.utc)
.isoformat()
.replace("+00:00", "Z")
),
+1 -3
View File
@@ -324,9 +324,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
# Fetch and verify credential
try:
stored_cred = db.get_credential_by_id(
credential.raw_id
)
stored_cred = db.get_credential_by_id(credential.raw_id)
except ValueError:
raise ValueError(
f"This passkey is no longer registered with {passkey.instance.rp_name}"
+1 -3
View File
@@ -116,9 +116,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
try:
session = await get_session(auth, host=host)
session_user_uuid = session.user_uuid
credential_ids = db.get_credentials_by_user_uuid(
session_user_uuid
)
credential_ids = db.get_credentials_by_user_uuid(session_user_uuid)
except ValueError:
pass # Invalid/expired session - allow normal authentication