1092 lines
36 KiB
Python
1092 lines
36 KiB
Python
import logging
|
|
from datetime import timezone
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from paskia import db
|
|
from paskia.authsession import EXPIRES, reset_expires
|
|
from paskia.fastapi import authz
|
|
from paskia.fastapi.session import AUTH_COOKIE
|
|
from paskia.util import (
|
|
frontend,
|
|
hostutil,
|
|
passphrase,
|
|
permutil,
|
|
querysafe,
|
|
useragent,
|
|
)
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
def is_global_admin(ctx) -> bool:
|
|
"""Check if user has global admin permission."""
|
|
effective_scopes = (
|
|
{p.scope for p in (ctx.permissions or [])}
|
|
if ctx.permissions
|
|
else set(ctx.role.permissions or [])
|
|
)
|
|
return "auth:admin" in effective_scopes
|
|
|
|
|
|
def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
|
|
"""Check if user has org admin permission.
|
|
|
|
If org_uuid is provided, checks if user is admin of that specific org.
|
|
If org_uuid is None, checks if user is admin of their own org.
|
|
"""
|
|
effective_scopes = (
|
|
{p.scope for p in (ctx.permissions or [])}
|
|
if ctx.permissions
|
|
else set(ctx.role.permissions or [])
|
|
)
|
|
if "auth:org:admin" not in effective_scopes:
|
|
return False
|
|
if org_uuid is None:
|
|
return True
|
|
# User must belong to the target org (via their role)
|
|
return ctx.org.uuid == org_uuid
|
|
|
|
|
|
def can_manage_org(ctx, org_uuid: UUID) -> bool:
|
|
"""Check if user can manage the specified organization."""
|
|
return is_global_admin(ctx) or is_org_admin(ctx, org_uuid)
|
|
|
|
|
|
@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)})
|
|
|
|
|
|
@app.exception_handler(authz.AuthException)
|
|
async def auth_exception_handler(_request, exc: authz.AuthException):
|
|
"""Handle AuthException with auth info for UI."""
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=await authz.auth_error_content(exc),
|
|
)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
|
|
logging.exception("Unhandled exception in admin app")
|
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
|
|
|
|
|
@app.get("/")
|
|
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
|
return Response(*await frontend.read("/auth/admin/index.html"))
|
|
|
|
|
|
# -------------------- 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 = db.list_organizations()
|
|
if not is_global_admin(ctx):
|
|
# Org admins can only see their own organization
|
|
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 = db.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(
|
|
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
|
|
):
|
|
ctx = await authz.verify(
|
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
|
)
|
|
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)
|
|
db.create_organization(org, ctx=ctx)
|
|
|
|
return {"uuid": str(org_uuid)}
|
|
|
|
|
|
@app.patch("/orgs/{org_uuid}")
|
|
async def admin_update_org_name(
|
|
org_uuid: UUID,
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
"""Update organization display name only."""
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
display_name = payload.get("display_name")
|
|
if not display_name:
|
|
raise ValueError("display_name is required")
|
|
|
|
db.update_organization_name(org_uuid, display_name, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/orgs/{org_uuid}")
|
|
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
max_age="5m",
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
if ctx.org.uuid == org_uuid:
|
|
raise ValueError("Cannot delete the organization you belong to")
|
|
|
|
# Delete organization-specific permissions
|
|
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
|
all_permissions = db.list_permissions()
|
|
for perm in all_permissions:
|
|
perm_scope_lower = perm.scope.lower()
|
|
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
|
if (
|
|
f":{org_perm_pattern}:" in perm_scope_lower
|
|
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
|
|
or perm_scope_lower.endswith(f":{org_perm_pattern}")
|
|
or perm_scope_lower == org_perm_pattern
|
|
):
|
|
db.delete_permission(str(perm.uuid), ctx=ctx)
|
|
|
|
db.delete_organization(org_uuid, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/orgs/{org_uuid}/permission")
|
|
async def admin_add_org_permission(
|
|
org_uuid: UUID,
|
|
permission_id: str,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
|
)
|
|
db.add_permission_to_organization(str(org_uuid), permission_id, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/orgs/{org_uuid}/permission")
|
|
async def admin_remove_org_permission(
|
|
org_uuid: UUID,
|
|
permission_id: str,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
|
)
|
|
|
|
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
|
|
if permission_id == "auth:admin" and ctx.org.uuid == org_uuid:
|
|
# Check if any other org grants auth:admin that we're a member of
|
|
# (we only know our current org, so this effectively means we can't remove it from our own org)
|
|
raise ValueError(
|
|
"Cannot remove auth:admin from your own organization. "
|
|
"This would lock you out of admin access."
|
|
)
|
|
|
|
db.remove_permission_from_organization(str(org_uuid), permission_id, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
# -------------------- Roles --------------------
|
|
|
|
|
|
@app.post("/orgs/{org_uuid}/roles")
|
|
async def admin_create_role(
|
|
org_uuid: UUID,
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
from ..db import Role as RoleDC
|
|
|
|
role_uuid = uuid4()
|
|
display_name = payload.get("display_name") or "New Role"
|
|
perms = payload.get("permissions") or []
|
|
org = db.get_organization(str(org_uuid))
|
|
grantable = set(org.permissions or [])
|
|
|
|
# Normalize permission IDs to UUIDs
|
|
permission_uuids = []
|
|
for pid in perms:
|
|
perm = db.get_permission(pid)
|
|
if not perm:
|
|
raise ValueError(f"Permission {pid} not found")
|
|
perm_uuid_str = str(perm.uuid)
|
|
if perm_uuid_str not in grantable:
|
|
raise ValueError(f"Permission not grantable by org: {pid}")
|
|
permission_uuids.append(perm_uuid_str)
|
|
|
|
role = RoleDC(
|
|
uuid=role_uuid,
|
|
org_uuid=org_uuid,
|
|
display_name=display_name,
|
|
permissions=permission_uuids,
|
|
)
|
|
db.create_role(role, ctx=ctx)
|
|
return {"uuid": str(role_uuid)}
|
|
|
|
|
|
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
|
|
async def admin_update_role_name(
|
|
org_uuid: UUID,
|
|
role_uuid: UUID,
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
"""Update role display name only."""
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
role = db.get_role(role_uuid)
|
|
if role.org_uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
|
|
display_name = payload.get("display_name")
|
|
if not display_name:
|
|
raise ValueError("display_name is required")
|
|
|
|
db.update_role_name(role_uuid, display_name, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
|
|
async def admin_add_role_permission(
|
|
org_uuid: UUID,
|
|
role_uuid: UUID,
|
|
permission_uuid: UUID,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
"""Add a permission to a role (intent-based API)."""
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
|
|
role = db.get_role(role_uuid)
|
|
if role.org_uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
|
|
# Verify permission exists and org can grant it
|
|
perm = db.get_permission(permission_uuid)
|
|
if not perm:
|
|
raise HTTPException(status_code=404, detail="Permission not found")
|
|
org = db.get_organization(str(org_uuid))
|
|
if str(permission_uuid) not in org.permissions:
|
|
raise ValueError("Permission not grantable by organization")
|
|
|
|
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}")
|
|
async def admin_remove_role_permission(
|
|
org_uuid: UUID,
|
|
role_uuid: UUID,
|
|
permission_uuid: UUID,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
"""Remove a permission from a role (intent-based API)."""
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
|
|
role = db.get_role(role_uuid)
|
|
if role.org_uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
|
|
# Sanity check: prevent admin from removing their own access
|
|
# Find auth:admin and auth:org:admin permission UUIDs
|
|
perm_uuid_str = str(permission_uuid)
|
|
perm = db.get_permission(permission_uuid)
|
|
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
|
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
|
# Check if removing this permission would leave no admin access
|
|
remaining_perms = set(role.permissions) - {perm_uuid_str}
|
|
has_admin = False
|
|
for rp_uuid in remaining_perms:
|
|
rp = db.get_permission(rp_uuid)
|
|
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
|
|
has_admin = True
|
|
break
|
|
if not has_admin:
|
|
raise ValueError("Cannot remove your own admin permissions")
|
|
|
|
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
|
async def admin_delete_role(
|
|
org_uuid: UUID,
|
|
role_uuid: UUID,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
max_age="5m",
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
role = db.get_role(role_uuid)
|
|
if role.org_uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="Role not found in organization")
|
|
|
|
# Sanity check: prevent admin from deleting their own role
|
|
if ctx.role.uuid == role_uuid:
|
|
raise ValueError("Cannot delete your own role")
|
|
|
|
db.delete_role(role_uuid, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
# -------------------- Users --------------------
|
|
|
|
|
|
@app.post("/orgs/{org_uuid}/users")
|
|
async def admin_create_user(
|
|
org_uuid: UUID,
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
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 = db.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,
|
|
)
|
|
db.create_user(user, ctx=ctx)
|
|
return {"uuid": str(user_uuid)}
|
|
|
|
|
|
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
|
|
async def admin_update_user_role(
|
|
org_uuid: UUID,
|
|
user_uuid: UUID,
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
new_role = payload.get("role")
|
|
if not new_role:
|
|
raise ValueError("role is required")
|
|
try:
|
|
user_org, _current_role = db.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 = db.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")
|
|
|
|
# Sanity check: prevent admin from removing their own access
|
|
if ctx.user.uuid == user_uuid:
|
|
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
|
if new_role_obj: # pragma: no branch - always true, role validated above
|
|
# Check if any permission in the new role is an admin permission
|
|
has_admin_access = False
|
|
for perm_uuid in new_role_obj.permissions:
|
|
perm = db.get_permission(perm_uuid)
|
|
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
|
has_admin_access = True
|
|
break
|
|
if not has_admin_access:
|
|
raise ValueError(
|
|
"Cannot change your own role to one without admin permissions"
|
|
)
|
|
|
|
db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
|
async def admin_create_user_registration_link(
|
|
org_uuid: UUID,
|
|
user_uuid: UUID,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
try:
|
|
user_org, _role_name = db.get_user_organization(user_uuid)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user_org.uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
max_age="5m",
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
|
|
# Check if user has existing credentials
|
|
credentials = db.get_credentials_by_user_uuid(user_uuid)
|
|
token_type = "user registration" if not credentials else "account recovery"
|
|
|
|
token = passphrase.generate()
|
|
expiry = reset_expires()
|
|
db.create_reset_token(
|
|
user_uuid=user_uuid,
|
|
passphrase=token,
|
|
expiry=expiry,
|
|
token_type=token_type,
|
|
ctx=ctx,
|
|
)
|
|
url = hostutil.reset_link_url(token)
|
|
return {
|
|
"url": url,
|
|
"expires": (
|
|
expiry.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
if expiry.tzinfo
|
|
else expiry.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
),
|
|
}
|
|
|
|
|
|
@app.get("/orgs/{org_uuid}/users/{user_uuid}")
|
|
async def admin_get_user_detail(
|
|
org_uuid: UUID,
|
|
user_uuid: UUID,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
try:
|
|
user_org, role_name = db.get_user_organization(user_uuid)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user_org.uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
user = db.get_user_by_uuid(user_uuid)
|
|
user_creds = db.get_credentials_by_user_uuid(user_uuid)
|
|
creds: list[dict] = []
|
|
aaguids: set[str] = set()
|
|
for c in user_creds:
|
|
aaguid_str = str(c.aaguid)
|
|
aaguids.add(aaguid_str)
|
|
creds.append(
|
|
{
|
|
"credential_uuid": str(c.uuid),
|
|
"aaguid": aaguid_str,
|
|
"created_at": (
|
|
c.created_at.astimezone(timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if c.created_at.tzinfo
|
|
else c.created_at.replace(tzinfo=timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
),
|
|
"last_used": (
|
|
c.last_used.astimezone(timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if c.last_used and c.last_used.tzinfo
|
|
else (
|
|
c.last_used.replace(tzinfo=timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if c.last_used
|
|
else None
|
|
)
|
|
),
|
|
"last_verified": (
|
|
c.last_verified.astimezone(timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if c.last_verified and c.last_verified.tzinfo
|
|
else (
|
|
c.last_verified.replace(tzinfo=timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if c.last_verified
|
|
else None
|
|
)
|
|
)
|
|
if c.last_verified
|
|
else None,
|
|
"sign_count": c.sign_count,
|
|
}
|
|
)
|
|
from .. import aaguid as aaguid_mod
|
|
|
|
aaguid_info = aaguid_mod.filter(aaguids)
|
|
|
|
# Get sessions for the user
|
|
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
|
session_records = db.list_sessions_for_user(user_uuid)
|
|
current_session_key = auth
|
|
sessions_payload: list[dict] = []
|
|
for entry in session_records:
|
|
renewed = entry.expiry - EXPIRES
|
|
sessions_payload.append(
|
|
{
|
|
"id": entry.key,
|
|
"credential_uuid": str(entry.credential_uuid),
|
|
"host": entry.host,
|
|
"ip": entry.ip,
|
|
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
|
"last_renewed": (
|
|
renewed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
if renewed.tzinfo
|
|
else renewed.replace(tzinfo=timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
),
|
|
"is_current": entry.key == current_session_key,
|
|
"is_current_host": bool(
|
|
normalized_request_host
|
|
and entry.host
|
|
and entry.host == normalized_request_host
|
|
),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"display_name": user.display_name,
|
|
"org": {"display_name": user_org.display_name},
|
|
"role": role_name,
|
|
"visits": user.visits,
|
|
"created_at": (
|
|
user.created_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
if user.created_at and user.created_at.tzinfo
|
|
else (
|
|
user.created_at.replace(tzinfo=timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if user.created_at
|
|
else None
|
|
)
|
|
),
|
|
"last_seen": (
|
|
user.last_seen.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
if user.last_seen and user.last_seen.tzinfo
|
|
else (
|
|
user.last_seen.replace(tzinfo=timezone.utc)
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
if user.last_seen
|
|
else None
|
|
)
|
|
),
|
|
"credentials": creds,
|
|
"aaguid_info": aaguid_info,
|
|
"sessions": sessions_payload,
|
|
}
|
|
|
|
|
|
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
|
async def admin_update_user_display_name(
|
|
org_uuid: UUID,
|
|
user_uuid: UUID,
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
try:
|
|
user_org, _role_name = db.get_user_organization(user_uuid)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user_org.uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
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")
|
|
db.update_user_display_name(user_uuid, new_name, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}")
|
|
async def admin_delete_user_credential(
|
|
org_uuid: UUID,
|
|
user_uuid: UUID,
|
|
credential_uuid: UUID,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
try:
|
|
user_org, _role_name = db.get_user_organization(user_uuid)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user_org.uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
max_age="5m",
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}")
|
|
async def admin_delete_user_session(
|
|
org_uuid: UUID,
|
|
user_uuid: UUID,
|
|
session_id: str,
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
try:
|
|
user_org, _role_name = db.get_user_organization(user_uuid)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if user_org.uuid != org_uuid:
|
|
raise HTTPException(status_code=404, detail="User not found in organization")
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
if not can_manage_org(ctx, org_uuid):
|
|
raise authz.AuthException(
|
|
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
|
)
|
|
|
|
target_session = db.get_session(session_id)
|
|
if not target_session or target_session.user_uuid != user_uuid:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
db.delete_session(session_id, ctx=ctx)
|
|
|
|
# Check if admin terminated their own session
|
|
current_terminated = session_id == auth
|
|
return {"status": "ok", "current_session_terminated": current_terminated}
|
|
|
|
|
|
# -------------------- Permissions (global) --------------------
|
|
|
|
|
|
def _perm_to_dict(p):
|
|
"""Convert Permission to dict, omitting domain if None."""
|
|
d = {"uuid": str(p.uuid), "scope": p.scope, "display_name": p.display_name}
|
|
if p.domain is not None:
|
|
d["domain"] = p.domain
|
|
return d
|
|
|
|
|
|
def _validate_permission_domain(domain: str | None) -> None:
|
|
"""Validate that domain is rp_id or a subdomain of it."""
|
|
if domain is None:
|
|
return
|
|
from paskia.globals import passkey
|
|
|
|
rp_id = passkey.instance.rp_id
|
|
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
|
return
|
|
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
|
|
|
|
|
|
def _check_admin_lockout(
|
|
perm_uuid: str, new_domain: str | None, current_host: str | None
|
|
) -> None:
|
|
"""Check if setting domain on auth:admin would lock out the admin.
|
|
|
|
Raises ValueError if this change would result in no auth:admin permissions
|
|
being accessible from the current host.
|
|
"""
|
|
from paskia.util.hostutil import normalize_host
|
|
|
|
normalized_host = normalize_host(current_host)
|
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
|
|
|
# Get all auth:admin permissions
|
|
all_perms = db.list_permissions()
|
|
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
|
|
|
# Check if at least one auth:admin would remain accessible
|
|
for p in admin_perms:
|
|
# If this is the permission being modified, use the new domain
|
|
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
|
|
|
|
# No domain restriction = accessible from anywhere
|
|
if domain is None:
|
|
return
|
|
|
|
# Domain matches current host
|
|
if host_without_port and domain == host_without_port:
|
|
return
|
|
|
|
# No accessible auth:admin permission would remain
|
|
raise ValueError(
|
|
"Cannot set this domain restriction: it would lock you out of admin access. "
|
|
"Ensure at least one auth:admin permission remains accessible from your current host."
|
|
)
|
|
|
|
|
|
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
|
|
"""Check if deleting an auth:admin permission would lock out the admin.
|
|
|
|
Raises ValueError if this deletion would result in no auth:admin permissions
|
|
being accessible from the current host.
|
|
"""
|
|
from paskia.util.hostutil import normalize_host
|
|
|
|
normalized_host = normalize_host(current_host)
|
|
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
|
|
|
# Get all auth:admin permissions
|
|
all_perms = db.list_permissions()
|
|
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
|
|
|
# Check if at least one auth:admin would remain accessible after deletion
|
|
for p in admin_perms:
|
|
# Skip the permission being deleted
|
|
if str(p.uuid) == perm_uuid:
|
|
continue
|
|
|
|
# No domain restriction = accessible from anywhere
|
|
if p.domain is None:
|
|
return
|
|
|
|
# Domain matches current host
|
|
if host_without_port and p.domain == host_without_port:
|
|
return
|
|
|
|
# No accessible auth:admin permission would remain
|
|
raise ValueError(
|
|
"Cannot delete this permission: it would lock you out of admin access. "
|
|
"Ensure at least one auth:admin permission remains accessible from your current host."
|
|
)
|
|
|
|
|
|
@app.get("/permissions")
|
|
async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin", "auth:org:admin"],
|
|
match=permutil.has_any,
|
|
host=request.headers.get("host"),
|
|
)
|
|
perms = db.list_permissions()
|
|
|
|
# Global admins see all permissions
|
|
if is_global_admin(ctx):
|
|
return [_perm_to_dict(p) for p in perms]
|
|
|
|
# Org admins only see permissions their org can grant (by UUID)
|
|
grantable = set(ctx.org.permissions or [])
|
|
filtered_perms = [p for p in perms if str(p.uuid) in grantable]
|
|
return [_perm_to_dict(p) for p in filtered_perms]
|
|
|
|
|
|
@app.post("/permissions")
|
|
async def admin_create_permission(
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin"],
|
|
host=request.headers.get("host"),
|
|
match=permutil.has_all,
|
|
max_age="5m",
|
|
)
|
|
import uuid7
|
|
|
|
from ..db import Permission as PermDC
|
|
|
|
scope = payload.get("scope") or payload.get(
|
|
"id"
|
|
) # Support both for backwards compat
|
|
display_name = payload.get("display_name")
|
|
domain = payload.get("domain") or None # Treat empty string as None
|
|
if not scope or not display_name:
|
|
raise ValueError("scope and display_name are required")
|
|
querysafe.assert_safe(scope, field="scope")
|
|
_validate_permission_domain(domain)
|
|
db.create_permission(
|
|
PermDC(
|
|
uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
|
|
),
|
|
ctx=ctx,
|
|
)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.patch("/permission")
|
|
async def admin_update_permission(
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
permission_uuid: str | None = None,
|
|
permission_id: str | None = None, # Backwards compat - treated as scope
|
|
display_name: str | None = None,
|
|
scope: str | None = None,
|
|
domain: str | None = None,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
|
)
|
|
|
|
# permission_uuid or permission_id (scope) to identify the permission
|
|
perm_identifier = permission_uuid or permission_id
|
|
if not perm_identifier:
|
|
raise ValueError("permission_uuid or permission_id required")
|
|
|
|
# Get existing permission
|
|
perm = db.get_permission(perm_identifier)
|
|
|
|
# Update fields that were provided
|
|
new_scope = scope if scope is not None else perm.scope
|
|
new_display_name = display_name if display_name is not None else perm.display_name
|
|
domain_value = domain if domain else None
|
|
|
|
if not new_display_name:
|
|
raise ValueError("display_name is required")
|
|
querysafe.assert_safe(new_scope, field="scope")
|
|
_validate_permission_domain(domain_value)
|
|
|
|
# Safety check: prevent admin lockout when setting domain on auth:admin
|
|
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
|
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
|
|
|
from ..db import Permission as PermDC
|
|
|
|
db.update_permission(
|
|
PermDC(
|
|
uuid=perm.uuid,
|
|
scope=new_scope,
|
|
display_name=new_display_name,
|
|
domain=domain_value,
|
|
),
|
|
ctx=ctx,
|
|
)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/permission/rename")
|
|
async def admin_rename_permission(
|
|
request: Request,
|
|
payload: dict = Body(...),
|
|
auth=AUTH_COOKIE,
|
|
):
|
|
ctx = await authz.verify(
|
|
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
|
)
|
|
old_scope = payload.get("old_scope") or payload.get("old_id") # Support both
|
|
new_scope = payload.get("new_scope") or payload.get("new_id") # Support both
|
|
display_name = payload.get("display_name")
|
|
domain = payload.get(
|
|
"domain"
|
|
) # Can be None (not provided), empty string (clear), or value
|
|
if not old_scope or not new_scope:
|
|
raise ValueError("old_scope and new_scope required")
|
|
|
|
# Sanity check: prevent renaming critical permissions
|
|
if old_scope == "auth:admin":
|
|
raise ValueError("Cannot rename the master admin permission")
|
|
|
|
querysafe.assert_safe(old_scope, field="old_scope")
|
|
querysafe.assert_safe(new_scope, field="new_scope")
|
|
|
|
# Get existing permission to preserve values not being changed
|
|
perm = db.get_permission(old_scope)
|
|
if display_name is None:
|
|
display_name = perm.display_name
|
|
# domain=None means "not provided, keep existing", domain="" means "clear it"
|
|
if domain is None:
|
|
domain_value = perm.domain
|
|
else:
|
|
domain_value = domain if domain else None
|
|
_validate_permission_domain(domain_value)
|
|
|
|
# Safety check: prevent admin lockout when setting domain on auth:admin
|
|
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
|
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
|
|
|
# All current backends support rename_permission
|
|
db.rename_permission(old_scope, new_scope, display_name, domain_value, ctx=ctx)
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.delete("/permission")
|
|
async def admin_delete_permission(
|
|
request: Request,
|
|
auth=AUTH_COOKIE,
|
|
permission_uuid: str | None = None,
|
|
permission_id: str | None = None, # Backwards compat - treated as scope
|
|
):
|
|
ctx = await authz.verify(
|
|
auth,
|
|
["auth:admin"],
|
|
host=request.headers.get("host"),
|
|
match=permutil.has_all,
|
|
max_age="5m",
|
|
)
|
|
|
|
perm_identifier = permission_uuid or permission_id
|
|
if not perm_identifier:
|
|
raise ValueError("permission_uuid or permission_id required")
|
|
querysafe.assert_safe(perm_identifier, field="permission_id")
|
|
|
|
# Get the permission to check its scope
|
|
perm = db.get_permission(perm_identifier)
|
|
|
|
# Sanity check: prevent deleting critical permissions if it would lock out admin
|
|
if perm.scope == "auth:admin":
|
|
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
|
|
|
|
db.delete_permission(str(perm.uuid), ctx=ctx)
|
|
return {"status": "ok"}
|