Restructure admin app separate of user api.
This commit is contained in:
parent
cead912ddc
commit
859cc9ed41
469
passkey/fastapi/admin.py
Normal file
469
passkey/fastapi/admin.py
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
"""Admin sub-application.
|
||||||
|
|
||||||
|
All admin API endpoints previously under /auth/admin/* are now implemented
|
||||||
|
in this standalone FastAPI app which is mounted by the main application at
|
||||||
|
the /auth/admin path prefix. The routes defined here therefore omit the
|
||||||
|
"/auth/admin" prefix and start at root (e.g. "/orgs" becomes
|
||||||
|
"/auth/admin/orgs" once mounted).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from fastapi import Body, Cookie, FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
from ..authsession import expires, get_session
|
||||||
|
from ..globals import db
|
||||||
|
from ..globals import passkey as global_passkey
|
||||||
|
from ..util import passphrase, querysafe, tokens
|
||||||
|
from ..util.tokens import session_key
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(ValueError)
|
||||||
|
async def value_error_handler(_request, exc: ValueError): # pragma: no cover - simple
|
||||||
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_ctx_and_admin_flags(auth_cookie: str):
|
||||||
|
"""Helper to get session context and admin flags from cookie."""
|
||||||
|
if not auth_cookie:
|
||||||
|
raise ValueError("Not authenticated")
|
||||||
|
ctx = await db.instance.get_session_context(session_key(auth_cookie))
|
||||||
|
if not ctx:
|
||||||
|
raise ValueError("Not authenticated")
|
||||||
|
role_perm_ids = set(ctx.role.permissions or [])
|
||||||
|
org_uuid_str = str(ctx.org.uuid)
|
||||||
|
is_global_admin = "auth:admin" in role_perm_ids
|
||||||
|
is_org_admin = f"auth:org:{org_uuid_str}" in role_perm_ids
|
||||||
|
return ctx, is_global_admin, is_org_admin
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@app.get("")
|
||||||
|
async def serve_admin_root(auth=Cookie(None)):
|
||||||
|
"""Serve the admin SPA root if an authenticated session exists.
|
||||||
|
|
||||||
|
Mirrors previous behavior from mainapp. If no valid session, serve the
|
||||||
|
main index.html with 401 so frontend can trigger login flow.
|
||||||
|
"""
|
||||||
|
if auth:
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
s = await get_session(auth)
|
||||||
|
if s.info and s.info.get("type") == "authenticated":
|
||||||
|
return FileResponse(STATIC_DIR / "admin" / "index.html")
|
||||||
|
return FileResponse(
|
||||||
|
STATIC_DIR / "index.html",
|
||||||
|
status_code=401,
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------- Organizations --------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/orgs")
|
||||||
|
async def admin_list_orgs(auth=Cookie(None)):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or is_org_admin):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
orgs = await db.instance.list_organizations()
|
||||||
|
if not is_global_admin: # limit org admin to their own org
|
||||||
|
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||||
|
|
||||||
|
def role_to_dict(r):
|
||||||
|
return {
|
||||||
|
"uuid": str(r.uuid),
|
||||||
|
"org_uuid": str(r.org_uuid),
|
||||||
|
"display_name": r.display_name,
|
||||||
|
"permissions": r.permissions,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def org_to_dict(o):
|
||||||
|
users = await db.instance.get_organization_users(str(o.uuid))
|
||||||
|
return {
|
||||||
|
"uuid": str(o.uuid),
|
||||||
|
"display_name": o.display_name,
|
||||||
|
"permissions": o.permissions,
|
||||||
|
"roles": [role_to_dict(r) for r in o.roles],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"uuid": str(u.uuid),
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"role": role_name,
|
||||||
|
"visits": u.visits,
|
||||||
|
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
||||||
|
}
|
||||||
|
for (u, role_name) in users
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return [await org_to_dict(o) for o in orgs]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/orgs")
|
||||||
|
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
from ..db import Org as OrgDC # local import to avoid cycles
|
||||||
|
|
||||||
|
org_uuid = uuid4()
|
||||||
|
display_name = payload.get("display_name") or "New Organization"
|
||||||
|
permissions = payload.get("permissions") or []
|
||||||
|
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||||
|
await db.instance.create_organization(org)
|
||||||
|
return {"uuid": str(org_uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/orgs/{org_uuid}")
|
||||||
|
async def admin_update_org(
|
||||||
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
|
):
|
||||||
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
from ..db import Org as OrgDC # local import to avoid cycles
|
||||||
|
|
||||||
|
current = await db.instance.get_organization(str(org_uuid))
|
||||||
|
display_name = payload.get("display_name") or current.display_name
|
||||||
|
permissions = payload.get("permissions") or current.permissions or []
|
||||||
|
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||||
|
await db.instance.update_organization(org)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/orgs/{org_uuid}")
|
||||||
|
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||||
|
ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
try:
|
||||||
|
acting_org_uuid = ctx.org.uuid if ctx.org else None
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
acting_org_uuid = None
|
||||||
|
if acting_org_uuid and acting_org_uuid == org_uuid:
|
||||||
|
raise ValueError("Cannot delete the organization you belong to")
|
||||||
|
await db.instance.delete_organization(org_uuid)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/orgs/{org_uuid}/permission")
|
||||||
|
async def admin_add_org_permission(
|
||||||
|
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
|
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/orgs/{org_uuid}/permission")
|
||||||
|
async def admin_remove_org_permission(
|
||||||
|
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
|
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------- Roles --------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/orgs/{org_uuid}/roles")
|
||||||
|
async def admin_create_role(
|
||||||
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
|
role_uuid = uuid4()
|
||||||
|
display_name = payload.get("display_name") or "New Role"
|
||||||
|
permissions = payload.get("permissions") or []
|
||||||
|
org = await db.instance.get_organization(str(org_uuid))
|
||||||
|
grantable = set(org.permissions or [])
|
||||||
|
for pid in permissions:
|
||||||
|
await db.instance.get_permission(pid)
|
||||||
|
if pid not in grantable:
|
||||||
|
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||||
|
role = RoleDC(
|
||||||
|
uuid=role_uuid,
|
||||||
|
org_uuid=org_uuid,
|
||||||
|
display_name=display_name,
|
||||||
|
permissions=permissions,
|
||||||
|
)
|
||||||
|
await db.instance.create_role(role)
|
||||||
|
return {"uuid": str(role_uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/roles/{role_uuid}")
|
||||||
|
async def admin_update_role(
|
||||||
|
role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
role = await db.instance.get_role(role_uuid)
|
||||||
|
if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
|
display_name = payload.get("display_name") or role.display_name
|
||||||
|
permissions = payload.get("permissions") or role.permissions
|
||||||
|
org = await db.instance.get_organization(str(role.org_uuid))
|
||||||
|
grantable = set(org.permissions or [])
|
||||||
|
for pid in permissions:
|
||||||
|
await db.instance.get_permission(pid)
|
||||||
|
if pid not in grantable:
|
||||||
|
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||||
|
updated = RoleDC(
|
||||||
|
uuid=role_uuid,
|
||||||
|
org_uuid=role.org_uuid,
|
||||||
|
display_name=display_name,
|
||||||
|
permissions=permissions,
|
||||||
|
)
|
||||||
|
await db.instance.update_role(updated)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/roles/{role_uuid}")
|
||||||
|
async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
role = await db.instance.get_role(role_uuid)
|
||||||
|
if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
await db.instance.delete_role(role_uuid)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------- Users --------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/orgs/{org_uuid}/users")
|
||||||
|
async def admin_create_user(
|
||||||
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
role_name = payload.get("role")
|
||||||
|
if not display_name or not role_name:
|
||||||
|
raise ValueError("display_name and role are required")
|
||||||
|
from ..db import User as UserDC
|
||||||
|
|
||||||
|
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||||
|
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||||
|
if not role_obj:
|
||||||
|
raise ValueError("Role not found in organization")
|
||||||
|
user_uuid = uuid4()
|
||||||
|
user = UserDC(
|
||||||
|
uuid=user_uuid,
|
||||||
|
display_name=display_name,
|
||||||
|
role_uuid=role_obj.uuid,
|
||||||
|
visits=0,
|
||||||
|
created_at=None,
|
||||||
|
)
|
||||||
|
await db.instance.create_user(user)
|
||||||
|
return {"uuid": str(user_uuid)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||||
|
async def admin_update_user_role(
|
||||||
|
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
new_role = payload.get("role")
|
||||||
|
if not new_role:
|
||||||
|
raise ValueError("role is required")
|
||||||
|
try:
|
||||||
|
user_org, _current_role = await db.instance.get_user_organization(user_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("User not found")
|
||||||
|
if user_org.uuid != org_uuid:
|
||||||
|
raise ValueError("User does not belong to this organization")
|
||||||
|
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||||
|
if not any(r.display_name == new_role for r in roles):
|
||||||
|
raise ValueError("Role not found in organization")
|
||||||
|
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/users/{user_uuid}/create-link")
|
||||||
|
async def admin_create_user_registration_link(user_uuid: UUID, auth=Cookie(None)):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
try:
|
||||||
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
token = passphrase.generate()
|
||||||
|
await db.instance.create_session(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
key=tokens.reset_key(token),
|
||||||
|
expires=expires(),
|
||||||
|
info={"type": "device addition", "created_by_admin": True},
|
||||||
|
)
|
||||||
|
origin = global_passkey.instance.origin
|
||||||
|
url = f"{origin}/auth/{token}"
|
||||||
|
return {"url": url, "expires": expires().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/users/{user_uuid}")
|
||||||
|
async def admin_get_user_detail(user_uuid: UUID, auth=Cookie(None)):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
try:
|
||||||
|
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||||
|
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
|
creds: list[dict] = []
|
||||||
|
aaguids: set[str] = set()
|
||||||
|
for cid in cred_ids:
|
||||||
|
try:
|
||||||
|
c = await db.instance.get_credential_by_id(cid)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
aaguid_str = str(c.aaguid)
|
||||||
|
aaguids.add(aaguid_str)
|
||||||
|
creds.append(
|
||||||
|
{
|
||||||
|
"credential_uuid": str(c.uuid),
|
||||||
|
"aaguid": aaguid_str,
|
||||||
|
"created_at": c.created_at.isoformat(),
|
||||||
|
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||||
|
"last_verified": c.last_verified.isoformat()
|
||||||
|
if c.last_verified
|
||||||
|
else None,
|
||||||
|
"sign_count": c.sign_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
from .. import aaguid as aaguid_mod
|
||||||
|
|
||||||
|
aaguid_info = aaguid_mod.filter(aaguids)
|
||||||
|
return {
|
||||||
|
"display_name": user.display_name,
|
||||||
|
"org": {"display_name": user_org.display_name},
|
||||||
|
"role": role_name,
|
||||||
|
"visits": user.visits,
|
||||||
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||||
|
"credentials": creds,
|
||||||
|
"aaguid_info": aaguid_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/users/{user_uuid}/display-name")
|
||||||
|
async def admin_update_user_display_name(
|
||||||
|
user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
|
):
|
||||||
|
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
try:
|
||||||
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
||||||
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
|
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")
|
||||||
|
await db.instance.update_user_display_name(user_uuid, new_name)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------- Permissions (global) --------------------
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/permissions")
|
||||||
|
async def admin_list_permissions(auth=Cookie(None)):
|
||||||
|
_, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not (is_global_admin or is_org_admin):
|
||||||
|
raise ValueError("Insufficient permissions")
|
||||||
|
perms = await db.instance.list_permissions()
|
||||||
|
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/permissions")
|
||||||
|
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
|
perm_id = payload.get("id")
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
if not perm_id or not display_name:
|
||||||
|
raise ValueError("id and display_name are required")
|
||||||
|
querysafe.assert_safe(perm_id, field="id")
|
||||||
|
await db.instance.create_permission(PermDC(id=perm_id, display_name=display_name))
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/permission")
|
||||||
|
async def admin_update_permission(
|
||||||
|
permission_id: str, display_name: str, auth=Cookie(None)
|
||||||
|
):
|
||||||
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
|
if not display_name:
|
||||||
|
raise ValueError("display_name is required")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
|
await db.instance.update_permission(
|
||||||
|
PermDC(id=permission_id, display_name=display_name)
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/permission/rename")
|
||||||
|
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
old_id = payload.get("old_id")
|
||||||
|
new_id = payload.get("new_id")
|
||||||
|
display_name = payload.get("display_name")
|
||||||
|
if not old_id or not new_id:
|
||||||
|
raise ValueError("old_id and new_id required")
|
||||||
|
querysafe.assert_safe(old_id, field="old_id")
|
||||||
|
querysafe.assert_safe(new_id, field="new_id")
|
||||||
|
if display_name is None:
|
||||||
|
perm = await db.instance.get_permission(old_id)
|
||||||
|
display_name = perm.display_name
|
||||||
|
rename_fn = getattr(db.instance, "rename_permission", None)
|
||||||
|
if not rename_fn:
|
||||||
|
raise ValueError("Permission renaming not supported by this backend")
|
||||||
|
await rename_fn(old_id, new_id, display_name)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/permission")
|
||||||
|
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||||
|
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||||
|
if not is_global_admin:
|
||||||
|
raise ValueError("Global admin required")
|
||||||
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
|
await db.instance.delete_permission(permission_id)
|
||||||
|
return {"status": "ok"}
|
@ -1,25 +1,26 @@
|
|||||||
"""
|
"""Core (non-admin) HTTP API routes.
|
||||||
API endpoints for user management and session handling.
|
|
||||||
|
|
||||||
This module contains all the HTTP API endpoints for:
|
Contains endpoints for:
|
||||||
- User information retrieval
|
* Session validation
|
||||||
- User credentials management
|
* Basic runtime settings
|
||||||
- Session token validation and refresh
|
* Authenticated user info & credential listing
|
||||||
- Login/logout functionality
|
* User self-service updates (display name)
|
||||||
|
* Logout / set-session / credential deletion
|
||||||
|
|
||||||
|
Admin endpoints have been moved to `adminapp.py` and mounted at
|
||||||
|
`/auth/admin` by the main application.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
|
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
from passkey.util import passphrase
|
|
||||||
|
|
||||||
from .. import aaguid
|
from .. import aaguid
|
||||||
from ..authsession import delete_credential, expires, get_reset, get_session
|
from ..authsession import delete_credential, get_reset, get_session
|
||||||
from ..globals import db
|
from ..globals import db
|
||||||
from ..globals import passkey as global_passkey
|
from ..globals import passkey as global_passkey
|
||||||
from ..util import querysafe, tokens
|
from ..util import tokens
|
||||||
from ..util.tokens import session_key
|
from ..util.tokens import session_key
|
||||||
from . import authz, session
|
from . import authz, session
|
||||||
|
|
||||||
@ -27,55 +28,24 @@ bearer_auth = HTTPBearer(auto_error=True)
|
|||||||
|
|
||||||
|
|
||||||
def register_api_routes(app: FastAPI):
|
def register_api_routes(app: FastAPI):
|
||||||
"""Register all API routes on the FastAPI app."""
|
"""Register non-admin API routes on the FastAPI app."""
|
||||||
|
|
||||||
async def _get_ctx_and_admin_flags(auth_cookie: str):
|
|
||||||
"""Helper to get session context and admin flags from cookie."""
|
|
||||||
if not auth_cookie:
|
|
||||||
raise ValueError("Not authenticated")
|
|
||||||
ctx = await db.instance.get_session_context(session_key(auth_cookie))
|
|
||||||
if not ctx:
|
|
||||||
raise ValueError("Not authenticated")
|
|
||||||
role_perm_ids = set(ctx.role.permissions or [])
|
|
||||||
org_uuid_str = str(ctx.org.uuid)
|
|
||||||
is_global_admin = "auth:admin" in role_perm_ids
|
|
||||||
is_org_admin = f"auth:org:{org_uuid_str}" in role_perm_ids
|
|
||||||
return ctx, is_global_admin, is_org_admin
|
|
||||||
|
|
||||||
@app.post("/auth/validate")
|
@app.post("/auth/validate")
|
||||||
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
||||||
"""Lightweight token validation endpoint.
|
|
||||||
|
|
||||||
Query Params:
|
|
||||||
- perm: repeated permission IDs the caller must possess (ALL required)
|
|
||||||
"""
|
|
||||||
|
|
||||||
s = await authz.verify(auth, perm)
|
s = await authz.verify(auth, perm)
|
||||||
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
||||||
|
|
||||||
@app.get("/auth/settings")
|
@app.get("/auth/settings")
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
"""Return server runtime settings safe for public consumption.
|
|
||||||
|
|
||||||
Provides relying party metadata used by the frontend to brand UI.
|
|
||||||
"""
|
|
||||||
pk = global_passkey.instance
|
pk = global_passkey.instance
|
||||||
return {
|
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
|
||||||
"rp_id": pk.rp_id,
|
|
||||||
"rp_name": pk.rp_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.post("/auth/user-info")
|
@app.post("/auth/user-info")
|
||||||
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
||||||
"""Get user information.
|
|
||||||
|
|
||||||
- For authenticated sessions: return full context (org/role/permissions/credentials)
|
|
||||||
- For reset tokens: return only basic user information to drive reset flow
|
|
||||||
"""
|
|
||||||
authenticated = False
|
authenticated = False
|
||||||
try:
|
try:
|
||||||
if reset:
|
if reset:
|
||||||
if not passphrase.is_well_formed(reset):
|
if not tokens.passphrase.is_well_formed(reset): # type: ignore[attr-defined]
|
||||||
raise ValueError("Invalid reset token")
|
raise ValueError("Invalid reset token")
|
||||||
s = await get_reset(reset)
|
s = await get_reset(reset)
|
||||||
else:
|
else:
|
||||||
@ -88,29 +58,23 @@ def register_api_routes(app: FastAPI):
|
|||||||
|
|
||||||
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
u = await db.instance.get_user_by_uuid(s.user_uuid)
|
||||||
|
|
||||||
# Minimal response for reset tokens
|
if not authenticated: # minimal response for reset tokens
|
||||||
if not authenticated:
|
|
||||||
return {
|
return {
|
||||||
"authenticated": False,
|
"authenticated": False,
|
||||||
"session_type": s.info.get("type"),
|
"session_type": s.info.get("type"),
|
||||||
"user": {
|
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
||||||
"user_uuid": str(u.uuid),
|
|
||||||
"user_name": u.display_name,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Full context for authenticated sessions
|
|
||||||
assert authenticated and auth is not None
|
assert authenticated and auth is not None
|
||||||
ctx = await db.instance.get_session_context(session_key(auth))
|
ctx = await db.instance.get_session_context(session_key(auth))
|
||||||
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
|
||||||
|
|
||||||
credentials: list[dict] = []
|
credentials: list[dict] = []
|
||||||
user_aaguids: set[str] = set()
|
user_aaguids: set[str] = set()
|
||||||
for cred_id in credential_ids:
|
for cred_id in credential_ids:
|
||||||
try:
|
try:
|
||||||
c = await db.instance.get_credential_by_id(cred_id)
|
c = await db.instance.get_credential_by_id(cred_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue # Skip dangling IDs
|
continue
|
||||||
aaguid_str = str(c.aaguid)
|
aaguid_str = str(c.aaguid)
|
||||||
user_aaguids.add(aaguid_str)
|
user_aaguids.add(aaguid_str)
|
||||||
credentials.append(
|
credentials.append(
|
||||||
@ -126,8 +90,7 @@ def register_api_routes(app: FastAPI):
|
|||||||
"is_current_session": s.credential_uuid == c.uuid,
|
"is_current_session": s.credential_uuid == c.uuid,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
credentials.sort(key=lambda cred: cred["created_at"])
|
||||||
credentials.sort(key=lambda cred: cred["created_at"]) # chronological
|
|
||||||
aaguid_info = aaguid.filter(user_aaguids)
|
aaguid_info = aaguid.filter(user_aaguids)
|
||||||
|
|
||||||
role_info = None
|
role_info = None
|
||||||
@ -147,12 +110,11 @@ def register_api_routes(app: FastAPI):
|
|||||||
"permissions": ctx.org.permissions,
|
"permissions": ctx.org.permissions,
|
||||||
}
|
}
|
||||||
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
effective_permissions = [p.id for p in (ctx.permissions or [])]
|
||||||
is_global_admin = "auth:admin" in role_info["permissions"]
|
is_global_admin = "auth:admin" in (role_info["permissions"] or [])
|
||||||
is_org_admin = (
|
if org_info:
|
||||||
f"auth:org:{org_info['uuid']}" in role_info["permissions"]
|
is_org_admin = f"auth:org:{org_info['uuid']}" in (
|
||||||
if org_info
|
role_info["permissions"] or []
|
||||||
else False
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
@ -173,335 +135,8 @@ def register_api_routes(app: FastAPI):
|
|||||||
"aaguid_info": aaguid_info,
|
"aaguid_info": aaguid_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
# -------------------- Admin API: Organizations --------------------
|
|
||||||
|
|
||||||
@app.get("/auth/admin/orgs")
|
|
||||||
async def admin_list_orgs(auth=Cookie(None)):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or is_org_admin):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
orgs = await db.instance.list_organizations()
|
|
||||||
# If only org admin, filter to their org
|
|
||||||
if not is_global_admin:
|
|
||||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
|
||||||
|
|
||||||
def role_to_dict(r):
|
|
||||||
return {
|
|
||||||
"uuid": str(r.uuid),
|
|
||||||
"org_uuid": str(r.org_uuid),
|
|
||||||
"display_name": r.display_name,
|
|
||||||
"permissions": r.permissions,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def org_to_dict(o):
|
|
||||||
# Fetch users for each org
|
|
||||||
users = await db.instance.get_organization_users(str(o.uuid))
|
|
||||||
return {
|
|
||||||
"uuid": str(o.uuid),
|
|
||||||
"display_name": o.display_name,
|
|
||||||
"permissions": o.permissions,
|
|
||||||
"roles": [role_to_dict(r) for r in o.roles],
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"uuid": str(u.uuid),
|
|
||||||
"display_name": u.display_name,
|
|
||||||
"role": role_name,
|
|
||||||
"visits": u.visits,
|
|
||||||
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
|
|
||||||
}
|
|
||||||
for (u, role_name) in users
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
return [await org_to_dict(o) for o in orgs]
|
|
||||||
|
|
||||||
@app.post("/auth/admin/orgs")
|
|
||||||
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles in typing
|
|
||||||
|
|
||||||
org_uuid = uuid4()
|
|
||||||
display_name = payload.get("display_name") or "New Organization"
|
|
||||||
permissions = payload.get("permissions") or []
|
|
||||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
|
||||||
await db.instance.create_organization(org)
|
|
||||||
return {"uuid": str(org_uuid)}
|
|
||||||
|
|
||||||
@app.put("/auth/admin/orgs/{org_uuid}")
|
|
||||||
async def admin_update_org(
|
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
|
||||||
):
|
|
||||||
# Only global admins can modify org definitions (simpler rule)
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
|
||||||
|
|
||||||
current = await db.instance.get_organization(str(org_uuid))
|
|
||||||
display_name = payload.get("display_name") or current.display_name
|
|
||||||
permissions = payload.get("permissions") or current.permissions or []
|
|
||||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
|
||||||
await db.instance.update_organization(org)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.delete("/auth/admin/orgs/{org_uuid}")
|
|
||||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
|
||||||
ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
# Org admins cannot delete at all (avoid self-lockout)
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
# Prevent deleting the organization that the acting global admin currently belongs to
|
|
||||||
# if that deletion would remove their effective access (e.g., last org granting auth/admin)
|
|
||||||
try:
|
|
||||||
acting_org_uuid = ctx.org.uuid if ctx.org else None
|
|
||||||
except Exception:
|
|
||||||
acting_org_uuid = None
|
|
||||||
if acting_org_uuid and acting_org_uuid == org_uuid:
|
|
||||||
# Never allow deletion of the caller's own organization to avoid immediate account deletion.
|
|
||||||
raise ValueError("Cannot delete the organization you belong to")
|
|
||||||
await db.instance.delete_organization(org_uuid)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Manage an org's grantable permissions (query param for permission_id)
|
|
||||||
@app.post("/auth/admin/orgs/{org_uuid}/permission")
|
|
||||||
async def admin_add_org_permission(
|
|
||||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
|
||||||
):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
|
||||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.delete("/auth/admin/orgs/{org_uuid}/permission")
|
|
||||||
async def admin_remove_org_permission(
|
|
||||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
|
||||||
):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
|
||||||
await db.instance.remove_permission_from_organization(
|
|
||||||
str(org_uuid), permission_id
|
|
||||||
)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# -------------------- Admin API: Roles --------------------
|
|
||||||
|
|
||||||
@app.post("/auth/admin/orgs/{org_uuid}/roles")
|
|
||||||
async def admin_create_role(
|
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
|
||||||
):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
from ..db import Role as RoleDC
|
|
||||||
|
|
||||||
role_uuid = uuid4()
|
|
||||||
display_name = payload.get("display_name") or "New Role"
|
|
||||||
permissions = payload.get("permissions") or []
|
|
||||||
# Validate that permissions exist and are allowed by org
|
|
||||||
org = await db.instance.get_organization(str(org_uuid))
|
|
||||||
grantable = set(org.permissions or [])
|
|
||||||
for pid in permissions:
|
|
||||||
await db.instance.get_permission(pid) # raises if not found
|
|
||||||
if pid not in grantable:
|
|
||||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
|
||||||
role = RoleDC(
|
|
||||||
uuid=role_uuid,
|
|
||||||
org_uuid=org_uuid,
|
|
||||||
display_name=display_name,
|
|
||||||
permissions=permissions,
|
|
||||||
)
|
|
||||||
await db.instance.create_role(role)
|
|
||||||
return {"uuid": str(role_uuid)}
|
|
||||||
|
|
||||||
@app.put("/auth/admin/roles/{role_uuid}")
|
|
||||||
async def admin_update_role(
|
|
||||||
role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
|
||||||
):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
role = await db.instance.get_role(role_uuid)
|
|
||||||
# Only org admins for that org or global admin can update
|
|
||||||
if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
from ..db import Role as RoleDC
|
|
||||||
|
|
||||||
display_name = payload.get("display_name") or role.display_name
|
|
||||||
permissions = payload.get("permissions") or role.permissions
|
|
||||||
# Validate against org grantable permissions
|
|
||||||
org = await db.instance.get_organization(str(role.org_uuid))
|
|
||||||
grantable = set(org.permissions or [])
|
|
||||||
for pid in permissions:
|
|
||||||
await db.instance.get_permission(pid) # raises if not found
|
|
||||||
if pid not in grantable:
|
|
||||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
|
||||||
updated = RoleDC(
|
|
||||||
uuid=role_uuid,
|
|
||||||
org_uuid=role.org_uuid,
|
|
||||||
display_name=display_name,
|
|
||||||
permissions=permissions,
|
|
||||||
)
|
|
||||||
await db.instance.update_role(updated)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/auth/admin/orgs/{org_uuid}/users")
|
|
||||||
async def admin_create_user(
|
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
|
||||||
):
|
|
||||||
"""Create a new user within an organization.
|
|
||||||
|
|
||||||
Body parameters:
|
|
||||||
- display_name: str (required)
|
|
||||||
- role: str (required) display name of existing role in that org
|
|
||||||
"""
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
display_name = payload.get("display_name")
|
|
||||||
role_name = payload.get("role")
|
|
||||||
if not display_name or not role_name:
|
|
||||||
raise ValueError("display_name and role are required")
|
|
||||||
# Validate role exists in org
|
|
||||||
from ..db import User as UserDC # local import to avoid cycles
|
|
||||||
|
|
||||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
|
||||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
|
||||||
if not role_obj:
|
|
||||||
raise ValueError("Role not found in organization")
|
|
||||||
# Create user
|
|
||||||
user_uuid = uuid4()
|
|
||||||
user = UserDC(
|
|
||||||
uuid=user_uuid,
|
|
||||||
display_name=display_name,
|
|
||||||
role_uuid=role_obj.uuid,
|
|
||||||
visits=0,
|
|
||||||
created_at=None,
|
|
||||||
)
|
|
||||||
await db.instance.create_user(user)
|
|
||||||
return {"uuid": str(user_uuid)}
|
|
||||||
|
|
||||||
@app.delete("/auth/admin/roles/{role_uuid}")
|
|
||||||
async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)):
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
role = await db.instance.get_role(role_uuid)
|
|
||||||
if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
await db.instance.delete_role(role_uuid)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# -------------------- Admin API: Users (role management) --------------------
|
|
||||||
|
|
||||||
@app.put("/auth/admin/orgs/{org_uuid}/users/{user_uuid}/role")
|
|
||||||
async def admin_update_user_role(
|
|
||||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
|
||||||
):
|
|
||||||
"""Change a user's role within their organization.
|
|
||||||
|
|
||||||
Body: {"role": "New Role Display Name"}
|
|
||||||
Only global admins or admins of the organization can perform this.
|
|
||||||
"""
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
new_role = payload.get("role")
|
|
||||||
if not new_role:
|
|
||||||
raise ValueError("role is required")
|
|
||||||
# Verify user belongs to this org
|
|
||||||
try:
|
|
||||||
user_org, _current_role = await db.instance.get_user_organization(user_uuid)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("User not found")
|
|
||||||
if user_org.uuid != org_uuid:
|
|
||||||
raise ValueError("User does not belong to this organization")
|
|
||||||
# Ensure role exists in org and update
|
|
||||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
|
||||||
if not any(r.display_name == new_role for r in roles):
|
|
||||||
raise ValueError("Role not found in organization")
|
|
||||||
await db.instance.update_user_role_in_organization(user_uuid, new_role)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/auth/admin/users/{user_uuid}/create-link")
|
|
||||||
async def admin_create_user_registration_link(user_uuid: UUID, auth=Cookie(None)):
|
|
||||||
"""Create a device registration/reset link for a specific user (admin only).
|
|
||||||
|
|
||||||
Returns JSON: {"url": str, "expires": iso8601}
|
|
||||||
"""
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
# Ensure user exists and fetch their org
|
|
||||||
try:
|
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
||||||
|
|
||||||
# Generate human-readable reset token and store as session with reset key
|
|
||||||
token = passphrase.generate()
|
|
||||||
await db.instance.create_session(
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
key=tokens.reset_key(token),
|
|
||||||
expires=expires(),
|
|
||||||
info={"type": "device addition", "created_by_admin": True},
|
|
||||||
)
|
|
||||||
origin = global_passkey.instance.origin
|
|
||||||
url = f"{origin}/auth/{token}"
|
|
||||||
return {"url": url, "expires": expires().isoformat()}
|
|
||||||
|
|
||||||
@app.get("/auth/admin/users/{user_uuid}")
|
|
||||||
async def admin_get_user_detail(user_uuid: UUID, auth=Cookie(None)):
|
|
||||||
"""Get detailed information about a user (admin only)."""
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
try:
|
|
||||||
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
||||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
|
||||||
# Gather credentials
|
|
||||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
|
||||||
creds: list[dict] = []
|
|
||||||
aaguids: set[str] = set()
|
|
||||||
for cid in cred_ids:
|
|
||||||
try:
|
|
||||||
c = await db.instance.get_credential_by_id(cid)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
aaguid_str = str(c.aaguid)
|
|
||||||
aaguids.add(aaguid_str)
|
|
||||||
creds.append(
|
|
||||||
{
|
|
||||||
"credential_uuid": str(c.uuid),
|
|
||||||
"aaguid": aaguid_str,
|
|
||||||
"created_at": c.created_at.isoformat(),
|
|
||||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
|
||||||
"last_verified": c.last_verified.isoformat()
|
|
||||||
if c.last_verified
|
|
||||||
else None,
|
|
||||||
"sign_count": c.sign_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
from .. import aaguid as aaguid_mod
|
|
||||||
|
|
||||||
aaguid_info = aaguid_mod.filter(aaguids)
|
|
||||||
return {
|
|
||||||
"display_name": user.display_name,
|
|
||||||
"org": {"display_name": user_org.display_name},
|
|
||||||
"role": role_name,
|
|
||||||
"visits": user.visits,
|
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
|
||||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
|
||||||
"credentials": creds,
|
|
||||||
"aaguid_info": aaguid_info,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.put("/auth/user/display-name")
|
@app.put("/auth/user/display-name")
|
||||||
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
"""Authenticated user updates their own display name."""
|
|
||||||
if not auth:
|
if not auth:
|
||||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||||
s = await get_session(auth)
|
s = await get_session(auth)
|
||||||
@ -513,106 +148,6 @@ def register_api_routes(app: FastAPI):
|
|||||||
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
await db.instance.update_user_display_name(s.user_uuid, new_name)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.put("/auth/admin/users/{user_uuid}/display-name")
|
|
||||||
async def admin_update_user_display_name(
|
|
||||||
user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
|
||||||
):
|
|
||||||
"""Admin updates a user's display name."""
|
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
try:
|
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
if not (is_global_admin or (is_org_admin and user_org.uuid == ctx.org.uuid)):
|
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
||||||
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")
|
|
||||||
await db.instance.update_user_display_name(user_uuid, new_name)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
# Admin API: Permissions (global)
|
|
||||||
|
|
||||||
@app.get("/auth/admin/permissions")
|
|
||||||
async def admin_list_permissions(auth=Cookie(None)):
|
|
||||||
_, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not (is_global_admin or is_org_admin):
|
|
||||||
raise ValueError("Insufficient permissions")
|
|
||||||
perms = await db.instance.list_permissions()
|
|
||||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
|
||||||
|
|
||||||
@app.post("/auth/admin/permissions")
|
|
||||||
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
from ..db import Permission as PermDC
|
|
||||||
|
|
||||||
perm_id = payload.get("id")
|
|
||||||
display_name = payload.get("display_name")
|
|
||||||
if not perm_id or not display_name:
|
|
||||||
raise ValueError("id and display_name are required")
|
|
||||||
querysafe.assert_safe(perm_id, field="id")
|
|
||||||
await db.instance.create_permission(
|
|
||||||
PermDC(id=perm_id, display_name=display_name)
|
|
||||||
)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.put("/auth/admin/permission")
|
|
||||||
async def admin_update_permission(
|
|
||||||
permission_id: str, display_name: str, auth=Cookie(None)
|
|
||||||
):
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
from ..db import Permission as PermDC
|
|
||||||
|
|
||||||
if not display_name:
|
|
||||||
raise ValueError("display_name is required")
|
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
|
||||||
await db.instance.update_permission(
|
|
||||||
PermDC(id=permission_id, display_name=display_name)
|
|
||||||
)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/auth/admin/permission/rename")
|
|
||||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|
||||||
"""Rename a permission's id (and optionally display name) updating all references.
|
|
||||||
|
|
||||||
Body: { "old_id": str, "new_id": str, "display_name": str|null }
|
|
||||||
"""
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
old_id = payload.get("old_id")
|
|
||||||
new_id = payload.get("new_id")
|
|
||||||
display_name = payload.get("display_name")
|
|
||||||
if not old_id or not new_id:
|
|
||||||
raise ValueError("old_id and new_id required")
|
|
||||||
querysafe.assert_safe(old_id, field="old_id")
|
|
||||||
querysafe.assert_safe(new_id, field="new_id")
|
|
||||||
if display_name is None:
|
|
||||||
# Fetch old to retain display name
|
|
||||||
perm = await db.instance.get_permission(old_id)
|
|
||||||
display_name = perm.display_name
|
|
||||||
# rename_permission added to interface; use getattr for forward compatibility
|
|
||||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
|
||||||
if not rename_fn:
|
|
||||||
raise ValueError("Permission renaming not supported by this backend")
|
|
||||||
await rename_fn(old_id, new_id, display_name)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.delete("/auth/admin/permission")
|
|
||||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
|
||||||
if not is_global_admin:
|
|
||||||
raise ValueError("Global admin required")
|
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
|
||||||
await db.instance.delete_permission(permission_id)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
@app.post("/auth/logout")
|
@app.post("/auth/logout")
|
||||||
async def api_logout(response: Response, auth=Cookie(None)):
|
async def api_logout(response: Response, auth=Cookie(None)):
|
||||||
"""Log out the current user by clearing the session cookie and deleting from database."""
|
"""Log out the current user by clearing the session cookie and deleting from database."""
|
||||||
@ -641,6 +176,5 @@ def register_api_routes(app: FastAPI):
|
|||||||
async def api_delete_credential(
|
async def api_delete_credential(
|
||||||
response: Response, uuid: UUID, auth: str = Cookie(None)
|
response: Response, uuid: UUID, auth: str = Cookie(None)
|
||||||
):
|
):
|
||||||
"""Delete a specific credential for the current user."""
|
|
||||||
await delete_credential(uuid, auth)
|
await delete_credential(uuid, auth)
|
||||||
return {"message": "Credential deleted successfully"}
|
return {"message": "Credential deleted successfully"}
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import contextlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -8,8 +7,7 @@ from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
|
|||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from ..authsession import get_session
|
from . import admin, authz, ws
|
||||||
from . import authz, ws
|
|
||||||
from .api import register_api_routes
|
from .api import register_api_routes
|
||||||
from .reset import register_reset_routes
|
from .reset import register_reset_routes
|
||||||
|
|
||||||
@ -51,6 +49,8 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.mount("/auth/ws", ws.app)
|
||||||
|
app.mount("/auth/admin", admin.app)
|
||||||
|
|
||||||
|
|
||||||
# Global exception handlers
|
# Global exception handlers
|
||||||
@ -67,10 +67,6 @@ async def general_exception_handler(request: Request, exc: Exception):
|
|||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
# Mount the WebSocket subapp
|
|
||||||
app.mount("/auth/ws", ws.app)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/forward-auth")
|
@app.get("/auth/forward-auth")
|
||||||
async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)):
|
async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)):
|
||||||
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request.
|
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request.
|
||||||
@ -104,27 +100,6 @@ async def redirect_to_index():
|
|||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/admin")
|
|
||||||
async def serve_admin(auth=Cookie(None)):
|
|
||||||
"""Serve the admin app entry point if an authenticated session exists.
|
|
||||||
|
|
||||||
If no valid authenticated session cookie is present, return a 401 with the
|
|
||||||
main app's index.html so the frontend can initiate login/registration flow.
|
|
||||||
"""
|
|
||||||
if auth:
|
|
||||||
with contextlib.suppress(ValueError):
|
|
||||||
s = await get_session(auth)
|
|
||||||
if s.info and s.info.get("type") == "authenticated":
|
|
||||||
return FileResponse(STATIC_DIR / "admin" / "index.html")
|
|
||||||
|
|
||||||
# Not authenticated: serve main index with 401
|
|
||||||
return FileResponse(
|
|
||||||
STATIC_DIR / "index.html",
|
|
||||||
status_code=401,
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Register API routes
|
# Register API routes
|
||||||
register_api_routes(app)
|
register_api_routes(app)
|
||||||
register_reset_routes(app)
|
register_reset_routes(app)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user