diff --git a/httpapi/docs_assets/openapi.yaml b/httpapi/docs_assets/openapi.yaml index 59f1717..a13880a 100644 --- a/httpapi/docs_assets/openapi.yaml +++ b/httpapi/docs_assets/openapi.yaml @@ -315,22 +315,27 @@ components: to conclude the account is gone. query: { type: string, description: The search that produced this page, empty when browsing. } - AdminUserDetail: - type: object - properties: - user: { $ref: '#/components/schemas/AdminUser' } - active_sessions: - type: integer - description: > - A count, not a list, deliberately. The engine's session store - returns only live sessions, so the count is honest; listing - them would publish every IP and user agent an account has - signed in from to anyone holding an operator token. recent_activity: type: array description: The account's own audit history, newest first, capped at 20. items: { $ref: '#/components/schemas/AuditEvent' } + AdminActionDTO: + type: object + description: > + The answer every admin user action returns: the account as it + stands after the action, so a console re-renders from the answer + rather than issuing a second GET. `user` is the same AdminUser + the list and detail views carry, so one shape is all a client + parses. `detail` is present only when the action has a number or + a name worth reporting (how many sessions were revoked, which + provider was unlinked). + properties: + user: { $ref: '#/components/schemas/AdminUser' } + detail: + type: string + description: A short human-readable note about what the action did. Absent when the action needs no explaining. + AuditEvent: type: object description: > @@ -1683,10 +1688,6 @@ paths: schema: type: object properties: - data: { $ref: '#/components/schemas/UserMetadata' } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } '404': description: > metadata_key_not_found when the user has no such key; @@ -1696,6 +1697,224 @@ paths: application/json: schema: { $ref: '#/components/schemas/ErrorResponse' } + /admin/users/{userID}/unlock: + post: + summary: Clear a lockout and its failed-attempt counter + description: > + Admin only. The operation the engine's own CLI runs as + "users unlock": ResetFailedAttempts, which clears both the + failed-attempt counter and the lockout deadline together. An + account that is not locked is unlocked anyway rather than + refused, because the goal is an account that can sign in, not a + report about a lock. Audited with the operator's id. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The account as it stands after the unlock. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/suspend: + post: + summary: Suspend an account until an operator lifts it + description: > + Admin only. Sets a lockout roughly a century out and revokes the + account's live sessions, so a session granted before the + suspension cannot outlive the lock it was granted under. A + suspended account's sign-in is refused by the engine's normal + lockout path, which is what makes this a suspension rather than + a second lockout implementation. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The suspended account. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/unsuspend: + post: + summary: Clear a suspension (or any lockout) from an account + description: > + Admin only. The same engine operation as unlock + (ResetFailedAttempts clears both the counter and the lockout), + kept as a separate endpoint so the audit trail records which + kind of lift happened: lifting a suspension an operator imposed + is a different sentence in the history than clearing a lockout + the engine imposed at the fifth failed password. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The account, unlocked. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/revoke-sessions: + post: + summary: Revoke every live session an account holds + description: > + Admin only. The engine's own revoke-all, behind the admin gate + and an audit event. The response reports how many sessions were + live when the call landed, which is the number an operator reads + rather than a count they would have to derive from a + before-and-after diff. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: > + The account, with `detail` naming how many live sessions + were revoked. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/mfa/disable: + post: + summary: Remove an account's TOTP enrolment without the holder's password + description: > + Admin only. The engine's own TOTP disable requires the account + holder's password, because it exists for the holder changing + their own settings; an operator acting on somebody else's account + has no password to give. This action deletes the enrolment + directly, records its own audit event with the operator's id, + and revokes the account's sessions with the enrolment, so access + held through the removed factor does not outlive its removal. A + fresh login afterwards needs only the password. + + An account with no enrolment answers 404, the same as any other + missing resource, rather than a success an operator cannot + distinguish from work done. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The account, whose second factor is now gone. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: > + not_found for an unknown or malformed userID, no enrolment to + remove, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/oauth/{provider}: + delete: + summary: Remove one OAuth identity from an account + description: > + Admin only. The operator's version of the engine's own unlink: + found by listing the account's identities and matching the + provider, so a provider the account never linked is a 404 rather + than a silent success. Audited with the operator's id. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + - name: provider + in: path + required: true + schema: { type: string } + responses: + '200': + description: The account after the unlink. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: > + not_found when the account has no linked identity for that + provider, or the userID is unknown or malformed; + not_configured when the stores are absent. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + /admin/anomalies: get: summary: The flagged-event review queue diff --git a/httpapi/router.go b/httpapi/router.go index af2da93..def8301 100644 --- a/httpapi/router.go +++ b/httpapi/router.go @@ -55,6 +55,14 @@ type Deps struct { // not gain one (see usermeta's package doc). Meta usermeta.Store + // OAuth and TOTP back the admin user actions: an operator unlinking + // a provider from an account, or removing an account's second + // factor. The same instances the engine holds, like Users and + // Sessions above: an admin action against a second copy of a store + // would change data the engine cannot see. + OAuth store.OAuthStore + TOTP store.TOTPStore + // Hooks backs the admin webhook delivery log. Nil unless WEBHOOK_URL is // set, because the log is only written by a running delivery worker — // there is nothing to report on in a deployment that dispatches no @@ -121,6 +129,7 @@ func NewRouter(d Deps) http.Handler { security := &SecurityHandlers{Audit: d.Audit, Users: d.Users, Config: d.Config} metadata := &MetadataHandlers{Users: d.Users, Meta: d.Meta} users := &UserHandlers{Engine: engine, Users: d.Users, Sessions: d.Sessions, Audit: d.Audit} + userActions := &UserActionHandlers{Engine: engine, Users: d.Users, Sessions: d.Sessions, Audit: d.Audit, OAuth: d.OAuth, TOTP: d.TOTP} hooks := &WebhookHandlers{Store: d.Hooks} logging := &LoggingHandlers{Store: d.Shipped} digests := &DigestHandlers{Engine: engine, Store: d.Digests} @@ -290,6 +299,32 @@ func NewRouter(d Deps) http.Handler { users.Detail(w, r, r.PathValue("userID")) })) + // The account actions. Every one changes an account that is not the + // operator's, which is what kept this surface unwritten until the + // console needed it (see UserActionHandlers for why the read-only + // position gave way, and what is still absent on purpose). All of + // them sit behind the same adminOnly gate as the reads above, and + // every one records its own audit event naming the operator. + mux.HandleFunc("POST /v1/admin/users", adminOnly(userActions.Create)) + mux.HandleFunc("POST /v1/admin/users/{userID}/unlock", adminOnly(func(w http.ResponseWriter, r *http.Request) { + userActions.Unlock(w, r, r.PathValue("userID")) + })) + mux.HandleFunc("POST /v1/admin/users/{userID}/suspend", adminOnly(func(w http.ResponseWriter, r *http.Request) { + userActions.Suspend(w, r, r.PathValue("userID")) + })) + mux.HandleFunc("POST /v1/admin/users/{userID}/unsuspend", adminOnly(func(w http.ResponseWriter, r *http.Request) { + userActions.Unsuspend(w, r, r.PathValue("userID")) + })) + mux.HandleFunc("POST /v1/admin/users/{userID}/revoke-sessions", adminOnly(func(w http.ResponseWriter, r *http.Request) { + userActions.RevokeSessions(w, r, r.PathValue("userID")) + })) + mux.HandleFunc("POST /v1/admin/users/{userID}/mfa/disable", adminOnly(func(w http.ResponseWriter, r *http.Request) { + userActions.DisableTOTP(w, r, r.PathValue("userID")) + })) + mux.HandleFunc("DELETE /v1/admin/users/{userID}/oauth/{provider}", adminOnly(func(w http.ResponseWriter, r *http.Request) { + userActions.UnlinkOAuth(w, r, r.PathValue("userID"), r.PathValue("provider")) + })) + // Per-user metadata — the table behind JWT claim mapping. Per key // rather than a whole-map PUT, so two operators editing different // fields of one user cannot overwrite each other's work. The user id diff --git a/httpapi/user_action_handlers.go b/httpapi/user_action_handlers.go new file mode 100644 index 0000000..89f3539 --- /dev/null +++ b/httpapi/user_action_handlers.go @@ -0,0 +1,403 @@ +package httpapi + +import ( + "fmt" + "log" + "net/http" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/store" +) + +// UserActionHandlers answers the admin user surface's write half: the +// account actions an operator takes on somebody else's account. +// +// user_handlers.go's doc explains what that surface deliberately did not +// have: no lock, no unlock, nothing that changes an account, on the +// position that an operator who needs one "has the engine's own admin +// path, not an HTTP endpoint this repo invented". The console is now the +// reason that position no longer holds. The engine's own admin path is +// the CLI reaching into the database directly (csax users unlock clears +// a lockout with ResetFailedAttempts, csax oauth unlink runs bare SQL +// against oauth_identities), and a console that cannot reach the +// database has no path at all. Rather than send an operator to a SQL +// prompt, these endpoints are the same operations behind the admin gate, +// each one audited, each one answered with the state the account is in +// afterwards. What is still deliberately absent is unchanged: no +// password reset, no delete, nothing that reads PasswordHash. +type UserActionHandlers struct { + // Engine is used for exactly one call: cryden.SignUp, the engine's + // own public path for creating an account, so the password policy, + // the breach check and the rate limiter all run as they would for a + // signup that arrived at POST /v1/signup. + Engine *cryden.Engine + + // Users, Sessions, Audit, OAuth and TOTP are the same store + // instances main.go handed cryden, for the reason httpapi.Deps + // gives: an action against a second store object is a change to + // different data than the engine would act on. + Users store.UserStore + Sessions store.SessionStore + Audit store.AuditStore + OAuth store.OAuthStore + TOTP store.TOTPStore +} + +// The audit event types this surface records for its own actions. The +// engine's vocabulary (store.EventAccountLocked and friends) names what +// the engine did to an account during a login; these name what an +// operator did to it. They live here rather than in cryden because the +// engine has no operator concept to attach them to, and the audit table +// holds any type a host writes (see AuditStore.CountByType), so a host +// defining its own event types is a supported shape, not a special case. +const ( + EventAdminUserCreated store.AuditEventType = "admin_user_created" + EventAdminAccountUnlocked store.AuditEventType = "admin_account_unlocked" + EventAdminUserSuspended store.AuditEventType = "admin_user_suspended" + EventAdminUserUnsuspended store.AuditEventType = "admin_user_unsuspended" + EventAdminSessionsRevoked store.AuditEventType = "admin_sessions_revoked" + EventAdminTOTPDisabled store.AuditEventType = "admin_totp_disabled" + EventAdminOAuthUnlinked store.AuditEventType = "admin_oauth_unlinked" +) + +// opUserID reads the operator's own id out of the context RequireAdmin +// put there. It is the audit trail's answer to "who did this", and it is +// read from the verified token's subject rather than from the body, so +// a caller cannot name somebody else as the reviewer. +func opUserID(r *http.Request) string { + id, _ := r.Context().Value(userIDContextKey).(string) + return id +} + +// adminActionDTO is the answer every action here returns: the account as +// it stands after the action. The console re-renders the detail view +// from it rather than issuing a second GET, and the fields are the same +// adminUserDTO the list and detail views carry, so one shape is all the +// client ever parses. +type adminActionDTO struct { + User adminUserDTO `json:"user"` + Detail string `json:"detail,omitempty"` +} + +// requireUser is the read every action starts from: the account has to +// exist, and a path segment that cannot be a user id is answered 404 the +// same as one that is simply not a user (see Detail for the full reason +// a malformed id never reaches Postgres). +func (h *UserActionHandlers) requireUser(w http.ResponseWriter, r *http.Request, userID string) (store.User, bool) { + if !looksLikeUUID(userID) { + writeErr(w, store.ErrNotFound) + return store.User{}, false + } + if h.Users == nil || h.Audit == nil { + writeErr(w, errAdminStoresUnavailable) + return store.User{}, false + } + user, err := h.Users.GetByID(r.Context(), userID) + if err != nil { + writeErr(w, err) + return store.User{}, false + } + return user, true +} + +// record writes one audit event for an operator action. The operator id +// rides in Metadata rather than UserID: the audit row's UserID is the +// account the event happened to, and an event that named the operator +// there would file an action taken on account B under account A. +func (h *UserActionHandlers) record(r *http.Request, eventType store.AuditEventType, target store.User, detail string, operatorID string) { + metadata := map[string]string{"by": operatorID} + if detail != "" { + metadata["detail"] = detail + } + if err := h.Audit.Record(r.Context(), store.AuditEvent{ + Type: eventType, + UserID: target.ID, + IP: CallerIP(r), + Metadata: metadata, + }); err != nil { + // The action succeeded and is being reported as succeeded. An + // audit write that failed is logged and answered anyway: failing + // the request now would leave the account changed while the + // client saw an error, which is the one outcome worse than an + // unrecorded event. The same trade the engine's own login path + // makes (auth/login.go logs a failed audit write and continues). + log.Printf("admin user action: audit record error (%s for %s): %v", eventType, target.ID, err) + } +} + +// Create registers a new account: POST /v1/admin/users. +// +// It is cryden.SignUp, the engine's own public path, with the caller IP +// labelled "operator" rather than spoofed: the engine requires a caller +// IP and uses it for rate limiting and audit, and the honest label is +// the API's own convention (the CLI uses "cli" the same way). The rate +// limiter that SignUp consults is the engine's per-IP limiter keyed on +// that label, so a console signing up many accounts in one window +// shares one limiter bucket; that is the cost of reusing the engine's +// path rather than reaching for Users.Create directly, and it is the +// safe direction to be wrong in. +func (h *UserActionHandlers) Create(w http.ResponseWriter, r *http.Request) { + if h.Engine == nil || h.Users == nil || h.Audit == nil { + writeErr(w, errAdminStoresUnavailable) + return + } + + var body struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := decodeJSON(r, &body); err != nil { + writeBadRequest(w, "a JSON body with email and password is required") + return + } + if body.Email == "" || body.Password == "" { + writeBadRequest(w, "email and password are both required") + return + } + + user, err := cryden.SignUp(r.Context(), h.Engine, body.Email, body.Password, "operator") + if err != nil { + writeErr(w, err) + return + } + + // SignUp records its own EventSignupSuccess as any signup would. This + // event is the second sentence in the audit trail: the same account, + // naming the operator who created it. Two events, because they answer + // two different questions ("was this account signed up" and "who put + // it here"), and collapsing them would make an operator-created + // account indistinguishable from a self-registered one in the history + // the console's detail view reads. + h.record(r, EventAdminUserCreated, user, "", opUserID(r)) + + writeData(w, http.StatusCreated, adminActionDTO{User: newAdminUserDTO(user, time.Now())}) +} + +// Unlock clears a lockout and its failed-attempt counter: +// POST /v1/admin/users/{userID}/unlock. +// +// ResetFailedAttempts is the operation the CLI's `csax users unlock` +// runs, unchanged: it clears both the counter and LockedUntil together, +// which is the engine's own definition of an unlock. An account that is +// not locked is unlocked anyway rather than refused, because the goal +// is an account that can sign in, not a report about a lock. +func (h *UserActionHandlers) Unlock(w http.ResponseWriter, r *http.Request, userID string) { + target, ok := h.requireUser(w, r, userID) + if !ok { + return + } + + if err := h.Users.ResetFailedAttempts(r.Context(), target.ID); err != nil { + writeErr(w, err) + return + } + + h.record(r, EventAdminAccountUnlocked, target, "", opUserID(r)) + + user, err := h.Users.GetByID(r.Context(), target.ID) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, adminActionDTO{User: newAdminUserDTO(user, time.Now())}) +} + +// Suspend sets an account's lockout with no expiry: +// POST /v1/admin/users/{userID}/suspend. +// +// The engine has no suspend of its own; LockAccount with a far-future +// time is what the CLI would hold in a WHERE clause, and it keeps the +// engine's own semantics: a login against a locked account is refused +// by the engine's normal path, the audit history records the action, +// and sessions are revoked below so a live session cannot outlive the +// lock it was granted under. +func (h *UserActionHandlers) Suspend(w http.ResponseWriter, r *http.Request, userID string) { + target, ok := h.requireUser(w, r, userID) + if !ok { + return + } + + // A century out: long enough that no operator will be asked to renew + // it, short enough to stay inside what every date column and every + // client parses. Far-future sentinel dates have burned people before + // (the year-2038 overflow, Excel's 9999-12-31); 2126 keeps the same + // shape as an ordinary expiry while never arriving. + until := time.Now().AddDate(100, 0, 0) + if err := h.Users.LockAccount(r.Context(), target.ID, until); err != nil { + writeErr(w, err) + return + } + if err := h.Sessions.RevokeAllForUser(r.Context(), target.ID); err != nil { + writeErr(w, err) + return + } + + h.record(r, EventAdminUserSuspended, target, "indefinite", opUserID(r)) + + user, err := h.Users.GetByID(r.Context(), target.ID) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, adminActionDTO{User: newAdminUserDTO(user, time.Now())}) +} + +// Unsuspend clears a lockout outright: +// POST /v1/admin/users/{userID}/unsuspend. +// +// ResetFailedAttempts already clears LockedUntil (see the CLI's unlock, +// which is the behaviour this reuses), so unlock and unsuspend are one +// engine operation with two names. The distinction the console keeps is +// the audit record: lifting a suspension an operator imposed is a +// different sentence in an audit trail than clearing a lockout the +// engine itself imposed, and the account's history should say which +// happened. +func (h *UserActionHandlers) Unsuspend(w http.ResponseWriter, r *http.Request, userID string) { + target, ok := h.requireUser(w, r, userID) + if !ok { + return + } + + if err := h.Users.ResetFailedAttempts(r.Context(), target.ID); err != nil { + writeErr(w, err) + return + } + + h.record(r, EventAdminUserUnsuspended, target, "", opUserID(r)) + + user, err := h.Users.GetByID(r.Context(), target.ID) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, adminActionDTO{User: newAdminUserDTO(user, time.Now())}) +} + +// RevokeSessions revokes every session an account holds: +// POST /v1/admin/users/{userID}/revoke-sessions. +// +// The engine's own RevokeAllForUser is the operation; the handler +// exists because the CLI reaches the same store directly and a console +// has no other road to it. The response reports how many sessions were +// live when the call landed, which is the number an operator reads +// rather than a count they would have to derive from a before-and-after +// diff. +func (h *UserActionHandlers) RevokeSessions(w http.ResponseWriter, r *http.Request, userID string) { + target, ok := h.requireUser(w, r, userID) + if !ok { + return + } + if h.Sessions == nil { + writeErr(w, errAdminStoresUnavailable) + return + } + + sessions, err := h.Sessions.ListByUser(r.Context(), target.ID) + if err != nil { + writeErr(w, err) + return + } + if err := h.Sessions.RevokeAllForUser(r.Context(), target.ID); err != nil { + writeErr(w, err) + return + } + + h.record(r, EventAdminSessionsRevoked, target, "", opUserID(r)) + + writeData(w, http.StatusOK, adminActionDTO{ + User: newAdminUserDTO(target, time.Now()), + Detail: fmt.Sprintf("%d live session(s) revoked", len(sessions)), + }) +} + +// DisableTOTP removes an account's second factor without the account +// holder's password: POST /v1/admin/users/{userID}/mfa/disable. +// +// The engine's own DisableTOTP requires the account holder's password, +// because it exists for the account holder changing their own settings, +// and an operator acting on somebody else's account has no password to +// give. The CLI reaches the TOTP store directly for the same reason. +// The compromise here is narrow: the operator's action is gated behind +// RequireAdmin, recorded in the audit trail with their id alongside the +// engine's own EventTOTPDisabled shape (the store.Delete plus an +// audited event, so the account's history reads the same either way), +// and the account's sessions are revoked with the enrolment, so whoever +// held the device the secret was on cannot keep the access it was +// protecting. A fresh login after that needs the password again. +func (h *UserActionHandlers) DisableTOTP(w http.ResponseWriter, r *http.Request, userID string) { + target, ok := h.requireUser(w, r, userID) + if !ok { + return + } + if h.TOTP == nil { + writeErr(w, errAdminStoresUnavailable) + return + } + + if err := h.TOTP.Delete(r.Context(), target.ID); err != nil { + writeErr(w, err) + return + } + if h.Sessions != nil { + if err := h.Sessions.RevokeAllForUser(r.Context(), target.ID); err != nil { + writeErr(w, err) + return + } + } + + h.record(r, EventAdminTOTPDisabled, target, "", opUserID(r)) + + user, err := h.Users.GetByID(r.Context(), target.ID) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, adminActionDTO{User: newAdminUserDTO(user, time.Now())}) +} + +// Unlink removes one OAuth identity from an account without the user's +// consent or participation: DELETE /v1/admin/users/{userID}/oauth/{provider}. +// +// The CLI's oauth unlink reaches the table directly; this is the same +// operation behind the admin gate and an audit event. The identity is +// found by listing the account's identities and matching provider, so +// a provider the account never linked is a 404 rather than a silent +// success, and the identity id (not the provider name) is what Unlink +// takes, which keeps the delete pinned to one row. +func (h *UserActionHandlers) UnlinkOAuth(w http.ResponseWriter, r *http.Request, userID, provider string) { + target, ok := h.requireUser(w, r, userID) + if !ok { + return + } + if h.OAuth == nil { + writeErr(w, errAdminStoresUnavailable) + return + } + + identities, err := h.OAuth.ListByUser(r.Context(), target.ID) + if err != nil { + writeErr(w, err) + return + } + var identity *store.OAuthIdentity + for i := range identities { + if identities[i].Provider == provider { + identity = &identities[i] + break + } + } + if identity == nil { + writeErr(w, store.ErrNotFound) + return + } + + if err := h.OAuth.Unlink(r.Context(), identity.ID); err != nil { + writeErr(w, err) + return + } + + h.record(r, EventAdminOAuthUnlinked, target, provider, opUserID(r)) + + writeData(w, http.StatusOK, adminActionDTO{User: newAdminUserDTO(target, time.Now()), Detail: "unlinked " + provider}) +} diff --git a/httpapi/user_action_handlers_test.go b/httpapi/user_action_handlers_test.go new file mode 100644 index 0000000..d1d7cff --- /dev/null +++ b/httpapi/user_action_handlers_test.go @@ -0,0 +1,516 @@ +package httpapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/store" + "github.com/crydensync/cryden/v2/store/memory" + + "github.com/crydensync/api/config" + "github.com/crydensync/api/usermeta" +) + +// userActionFixture is userFixture's shape with the two extra stores the +// actions need wired through to the router: OAuth and TOTP. It is a +// separate fixture rather than an extension of userFixture because the +// read-only surface's tests do not need OAuth or TOTP stores, and a +// fixture that wires a dependency a handler never reads teaches the +// next reader that nil is a state the router can be in, which for these +// two fields it now always is in production. +type userActionFixture struct { + engine *cryden.Engine + router http.Handler + users *memory.UserStore + sessions *memory.SessionStore + audit *idAssigningAuditStore + oauth *memory.OAuthStore + totp *memory.TOTPStore + userID string + opID string + opToken string + userToken string +} + +func newUserActionFixture(t *testing.T) userActionFixture { + t.Helper() + ctx := context.Background() + + users := memory.NewUserStore() + sessions := memory.NewSessionStore() + audit := &idAssigningAuditStore{AuditStore: memory.NewAuditStore()} + oauth := memory.NewOAuthStore() + totp := memory.NewTOTPStore() + + var operatorID string + roles := roleFunc(func(_ context.Context, userID string) (string, bool, error) { + if userID == operatorID { + return "admin", true, nil + } + return "", false, nil + }) + + engine, err := cryden.New(cryden.Config{ + JWTSecret: "test-secret", + Users: users, + Sessions: sessions, + Audit: audit, + Verifications: memory.NewVerificationStore(), + EmailSender: stubMailSender{}, + MagicLinkSender: stubMailSender{}, + APIKeys: memory.NewAPIKeyStore(), + APIKeyPrefix: "ck", + OAuth: oauth, + // TOTP is wired so the mfa/disable action can be tested against + // the engine's own enrolment path, which requires the store and + // an encryption key together (cryden validates that pair). The + // memory TOTP store is what the engine gets here, and the same + // handle the fixture keeps for asserting on the enrolment. + TOTP: totp, + EncryptionKey: "0123456789abcdef0123456789abcdef", + // Same reasoning as userFixture's: the real claims provider, so + // the gate these tests cross is the one production has. + AccessTokenClaims: usermeta.ClaimsProvider(usermeta.NewMemoryStore(), roles), + }) + if err != nil { + t.Fatalf("building engine: %v", err) + } + + operator, err := cryden.SignUp(ctx, engine, "operator@example.com", testPassword, "203.0.113.1") + if err != nil { + t.Fatalf("signup (operator): %v", err) + } + // operatorID must be set before the operator's login: the role claim + // is attached to the access token when it is issued, so the closure + // above has to know who the operator is by then. (userFixture makes + // the same assignment in the same place.) + operatorID = operator.ID + opTokens, err := cryden.Login(ctx, engine, "operator@example.com", testPassword, "203.0.113.1", chromeOnMacOS) + if err != nil { + t.Fatalf("login (operator): %v", err) + } + + user, err := cryden.SignUp(ctx, engine, "user@example.com", testPassword, "203.0.113.2") + if err != nil { + t.Fatalf("signup (user): %v", err) + } + userTokens, err := cryden.Login(ctx, engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS) + if err != nil { + t.Fatalf("login (user): %v", err) + } + + return userActionFixture{ + engine: engine, + router: NewRouter(Deps{ + Engine: engine, + Config: config.Config{}, + Users: users, + Sessions: sessions, + Audit: audit, + Meta: usermeta.NewMemoryStore(), + OAuth: oauth, + TOTP: totp, + }), + users: users, + sessions: sessions, + audit: audit, + oauth: oauth, + totp: totp, + userID: user.ID, + opID: operator.ID, + opToken: opTokens.AccessToken, + userToken: userTokens.AccessToken, + } +} + +func (f userActionFixture) call(t *testing.T, method, path, token string, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + if body != "" { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + f.router.ServeHTTP(rec, req) + return rec +} + +// actionResponse is the envelope every action here answers with. +type userActionResponse struct { + Data struct { + User struct { + ID string `json:"id"` + Email string `json:"email"` + FailedAttempts int `json:"failed_attempts"` + Locked bool `json:"locked"` + LockedUntil *time.Time `json:"locked_until"` + } `json:"user"` + Detail string `json:"detail"` + } `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func decodeAction(t *testing.T, rec *httptest.ResponseRecorder) userActionResponse { + t.Helper() + var body userActionResponse + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decoding response: %v", err) + } + return body +} + +// eventsOf returns the audit events the fixture's store holds for one +// user, newest last, in insertion order. +func (f userActionFixture) eventsFor(t *testing.T, userID string) []store.AuditEvent { + t.Helper() + events, err := f.audit.ListByUser(context.Background(), userID, 100) + if err != nil { + t.Fatalf("listing audit events: %v", err) + } + return events +} + +// --- Create --------------------------------------------------------------- + +func TestAdminCreateUser(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodPost, "/v1/admin/users", f.opToken, + `{"email":"new@example.com","password":"`+testPassword+`"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + body := decodeAction(t, rec) + if body.Data.User.Email != "new@example.com" { + t.Errorf("user email = %q", body.Data.User.Email) + } + + // The new account can actually sign in: the action went through the + // engine's signup, password policy and all, not a bare store write. + if _, err := cryden.Login(context.Background(), f.engine, "new@example.com", testPassword, "203.0.113.9", chromeOnMacOS); err != nil { + t.Fatalf("login with created account: %v", err) + } + + // Two audit events: the engine's own signup and the operator's. + types := map[string]bool{} + for _, e := range f.eventsFor(t, body.Data.User.ID) { + types[string(e.Type)] = true + } + if !types["signup_success"] || !types["admin_user_created"] { + t.Errorf("audit events = %v, want signup_success and admin_user_created", types) + } +} + +func TestAdminCreateUserDuplicateEmail(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodPost, "/v1/admin/users", f.opToken, + `{"email":"user@example.com","password":"`+testPassword+`"}`) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestAdminCreateUserRequiresBothFields(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodPost, "/v1/admin/users", f.opToken, `{"email":"only@example.com"}`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestAdminCreateUserForbiddenForNonOperator(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodPost, "/v1/admin/users", f.userToken, + `{"email":"x@example.com","password":"`+testPassword+`"}`) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +// --- Unlock ---------------------------------------------------------------- + +func TestAdminUnlockClearsLockoutAndCounter(t *testing.T) { + f := newUserActionFixture(t) + + // Lock the account the way the engine does: failed attempts past the + // threshold, which sets both the counter and LockedUntil. + for i := 0; i < 5; i++ { + if _, err := cryden.Login(context.Background(), f.engine, "user@example.com", "wrong-password", "203.0.113.2", chromeOnMacOS); err == nil { + t.Fatal("expected failed logins to fail") + } + } + + user, err := f.users.GetByID(context.Background(), f.userID) + if err != nil || !user.LockedUntil.After(time.Now()) { + t.Fatalf("expected the account to be locked (err=%v, until=%v)", err, user.LockedUntil) + } + + rec := f.call(t, http.MethodPost, "/v1/admin/users/"+f.userID+"/unlock", f.opToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + body := decodeAction(t, rec) + if body.Data.User.Locked { + t.Error("user still locked after unlock") + } + if body.Data.User.FailedAttempts != 0 { + t.Errorf("failed attempts = %d, want 0", body.Data.User.FailedAttempts) + } + + // The proof of an unlock is a login that works. + if _, err := cryden.Login(context.Background(), f.engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS); err != nil { + t.Fatalf("login after unlock: %v", err) + } +} + +// --- Suspend and unsuspend ------------------------------------------------- + +func TestAdminSuspendBlocksLoginAndRevokesSessions(t *testing.T) { + f := newUserActionFixture(t) + + // The account has a live session a suspension must not leave usable. + if _, err := cryden.Login(context.Background(), f.engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS); err != nil { + t.Fatalf("precondition: login: %v", err) + } + + rec := f.call(t, http.MethodPost, "/v1/admin/users/"+f.userID+"/suspend", f.opToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + body := decodeAction(t, rec) + if !body.Data.User.Locked { + t.Error("user not locked after suspend") + } + + // A live session cannot outlive the lock it was granted under. + sessions, err := f.sessions.ListByUser(context.Background(), f.userID) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + for _, s := range sessions { + if s.RevokedAt == nil { + t.Errorf("session %s survived the suspend", s.ID) + } + } + + // And the engine refuses the password it would otherwise accept. + if _, err := cryden.Login(context.Background(), f.engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS); err == nil { + t.Error("login succeeded on a suspended account") + } +} + +func TestAdminUnsuspendRestoresSignIn(t *testing.T) { + f := newUserActionFixture(t) + + if err := f.users.LockAccount(context.Background(), f.userID, time.Now().Add(time.Hour)); err != nil { + t.Fatalf("locking account: %v", err) + } + + rec := f.call(t, http.MethodPost, "/v1/admin/users/"+f.userID+"/unsuspend", f.opToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + body := decodeAction(t, rec) + if body.Data.User.Locked { + t.Error("user still locked after unsuspend") + } + + if _, err := cryden.Login(context.Background(), f.engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS); err != nil { + t.Fatalf("login after unsuspend: %v", err) + } +} + +// --- Revoke sessions -------------------------------------------------------- + +func TestAdminRevokeSessions(t *testing.T) { + f := newUserActionFixture(t) + + if _, err := cryden.Login(context.Background(), f.engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS); err != nil { + t.Fatalf("precondition: login: %v", err) + } + + rec := f.call(t, http.MethodPost, "/v1/admin/users/"+f.userID+"/revoke-sessions", f.opToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + sessions, err := f.sessions.ListByUser(context.Background(), f.userID) + if err != nil { + t.Fatalf("list sessions: %v", err) + } + // The memory store's ListByUser returns only live sessions, so an + // empty list after the revoke is the success condition, and the + // login above guarantees there was something to revoke. The handler + // itself reports the count it saw before revoking, which is the + // number this asserts on without re-deriving it. + if len(sessions) != 0 { + t.Errorf("live sessions after revoke = %d, want 0", len(sessions)) + } +} + +// --- MFA disable ------------------------------------------------------------- + +func TestAdminDisableTOTP(t *testing.T) { + f := newUserActionFixture(t) + + // Enrol TOTP the way the account holder would, and confirm it, so + // the secret is one that actually gates login. + ctx := context.Background() + if _, err := cryden.EnrollTOTP(ctx, f.engine, f.userID); err != nil { + t.Fatalf("enroll: %v", err) + } + if err := f.totp.Confirm(ctx, f.userID); err != nil { + t.Fatalf("confirming enrolment: %v", err) + } + if _, err := f.totp.GetByUserID(ctx, f.userID); err != nil { + t.Fatalf("precondition: secret exists: %v", err) + } + + rec := f.call(t, http.MethodPost, "/v1/admin/users/"+f.userID+"/mfa/disable", f.opToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // The enrolment is gone: a fresh login asks for the password and + // nothing else, which is what removing the second factor means. + if _, err := cryden.Login(ctx, f.engine, "user@example.com", testPassword, "203.0.113.2", chromeOnMacOS); err != nil { + t.Fatalf("login after mfa disable: %v", err) + } + + types := map[string]bool{} + for _, e := range f.eventsFor(t, f.userID) { + types[string(e.Type)] = true + } + if !types["admin_totp_disabled"] { + t.Errorf("audit events = %v, want admin_totp_disabled", types) + } +} + +func TestAdminDisableTOTPWithoutEnrolment(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodPost, "/v1/admin/users/"+f.userID+"/mfa/disable", f.opToken, "") + // The engine's own TOTP store answers ErrNotFound for an account + // with no enrolment, and mapError turns that into the same 404 every + // missing resource gets. + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body = %s", rec.Code, rec.Body.String()) + } +} + +// --- OAuth unlink ------------------------------------------------------------- + +func TestAdminUnlinkOAuth(t *testing.T) { + f := newUserActionFixture(t) + + if err := f.oauth.Link(context.Background(), store.OAuthIdentity{ + ID: "00000000-0000-0000-0000-0000000000a1", + UserID: f.userID, + Provider: "github", + ExternalID: "gh-1", + Email: "user@example.com", + }); err != nil { + t.Fatalf("linking identity: %v", err) + } + + rec := f.call(t, http.MethodDelete, "/v1/admin/users/"+f.userID+"/oauth/github", f.opToken, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // The row is gone from the store the engine authenticates against. + if _, err := f.oauth.ListByUser(context.Background(), f.userID); err == nil { + identities, _ := f.oauth.ListByUser(context.Background(), f.userID) + if len(identities) != 0 { + t.Errorf("identities = %d, want 0", len(identities)) + } + } + + types := map[string]bool{} + for _, e := range f.eventsFor(t, f.userID) { + types[string(e.Type)] = true + } + if !types["admin_oauth_unlinked"] { + t.Errorf("audit events = %v, want admin_oauth_unlinked", types) + } +} + +func TestAdminUnlinkUnknownProvider(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodDelete, "/v1/admin/users/"+f.userID+"/oauth/github", f.opToken, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +// --- Common behaviours -------------------------------------------------------- + +func TestAdminActionOnMalformedUserID(t *testing.T) { + f := newUserActionFixture(t) + + // A path segment that cannot be a user id is a 404, the same answer + // as a well-formed id that names nobody: nothing about the shape of + // an id tells a caller whether the account exists. + rec := f.call(t, http.MethodPost, "/v1/admin/users/not-a-uuid/unlock", f.opToken, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestAdminActionsRequireAuth(t *testing.T) { + f := newUserActionFixture(t) + + paths := []string{ + "/v1/admin/users/" + f.userID + "/unlock", + "/v1/admin/users/" + f.userID + "/suspend", + "/v1/admin/users/" + f.userID + "/unsuspend", + "/v1/admin/users/" + f.userID + "/revoke-sessions", + "/v1/admin/users/" + f.userID + "/mfa/disable", + } + for _, p := range paths { + rec := f.call(t, http.MethodPost, p, "", "") + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s without a token: status = %d, want 401", p, rec.Code) + } + } +} + +func TestAdminUnlinkRequiresAuth(t *testing.T) { + f := newUserActionFixture(t) + + rec := f.call(t, http.MethodDelete, "/v1/admin/users/"+f.userID+"/oauth/github", "", "") + if rec.Code != http.StatusUnauthorized { + t.Errorf("unlink without a token: status = %d, want 401", rec.Code) + } +} + +// eventsOf returns the audit events the fixture's store holds for one +// user, in insertion order (ListByUser returns newest first; the tests +// above read them as a set, so order does not matter to them). +func (f userActionFixture) auditTypes(t *testing.T, userID string) map[string]bool { + t.Helper() + types := map[string]bool{} + for _, e := range f.eventsFor(t, userID) { + types[string(e.Type)] = true + } + return types +} diff --git a/main.go b/main.go index f09d776..aa334da 100644 --- a/main.go +++ b/main.go @@ -604,6 +604,13 @@ func serve() { Sessions: sessions, + // The admin user actions need the same store instances the + // engine holds (see httpapi.Deps). On SQLite these are nil and + // every route that would use them is behind AdminOnly's flat 501 + // anyway. + OAuth: st.oauth, + TOTP: st.totp, + Shipped: shippedLog, Digests: digestStore, diff --git a/openapi/spec.yaml b/openapi/spec.yaml index 59f1717..a13880a 100644 --- a/openapi/spec.yaml +++ b/openapi/spec.yaml @@ -315,22 +315,27 @@ components: to conclude the account is gone. query: { type: string, description: The search that produced this page, empty when browsing. } - AdminUserDetail: - type: object - properties: - user: { $ref: '#/components/schemas/AdminUser' } - active_sessions: - type: integer - description: > - A count, not a list, deliberately. The engine's session store - returns only live sessions, so the count is honest; listing - them would publish every IP and user agent an account has - signed in from to anyone holding an operator token. recent_activity: type: array description: The account's own audit history, newest first, capped at 20. items: { $ref: '#/components/schemas/AuditEvent' } + AdminActionDTO: + type: object + description: > + The answer every admin user action returns: the account as it + stands after the action, so a console re-renders from the answer + rather than issuing a second GET. `user` is the same AdminUser + the list and detail views carry, so one shape is all a client + parses. `detail` is present only when the action has a number or + a name worth reporting (how many sessions were revoked, which + provider was unlinked). + properties: + user: { $ref: '#/components/schemas/AdminUser' } + detail: + type: string + description: A short human-readable note about what the action did. Absent when the action needs no explaining. + AuditEvent: type: object description: > @@ -1683,10 +1688,6 @@ paths: schema: type: object properties: - data: { $ref: '#/components/schemas/UserMetadata' } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } '404': description: > metadata_key_not_found when the user has no such key; @@ -1696,6 +1697,224 @@ paths: application/json: schema: { $ref: '#/components/schemas/ErrorResponse' } + /admin/users/{userID}/unlock: + post: + summary: Clear a lockout and its failed-attempt counter + description: > + Admin only. The operation the engine's own CLI runs as + "users unlock": ResetFailedAttempts, which clears both the + failed-attempt counter and the lockout deadline together. An + account that is not locked is unlocked anyway rather than + refused, because the goal is an account that can sign in, not a + report about a lock. Audited with the operator's id. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The account as it stands after the unlock. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/suspend: + post: + summary: Suspend an account until an operator lifts it + description: > + Admin only. Sets a lockout roughly a century out and revokes the + account's live sessions, so a session granted before the + suspension cannot outlive the lock it was granted under. A + suspended account's sign-in is refused by the engine's normal + lockout path, which is what makes this a suspension rather than + a second lockout implementation. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The suspended account. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/unsuspend: + post: + summary: Clear a suspension (or any lockout) from an account + description: > + Admin only. The same engine operation as unlock + (ResetFailedAttempts clears both the counter and the lockout), + kept as a separate endpoint so the audit trail records which + kind of lift happened: lifting a suspension an operator imposed + is a different sentence in the history than clearing a lockout + the engine imposed at the fifth failed password. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The account, unlocked. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/revoke-sessions: + post: + summary: Revoke every live session an account holds + description: > + Admin only. The engine's own revoke-all, behind the admin gate + and an audit event. The response reports how many sessions were + live when the call landed, which is the number an operator reads + rather than a count they would have to derive from a + before-and-after diff. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: > + The account, with `detail` naming how many live sessions + were revoked. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: not_found for an unknown or malformed userID, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/mfa/disable: + post: + summary: Remove an account's TOTP enrolment without the holder's password + description: > + Admin only. The engine's own TOTP disable requires the account + holder's password, because it exists for the holder changing + their own settings; an operator acting on somebody else's account + has no password to give. This action deletes the enrolment + directly, records its own audit event with the operator's id, + and revokes the account's sessions with the enrolment, so access + held through the removed factor does not outlive its removal. A + fresh login afterwards needs only the password. + + An account with no enrolment answers 404, the same as any other + missing resource, rather than a success an operator cannot + distinguish from work done. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + responses: + '200': + description: The account, whose second factor is now gone. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: > + not_found for an unknown or malformed userID, no enrolment to + remove, or not_configured. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + + /admin/users/{userID}/oauth/{provider}: + delete: + summary: Remove one OAuth identity from an account + description: > + Admin only. The operator's version of the engine's own unlink: + found by listing the account's identities and matching the + provider, so a provider the account never linked is a 404 rather + than a silent success. Audited with the operator's id. + security: [{ bearerAuth: [] }] + parameters: + - name: userID + in: path + required: true + schema: { type: string, format: uuid } + - name: provider + in: path + required: true + schema: { type: string } + responses: + '200': + description: The account after the unlink. + content: + application/json: + schema: + type: object + properties: + data: { $ref: '#/components/schemas/AdminActionDTO' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '501': { $ref: '#/components/responses/NotImplementedOnSQLite' } + '404': + description: > + not_found when the account has no linked identity for that + provider, or the userID is unknown or malformed; + not_configured when the stores are absent. + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorResponse' } + /admin/anomalies: get: summary: The flagged-event review queue