From 5f27ed9fcaf0149d2db56b4fd967e2fc50b2901e Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 15 Mar 2026 03:52:42 -0700 Subject: [PATCH] feat: OIDC identity management inline in Users admin tab (#72) Expandable user rows in the console Users tab reveal OIDC identities linked to each user. Issuer badge, truncated subject, email, relative last-login time, and unlink action with confirmation modal + audit trail. Keyboard accessible (tabindex, Enter/Space, aria-expanded, focus-visible). In-place refresh after unlink (no close/reopen flicker). Audit captures user_id before delete. Mobile responsive (3-column at <700px). Reduced-motion support. 2 new admin API endpoints reusing admin.users permission and existing storage methods. --- turnstone/api/console_spec.py | 14 ++ turnstone/console/server.py | 70 ++++++++ turnstone/console/static/admin.js | 279 ++++++++++++++++++++++++++++- turnstone/console/static/style.css | 99 ++++++++++ 4 files changed, 461 insertions(+), 1 deletion(-) diff --git a/turnstone/api/console_spec.py b/turnstone/api/console_spec.py index 38343273..65642311 100644 --- a/turnstone/api/console_spec.py +++ b/turnstone/api/console_spec.py @@ -294,6 +294,20 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [ error_codes=[404], tags=["Admin"], ), + # --- OIDC Identities --- + EndpointSpec( + "/v1/api/admin/users/{user_id}/oidc-identities", + "GET", + "List OIDC identities linked to a user", + tags=["Admin"], + ), + EndpointSpec( + "/v1/api/admin/oidc-identities", + "DELETE", + "Unlink an OIDC identity (issuer + subject as query params)", + error_codes=[400, 404], + tags=["Admin"], + ), # --- Schedules --- EndpointSpec( "/v1/api/admin/schedules", diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 7eb6fcb5..0988558d 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -1098,6 +1098,67 @@ async def admin_delete_channel(request: Request) -> JSONResponse: return JSONResponse({"error": "Channel link not found"}, status_code=404) +# --------------------------------------------------------------------------- +# Admin API endpoints — OIDC identities +# --------------------------------------------------------------------------- + + +async def admin_list_oidc_identities(request: Request) -> JSONResponse: + """GET /v1/api/admin/users/{user_id}/oidc-identities — list OIDC links for a user.""" + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") + if err: + return err + + user_id = request.path_params["user_id"] + identities = storage.list_oidc_identities_for_user(user_id) + return JSONResponse({"oidc_identities": identities}) + + +async def admin_delete_oidc_identity(request: Request) -> JSONResponse: + """DELETE /v1/api/admin/oidc-identities?issuer=...&subject=... — unlink OIDC identity.""" + from turnstone.core.audit import record_audit + from turnstone.core.auth import require_permission + from turnstone.core.web_helpers import require_storage_or_503 + + storage, err = require_storage_or_503(request) + if err: + return err + err = require_permission(request, "admin.users") + if err: + return err + + issuer = request.query_params.get("issuer", "") + subject = request.query_params.get("subject", "") + if not issuer or not subject: + return JSONResponse({"error": "issuer and subject required"}, status_code=400) + + # Look up before delete so audit captures which user was affected + identity = storage.get_oidc_identity(issuer, subject) + if not identity: + return JSONResponse({"error": "Identity not found"}, status_code=404) + + storage.delete_oidc_identity(issuer, subject) + + audit_uid, ip = _audit_context(request) + record_audit( + storage, + audit_uid, + "oidc_identity.delete", + "oidc_identity", + f"{issuer}:{subject}", + {"user_id": identity["user_id"]}, + ip, + ) + + return JSONResponse({"status": "ok"}) + + # --------------------------------------------------------------------------- # Admin API endpoints — scheduled tasks # --------------------------------------------------------------------------- @@ -3628,6 +3689,15 @@ def create_app( admin_delete_channel, methods=["DELETE"], ), + Route( + "/api/admin/users/{user_id}/oidc-identities", + admin_list_oidc_identities, + ), + Route( + "/api/admin/oidc-identities", + admin_delete_oidc_identity, + methods=["DELETE"], + ), Route("/api/admin/schedules", admin_list_schedules), Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]), Route("/api/admin/schedules/{task_id}", admin_get_schedule), diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js index 00ce7683..56404cb9 100644 --- a/turnstone/console/static/admin.js +++ b/turnstone/console/static/admin.js @@ -267,8 +267,13 @@ function _renderUsers(users) { for (var i = 0; i < users.length; i++) { var u = users[i]; html += - '
' + + '