DB transactions cleanup, better actor/user data. Simplified admin API. Use UUID to refer to a specific permission in admin API. Other cleanup.

This commit is contained in:
Leo Vasanko
2026-01-27 21:48:21 +00:00
parent e3b64ef537
commit d40a2b8682
11 changed files with 180 additions and 317 deletions
+17 -20
View File
@@ -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()
+5 -21
View File
@@ -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)
+10 -12
View File
@@ -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",
+50 -75
View File
@@ -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)
+13 -79
View File
@@ -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,
+13 -13
View File
@@ -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),
}
+18 -24
View File
@@ -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 {
+4 -6
View File
@@ -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)
+5 -5
View File
@@ -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
+40 -57
View File
@@ -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
+5 -5
View File
@@ -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: