From 253387be976db83381739c954b033f89e78d6736 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 23 Jan 2026 13:54:31 +0000 Subject: [PATCH] Permissions refactor. Permissions have UUID and scope (previously id) and the latter no longer needs to be unique. Org admin uses a single global permission now. Domain scoped permissions. Removed from user info the admin fields, use effective_permission checks instead. --- frontend/auth/admin/AdminApp.vue | 53 ++-- frontend/src/admin/AdminDialogs.vue | 11 +- frontend/src/admin/AdminOrgDetail.vue | 4 +- frontend/src/admin/AdminOverview.vue | 51 ++-- frontend/src/components/ProfileView.vue | 5 +- paskia/bootstrap.py | 28 +- paskia/db/json.py | 354 ++++++++++++++++++------ paskia/fastapi/admin.py | 274 ++++++++++++------ paskia/fastapi/api.py | 8 +- paskia/fastapi/remote.py | 4 +- paskia/fastapi/ws.py | 4 +- paskia/migrate/__init__.py | 73 ++++- paskia/migrate/sql.py | 23 +- paskia/util/userinfo.py | 13 +- tests/conftest.py | 29 +- tests/test_admin.py | 113 +++++--- 16 files changed, 729 insertions(+), 318 deletions(-) diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 981defc..bca60ee 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -46,6 +46,10 @@ const adminUserDetailRef = ref(null) // Check if any modal/dialog is open (blocks arrow key navigation) const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value) +// Derive admin status from permissions +const isGlobalAdmin = computed(() => info.value?.permissions?.includes('auth:admin') ?? false) +const isOrgAdmin = computed(() => info.value?.permissions?.includes('auth:org:admin') ?? false) + function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') } function handleGlobalClick(e) { @@ -108,7 +112,7 @@ const permissionSummary = computed(() => { return display }) -function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, id: p.id, display_name: p.display_name }) } +function renamePermissionDisplay(p) { openDialog('perm-display', { permission: p, scope: p.scope, display_name: p.display_name, domain: p.domain || '' }) } function parseHash() { @@ -153,7 +157,7 @@ async function load() { // If we get here, user has admin access - now fetch user info for display await loadUserInfo() - if (!info.value.is_global_admin && info.value.is_org_admin && orgs.value.length === 1) { + if (!isGlobalAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { currentOrgId.value = orgs.value[0].uuid window.location.hash = `#org/${currentOrgId.value}` @@ -186,7 +190,7 @@ async function performOrgDeletion(orgUuid) { } function deleteOrg(org) { - if (!info.value?.is_global_admin) { authStore.showMessage('Global admin only'); return } + if (!isGlobalAdmin.value) { authStore.showMessage('Global admin only'); return } const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0) @@ -289,20 +293,20 @@ async function toggleRolePermission(role, pid, checked) { } // Permission actions -async function performPermissionDeletion(permissionId) { - const params = new URLSearchParams({ permission_id: permissionId }) +async function performPermissionDeletion(permissionScope) { + const params = new URLSearchParams({ permission_id: permissionScope }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' }) await loadPermissions() } function deletePermission(p) { - const userCount = permissionSummary.value[p.id]?.userCount || 0 + const userCount = permissionSummary.value[p.scope]?.userCount || 0 // Count roles that have this permission let roleCount = 0 for (const org of orgs.value) { for (const role of org.roles) { - if (role.permissions.includes(p.id)) { + if (role.permissions.includes(p.scope)) { roleCount++ } } @@ -310,7 +314,7 @@ function deletePermission(p) { if (roleCount === 0) { // No roles have this permission, safe to delete directly - performPermissionDeletion(p.id) + performPermissionDeletion(p.scope) .then(() => { authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500) }) @@ -326,7 +330,7 @@ function deletePermission(p) { const affects = parts.join(', ') openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => { - await performPermissionDeletion(p.id) + await performPermissionDeletion(p.scope) } }) } @@ -626,21 +630,24 @@ async function submitDialog() { return // Don't call closeDialog() again } else if (t === 'perm-display') { const { permission } = dialog.value.data - const newId = dialog.value.data.id?.trim() + const newId = 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('ID required') + if (!newId) throw new Error('Scope required') // Close dialog immediately, then perform async operation closeDialog() + const oldDomain = permission.domain || '' let apiCall; - if (newId !== permission.id) { - // ID changed, use rename endpoint - apiCall = apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_id: permission.id, new_id: newId, display_name: newDisplay } }) - } else if (newDisplay !== permission.display_name) { - // Only display name changed - const params = new URLSearchParams({ permission_id: permission.id, display_name: newDisplay }) + 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: 'PUT' }) } else { // No changes @@ -655,13 +662,15 @@ async function submitDialog() { .catch(e => { authStore.showMessage(e.message || 'Failed to update permission', 'error') }) - return // Don't call closeDialog() again else if (t === 'perm-create') { - const id = dialog.value.data.id?.trim(); if (!id) throw new Error('ID required') + return // Don't call closeDialog() again + } else if (t === 'perm-create') { + const scope = dialog.value.data.scope?.trim(); if (!scope) throw new Error('Scope required') const display_name = dialog.value.data.display_name?.trim(); if (!display_name) throw new Error('Display name required') + const domain = dialog.value.data.domain?.trim() || '' // Close dialog immediately, then perform async operation closeDialog() - apiJson('/auth/api/admin/permissions', { method: 'POST', body: { id, display_name } }) + apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } }) .then(() => { authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500) loadPermissions() @@ -689,7 +698,7 @@ async function submitDialog() { v-else-if="showBackMessage" @reload="reloadPage" /> -
+

{{ pageHeading }}

@@ -700,7 +709,7 @@ async function submitDialog() {
{{ error }}
Display Name -