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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user