Combined admin/info endpoint, replaces old separate endpoints.
This commit is contained in:
@@ -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')
|
||||
|
||||
+51
-67
@@ -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,
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
+18
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user