diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 10272cf..c8c8d33 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -165,9 +165,12 @@ function parseHash() { } } -async function loadOrgs() { - const data = await apiJson('/auth/api/admin/orgs') - orgs.value = Object.entries(data).map(([uuid, o]) => ({ uuid, ...o })) +async function loadAdminData() { + const data = await apiJson('/auth/api/admin/info') + // Convert dicts to arrays with uuid added + orgs.value = Object.entries(data.orgs).map(([uuid, o]) => ({ uuid, ...o })) + permissions.value = Object.entries(data.permissions).map(([uuid, p]) => ({ uuid, ...p })) + oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c })) } // Helper to get users for a role as sorted array of [uuid, user] @@ -191,29 +194,6 @@ function orgUserCount(org) { return Object.keys(org.users).length } -async function loadPermissions() { - const data = await apiJson('/auth/api/admin/permissions') - permissions.value = Object.entries(data).map(([uuid, p]) => ({ uuid, ...p })) -} - -async function loadOidcClients() { - // Only master admins can view OIDC clients - if (!isMasterAdmin.value) { - oidcClients.value = [] - return - } - try { - oidcClients.value = await apiJson('/auth/api/admin/oidc-clients') - } catch (e) { - // If 403, user is not master admin - silently skip - if (e.message?.includes('403') || e.message?.includes('Forbidden')) { - oidcClients.value = [] - } else { - throw e - } - } -} - async function loadUserInfo() { const data = await apiJson('/auth/api/validate', { method: 'POST' }) info.value = data @@ -251,11 +231,9 @@ async function load() { error.value = null try { // Load admin data first - apiJson will handle 401/403 with iframe authentication - await Promise.all([loadOrgs(), loadPermissions()]) + await loadAdminData() // If we get here, user has admin access - now fetch user info for display await loadUserInfo() - // Load OIDC clients after authentication (master admin only) - await loadOidcClients() if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { @@ -281,7 +259,7 @@ function editUserName(user) { openDialog('user-update-name', { user, name: user. async function performOrgDeletion(orgUuid) { await apiJson(`/auth/api/admin/orgs/${orgUuid}`, { method: 'DELETE' }) - await Promise.all([loadOrgs(), loadPermissions()]) + await Promise.all([loadAdminData()]) } function deleteOrg(org) { @@ -339,7 +317,7 @@ async function performUserDeletion(userUuid, userName, orgUuid) { try { await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' }) authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500) - await loadOrgs() + await loadAdminData() window.location.hash = `#org/${orgUuid}` } catch (e) { authStore.showMessage(e.message || 'Failed to delete user', 'error') @@ -353,7 +331,7 @@ async function moveUserToRole(user, targetRoleUuid) { method: 'PATCH', body: { role_uuid: targetRoleUuid } }) - await loadOrgs() + await loadAdminData() } catch (e) { authStore.showMessage(e.message || 'Failed to update user role') } @@ -389,7 +367,7 @@ function deleteRole(role) { apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' }) .then(() => { authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to delete role', 'error') @@ -412,7 +390,7 @@ async function toggleRolePermission(role, pid, checked) { await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, { method }) - await loadOrgs() + await loadAdminData() } catch (e) { authStore.showMessage(e.message || 'Failed to update role permission') role.permissions = prevPermissions // revert @@ -423,7 +401,7 @@ async function toggleRolePermission(role, pid, checked) { async function performPermissionDeletion(permissionUuid) { const params = new URLSearchParams({ permission_uuid: permissionUuid }) await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' }) - await loadPermissions() + await loadAdminData() } function deletePermission(p) { @@ -513,7 +491,7 @@ function deleteOidcClient(client) { async function performOidcClientDeletion(clientUuid, clientName) { await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' }) authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500) - await loadOidcClients() + await loadAdminData() } async function handleOidcSave(data) { @@ -531,7 +509,7 @@ async function handleOidcSave(data) { await apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) } authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) - await loadOidcClients() + await loadAdminData() window.location.hash = '#overview' } catch (e) { authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') @@ -636,7 +614,7 @@ async function toggleOrgPermission(org, permId, checked) { try { const params = new URLSearchParams({ permission_uuid: permId }) await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' }) - await loadOrgs() + await loadAdminData() } catch (e) { authStore.showMessage(e.message || 'Failed to update organization permission', 'error') org.permissions = prev // revert @@ -746,7 +724,7 @@ function handlePanelNavigateOut(direction) { } async function refreshUserDetail() { - await loadOrgs() + await loadAdminData() if (selectedUser.value) { try { userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`) @@ -772,7 +750,7 @@ async function submitDialog() { apiJson('/auth/api/admin/orgs', { method: 'POST', body: { display_name: name, permissions: [] } }) .then(() => { authStore.showMessage(`Organization "${name}" created.`, 'success', 2500) - Promise.all([loadOrgs(), loadPermissions()]) + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to create organization', 'error') @@ -786,7 +764,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } }) .then(() => { authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to update organization', 'error') @@ -800,7 +778,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/orgs/${org.uuid}/roles`, { method: 'POST', body: { display_name: name, permissions: [] } }) .then(() => { authStore.showMessage(`Role "${name}" created.`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to create role', 'error') @@ -828,7 +806,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/orgs/${org.uuid}/users`, { method: 'POST', body: { display_name: name, role: role.display_name } }) .then(() => { authStore.showMessage(`User "${name}" added to ${role.display_name} role.`, 'success', 2500) - loadOrgs() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to add user', 'error') @@ -874,7 +852,7 @@ async function submitDialog() { apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' }) .then(() => { authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500) - loadPermissions() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to update permission', 'error') @@ -890,7 +868,7 @@ async function submitDialog() { apiJson('/auth/api/admin/permissions', { method: 'POST', body: { scope, display_name, domain: domain || undefined } }) .then(() => { authStore.showMessage(`Permission "${display_name}" created.`, 'success', 2500) - loadPermissions() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || 'Failed to create permission', 'error') @@ -915,7 +893,7 @@ async function submitDialog() { req .then(() => { authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) - loadOidcClients() + loadAdminData() }) .catch(e => { authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 5eafa59..075becc 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -25,7 +25,9 @@ from paskia.util import ( ) from paskia.util.apistructs import ( ApiAaguidInfo, + ApiAdminInfo, ApiCreateLinkResponse, + ApiOidcClient, ApiOrg, ApiOrgResponse, ApiPermission, @@ -82,31 +84,6 @@ async def adminapp(request: Request, auth=AUTH_COOKIE): # -------------------- Organizations -------------------- -@app.get("/orgs") -async def admin_list_orgs(request: Request, auth=AUTH_COOKIE): - ctx = await authz.verify( - auth, - ["auth:admin", "auth:org:admin"], - match=permutil.has_any, - host=request.headers.get("host"), - ) - orgs = list(db.data().orgs.values()) - if not master_admin(ctx): - # Org admins can only see their own organization - orgs = [o for o in orgs if o.uuid == ctx.org.uuid] - - def org_to_dict(o): - roles = o.roles - return ApiOrgResponse( - org=ApiOrg.from_db(o), - permissions={p.uuid: p for p in o.permissions}, - roles={r.uuid: r for r in roles}, - users={u.uuid: u for r in roles for u in r.users}, - ) - - return MsgspecResponse({o.uuid: org_to_dict(o) for o in orgs}) - - @app.post("/orgs") async def admin_create_org( request: Request, payload: dict = Body(...), auth=AUTH_COOKIE @@ -823,16 +800,62 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> ) -@app.get("/permissions") -async def admin_list_permissions(request: Request, auth=AUTH_COOKIE): +@app.get("/info") +async def admin_info(request: Request, auth=AUTH_COOKIE): ctx = await authz.verify( auth, ["auth:admin", "auth:org:admin"], match=permutil.has_any, host=request.headers.get("host"), ) + + # Orgs + orgs = list(db.data().orgs.values()) + if not master_admin(ctx): + # Org admins can only see their own organization + orgs = [o for o in orgs if o.uuid == ctx.org.uuid] + + def org_to_dict(o): + roles = o.roles + return ApiOrgResponse( + org=ApiOrg.from_db(o), + permissions={p.uuid: p for p in o.permissions}, + roles={r.uuid: r for r in roles}, + users={u.uuid: u for r in roles for u in r.users}, + ) + + orgs_dict = {o.uuid: org_to_dict(o) for o in orgs} + + # Permissions perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions - return MsgspecResponse({p.uuid: ApiPermission.from_db(p) for p in perms}) + perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms} + + # OIDC Clients (master admin only) + oidc_clients_dict = {} + if master_admin(ctx): + clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid) + sessions = db.data().sessions + # Count active sessions per client + client_session_counts = {} + for session in sessions.values(): + if session.client_uuid: + client_session_counts[session.client_uuid] = ( + client_session_counts.get(session.client_uuid, 0) + 1 + ) + oidc_clients_dict = { + client.uuid: ApiOidcClient.from_db( + client, client_session_counts.get(client.uuid, 0) + ) + for client in clients + } + + return MsgspecResponse( + ApiAdminInfo( + orgs=orgs_dict, + permissions=perms_dict, + oidc_clients=oidc_clients_dict, + ) + ) @app.post("/permissions") @@ -937,45 +960,6 @@ async def admin_delete_permission( # -------------------- OIDC Clients -------------------- -@app.get("/oidc-clients") -async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE): - """List all OIDC clients (master admin only).""" - ctx = await authz.verify( - auth, - ["auth:admin"], - host=request.headers.get("host"), - match=permutil.has_all, - ) - if not master_admin(ctx): - raise authz.AuthException( - status_code=403, - detail="Only master admin can manage OIDC clients", - mode="forbidden", - ) - - clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid) - sessions = db.data().sessions - # Count active sessions per client - client_session_counts = {} - for session in sessions.values(): - if session.client_uuid: - client_session_counts[session.client_uuid] = ( - client_session_counts.get(session.client_uuid, 0) + 1 - ) - return MsgspecResponse( - [ - { - "uuid": str(client.uuid), - "name": client.name, - "redirect_uris": client.redirect_uris, - "backchannel_logout_uri": client.backchannel_logout_uri, - "active_sessions": client_session_counts.get(client.uuid, 0), - } - for client in clients - ] - ) - - @app.post("/oidc-clients") async def admin_create_oidc_client( request: Request, diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py index 09c5e0c..4ff59d9 100644 --- a/paskia/util/apistructs.py +++ b/paskia/util/apistructs.py @@ -66,6 +66,24 @@ class ApiPermission(msgspec.Struct, kw_only=True): ) +class ApiOidcClient(msgspec.Struct, kw_only=True): + """OIDC Client for API responses.""" + + name: str + redirect_uris: list[str] + backchannel_logout_uri: str | None = None + active_sessions: int = 0 + + @classmethod + def from_db(cls, c, active_sessions: int = 0): + return cls( + name=c.name, + redirect_uris=c.redirect_uris, + backchannel_logout_uri=c.backchannel_logout_uri, + active_sessions=active_sessions, + ) + + class ApiAaguidInfo(msgspec.Struct, kw_only=True, omit_defaults=True): """AAGUID information for authenticators.""" @@ -213,3 +231,11 @@ class ApiValidateResponse(msgspec.Struct): valid: bool renewed: bool ctx: ApiSessionContext + + +class ApiAdminInfo(msgspec.Struct, kw_only=True): + """Combined admin info response.""" + + orgs: dict[UUID, ApiOrgResponse] + permissions: dict[UUID, ApiPermission] + oidc_clients: dict[UUID, ApiOidcClient] = {} diff --git a/tests/test_admin.py b/tests/test_admin.py index 9cc8b41..e26878d 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -179,7 +179,7 @@ class TestExceptionHandlers: async def test_auth_exception_handler(self, client: httpx.AsyncClient): """AuthException should return proper JSON with auth info.""" # Accessing admin without auth triggers AuthException - response = await client.get("/auth/api/admin/orgs") + response = await client.get("/auth/api/admin/info") assert response.status_code == 401 data = response.json() assert "detail" in data @@ -216,7 +216,7 @@ class TestAdminOrganizations: @pytest.mark.asyncio async def test_list_orgs_requires_auth(self, client: httpx.AsyncClient): """List orgs without auth should return 401.""" - response = await client.get("/auth/api/admin/orgs") + response = await client.get("/auth/api/admin/info") assert response.status_code == 401 @pytest.mark.asyncio @@ -225,7 +225,7 @@ class TestAdminOrganizations: ): """List orgs without admin permission should return 403.""" response = await client.get( - "/auth/api/admin/orgs", + "/auth/api/admin/info", headers={ **auth_headers(regular_session_token), "Host": "localhost:4401", @@ -239,15 +239,18 @@ class TestAdminOrganizations: ): """Admin user should be able to list organizations.""" response = await client.get( - "/auth/api/admin/orgs", + "/auth/api/admin/info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert isinstance(data, dict) - assert len(data) >= 1 + assert "orgs" in data + orgs_data = data["orgs"] + assert isinstance(orgs_data, dict) + assert len(orgs_data) >= 1 # Check org structure - org_data = list(data.values())[0] + org_data = list(orgs_data.values())[0] assert "org" in org_data org = org_data["org"] assert "uuid" in org @@ -265,13 +268,13 @@ class TestAdminOrganizations: ): """Org admin should only see their own organization.""" response = await client.get( - "/auth/api/admin/orgs", + "/auth/api/admin/info", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() # Should only see their own org, not the second org - org_uuids = [org_data["org"]["uuid"] for org_data in data.values()] + org_uuids = [org_data["org"]["uuid"] for org_data in data["orgs"].values()] assert str(test_org.uuid) in org_uuids @pytest.mark.asyncio @@ -1396,14 +1399,17 @@ class TestAdminPermissions: ): """Admin should be able to list all permissions.""" response = await client.get( - "/auth/api/admin/permissions", + "/auth/api/admin/info", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() assert isinstance(data, dict) + assert "permissions" in data + permissions_data = data["permissions"] + assert isinstance(permissions_data, dict) # Should include at least auth:admin - perm_scopes = [p["scope"] for p in data.values()] + perm_scopes = [p["scope"] for p in permissions_data.values()] assert "auth:admin" in perm_scopes @pytest.mark.asyncio @@ -1416,13 +1422,13 @@ class TestAdminPermissions: ): """Org admin should only see permissions their org can grant.""" response = await client.get( - "/auth/api/admin/permissions", + "/auth/api/admin/info", headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 data = response.json() # Should only see permissions the org can grant - perm_scopes = [p["scope"] for p in data.values()] + perm_scopes = [p["scope"] for p in data["permissions"].values()] assert grantable_permission.scope in perm_scopes # test_org CAN grant auth:admin (it's in org.permissions), so org admin sees it assert "auth:admin" in perm_scopes