Better handling of Org Admin permission. More guardrails for Master Admin not locking himself out by changes. Admin app UI improvements.

This commit is contained in:
2026-01-23 15:11:01 +00:00
parent 3430c7f0cf
commit 2c783498a4
8 changed files with 234 additions and 52 deletions
+35 -7
View File
@@ -11,9 +11,10 @@ import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
import AdminUserDetail from '@/admin/AdminUserDetail.vue' import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson } from '@/utils/api'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
@@ -64,8 +65,8 @@ function handleGlobalClick(e) {
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', handleGlobalClick) document.addEventListener('click', handleGlobalClick)
window.addEventListener('hashchange', parseHash) window.addEventListener('hashchange', parseHash)
const settings = await getSettings() await authStore.loadSettings()
if (settings?.rp_name) document.title = settings.rp_name + ' Admin' if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
await load() await load()
}) })
@@ -418,7 +419,7 @@ async function toggleOrgPermission(org, permId, checked) {
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
await loadOrgs() await loadOrgs()
} catch (e) { } catch (e) {
authStore.showMessage(e.message || 'Failed to update organization permission') authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
org.permissions = prev // revert org.permissions = prev // revert
} }
} }
@@ -680,7 +681,17 @@ async function submitDialog() {
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'confirm') { } else if (t === 'confirm') {
const action = dialog.value.data.action; if (action) await action() const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage)
closeDialog()
if (action) {
try {
await action()
} catch (e) {
authStore.showMessage(e.message || 'Action failed', 'error')
}
}
return // Already closed
} }
closeDialog() closeDialog()
} catch (e) { } catch (e) {
@@ -698,6 +709,18 @@ async function submitDialog() {
v-else-if="showBackMessage" v-else-if="showBackMessage"
@reload="reloadPage" @reload="reloadPage"
/> />
<!-- Access denied: authenticated but not admin, or error occurred -->
<div v-else-if="error || (authenticated && !isGlobalAdmin && !isOrgAdmin)" class="access-denied-container">
<div class="access-denied-content">
<h2> Access Denied</h2>
<p v-if="error" class="error-detail">{{ error }}</p>
<p v-else class="error-detail">You do not have admin permissions for this application.</p>
<div class="button-row">
<button class="btn-secondary" @click="goBack">Back</button>
<button class="btn-primary" @click="reloadPage">Reload Page</button>
</div>
</div>
</div>
<section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin"> <section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
<header class="view-header"> <header class="view-header">
<h1>{{ pageHeading }}</h1> <h1>{{ pageHeading }}</h1>
@@ -706,8 +729,7 @@ async function submitDialog() {
<section class="section-block admin-section"> <section class="section-block admin-section">
<div class="section-body admin-section-body"> <div class="section-body admin-section-body">
<div v-if="error" class="surface surface--tight error">{{ error }}</div> <div class="admin-panels">
<div v-else class="admin-panels">
<AdminOverview <AdminOverview
v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)" v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)"
ref="adminOverviewRef" ref="adminOverviewRef"
@@ -772,6 +794,7 @@ async function submitDialog() {
<AdminDialogs <AdminDialogs
:dialog="dialog" :dialog="dialog"
:permission-id-pattern="PERMISSION_ID_PATTERN" :permission-id-pattern="PERMISSION_ID_PATTERN"
:settings="authStore.settings"
@submit-dialog="submitDialog" @submit-dialog="submitDialog"
@close-dialog="closeDialog" @close-dialog="closeDialog"
/> />
@@ -784,4 +807,9 @@ async function submitDialog() {
.admin-section { margin-top: var(--space-xl); } .admin-section { margin-top: var(--space-xl); }
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); } .admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); } .admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
.access-denied-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 2rem; }
.access-denied-content { text-align: center; max-width: 480px; }
.access-denied-content h2 { margin: 0 0 1rem; color: var(--color-heading); font-size: 1.5rem; }
.access-denied-content .error-detail { margin: 0 0 1.5rem; color: var(--color-text-muted); }
.access-denied-content .button-row { display: flex; gap: 0.75rem; justify-content: center; }
</style> </style>
+7 -4
View File
@@ -1,15 +1,18 @@
<script setup> <script setup>
import { computed } from 'vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import NameEditForm from '@/components/NameEditForm.vue' import NameEditForm from '@/components/NameEditForm.vue'
const props = defineProps({ const props = defineProps({
dialog: Object, dialog: Object,
PERMISSION_ID_PATTERN: String PERMISSION_ID_PATTERN: String,
settings: Object
}) })
const emit = defineEmits(['submitDialog', 'closeDialog']) const emit = defineEmits(['submitDialog', 'closeDialog'])
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
</script> </script>
<template> <template>
@@ -75,11 +78,11 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
<label>Permission Scope <label>Permission Scope
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" /> <input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
</label> </label>
<label>Domain Scope <span class="optional">(optional)</span> <p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
<label>Domain Scope
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" /> <input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
</label> </label>
<p class="small muted">If set, this permission only applies when accessed from the specified domain. Must be the RP ID or a subdomain of it.</p> <p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
<p class="small muted">The permission scope is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
</template> </template>
<template v-else-if="dialog.type==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
+5 -3
View File
@@ -26,16 +26,18 @@ export const useAuthStore = defineStore('auth', {
setLoading(flag) { setLoading(flag) {
this.isLoading = !!flag this.isLoading = !!flag
}, },
showMessage(message, type = 'info', duration = 3000) { showMessage(message, type = 'info', duration = null) {
// Default duration: 5 seconds for errors, 3 seconds for others
const effectiveDuration = duration ?? (type === 'error' ? 5000 : 3000)
this.status = { this.status = {
message, message,
type, type,
show: true show: true
} }
if (duration > 0) { if (effectiveDuration > 0) {
setTimeout(() => { setTimeout(() => {
this.status.show = false this.status.show = false
}, duration) }, effectiveDuration)
} }
}, },
async setSessionCookie(result) { async setSessionCookie(result) {
+9 -4
View File
@@ -66,20 +66,25 @@ async def bootstrap_system() -> dict:
) )
db.create_permission(perm0) db.create_permission(perm0)
# Create org admin permission - allows managing users within an org
perm_org_admin = Permission(
uuid=uuid7.create(), scope="auth:org:admin", display_name="Org Admin"
)
db.create_permission(perm_org_admin)
org = Org(uuid7.create(), "Organization") org = Org(uuid7.create(), "Organization")
db.create_organization(org) db.create_organization(org)
# After creation, org.permissions now includes the auto-created org admin permission (auth:org:admin) # Allow this org to grant global admin and org admin permissions
# Allow this org to grant global admin explicitly
db.add_permission_to_organization(str(org.uuid), perm0.scope) db.add_permission_to_organization(str(org.uuid), perm0.scope)
db.add_permission_to_organization(str(org.uuid), perm_org_admin.scope)
# Create an Administration role granting both org and global admin # Create an Administration role granting both org and global admin
# Compose permissions for Administration role: global admin + org admin auto-perm
role = Role( role = Role(
uuid7.create(), uuid7.create(),
org.uuid, org.uuid,
"Administration", "Administration",
permissions=[perm0.scope, *org.permissions], permissions=[perm0.scope, perm_org_admin.scope],
) )
db.create_role(role) db.create_role(role)
+10 -23
View File
@@ -790,8 +790,6 @@ class DB:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def create_organization(self, org: Org, actor: str = "system") -> None: def create_organization(self, org: Org, actor: str = "system") -> None:
import uuid7
with self.session(actor): with self.session(actor):
key = str(org.uuid) key = str(org.uuid)
self._data.orgs[key] = _OrgData( self._data.orgs[key] = _OrgData(
@@ -806,30 +804,15 @@ class DB:
p.orgs[key] = True p.orgs[key] = True
break break
# Automatically create or enable the common org admin permission # Automatically allow the org to grant the org admin permission if it exists
org_admin_scope = "auth:org:admin" org_admin_scope = "auth:org:admin"
org_admin_key = None
for pid, p in self._data.permissions.items(): for pid, p in self._data.permissions.items():
if p.scope == org_admin_scope: if p.scope == org_admin_scope:
org_admin_key = pid p.orgs[key] = True
if org_admin_scope not in org.permissions:
org.permissions.append(org_admin_scope)
break break
if org_admin_key is None:
# Create the common org admin permission
org_admin_key = str(uuid7.create())
self._data.permissions[org_admin_key] = _PermissionData(
scope=org_admin_scope,
display_name="Organization Admin",
orgs={key: True},
)
else:
# Ensure this org can grant the org admin permission
self._data.permissions[org_admin_key].orgs[key] = True
# Reflect the org admin permission in the dataclass instance
if org_admin_scope not in org.permissions:
org.permissions.append(org_admin_scope)
def get_organization(self, org_id: str) -> Org: def get_organization(self, org_id: str) -> Org:
with self._lock: with self._lock:
if org_id not in self._data.orgs: if org_id not in self._data.orgs:
@@ -1438,6 +1421,10 @@ class DB:
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
normalized_host = normalize_host(host) normalized_host = normalize_host(host)
# Strip port for domain matching (e.g., localhost:4401 -> localhost)
host_without_port = (
normalized_host.rsplit(":", 1)[0] if normalized_host else None
)
effective_permissions = [] effective_permissions = []
for scope in role_obj.permissions: for scope in role_obj.permissions:
if scope not in org_obj.permissions: if scope not in org_obj.permissions:
@@ -1445,8 +1432,8 @@ class DB:
# Find the permission by scope # Find the permission by scope
for pid, p in self._data.permissions.items(): for pid, p in self._data.permissions.items():
if p.scope == scope: if p.scope == scope:
# Check domain restriction # Check domain restriction (compare without port)
if p.domain is not None and p.domain != normalized_host: if p.domain is not None and p.domain != host_without_port:
continue continue
effective_permissions.append( effective_permissions.append(
Permission( Permission(
+98 -7
View File
@@ -5,10 +5,10 @@ from uuid import UUID, uuid4
from fastapi import Body, FastAPI, HTTPException, Request, Response from fastapi import Body, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from paskia import db
from paskia.authsession import EXPIRES, reset_expires from paskia.authsession import EXPIRES, reset_expires
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia import db
from paskia.util import ( from paskia.util import (
frontend, frontend,
hostutil, hostutil,
@@ -243,9 +243,19 @@ async def admin_remove_org_permission(
request: Request, request: Request,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
await authz.verify( ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
) )
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
if permission_id == "auth:admin" and ctx.org.uuid == org_uuid:
# Check if any other org grants auth:admin that we're a member of
# (we only know our current org, so this effectively means we can't remove it from our own org)
raise ValueError(
"Cannot remove auth:admin from your own organization. "
"This would lock you out of admin access."
)
db.remove_permission_from_organization(str(org_uuid), permission_id) db.remove_permission_from_organization(str(org_uuid), permission_id)
return {"status": "ok"} return {"status": "ok"}
@@ -778,13 +788,84 @@ def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id or a subdomain of it.""" """Validate that domain is rp_id or a subdomain of it."""
if domain is None: if domain is None:
return return
from paskia.globals import global_passkey from paskia.globals import passkey
rp_id = global_passkey.instance.rp_id rp_id = passkey.instance.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"): if domain == rp_id or domain.endswith(f".{rp_id}"):
return return
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
def _check_admin_lockout(
perm_uuid: str, new_domain: str | None, current_host: str | None
) -> None:
"""Check if setting domain on auth:admin would lock out the admin.
Raises ValueError if this change would result in no auth:admin permissions
being accessible from the current host.
"""
from paskia.util.hostutil import normalize_host
normalized_host = normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = db.list_permissions()
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible
for p in admin_perms:
# If this is the permission being modified, use the new domain
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
# No domain restriction = accessible from anywhere
if domain is None:
return
# Domain matches current host
if host_without_port and domain == host_without_port:
return
# No accessible auth:admin permission would remain
raise ValueError( raise ValueError(
f"Domain '{domain}' must be the same as or a subdomain of rp_id '{rp_id}'" "Cannot set this domain restriction: it would lock you out of admin access. "
"Ensure at least one auth:admin permission remains accessible from your current host."
)
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
"""Check if deleting an auth:admin permission would lock out the admin.
Raises ValueError if this deletion would result in no auth:admin permissions
being accessible from the current host.
"""
from paskia.util.hostutil import normalize_host
normalized_host = normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = db.list_permissions()
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
# Check if at least one auth:admin would remain accessible after deletion
for p in admin_perms:
# Skip the permission being deleted
if str(p.uuid) == perm_uuid:
continue
# No domain restriction = accessible from anywhere
if p.domain is None:
return
# Domain matches current host
if host_without_port and p.domain == host_without_port:
return
# No accessible auth:admin permission would remain
raise ValueError(
"Cannot delete this permission: it would lock you out of admin access. "
"Ensure at least one auth:admin permission remains accessible from your current host."
) )
@@ -822,6 +903,7 @@ async def admin_create_permission(
max_age="5m", max_age="5m",
) )
import uuid7 import uuid7
from ..db import Permission as PermDC from ..db import Permission as PermDC
scope = payload.get("scope") or payload.get( scope = payload.get("scope") or payload.get(
@@ -873,6 +955,10 @@ async def admin_update_permission(
querysafe.assert_safe(new_scope, field="scope") querysafe.assert_safe(new_scope, field="scope")
_validate_permission_domain(domain_value) _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"))
from ..db import Permission as PermDC from ..db import Permission as PermDC
db.update_permission( db.update_permission(
@@ -921,6 +1007,11 @@ async def admin_rename_permission(
else: else:
domain_value = domain if domain else None domain_value = domain if domain else None
_validate_permission_domain(domain_value) _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 # All current backends support rename_permission
db.rename_permission(old_scope, new_scope, display_name, domain_value) db.rename_permission(old_scope, new_scope, display_name, domain_value)
return {"status": "ok"} return {"status": "ok"}
@@ -949,9 +1040,9 @@ async def admin_delete_permission(
# Get the permission to check its scope # Get the permission to check its scope
perm = db.get_permission(perm_identifier) perm = db.get_permission(perm_identifier)
# Sanity check: prevent deleting critical permissions # Sanity check: prevent deleting critical permissions if it would lock out admin
if perm.scope == "auth:admin": if perm.scope == "auth:admin":
raise ValueError("Cannot delete the master admin permission") _check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
db.delete_permission(str(perm.uuid)) db.delete_permission(str(perm.uuid))
return {"status": "ok"} return {"status": "ok"}
+3 -1
View File
@@ -61,6 +61,8 @@ async def migrate_from_sql(
from paskia.db.json import ( from paskia.db.json import (
DB as JSONDB, DB as JSONDB,
)
from paskia.db.json import (
_CredentialData, _CredentialData,
_OrgData, _OrgData,
_PermissionData, _PermissionData,
@@ -99,7 +101,7 @@ async def migrate_from_sql(
org_admin_perm_uuid = str(uuid7.create()) org_admin_perm_uuid = str(uuid7.create())
json_db._data.permissions[org_admin_perm_uuid] = _PermissionData( json_db._data.permissions[org_admin_perm_uuid] = _PermissionData(
scope="auth:org:admin", scope="auth:org:admin",
display_name="Organization Admin", display_name="Org Admin",
orgs={}, orgs={},
) )
+67 -3
View File
@@ -1504,17 +1504,81 @@ class TestAdminPermissions:
assert data["status"] == "ok" assert data["status"] == "ok"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_permission_auth_admin_fails( async def test_delete_permission_auth_admin_last_one_fails(
self, client: httpx.AsyncClient, session_token: str self, client: httpx.AsyncClient, session_token: str
): ):
"""Cannot delete the auth:admin permission.""" """Cannot delete the only auth:admin permission (would lock out admin)."""
response = await client.delete( response = await client.delete(
"/auth/api/admin/permission?permission_id=auth:admin", "/auth/api/admin/permission?permission_id=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
assert "Cannot delete the master admin" in data["detail"] assert "lock you out of admin access" in data["detail"]
@pytest.mark.asyncio
async def test_delete_permission_auth_admin_with_another_succeeds(
self, client: httpx.AsyncClient, session_token: str, test_db: DB
):
"""Can delete an auth:admin permission if another accessible one exists."""
import uuid7
from paskia.db import Permission
# Create a second auth:admin permission (no domain restriction)
perm2 = Permission(
uuid=uuid7.create(), scope="auth:admin", display_name="Secondary Admin"
)
test_db.create_permission(perm2)
# Now we can delete the original one
response = await client.delete(
"/auth/api/admin/permission?permission_id=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.asyncio
async def test_delete_permission_auth_admin_domain_mismatch_fails(
self, client: httpx.AsyncClient, session_token: str, test_db: DB
):
"""Cannot delete auth:admin if remaining one has mismatched domain."""
import uuid7
from paskia.db import Permission
# Create a second auth:admin permission with a different domain
perm2 = Permission(
uuid=uuid7.create(),
scope="auth:admin",
display_name="Other Domain Admin",
domain="other.example.com",
)
test_db.create_permission(perm2)
# Cannot delete the original one because the remaining one is not accessible
response = await client.delete(
"/auth/api/admin/permission?permission_id=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
data = response.json()
assert "lock you out of admin access" in data["detail"]
@pytest.mark.asyncio
async def test_remove_auth_admin_from_own_org_fails(
self, client: httpx.AsyncClient, session_token: str, test_org
):
"""Cannot remove auth:admin permission from your own organization."""
response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
data = response.json()
assert "lock you out of admin access" in data["detail"]
# -------------------- Edge Cases for AuthException in Org-Admin Checks -------------------- # -------------------- Edge Cases for AuthException in Org-Admin Checks --------------------