From d0afb8f86beb983e7548602bb2a188aab13a0c1f Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Mon, 12 Oct 2026 14:45:52 +0000 Subject: [PATCH 1/2] feat(admin): the user actions behind the admin gate The console's Users screen needs the account actions the CLI reaches the database for directly (users unlock, oauth unlink): an operator who cannot reach the database has no path at all, so the read-only position in user_handlers.go gives way. Still absent on purpose: no password reset, no delete, nothing that reads PasswordHash. Six endpoints, all behind AdminOnly, each audited with the operator's id in metadata and answering the account's state after the action: POST /v1/admin/users (cryden.SignUp) POST /v1/admin/users/{id}/unlock (ResetFailedAttempts) POST /v1/admin/users/{userID}/suspend (LockAccount + revoke-all) POST /v1/admin/users/{userID}/unsuspend (same op as unlock, its own audit type) POST /v1/admin/users/{userID}/revoke-sessions (RevokeAllForUser) POST /v1/admin/users/{userID}/mfa/disable (totp.Delete + revoke) DELETE /v1/admin/users/{userID}/oauth/{provider} (ListByUser + Unlink) Suspend is LockAccount with a far-future deadline, not a new engine mechanism: a suspended sign-in is refused by the engine's own lockout path. MFA disable deletes the enrolment through the TOTP store rather than cryden.DisableTOTP, which requires the holder's password an operator does not have; the enrolment's removal revokes the account's sessions so access held through the factor does not outlive it. Unlock and unsuspend are one engine operation with two names, kept apart for the audit trail: lifting an operator-imposed suspension and clearing an engine-imposed lockout are different sentences in the history. Admin-created accounts go through cryden.SignUp, so the password policy, breach check and rate limiter all run as they would at POST /v1/signup; the audit records both the engine's signup event and the operator's admin_user_created. Every action answers AdminActionDTO: the account as it stands, so a console re-renders without a second GET, with detail carrying what is worth reporting (the revoked session count, the unlinked provider). Tested with a real engine on memory stores behind the real gate: create (sign-up through the policy, duplicate 409), unlock clearing lockout and counter and the proof being a working login, suspend revoking live sessions and blocking the next login, unsuspend restoring sign-in, revoke-sessions emptying the live list, TOTP removal through the engine's own enrolment path and its 404 without one, OAuth unlink and its unknown-provider 404, malformed ids answering 404, and every action refusing a non-operator token. The Tier 6 SQLite 501 test extends to the new paths through its existing adminPaths table. openapi/spec.yaml documents each route and the AdminActionDTO schema; the embedded copy is regenerated. --- httpapi/docs_assets/openapi.yaml | 249 ++++++++++++- httpapi/router.go | 35 ++ httpapi/user_action_handlers.go | 403 +++++++++++++++++++++ httpapi/user_action_handlers_test.go | 516 +++++++++++++++++++++++++++ main.go | 7 + openapi/spec.yaml | 249 ++++++++++++- 6 files changed, 1429 insertions(+), 30 deletions(-) create mode 100644 httpapi/user_action_handlers.go create mode 100644 httpapi/user_action_handlers_test.go 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..cc9bff9 --- /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 From 20038b2db82e25010979746e271650d0eae14f90 Mon Sep 17 00:00:00 2001 From: raymondproguy Date: Mon, 12 Oct 2026 20:25:18 +0000 Subject: [PATCH 2/2] style: gofmt the user action test file --- httpapi/user_action_handlers_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/httpapi/user_action_handlers_test.go b/httpapi/user_action_handlers_test.go index cc9bff9..d1d7cff 100644 --- a/httpapi/user_action_handlers_test.go +++ b/httpapi/user_action_handlers_test.go @@ -57,11 +57,11 @@ func newUserActionFixture(t *testing.T) userActionFixture { }) engine, err := cryden.New(cryden.Config{ - JWTSecret: "test-secret", - Users: users, - Sessions: sessions, - Audit: audit, - Verifications: memory.NewVerificationStore(), + JWTSecret: "test-secret", + Users: users, + Sessions: sessions, + Audit: audit, + Verifications: memory.NewVerificationStore(), EmailSender: stubMailSender{}, MagicLinkSender: stubMailSender{}, APIKeys: memory.NewAPIKeyStore(),