Refactoring permissions checks.
This commit is contained in:
parent
3cd6a59b26
commit
bfc777fb56
@ -1,26 +1,13 @@
|
|||||||
"""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
|
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import Body, Cookie, FastAPI, HTTPException
|
from fastapi import Body, Cookie, FastAPI, HTTPException
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from ..authsession import expires, get_session
|
from ..authsession import expires
|
||||||
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 frontend, passphrase, querysafe, tokens
|
from ..util import frontend, passphrase, permutil, querysafe, tokens
|
||||||
from ..util.tokens import session_key
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@ -36,33 +23,12 @@ async def general_exception_handler(_request, exc: Exception):
|
|||||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
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 admin_frontend(auth=Cookie(None)):
|
async def admin_frontend(auth=Cookie(None)):
|
||||||
"""Serve the admin SPA root if an authorized session exists."""
|
ctx = await permutil.session_context(auth)
|
||||||
if auth:
|
if permutil.has_any(ctx, ["auth:admin", "auth:org:*"]):
|
||||||
with contextlib.suppress(ValueError):
|
return FileResponse(frontend.file("admin/index.html"))
|
||||||
s = await get_session(auth)
|
return FileResponse(frontend.file("index.html"), status_code=401 if ctx else 403)
|
||||||
if s.info and s.info.get("type") == "authenticated":
|
|
||||||
return FileResponse(frontend.file("admin/index.html"))
|
|
||||||
return FileResponse(
|
|
||||||
frontend.file("index.html"),
|
|
||||||
status_code=401,
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------- Organizations --------------------
|
# -------------------- Organizations --------------------
|
||||||
@ -70,11 +36,11 @@ async def admin_frontend(auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.get("/orgs")
|
@app.get("/orgs")
|
||||||
async def admin_list_orgs(auth=Cookie(None)):
|
async def admin_list_orgs(auth=Cookie(None)):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or is_org_admin):
|
if not permutil.has_any(ctx, ["auth:admin", "auth:org:*"]):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
orgs = await db.instance.list_organizations()
|
orgs = await db.instance.list_organizations()
|
||||||
if not is_global_admin: # limit org admin to their own org
|
if not permutil.has_any(ctx, ["auth:admin"]): # limit org admin to their own org
|
||||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||||
|
|
||||||
def role_to_dict(r):
|
def role_to_dict(r):
|
||||||
@ -109,8 +75,8 @@ async def admin_list_orgs(auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.post("/orgs")
|
@app.post("/orgs")
|
||||||
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
from ..db import Org as OrgDC # local import to avoid cycles
|
||||||
|
|
||||||
@ -126,8 +92,8 @@ async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
async def admin_update_org(
|
async def admin_update_org(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
from ..db import Org as OrgDC # local import to avoid cycles
|
from ..db import Org as OrgDC # local import to avoid cycles
|
||||||
|
|
||||||
@ -141,8 +107,8 @@ async def admin_update_org(
|
|||||||
|
|
||||||
@app.delete("/orgs/{org_uuid}")
|
@app.delete("/orgs/{org_uuid}")
|
||||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||||
ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
try:
|
try:
|
||||||
acting_org_uuid = ctx.org.uuid if ctx.org else None
|
acting_org_uuid = ctx.org.uuid if ctx.org else None
|
||||||
@ -158,8 +124,13 @@ async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
|||||||
async def admin_add_org_permission(
|
async def admin_add_org_permission(
|
||||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||||
):
|
):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{org_uuid}"]) and ctx.org.uuid == org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
|
||||||
@ -170,8 +141,14 @@ async def admin_add_org_permission(
|
|||||||
async def admin_remove_org_permission(
|
async def admin_remove_org_permission(
|
||||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||||
):
|
):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{org_uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||||
@ -185,17 +162,23 @@ async def admin_remove_org_permission(
|
|||||||
async def admin_create_role(
|
async def admin_create_role(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{org_uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
from ..db import Role as RoleDC
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
role_uuid = uuid4()
|
role_uuid = uuid4()
|
||||||
display_name = payload.get("display_name") or "New Role"
|
display_name = payload.get("display_name") or "New Role"
|
||||||
permissions = payload.get("permissions") or []
|
perms = payload.get("permissions") or []
|
||||||
org = await db.instance.get_organization(str(org_uuid))
|
org = await db.instance.get_organization(str(org_uuid))
|
||||||
grantable = set(org.permissions or [])
|
grantable = set(org.permissions or [])
|
||||||
for pid in permissions:
|
for pid in perms:
|
||||||
await db.instance.get_permission(pid)
|
await db.instance.get_permission(pid)
|
||||||
if pid not in grantable:
|
if pid not in grantable:
|
||||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||||
@ -203,7 +186,7 @@ async def admin_create_role(
|
|||||||
uuid=role_uuid,
|
uuid=role_uuid,
|
||||||
org_uuid=org_uuid,
|
org_uuid=org_uuid,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
permissions=permissions,
|
permissions=perms,
|
||||||
)
|
)
|
||||||
await db.instance.create_role(role)
|
await db.instance.create_role(role)
|
||||||
return {"uuid": str(role_uuid)}
|
return {"uuid": str(role_uuid)}
|
||||||
@ -213,9 +196,15 @@ async def admin_create_role(
|
|||||||
async def admin_update_role(
|
async def admin_update_role(
|
||||||
role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{role.org_uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == role.org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
from ..db import Role as RoleDC
|
from ..db import Role as RoleDC
|
||||||
|
|
||||||
@ -239,9 +228,15 @@ async def admin_update_role(
|
|||||||
|
|
||||||
@app.delete("/roles/{role_uuid}")
|
@app.delete("/roles/{role_uuid}")
|
||||||
async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)):
|
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)
|
ctx = await permutil.session_context(auth)
|
||||||
role = await db.instance.get_role(role_uuid)
|
role = await db.instance.get_role(role_uuid)
|
||||||
if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{role.org_uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == role.org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
await db.instance.delete_role(role_uuid)
|
await db.instance.delete_role(role_uuid)
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@ -254,8 +249,14 @@ async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)):
|
|||||||
async def admin_create_user(
|
async def admin_create_user(
|
||||||
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{org_uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
display_name = payload.get("display_name")
|
display_name = payload.get("display_name")
|
||||||
role_name = payload.get("role")
|
role_name = payload.get("role")
|
||||||
@ -283,8 +284,14 @@ async def admin_create_user(
|
|||||||
async def admin_update_user_role(
|
async def admin_update_user_role(
|
||||||
org_uuid: UUID, user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
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)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{org_uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == org_uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
new_role = payload.get("role")
|
new_role = payload.get("role")
|
||||||
if not new_role:
|
if not new_role:
|
||||||
@ -304,12 +311,18 @@ async def admin_update_user_role(
|
|||||||
|
|
||||||
@app.post("/users/{user_uuid}/create-link")
|
@app.post("/users/{user_uuid}/create-link")
|
||||||
async def admin_create_user_registration_link(user_uuid: UUID, auth=Cookie(None)):
|
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)
|
ctx = await permutil.session_context(auth)
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{user_org.uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == user_org.uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
token = passphrase.generate()
|
token = passphrase.generate()
|
||||||
await db.instance.create_session(
|
await db.instance.create_session(
|
||||||
@ -325,12 +338,18 @@ async def admin_create_user_registration_link(user_uuid: UUID, auth=Cookie(None)
|
|||||||
|
|
||||||
@app.get("/users/{user_uuid}")
|
@app.get("/users/{user_uuid}")
|
||||||
async def admin_get_user_detail(user_uuid: UUID, auth=Cookie(None)):
|
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)
|
ctx = await permutil.session_context(auth)
|
||||||
try:
|
try:
|
||||||
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{user_org.uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == user_org.uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||||
@ -374,12 +393,18 @@ async def admin_get_user_detail(user_uuid: UUID, auth=Cookie(None)):
|
|||||||
async def admin_update_user_display_name(
|
async def admin_update_user_display_name(
|
||||||
user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
user_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
|
||||||
):
|
):
|
||||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
try:
|
try:
|
||||||
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
user_org, _role_name = await db.instance.get_user_organization(user_uuid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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)):
|
if not ctx or not (
|
||||||
|
permutil.has_any(ctx, ["auth:admin"])
|
||||||
|
or (
|
||||||
|
permutil.has_any(ctx, [f"auth:org:{user_org.uuid}"])
|
||||||
|
and getattr(ctx.org, "uuid", None) == user_org.uuid
|
||||||
|
)
|
||||||
|
):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
new_name = (payload.get("display_name") or "").strip()
|
new_name = (payload.get("display_name") or "").strip()
|
||||||
if not new_name:
|
if not new_name:
|
||||||
@ -395,8 +420,8 @@ async def admin_update_user_display_name(
|
|||||||
|
|
||||||
@app.get("/permissions")
|
@app.get("/permissions")
|
||||||
async def admin_list_permissions(auth=Cookie(None)):
|
async def admin_list_permissions(auth=Cookie(None)):
|
||||||
_, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not (is_global_admin or is_org_admin):
|
if not ctx or not permutil.has_any(ctx, ["auth:admin", "auth:org:*"]):
|
||||||
raise ValueError("Insufficient permissions")
|
raise ValueError("Insufficient permissions")
|
||||||
perms = await db.instance.list_permissions()
|
perms = await db.instance.list_permissions()
|
||||||
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
return [{"id": p.id, "display_name": p.display_name} for p in perms]
|
||||||
@ -404,8 +429,8 @@ async def admin_list_permissions(auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.post("/permissions")
|
@app.post("/permissions")
|
||||||
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not ctx or not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
from ..db import Permission as PermDC
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
@ -422,8 +447,8 @@ async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
async def admin_update_permission(
|
async def admin_update_permission(
|
||||||
permission_id: str, display_name: str, auth=Cookie(None)
|
permission_id: str, display_name: str, auth=Cookie(None)
|
||||||
):
|
):
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not ctx or not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
from ..db import Permission as PermDC
|
from ..db import Permission as PermDC
|
||||||
|
|
||||||
@ -438,8 +463,8 @@ async def admin_update_permission(
|
|||||||
|
|
||||||
@app.post("/permission/rename")
|
@app.post("/permission/rename")
|
||||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not ctx or not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
old_id = payload.get("old_id")
|
old_id = payload.get("old_id")
|
||||||
new_id = payload.get("new_id")
|
new_id = payload.get("new_id")
|
||||||
@ -460,8 +485,8 @@ async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
|||||||
|
|
||||||
@app.delete("/permission")
|
@app.delete("/permission")
|
||||||
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
async def admin_delete_permission(permission_id: str, auth=Cookie(None)):
|
||||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
ctx = await permutil.session_context(auth)
|
||||||
if not is_global_admin:
|
if not ctx or not permutil.has_any(ctx, ["auth:admin"]):
|
||||||
raise ValueError("Global admin required")
|
raise ValueError("Global admin required")
|
||||||
querysafe.assert_safe(permission_id, field="permission_id")
|
querysafe.assert_safe(permission_id, field="permission_id")
|
||||||
await db.instance.delete_permission(permission_id)
|
await db.instance.delete_permission(permission_id)
|
||||||
|
@ -19,7 +19,7 @@ from .. import aaguid
|
|||||||
from ..authsession import delete_credential, expires, get_reset, get_session
|
from ..authsession import delete_credential, expires, 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 passphrase, tokens
|
from ..util import passphrase, permutil, tokens
|
||||||
from ..util.tokens import session_key
|
from ..util.tokens import session_key
|
||||||
from . import authz, session
|
from . import authz, session
|
||||||
|
|
||||||
@ -42,13 +42,13 @@ async def general_exception_handler(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/validate")
|
@app.post("/validate")
|
||||||
async def validate_token(perm=Query(None), auth=Cookie(None)):
|
async def validate_token(perm: list[str] = Query([]), auth=Cookie(None)):
|
||||||
s = await authz.verify(auth, perm)
|
ctx = await authz.verify(auth, perm)
|
||||||
return {"valid": True, "user_uuid": str(s.user_uuid)}
|
return {"valid": True, "user_uuid": str(ctx.session.user_uuid)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/forward")
|
@app.get("/forward")
|
||||||
async def forward_authentication(perm=Query(None), auth=Cookie(None)):
|
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
|
||||||
"""Forward auth validation for Caddy/Nginx (moved from /auth/forward-auth).
|
"""Forward auth validation for Caddy/Nginx (moved from /auth/forward-auth).
|
||||||
|
|
||||||
Query Params:
|
Query Params:
|
||||||
@ -58,8 +58,10 @@ async def forward_authentication(perm=Query(None), auth=Cookie(None)):
|
|||||||
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
|
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
s = await authz.verify(auth, perm)
|
ctx = await authz.verify(auth, perm)
|
||||||
return Response(status_code=204, headers={"x-auth-user-uuid": str(s.user_uuid)})
|
return Response(
|
||||||
|
status_code=204, headers={"x-auth-user-uuid": str(ctx.session.user_uuid)}
|
||||||
|
)
|
||||||
except HTTPException as e: # pass through explicitly
|
except HTTPException as e: # pass through explicitly
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
@ -96,7 +98,8 @@ async def api_user_info(reset: str | None = None, auth=Cookie(None)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 permutil.session_context(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()
|
||||||
|
@ -1,39 +1,25 @@
|
|||||||
"""Authorization utilities shared across FastAPI endpoints.
|
|
||||||
|
|
||||||
Provides helper(s) to validate a session token (from cookie) and optionally
|
|
||||||
enforce that the user possesses a given permission (either via their role or
|
|
||||||
their organization level permissions).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from ..authsession import get_session
|
from ..util import permutil
|
||||||
from ..globals import db
|
|
||||||
from ..util.tokens import session_key
|
|
||||||
|
|
||||||
|
|
||||||
async def verify(auth: str | None, perm: list[str] | str | None):
|
async def verify(auth: str | None, perm: list[str], match=permutil.has_all):
|
||||||
"""Validate session token and optional list of required permissions.
|
"""Validate session token and optional list of required permissions.
|
||||||
|
|
||||||
Returns the Session object on success. Raises HTTPException on failure.
|
Returns the session context.
|
||||||
401: unauthenticated / invalid session
|
|
||||||
403: one or more required permissions missing
|
Raises HTTPException on failure:
|
||||||
|
401: unauthenticated / invalid session
|
||||||
|
403: required permissions missing
|
||||||
"""
|
"""
|
||||||
if not auth:
|
if not auth:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
session = await get_session(auth)
|
|
||||||
if perm is not None:
|
|
||||||
if isinstance(perm, str):
|
|
||||||
perm = [perm]
|
|
||||||
ctx = await db.instance.get_session_context(session_key(auth))
|
|
||||||
if not ctx:
|
|
||||||
raise HTTPException(status_code=401, detail="Session not found")
|
|
||||||
available = set(ctx.role.permissions or []) | (
|
|
||||||
set(ctx.org.permissions or []) if ctx.org else set()
|
|
||||||
)
|
|
||||||
if any(p not in available for p in perm):
|
|
||||||
raise HTTPException(status_code=403, detail="Permission required")
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
ctx = await permutil.session_context(auth)
|
||||||
|
if not ctx:
|
||||||
|
raise HTTPException(status_code=401, detail="Session not found")
|
||||||
|
|
||||||
__all__ = ["verify"]
|
if not match(ctx, perm):
|
||||||
|
raise HTTPException(status_code=403, detail="Permission required")
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
28
passkey/util/permutil.py
Normal file
28
passkey/util/permutil.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Minimal permission helpers with '*' wildcard support (no DB expansion)."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from fnmatch import fnmatchcase
|
||||||
|
|
||||||
|
from ..globals import db
|
||||||
|
from .tokens import session_key
|
||||||
|
|
||||||
|
__all__ = ["has_any", "has_all", "session_context"]
|
||||||
|
|
||||||
|
|
||||||
|
def _match(perms: set[str], patterns: Sequence[str]):
|
||||||
|
return (
|
||||||
|
any(fnmatchcase(p, pat) for p in perms) if "*" in pat else False
|
||||||
|
for pat in patterns
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_any(ctx, patterns: Sequence[str]) -> bool:
|
||||||
|
return any(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||||
|
|
||||||
|
|
||||||
|
def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||||
|
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||||
|
|
||||||
|
|
||||||
|
async def session_context(auth: str | None):
|
||||||
|
return await db.instance.get_session_context(session_key(auth)) if auth else None
|
Loading…
x
Reference in New Issue
Block a user