diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 6563452..fa3551c 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -300,8 +300,8 @@ async function toggleRolePermission(role, pid, checked) { } // Permission actions -async function performPermissionDeletion(permissionScope) { - const params = new URLSearchParams({ permission_id: permissionScope }) +async function performPermissionDeletion(permissionUuid) { + const params = new URLSearchParams({ permission_uuid: permissionUuid }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' }) await loadPermissions() } @@ -321,7 +321,7 @@ function deletePermission(p) { if (roleCount === 0) { // No roles have this permission, safe to delete directly - performPermissionDeletion(p.scope) + performPermissionDeletion(p.uuid) .then(() => { authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500) }) @@ -337,7 +337,7 @@ function deletePermission(p) { const affects = parts.join(', ') openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => { - await performPermissionDeletion(p.scope) + await performPermissionDeletion(p.uuid) } }) } @@ -417,7 +417,7 @@ async function toggleOrgPermission(org, permId, checked) { const prev = [...org.permissions] org.permissions = next try { - const params = new URLSearchParams({ permission_id: permId }) + const params = new URLSearchParams({ permission_uuid: permId }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) await loadOrgs() } catch (e) { @@ -633,31 +633,28 @@ async function submitDialog() { return // Don't call closeDialog() again } else if (t === 'perm-display') { const { permission } = dialog.value.data - const newId = dialog.value.data.scope?.trim() + const newScope = dialog.value.data.scope?.trim() const newDisplay = dialog.value.data.display_name?.trim() const newDomain = dialog.value.data.domain?.trim() || '' if (!newDisplay) throw new Error('Display name required') - if (!newId) throw new Error('Scope required') + if (!newScope) throw new Error('Scope required') // Close dialog immediately, then perform async operation closeDialog() const oldDomain = permission.domain || '' - let apiCall; - if (newId !== permission.scope) { - // Scope changed, use rename endpoint (also update domain) - apiCall = apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_scope: permission.scope, new_scope: newId, display_name: newDisplay, domain: newDomain } }) - } else if (newDisplay !== permission.display_name || newDomain !== oldDomain) { - // Display name or domain changed - const params = new URLSearchParams({ permission_id: permission.scope, display_name: newDisplay }) - if (newDomain) params.set('domain', newDomain) - apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) - } else { - // No changes - return + // Check if anything changed + if (newScope === permission.scope && newDisplay === permission.display_name && newDomain === oldDomain) { + return // No changes } - apiCall + // Always use PATCH with permission_uuid + const params = new URLSearchParams({ permission_uuid: permission.uuid }) + if (newScope !== permission.scope) params.set('scope', newScope) + if (newDisplay !== permission.display_name) params.set('display_name', newDisplay) + if (newDomain !== oldDomain) params.set('domain', newDomain || '') + + apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) .then(() => { authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500) loadPermissions() diff --git a/paskia/authsession.py b/paskia/authsession.py index f9224a2..4169394 100644 --- a/paskia/authsession.py +++ b/paskia/authsession.py @@ -17,7 +17,7 @@ from paskia.config import RESET_LIFETIME, SESSION_LIFETIME from paskia.util import hostutil if TYPE_CHECKING: - from paskia.db import ResetToken, Session + from paskia.db import ResetToken EXPIRES = SESSION_LIFETIME @@ -39,24 +39,6 @@ async def get_reset(token: str) -> "ResetToken": raise ValueError("This authentication link is no longer valid.") -async def get_session(token: str, host: str | None = None) -> "Session": - """Validate a session token and return session data if valid.""" - - host = hostutil.normalize_host(host) - if not host: - raise ValueError("Invalid host") - session = db.data().sessions.get(token) - if session: - if session.host is None: - # First time binding: store exact host:port (or IPv6 form) now. - db.set_session_host(session.key, host) - session.host = host - elif session.host != host: - raise ValueError("Session host mismatch") - return session - raise ValueError("Your session has expired. Please sign in again!") - - async def refresh_session_token(token: str, *, ip: str, user_agent: str): """Refresh a session extending its expiry.""" session_record = db.data().sessions.get(token) @@ -74,5 +56,7 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str): async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): """Delete a specific credential for the current user.""" - s = await get_session(auth, host=host) - db.delete_credential(credential_uuid, s.user) + ctx = db.get_session_context(auth, hostutil.normalize_host(host)) + if not ctx: + raise ValueError("Session expired") + db.delete_credential(credential_uuid, ctx.user.uuid) diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index a69a9d5..e24658e 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -27,20 +27,20 @@ from paskia.db.background import ( stop_cleanup, ) from paskia.db.operations import ( - add_permission_to_organization, + add_permission_to_org, add_permission_to_role, bootstrap, cleanup_expired, create_credential, create_credential_session, - create_organization, + create_org, create_permission, create_reset_token, create_role, create_session, create_user, delete_credential, - delete_organization, + delete_org, delete_permission, delete_reset_token, delete_role, @@ -54,12 +54,11 @@ from paskia.db.operations import ( get_user_organization, init, login, - remove_permission_from_organization, + remove_permission_from_org, remove_permission_from_role, - rename_permission, set_session_host, update_credential_sign_count, - update_organization_name, + update_org_name, update_permission, update_role_name, update_session, @@ -118,20 +117,20 @@ __all__ = [ "get_user_credential_ids", "get_user_organization", # Write ops - "add_permission_to_organization", + "add_permission_to_org", "add_permission_to_role", "bootstrap", "cleanup_expired", "create_credential", "create_credential_session", - "create_organization", + "create_org", "create_permission", "create_reset_token", "create_role", "create_session", "create_user", "delete_credential", - "delete_organization", + "delete_org", "delete_permission", "delete_reset_token", "delete_role", @@ -139,12 +138,11 @@ __all__ = [ "delete_sessions_for_user", "delete_user", "login", - "remove_permission_from_organization", + "remove_permission_from_org", "remove_permission_from_role", - "rename_permission", "set_session_host", "update_credential_sign_count", - "update_organization_name", + "update_org_name", "update_permission", "update_role_name", "update_session", diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 8a4458b..4bafc0e 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -154,7 +154,7 @@ def get_session_context( if host is not None: if s.host is None: # Bind session to this host - with _db.transaction("host_binding"): + with _db.transaction("bind_session_host"): s.host = host elif s.host != host: # Session bound to different host @@ -221,7 +221,7 @@ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> """Create a new permission.""" if perm.uuid in _db.permissions: raise ValueError(f"Permission {perm.uuid} already exists") - with _db.transaction("Created permission", ctx): + with _db.transaction("admin:create_permission", ctx): _db.permissions[perm.uuid] = perm @@ -229,54 +229,31 @@ def update_permission(perm: Permission, *, ctx: SessionContext | None = None) -> """Update a permission's scope, display_name, and domain.""" if perm.uuid not in _db.permissions: raise ValueError(f"Permission {perm.uuid} not found") - with _db.transaction("Updated permission", ctx): + with _db.transaction("admin:update_permission", ctx): _db.permissions[perm.uuid].scope = perm.scope _db.permissions[perm.uuid].display_name = perm.display_name _db.permissions[perm.uuid].domain = perm.domain -def rename_permission( - uuid: UUID, - new_scope: str, - display_name: str, - domain: str | None = None, - *, - ctx: SessionContext | None = None, -) -> None: - """Rename a permission's scope. The UUID remains the same. - - 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). - """ - if uuid not in _db.permissions: - raise ValueError(f"Permission {uuid} not found") - - with _db.transaction("Renamed permission", ctx): - # Update the permission - _db.permissions[uuid].scope = new_scope - _db.permissions[uuid].display_name = display_name - _db.permissions[uuid].domain = domain - - def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None: """Delete a permission and remove it from all roles.""" if uuid not in _db.permissions: raise ValueError(f"Permission {uuid} not found") - with _db.transaction("Deleted permission", ctx): + with _db.transaction("admin:delete_permission", ctx): # Remove this permission from all roles for role in _db.roles.values(): role.permissions.pop(uuid, None) del _db.permissions[uuid] -def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None: +def create_org(org: Org, *, ctx: SessionContext | None = None) -> None: """Create a new organization with an Administration role. Automatically creates an 'Administration' role with auth:org:admin permission. """ if org.uuid in _db.orgs: raise ValueError(f"Organization {org.uuid} already exists") - with _db.transaction("Created organization", ctx): + with _db.transaction("admin:create_org", ctx): new_org = Org( display_name=org.display_name, created_at=datetime.now(timezone.utc) ) @@ -301,7 +278,7 @@ def create_organization(org: Org, *, ctx: SessionContext | None = None) -> None: _db.roles[admin_role_uuid] = admin_role -def update_organization_name( +def update_org_name( uuid: UUID, display_name: str, *, @@ -310,15 +287,15 @@ def update_organization_name( """Update organization display name.""" if uuid not in _db.orgs: raise ValueError(f"Organization {uuid} not found") - with _db.transaction("Renamed organization", ctx): + with _db.transaction("admin:update_org_name", ctx): _db.orgs[uuid].display_name = display_name -def delete_organization(uuid: UUID, *, ctx: SessionContext | None = None) -> None: +def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None: """Delete organization and all its roles/users.""" if uuid not in _db.orgs: raise ValueError(f"Organization {uuid} not found") - with _db.transaction("Deleted organization", ctx): + with _db.transaction("admin:delete_org", ctx): # Remove org from all permissions for p in _db.permissions.values(): p.orgs.pop(uuid, None) @@ -333,7 +310,7 @@ def delete_organization(uuid: UUID, *, ctx: SessionContext | None = None) -> Non del _db.orgs[uuid] -def add_permission_to_organization( +def add_permission_to_org( org_uuid: UUID, permission_uuid: UUID, *, @@ -346,11 +323,11 @@ def add_permission_to_organization( if permission_uuid not in _db.permissions: raise ValueError(f"Permission {permission_uuid} not found") - with _db.transaction("Granted org permission", ctx): + with _db.transaction("admin:add_permission_to_org", ctx): _db.permissions[permission_uuid].orgs[org_uuid] = True -def remove_permission_from_organization( +def remove_permission_from_org( org_uuid: UUID, permission_uuid: UUID, *, @@ -363,7 +340,7 @@ def remove_permission_from_organization( if permission_uuid not in _db.permissions: return # Permission not found, silently return - with _db.transaction("Revoked org permission", ctx): + with _db.transaction("admin:remove_permission_from_org", ctx): _db.permissions[permission_uuid].orgs.pop(org_uuid, None) @@ -373,7 +350,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None: raise ValueError(f"Role {role.uuid} already exists") if role.org not in _db.orgs: raise ValueError(f"Organization {role.org} not found") - with _db.transaction("Created role", ctx): + with _db.transaction("admin:create_role", ctx): _db.roles[role.uuid] = role @@ -386,7 +363,7 @@ def update_role_name( """Update role display name.""" if uuid not in _db.roles: raise ValueError(f"Role {uuid} not found") - with _db.transaction("Renamed role", ctx): + with _db.transaction("admin:update_role_name", ctx): _db.roles[uuid].display_name = display_name @@ -401,7 +378,7 @@ def add_permission_to_role( raise ValueError(f"Role {role_uuid} not found") if permission_uuid not in _db.permissions: raise ValueError(f"Permission {permission_uuid} not found") - with _db.transaction("Granted role permission", ctx): + with _db.transaction("admin:add_permission_to_role", ctx): _db.roles[role_uuid].permissions[permission_uuid] = True @@ -414,7 +391,7 @@ def remove_permission_from_role( """Remove permission from role by UUID.""" if role_uuid not in _db.roles: raise ValueError(f"Role {role_uuid} not found") - with _db.transaction("Revoked role permission", ctx): + with _db.transaction("admin:remove_permission_from_role", ctx): _db.roles[role_uuid].permissions.pop(permission_uuid, None) @@ -425,7 +402,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None: # Check no users have this role if any(u.role == uuid for u in _db.users.values()): raise ValueError(f"Cannot delete role {uuid}: users still assigned") - with _db.transaction("Deleted role", ctx): + with _db.transaction("admin:delete_role", ctx): del _db.roles[uuid] @@ -435,7 +412,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None: raise ValueError(f"User {new_user.uuid} already exists") if new_user.role not in _db.roles: raise ValueError(f"Role {new_user.role} not found") - with _db.transaction("Created user", ctx): + with _db.transaction("admin:create_user", ctx): _db.users[new_user.uuid] = new_user @@ -447,16 +424,15 @@ def update_user_display_name( ) -> None: """Update user display name. - For self-service (user updating own name), ctx can be None and user is derived from uuid. - For admin operations, ctx should be provided. + The acting user should be logged via ctx. + For self-service (user updating own name), pass user's ctx. + For admin operations, pass admin's ctx. """ if isinstance(uuid, str): uuid = UUID(uuid) if uuid not in _db.users: raise ValueError(f"User {uuid} not found") - # For self-service, derive user from the uuid being modified - user_str = str(uuid) if not ctx else None - with _db.transaction("Renamed user", ctx, user=user_str): + with _db.transaction("update_user_display_name", ctx): _db.users[uuid].display_name = display_name @@ -471,7 +447,7 @@ def update_user_role( raise ValueError(f"User {uuid} not found") if role_uuid not in _db.roles: raise ValueError(f"Role {role_uuid} not found") - with _db.transaction("Changed user role", ctx): + with _db.transaction("admin:update_user_role", ctx): _db.users[uuid].role = role_uuid @@ -496,7 +472,7 @@ def update_user_role_in_organization( break if new_role_uuid is None: raise ValueError(f"Role '{role_name}' not found in organization") - with _db.transaction("Changed user role", ctx): + with _db.transaction("admin:update_user_role", ctx): _db.users[user_uuid].role = new_role_uuid @@ -504,7 +480,7 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None: """Delete user and their credentials/sessions.""" if uuid not in _db.users: raise ValueError(f"User {uuid} not found") - with _db.transaction("Deleted user", ctx): + with _db.transaction("admin:delete_user", ctx): # Delete credentials cred_uuids = [cid for cid, c in _db.credentials.items() if c.user == uuid] for cid in cred_uuids: @@ -526,7 +502,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> raise ValueError(f"Credential {cred.uuid} already exists") if cred.user not in _db.users: raise ValueError(f"User {cred.user} not found") - with _db.transaction("Added credential", ctx): + with _db.transaction("create_credential", ctx): _db.credentials[cred.uuid] = cred @@ -540,7 +516,7 @@ def update_credential_sign_count( """Update credential sign count and last_used.""" if uuid not in _db.credentials: raise ValueError(f"Credential {uuid} not found") - with _db.transaction("Updated credential", ctx): + with _db.transaction("update_credential_sign_count", ctx): _db.credentials[uuid].sign_count = sign_count if last_used: _db.credentials[uuid].last_used = last_used @@ -562,7 +538,7 @@ def delete_credential( cred_user = _db.credentials[uuid].user if cred_user != user_uuid: raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") - with _db.transaction("Deleted credential", ctx): + with _db.transaction("delete_credential", ctx): # Delete all sessions using this credential keys = [k for k, s in _db.sessions.items() if s.credential == uuid] for k in keys: @@ -588,7 +564,7 @@ def create_session( raise ValueError(f"User {user_uuid} not found") if credential_uuid not in _db.credentials: raise ValueError(f"Credential {credential_uuid} not found") - with _db.transaction("Created session", ctx): + with _db.transaction("create_session", ctx): _db.sessions[key] = Session( user=user_uuid, credential=credential_uuid, @@ -611,7 +587,7 @@ def update_session( """Update session metadata.""" if key not in _db.sessions: raise ValueError("Session not found") - with _db.transaction("Updated session", ctx): + with _db.transaction("update_session", ctx): s = _db.sessions[key] if host is not None: s.host = host @@ -631,14 +607,13 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) def delete_session(key: str, *, ctx: SessionContext | None = None) -> None: """Delete a session. - For logout (user deleting own session), ctx can be None and user is derived from session. - For admin operations, ctx should be provided. + The acting user should be logged via ctx. + For user logout, pass ctx of the user's session. + For admin terminating a session, pass admin's ctx. """ if key not in _db.sessions: raise ValueError("Session not found") - # For self-service logout, derive user from the session being deleted - user_str = str(_db.sessions[key].user) if not ctx else None - with _db.transaction("Deleted session", ctx, user=user_str): + with _db.transaction("delete_session", ctx): del _db.sessions[key] @@ -647,12 +622,11 @@ def delete_sessions_for_user( ) -> None: """Delete all sessions for a user. - For logout-all (user deleting own sessions), ctx can be None and user is derived from user_uuid. - For admin operations, ctx should be provided. + The acting user should be logged via ctx. + For user logout-all, pass ctx of the user's session. + For admin bulk termination, pass admin's ctx. """ - # For self-service, derive user from the user_uuid param - user_str = str(user_uuid) if not ctx else None - with _db.transaction("Deleted user sessions", ctx, user=user_str): + with _db.transaction("admin:delete_sessions_for_user", ctx): keys = [k for k, s in _db.sessions.items() if s.user == user_uuid] for k in keys: del _db.sessions[k] @@ -668,17 +642,17 @@ def create_reset_token( ) -> None: """Create a reset token from a passphrase. - For self-service (user creating own recovery link), ctx can be None and user is derived from user_uuid. - For admin operations, ctx should be provided. + The acting user should be logged via ctx. + For self-service (user creating own recovery link), pass user's ctx. + For admin operations, pass admin's ctx. + For system operations (bootstrap), pass neither to log no user. """ key = _reset_key(passphrase) if key in _db.reset_tokens: raise ValueError("Reset token already exists") if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") - # For self-service, derive user from the user_uuid param - user_str = str(user_uuid) if not ctx else None - with _db.transaction("Created reset token", ctx, user=user_str): + with _db.transaction("create_reset_token", ctx): _db.reset_tokens[key] = ResetToken( user=user_uuid, expiry=expiry, token_type=token_type ) @@ -688,7 +662,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None """Delete a reset token.""" if key not in _db.reset_tokens: raise ValueError("Reset token not found") - with _db.transaction("Deleted reset token", ctx): + with _db.transaction("delete_reset_token", ctx): del _db.reset_tokens[key] @@ -701,7 +675,7 @@ def cleanup_expired() -> int: """Remove expired sessions and reset tokens. Returns count removed.""" now = datetime.now(timezone.utc) count = 0 - with _db.transaction("Cleaned up expired"): + with _db.transaction("admin:cleanup_expired"): expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now] for k in expired_sessions: del _db.sessions[k] @@ -751,7 +725,7 @@ def login( session_key = _create_token() user_str = str(user_uuid) - with _db.transaction("User logged in", user=user_str): + with _db.transaction("login", user=user_str): # Update user _db.users[user_uuid].last_seen = now _db.users[user_uuid].visits += 1 @@ -798,7 +772,7 @@ def create_credential_session( raise ValueError(f"User {user_uuid} not found") user_str = str(user_uuid) - with _db.transaction("Registered credential", user=user_str): + with _db.transaction("create_credential_session", user=user_str): # Update display name if provided if display_name: _db.users[user_uuid].display_name = display_name @@ -875,6 +849,7 @@ def bootstrap( reset_passphrase = generate_passphrase() if reset_expiry is None: from paskia.util.timeutil import reset_expires # noqa: PLC0415 + reset_expiry = reset_expires() reset_key = _reset_key(reset_passphrase) diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index e039521..c70e00c 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -148,10 +148,10 @@ async def admin_create_org( display_name = payload.get("display_name") or "New Organization" permissions = payload.get("permissions") or [] org = OrgDC.create(display_name=display_name) - db.create_organization(org, ctx=ctx) + db.create_org(org, ctx=ctx) # Grant requested permissions to the new org for perm in permissions: - db.add_permission_to_organization(str(org.uuid), perm) + db.add_permission_to_org(str(org.uuid), perm) return {"uuid": str(org.uuid)} @@ -178,7 +178,7 @@ async def admin_update_org_name( if not display_name: raise ValueError("display_name is required") - db.update_organization_name(org_uuid, display_name, ctx=ctx) + db.update_org_name(org_uuid, display_name, ctx=ctx) return {"status": "ok"} @@ -212,7 +212,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): ): db.delete_permission(perm.uuid, ctx=ctx) - db.delete_organization(org_uuid, ctx=ctx) + db.delete_org(org_uuid, ctx=ctx) return {"status": "ok"} @@ -220,27 +220,14 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE): async def admin_add_org_permission( org_uuid: UUID, request: Request, - permission_id: str = Query(...), + permission_uuid: UUID = Query(...), auth=AUTH_COOKIE, ): ctx = await authz.verify( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) - # Convert permission_id to UUID - try: - permission_uuid = UUID(permission_id) - except ValueError: - # It's a scope - look up the UUID - perm = next( - (p for p in db.data().permissions.values() if p.scope == permission_id), - None, - ) - if not perm: - raise HTTPException(status_code=404, detail="Permission not found") - permission_uuid = perm.uuid - - db.add_permission_to_organization(org_uuid, permission_uuid, ctx=ctx) + db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx) return {"status": "ok"} @@ -248,25 +235,14 @@ async def admin_add_org_permission( async def admin_remove_org_permission( org_uuid: UUID, request: Request, - permission_id: str = Query(...), + permission_uuid: UUID = Query(...), auth=AUTH_COOKIE, ): ctx = await authz.verify( auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all ) - # Convert permission_id to UUID - try: - permission_uuid = UUID(permission_id) - except ValueError: - # It's a scope - look up the UUID - perm = next( - (p for p in db.data().permissions.values() if p.scope == permission_id), - None, - ) - if not perm: - raise HTTPException(status_code=404, detail="Permission not found") - permission_uuid = perm.uuid + db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx) # Guard rail: prevent removing auth:admin from your own org if it would lock you out perm = db.data().permissions.get(permission_uuid) @@ -278,7 +254,7 @@ async def admin_remove_org_permission( "This would lock you out of admin access." ) - db.remove_permission_from_organization(org_uuid, permission_uuid, ctx=ctx) + db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx) return {"status": "ok"} @@ -1008,6 +984,10 @@ async def admin_update_permission( new_display_name = display_name if display_name is not None else perm.display_name domain_value = domain if domain else None + # Sanity check: prevent changing the auth:admin permission scope + if perm.scope == "auth:admin" and new_scope != "auth:admin": + raise ValueError("Cannot rename the master admin permission") + if not new_display_name: raise ValueError("display_name is required") querysafe.assert_safe(new_scope, field="scope") @@ -1027,52 +1007,6 @@ async def admin_update_permission( return {"status": "ok"} -@app.post("/permission/rename") -@app.put("/permission/rename") -async def admin_rename_permission( - request: Request, - permission_uuid: UUID = Query(...), - payload: dict = Body(...), - auth=AUTH_COOKIE, -): - ctx = await authz.verify( - auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all - ) - new_scope = payload.get("new_scope") or payload.get("new_id") # Support both - display_name = payload.get("display_name") - domain = payload.get( - "domain" - ) # Can be None (not provided), empty string (clear), or value - if not new_scope: - raise ValueError("new_scope required") - - # Sanity check: prevent renaming critical permissions - perm = db.data().permissions.get(permission_uuid) - if perm.scope == "auth:admin": - raise ValueError("Cannot rename the master admin permission") - - querysafe.assert_safe(new_scope, field="new_scope") - - if display_name is None: - 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) - - # Safety check: prevent admin lockout when setting domain on auth:admin - if perm.scope == "auth:admin" or new_scope == "auth:admin": - _check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host")) - - # All current backends support rename_permission - db.rename_permission( - permission_uuid, new_scope, display_name, domain_value, ctx=ctx - ) - return {"status": "ok"} - - @app.delete("/permission") async def admin_delete_permission( request: Request, diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 5bb37da..730a968 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -17,7 +17,6 @@ from paskia import db from paskia.authsession import ( EXPIRES, get_reset, - get_session, refresh_session_token, ) from paskia.fastapi import authz, session, user @@ -234,15 +233,14 @@ async def api_user_info( detail="Authentication required", mode="login", ) - try: - session_record = await get_session(auth, host=request.headers.get("host")) - except ValueError as e: - raise HTTPException(401, str(e)) + ctx = db.get_session_context(auth, request.headers.get("host")) + if not ctx: + raise HTTPException(401, "Session expired") return await userinfo.format_user_info( - user_uuid=session_record.user, + user_uuid=ctx.user.uuid, auth=auth, - session_record=session_record, + session_record=ctx.session, request_host=request.headers.get("host"), ) @@ -251,12 +249,12 @@ async def api_user_info( async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): if not auth: return {"message": "Already logged out"} - try: - _s = await get_session(auth, host=request.headers.get("host")) - except ValueError: + host = request.headers.get("host") + ctx = db.get_session_context(auth, host) + if not ctx: return {"message": "Already logged out"} with suppress(Exception): - db.delete_session(auth) + db.delete_session(auth, ctx=ctx) session.clear_session_cookie(response) return {"message": "Logged out successfully"} @@ -265,9 +263,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): async def api_set_session( request: Request, response: Response, auth=Depends(bearer_auth) ): - user = await get_session(auth.credentials, host=request.headers.get("host")) + ctx = db.get_session_context(auth.credentials, request.headers.get("host")) + if not ctx: + raise HTTPException(401, "Session expired") session.set_session_cookie(response, auth.credentials) return { "message": "Session cookie set successfully", - "user": str(user.user), + "user": str(ctx.user.uuid), } diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index aa8411a..304e2b6 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -14,7 +14,6 @@ from paskia import db from paskia.authsession import ( delete_credential, expires, - get_session, ) from paskia.fastapi import authz, session from paskia.fastapi.session import AUTH_COOKIE @@ -43,18 +42,18 @@ async def user_update_display_name( raise authz.AuthException( status_code=401, detail="Authentication Required", mode="login" ) - try: - s = await get_session(auth, host=request.headers.get("host")) - except ValueError as e: + host = request.headers.get("host") + ctx = db.get_session_context(auth, host) + if not ctx: raise authz.AuthException( status_code=401, detail="Session expired", mode="login" - ) from e + ) new_name = (payload.get("display_name") or "").strip() if not new_name: raise HTTPException(status_code=400, detail="display_name required") if len(new_name) > 64: raise HTTPException(status_code=400, detail="display_name too long") - db.update_user_display_name(s.user, new_name) + db.update_user_display_name(ctx.user.uuid, new_name, ctx=ctx) return {"status": "ok"} @@ -62,13 +61,13 @@ async def user_update_display_name( async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE): if not auth: return {"message": "Already logged out"} - try: - s = await get_session(auth, host=request.headers.get("host")) - except ValueError: + host = request.headers.get("host") + ctx = db.get_session_context(auth, host) + if not ctx: raise authz.AuthException( status_code=401, detail="Session expired", mode="login" ) - db.delete_sessions_for_user(s.user) + db.delete_sessions_for_user(ctx.user.uuid, ctx=ctx) session.clear_session_cookie(response) return {"message": "Logged out from all hosts"} @@ -84,18 +83,18 @@ async def api_delete_session( raise authz.AuthException( status_code=401, detail="Authentication Required", mode="login" ) - try: - current_session = await get_session(auth, host=request.headers.get("host")) - except ValueError as exc: + host = request.headers.get("host") + ctx = db.get_session_context(auth, host) + if not ctx: raise authz.AuthException( status_code=401, detail="Session expired", mode="login" - ) from exc + ) target_session = db.data().sessions.get(session_id) - if not target_session or target_session.user != current_session.user: + if not target_session or target_session.user != ctx.user.uuid: raise HTTPException(status_code=404, detail="Session not found") - db.delete_session(session_id) + db.delete_session(session_id, ctx=ctx) current_terminated = session_id == auth if current_terminated: session.clear_session_cookie(response) # explicit because 200 @@ -127,20 +126,15 @@ async def api_create_link( auth=AUTH_COOKIE, ): # Require recent authentication for sensitive operation - await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m") - try: - s = await get_session(auth, host=request.headers.get("host")) - except ValueError as e: - raise authz.AuthException( - status_code=401, detail="Session expired", mode="login" - ) from e + ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m") token = passphrase.generate() expiry = expires() db.create_reset_token( - user_uuid=s.user, + user_uuid=ctx.user.uuid, passphrase=token, expiry=expiry, token_type="device addition", + ctx=ctx, ) url = hostutil.reset_link_url(token) return { diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index deb8e3d..02a398a 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -1,7 +1,7 @@ from fastapi import FastAPI, WebSocket from paskia import db -from paskia.authsession import expires, get_reset, get_session +from paskia.authsession import expires, get_reset from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wschat import authenticate_chat, register_chat @@ -91,12 +91,10 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): session_user_uuid = None credential_ids = None if auth: - try: - session = await get_session(auth, host=host) - session_user_uuid = session.user + ctx = db.get_session_context(auth, host) + if ctx: + session_user_uuid = ctx.user.uuid credential_ids = db.get_user_credential_ids(session_user_uuid) or None - except ValueError: - pass # Invalid/expired session - allow normal authentication cred = await authenticate_chat(ws, origin, credential_ids) diff --git a/tests/conftest.py b/tests/conftest.py index 8094205..8edc395 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,9 +28,9 @@ from paskia.db import ( Permission, Role, User, - add_permission_to_organization, + add_permission_to_org, create_credential, - create_organization, + create_org, create_permission, create_reset_token, create_role, @@ -86,9 +86,9 @@ async def passkey_instance() -> Passkey: async def test_org(test_db: DB, admin_permission: Permission) -> Org: """Create a test organization with admin permission.""" org = Org.create(display_name="Test Organization") - create_organization(org) + create_org(org) # Grant admin permission to this org - add_permission_to_organization(org.uuid, admin_permission.uuid) + add_permission_to_org(org.uuid, admin_permission.uuid) return org @@ -106,7 +106,7 @@ async def org_admin_permission(test_db: DB, test_org: Org) -> Permission: perm = Permission.create(scope="auth:org:admin", display_name="Organization Admin") create_permission(perm) # Make it grantable by the org - add_permission_to_organization(test_org.uuid, perm.uuid) + add_permission_to_org(test_org.uuid, perm.uuid) return perm diff --git a/tests/test_admin.py b/tests/test_admin.py index b1e7fe2..f89ccd4 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -28,9 +28,9 @@ from paskia.db import ( Permission, Role, User, - add_permission_to_organization, + add_permission_to_org, create_credential, - create_organization, + create_org, create_permission, create_role, create_session, @@ -48,7 +48,7 @@ async def second_org(test_db: DB) -> Org: org = Org.create( display_name="Second Organization", ) - create_organization(org) + create_org(org) return org @@ -176,7 +176,7 @@ async def grantable_permission(test_db: DB, test_org: Org) -> Permission: perm = Permission.create(scope="test:grantable:perm", display_name="Grantable Perm") create_permission(perm) # Add to org's grantable permissions - add_permission_to_organization(test_org.uuid, perm.uuid) + add_permission_to_org(test_org.uuid, perm.uuid) return perm @@ -369,10 +369,13 @@ class TestAdminOrganizations: ): """Org admin cannot remove their org admin permission from org's permissions.""" # The auth:org:admin perm is already created and added by org_admin_permission fixture + org_admin_perm = next( + p for p in db.data().permissions.values() if p.scope == "auth:org:admin" + ) # Try to remove org admin perm (this is validated server-side in the remove endpoint) response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:org:admin", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={org_admin_perm.uuid}", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) # This should fail because only global admin can remove perms from org @@ -403,7 +406,7 @@ class TestAdminOrganizations: org_to_delete = Org.create( display_name="Org To Delete", ) - create_organization(org_to_delete) + create_org(org_to_delete) # Create some org-specific permissions to test cleanup org_perm = Permission.create( @@ -433,15 +436,12 @@ class TestAdminOrgPermissions: ): """Admin should be able to add a permission to an org.""" # First create a permission - await client.post( - "/auth/api/admin/permissions", - json={"scope": "test:org:addable", "display_name": "Addable"}, - headers={**auth_headers(session_token), "Host": "localhost:4401"}, - ) + perm = Permission.create(scope="test:org:addable", display_name="Addable") + create_permission(perm) # Add it to the org response = await client.post( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=test:org:addable", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={perm.uuid}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -456,8 +456,11 @@ class TestAdminOrgPermissions: test_org, ): """Org admin cannot add permissions to org (requires global admin).""" + admin_perm = next( + p for p in db.data().permissions.values() if p.scope == "auth:admin" + ) response = await client.post( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={admin_perm.uuid}", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) assert response.status_code == 403 @@ -468,19 +471,16 @@ class TestAdminOrgPermissions: ): """Admin should be able to remove a permission from an org.""" # First create and add a permission + perm = Permission.create(scope="test:org:removable", display_name="Removable") + create_permission(perm) await client.post( - "/auth/api/admin/permissions", - json={"scope": "test:org:removable", "display_name": "Removable"}, - headers={**auth_headers(session_token), "Host": "localhost:4401"}, - ) - await client.post( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=test:org:removable", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={perm.uuid}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) # Remove it response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=test:org:removable", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={perm.uuid}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -495,8 +495,11 @@ class TestAdminOrgPermissions: test_org, ): """Org admin cannot remove permissions from org (requires global admin).""" + admin_perm = next( + p for p in db.data().permissions.values() if p.scope == "auth:admin" + ) response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={admin_perm.uuid}", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) assert response.status_code == 403 @@ -1385,51 +1388,32 @@ class TestAdminPermissions: assert "display_name is required" in data["detail"] @pytest.mark.asyncio - async def test_rename_permission( + async def test_update_permission_scope( self, client: httpx.AsyncClient, session_token: str, test_db: DB ): - """Admin should be able to rename a permission.""" + """Admin should be able to update a permission's scope via PATCH.""" # Create permission first perm = Permission.create(scope="test:renameable2", display_name="Renameable") create_permission(perm) - response = await client.post( - f"/auth/api/admin/permission/rename?permission_uuid={perm.uuid}", - json={"new_scope": "test:renamed2"}, + response = await client.patch( + f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed2", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @pytest.mark.asyncio - async def test_rename_permission_missing_ids( + async def test_update_permission_auth_admin_scope_fails( self, client: httpx.AsyncClient, session_token: str ): - """Renaming permission without IDs should fail.""" - response = await client.post( - "/auth/api/admin/permission/rename", - json={}, - headers={**auth_headers(session_token), "Host": "localhost:4401"}, - ) - assert response.status_code == 422 - data = response.json() - assert any( - "required" in str(error) or "Field required" in str(error) - for error in data["detail"] - ) - - @pytest.mark.asyncio - async def test_rename_permission_auth_admin_fails( - self, client: httpx.AsyncClient, session_token: str - ): - """Cannot rename the auth:admin permission.""" + """Cannot change the auth:admin permission scope.""" # Get the auth:admin permission perms = list(db.data().permissions.values()) admin_perm = next(p for p in perms if p.scope == "auth:admin") - response = await client.post( - f"/auth/api/admin/permission/rename?permission_uuid={admin_perm.uuid}", - json={"new_scope": "auth:superadmin"}, + response = await client.patch( + f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}&scope=auth:superadmin", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 400 @@ -1437,19 +1421,15 @@ class TestAdminPermissions: assert "Cannot rename the master admin" in data["detail"] @pytest.mark.asyncio - async def test_rename_permission_with_display_name( + async def test_update_permission_scope_and_display_name( self, client: httpx.AsyncClient, session_token: str, test_db: DB ): - """Renaming permission can also update display name.""" + """Updating permission can change scope and display name together.""" perm = Permission.create(scope="test:rename:withname", display_name="Old Name") create_permission(perm) - response = await client.post( - f"/auth/api/admin/permission/rename?permission_uuid={perm.uuid}", - json={ - "new_scope": "test:renamed:withname", - "display_name": "New Display Name", - }, + response = await client.patch( + f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed:withname&display_name=New%20Display%20Name", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -1549,8 +1529,11 @@ class TestAdminPermissions: self, client: httpx.AsyncClient, session_token: str, test_org ): """Cannot remove auth:admin permission from your own organization.""" + admin_perm = next( + p for p in db.data().permissions.values() if p.scope == "auth:admin" + ) response = await client.delete( - f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin", + f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={admin_perm.uuid}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 400 diff --git a/tests/test_api.py b/tests/test_api.py index b18632b..9d14ad7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -444,7 +444,7 @@ class TestSetSessionErrors: async def test_set_session_with_invalid_bearer_token( self, client: httpx.AsyncClient ): - """Set session with invalid (malformed) bearer token should return 400.""" + """Set session with invalid (malformed) bearer token should return 401.""" response = await client.post( "/auth/api/set-session", headers={ @@ -452,8 +452,8 @@ class TestSetSessionErrors: "Host": "localhost:4401", }, ) - # Invalid token format returns 400 - assert response.status_code == 400 + # Invalid token returns 401 (session not found) + assert response.status_code == 401 @pytest.mark.asyncio async def test_set_session_with_nonexistent_token(self, client: httpx.AsyncClient): @@ -467,8 +467,8 @@ class TestSetSessionErrors: "Host": "localhost:4401", }, ) - # Non-existent session returns 400 (ValueError -> 400) - assert response.status_code == 400 + # Non-existent session returns 401 (session expired) + assert response.status_code == 401 class TestValidateSessionRefresh: