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 += - '
' + + '