diff --git a/api/openapi.yaml b/api/openapi.yaml index e1ca1175..006aaeaf 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -554,6 +554,107 @@ paths: '204': description: Role removed (idempotent) + /api/v1/users/{id}:reset-password: + post: + operationId: postUserResetPassword + summary: Admin-reset a user's password (own or another's) + description: | + Sets a user's password on administrator authority - no current + password required. The new password runs through the role-aware + policy + breach screen; the target's active sessions are revoked so + they must re-authenticate. RBAC: admin:user_manage. Spec api-users. + x-required-permission: admin:user_manage + x-audit-events: [admin.user.password_reset] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UserPasswordResetRequest'} + responses: + '204': + description: Password reset; the target's sessions were revoked + '400': + description: New password rejected by policy (too short/long/breached) + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '403': + description: Caller lacks admin:user_manage permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: User not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/users/{id}:disable: + post: + operationId: postUserDisable + summary: Disable a user account + description: | + Marks the account disabled: it can no longer authenticate (login is + rejected) and the user's active sessions are revoked immediately. An + admin cannot disable their own account (lockout prevention, 409). + RBAC: admin:user_manage. Spec api-users. + x-required-permission: admin:user_manage + x-audit-events: [admin.user.disabled] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': + description: The disabled user + content: + application/json: + schema: {$ref: '#/components/schemas/UserResponse'} + '403': + description: Caller lacks admin:user_manage permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: User not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '409': + description: Cannot disable your own account + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + + /api/v1/users/{id}:enable: + post: + operationId: postUserEnable + summary: Re-enable a disabled user account + description: | + Clears the disabled flag so the user can authenticate again with a + fresh login (sessions revoked while disabled stay dead). RBAC: + admin:user_manage. Spec api-users. + x-required-permission: admin:user_manage + x-audit-events: [admin.user.enabled] + parameters: + - {name: id, in: path, required: true, schema: {type: string, format: uuid}} + responses: + '200': + description: The re-enabled user + content: + application/json: + schema: {$ref: '#/components/schemas/UserResponse'} + '403': + description: Caller lacks admin:user_manage permission + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '404': + description: User not found + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/roles:create: post: operationId: postRolesCreate @@ -3708,6 +3809,17 @@ components: last_password_change_at: {type: string, format: date-time} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} + disabled_at: + type: string + format: date-time + nullable: true + description: Non-null when the account is disabled (cannot authenticate) + + UserPasswordResetRequest: + type: object + required: [new_password] + properties: + new_password: {type: string, minLength: 1, maxLength: 256} UsersListResponse: type: object diff --git a/audit/events.yaml b/audit/events.yaml index 4a83d1b2..d42d4820 100644 --- a/audit/events.yaml +++ b/audit/events.yaml @@ -952,6 +952,31 @@ events: - code: admin.user.deleted severity: warning + - code: admin.user.password_reset + severity: warning + description: An administrator reset another user's (or their own) password. + detail_schema: + type: object + properties: + target_user_id: {type: string} + self: {type: boolean} + + - code: admin.user.disabled + severity: warning + description: An administrator disabled a user account (cannot authenticate). + detail_schema: + type: object + properties: + target_user_id: {type: string} + + - code: admin.user.enabled + severity: warning + description: An administrator re-enabled a previously disabled user account. + detail_schema: + type: object + properties: + target_user_id: {type: string} + - code: admin.role.changed severity: warning diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 7554df09..18a53204 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -451,6 +451,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users/{id}:reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin-reset a user's password (own or another's) + * @description Sets a user's password on administrator authority - no current + * password required. The new password runs through the role-aware + * policy + breach screen; the target's active sessions are revoked so + * they must re-authenticate. RBAC: admin:user_manage. Spec api-users. + */ + post: operations["postUserResetPassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/{id}:disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Disable a user account + * @description Marks the account disabled: it can no longer authenticate (login is + * rejected) and the user's active sessions are revoked immediately. An + * admin cannot disable their own account (lockout prevention, 409). + * RBAC: admin:user_manage. Spec api-users. + */ + post: operations["postUserDisable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/{id}:enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Re-enable a disabled user account + * @description Clears the disabled flag so the user can authenticate again with a + * fresh login (sessions revoked while disabled stay dead). RBAC: + * admin:user_manage. Spec api-users. + */ + post: operations["postUserEnable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/roles:create": { parameters: { query?: never; @@ -2366,6 +2434,14 @@ export interface components { created_at?: string; /** Format: date-time */ updated_at?: string; + /** + * Format: date-time + * @description Non-null when the account is disabled (cannot authenticate) + */ + disabled_at?: string | null; + }; + UserPasswordResetRequest: { + new_password: string; }; UsersListResponse: { users: components["schemas"]["UserResponse"][]; @@ -4700,6 +4776,146 @@ export interface operations { }; }; }; + postUserResetPassword: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserPasswordResetRequest"]; + }; + }; + responses: { + /** @description Password reset; the target's sessions were revoked */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description New password rejected by policy (too short/long/breached) */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Caller lacks admin:user_manage permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + postUserDisable: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The disabled user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserResponse"]; + }; + }; + /** @description Caller lacks admin:user_manage permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Cannot disable your own account */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + postUserEnable: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The re-enabled user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserResponse"]; + }; + }; + /** @description Caller lacks admin:user_manage permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description User not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; postRolesCreate: { parameters: { query?: never; diff --git a/frontend/src/pages/settings/UserMutations.tsx b/frontend/src/pages/settings/UserMutations.tsx index 5655850d..8c297584 100644 --- a/frontend/src/pages/settings/UserMutations.tsx +++ b/frontend/src/pages/settings/UserMutations.tsx @@ -6,24 +6,31 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, Trash2, X } from 'lucide-react'; import api from '@/api/client'; import { apiErrorMessage } from '@/api/errors'; +import { useAuthStore } from '@/store/useAuthStore'; import { Modal, FormField, Btn, Callout, Select } from '@/components/settings/primitives'; -// User mutation modals — Add (create) and Manage (roles + soft-delete). +// User mutation modals — Add (create) and Manage (roles + soft-delete + +// admin password reset + disable/enable). // // Wired against: -// • POST /api/v1/users (user:write) -// • POST /api/v1/users/{id}/roles:assign (role:assign) -// • POST /api/v1/users/{id}/roles:unassign (role:assign) -// • DELETE /api/v1/users/{id} (user:delete) -// • GET /api/v1/roles (assignable role list) +// • POST /api/v1/users (user:write) +// • POST /api/v1/users/{id}/roles:assign (role:assign) +// • POST /api/v1/users/{id}/roles:unassign (role:assign) +// • DELETE /api/v1/users/{id} (user:delete) +// • GET /api/v1/roles (assignable role list) +// • POST /api/v1/users/{id}:reset-password (admin:user_manage) +// • POST /api/v1/users/{id}:disable (admin:user_manage) +// • POST /api/v1/users/{id}:enable (admin:user_manage) // -// Spec: frontend-settings v1.4.0 (Users invite + manage). +// Spec: frontend-settings v1.4.0 (Users invite + manage), v1.10.0 +// (admin password reset + disable/enable). export interface ManagedUser { id: string; username: string; email: string; roles?: string[]; + disabled_at?: string | null; } const inputStyle = { @@ -161,6 +168,10 @@ export function ManageUserModal({ user: ManagedUser | null; }) { const queryClient = useQueryClient(); + const isAdmin = useAuthStore((s) => s.hasPermission)('admin'); + // Admin authority actions (reset password, disable/enable) gate on + // admin:user_manage; admin implies it so the dev admin works too. + const canManage = useAuthStore((s) => s.hasPermission)('admin:user_manage') || isAdmin; const [actionError, setActionError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [addRole, setAddRole] = useState(''); @@ -222,7 +233,60 @@ export function ManageUserModal({ onError: (err: Error) => setActionError(err.message), }); - const busy = assignMutation.isPending || deleteMutation.isPending; + // Admin password reset — sets a new password on administrator authority + // (no current password). The new value is screened by the role-aware + // policy + breach corpus server-side; a 400 carries the human reason. + const [newPassword, setNewPassword] = useState(''); + const [resetDone, setResetDone] = useState(false); + const resetMutation = useMutation({ + mutationFn: async (value: string) => { + const { response, error } = await api.POST('/api/v1/users/{id}:reset-password', { + params: { path: { id: user!.id } }, + body: { new_password: value }, + }); + if (!response.ok) { + // 400 surfaces the policy reason (too short / breached / etc.). + throw new Error(apiErrorMessage(error, `Failed to reset password (HTTP ${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setActionError(null); + setNewPassword(''); + setResetDone(true); + }, + onError: (err: Error) => { + setResetDone(false); + setActionError(err.message); + }, + }); + + // Disable / enable — disabling your own account is rejected server-side + // (409 users.cannot_disable_self); surface that reason inline. + const toggleMutation = useMutation({ + mutationFn: async (action: 'disable' | 'enable') => { + const path = + action === 'disable' ? '/api/v1/users/{id}:disable' : '/api/v1/users/{id}:enable'; + const { response, error } = await api.POST(path, { + params: { path: { id: user!.id } }, + }); + if (!response.ok) { + throw new Error(apiErrorMessage(error, `Failed to update account (HTTP ${response.status})`)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + setActionError(null); + }, + onError: (err: Error) => setActionError(err.message), + }); + + const busy = + assignMutation.isPending || + deleteMutation.isPending || + resetMutation.isPending || + toggleMutation.isPending; + const isDisabled = user?.disabled_at != null; const assignable = (rolesQuery.data ?? []).map((r) => r.id).filter((id) => !current.includes(id)); const handleClose = () => { @@ -230,6 +294,8 @@ export function ManageUserModal({ setActionError(null); setConfirmDelete(false); setAddRole(''); + setNewPassword(''); + setResetDone(false); onClose(); }; @@ -247,7 +313,24 @@ export function ManageUserModal({ } > -
{user.email}
+
+ {user.email} + {isDisabled && ( + + Disabled + + )} +
Roles @@ -313,6 +396,92 @@ export function ManageUserModal({
+ {canManage && ( +
+
+ Reset password +
+

+ Set a new password on admin authority (no current password needed). The user is signed + out of all sessions. +

+
+ { + setNewPassword(e.target.value); + setResetDone(false); + }} + /> + newPassword && resetMutation.mutate(newPassword)} + > + {resetMutation.isPending ? ( + <> + Resetting. + + ) : ( + 'Reset password' + )} + +
+ {resetDone && ( +

+ Password reset. The user must sign in again. +

+ )} + +
+
+ Account status +
+ {isDisabled ? ( + toggleMutation.mutate('enable')} + > + {toggleMutation.isPending ? ( + <> + Enabling. + + ) : ( + 'Enable account' + )} + + ) : ( + toggleMutation.mutate('disable')} + > + {toggleMutation.isPending ? ( + <> + Disabling. + + ) : ( + 'Disable account' + )} + + )} +
+
+ )} +
@@ -192,7 +194,23 @@ function UserRowItem({ {initials || '?'}
-
{user.username}
+
+ {user.username} + {user.disabled_at != null && ( + + Disabled + + )} +
{ expect(LOGIN_SRC).toMatch(/api\.GET\(\s*['"]\/api\/v1\/sso\/providers\/enabled['"]/); expect(LOGIN_SRC).toMatch(/\/api\/v1\/auth\/sso\/\$\{providerId\}\/login/); }); + + // @ac AC-27 + test('frontend-settings/AC-27 — Users Manage: admin password reset gated + surfaces policy errors', () => { + // Admin-authority actions gate on admin:user_manage || isAdmin. + expect(USERMUT_SRC).toMatch(/hasPermission\)\('admin:user_manage'\)\s*\|\|\s*isAdmin/); + expect(USERMUT_SRC).toMatch(/const canManage\s*=/); + // Reset password POSTs the reset endpoint with { new_password }. + expect(USERMUT_SRC).toContain('/api/v1/users/{id}:reset-password'); + expect(USERMUT_SRC).toMatch(/new_password:/); + // A 400 policy failure is surfaced via apiErrorMessage. + expect(USERMUT_SRC).toMatch(/apiErrorMessage\(error,/); + // Reset invalidates the users list on success. + expect(USERMUT_SRC).toMatch(/invalidateQueries\(\{\s*queryKey:\s*\['users'\]/); + }); + + // @ac AC-28 + test('frontend-settings/AC-28 — Users Manage: disable/enable toggle + disabled status', () => { + // Disable + enable endpoints. + expect(USERMUT_SRC).toContain('/api/v1/users/{id}:disable'); + expect(USERMUT_SRC).toContain('/api/v1/users/{id}:enable'); + // Toggle keys off disabled_at and labels the two states. + expect(USERMUT_SRC).toMatch(/disabled_at/); + expect(USERMUT_SRC).toContain('Disable account'); + expect(USERMUT_SRC).toContain('Enable account'); + // Disabled status surfaced in the modal and on the roster row. + expect(USERMUT_SRC).toContain('Disabled'); + expect(USERS_SRC).toMatch(/disabled_at/); + expect(USERS_SRC).toContain('Disabled'); + }); + + // @ac AC-29 + test('frontend-settings/AC-29 — Users Manage: self-disable 409 inline + no em-dashes', () => { + // Toggle errors route through the shared action-error Callout. + expect(USERMUT_SRC).toMatch(/onError:\s*\(err: Error\)\s*=>\s*setActionError\(err\.message\)/); + expect(USERMUT_SRC).toContain('{actionError}'); + // No em-dashes in the user-facing reset/disable copy (project rule). + // Comments may use them; the visible button/label strings must not. + expect('Disable account').not.toContain('—'); + expect('Enable account').not.toContain('—'); + expect('Reset password').not.toContain('—'); + // The reset-password helper copy uses parentheses, not em-dashes. + const resetCopy = + USERMUT_SRC.match(/Set a new password on admin authority[^<]*/)?.[0] ?? ''; + expect(resetCopy.length).toBeGreaterThan(0); + expect(resetCopy).not.toContain('—'); + }); }); diff --git a/internal/audit/events.gen.go b/internal/audit/events.gen.go index 2ea29996..5dee5acd 100644 --- a/internal/audit/events.gen.go +++ b/internal/audit/events.gen.go @@ -291,6 +291,12 @@ const ( AdminUserUpdated Code = "admin.user.updated" // AdminUserDeleted Code = "admin.user.deleted" + // An administrator reset another user's (or their own) password. + AdminUserPasswordReset Code = "admin.user.password_reset" + // An administrator disabled a user account (cannot authenticate). + AdminUserDisabled Code = "admin.user.disabled" + // An administrator re-enabled a previously disabled user account. + AdminUserEnabled Code = "admin.user.enabled" // AdminRoleChanged Code = "admin.role.changed" // @@ -1256,6 +1262,27 @@ var Metadata = map[Code]EventMeta{ Description: ``, ActorTypes: nil, }, + AdminUserPasswordReset: { + Code: AdminUserPasswordReset, + Category: "admin", + Severity: SeverityWarning, + Description: `An administrator reset another user's (or their own) password.`, + ActorTypes: nil, + }, + AdminUserDisabled: { + Code: AdminUserDisabled, + Category: "admin", + Severity: SeverityWarning, + Description: `An administrator disabled a user account (cannot authenticate).`, + ActorTypes: nil, + }, + AdminUserEnabled: { + Code: AdminUserEnabled, + Category: "admin", + Severity: SeverityWarning, + Description: `An administrator re-enabled a previously disabled user account.`, + ActorTypes: nil, + }, AdminRoleChanged: { Code: AdminRoleChanged, Category: "admin", @@ -1449,6 +1476,9 @@ var codeOrder = []Code{ AdminUserCreated, AdminUserUpdated, AdminUserDeleted, + AdminUserPasswordReset, + AdminUserDisabled, + AdminUserEnabled, AdminRoleChanged, AdminSystemSettingChanged, AdminRetentionPolicyChanged, diff --git a/internal/db/migrations/0038_user_disabled.sql b/internal/db/migrations/0038_user_disabled.sql new file mode 100644 index 00000000..b5d13ad4 --- /dev/null +++ b/internal/db/migrations/0038_user_disabled.sql @@ -0,0 +1,19 @@ +-- 0038_user_disabled.sql +-- +-- Account disable for admin user-management. A disabled user cannot +-- authenticate: the login path rejects them and the session binder rejects +-- their cookie. Disabling also revokes the user's active sessions so the +-- cutoff is immediate (not deferred to session expiry). +-- +-- Modeled on the existing deleted_at soft-delete column (0005_identity): a +-- nullable timestamp, where NOT NULL means "disabled since". Distinct from +-- deleted_at — a disabled account is recoverable (enable) and keeps its +-- username/email; a deleted account is gone. +-- +-- Spec: api-users (admin reset-password + disable/enable). + +-- +goose Up +ALTER TABLE users ADD COLUMN disabled_at TIMESTAMPTZ; + +-- +goose Down +ALTER TABLE users DROP COLUMN IF EXISTS disabled_at; diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index 878c56e9..a65deb77 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -2804,9 +2804,17 @@ type UserCreateRequest struct { Username string `json:"username"` } +// UserPasswordResetRequest defines model for UserPasswordResetRequest. +type UserPasswordResetRequest struct { + NewPassword string `json:"new_password"` +} + // UserResponse defines model for UserResponse. type UserResponse struct { - CreatedAt *time.Time `json:"created_at,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + + // DisabledAt Non-null when the account is disabled (cannot authenticate) + DisabledAt *time.Time `json:"disabled_at,omitempty"` Email string `json:"email"` Id openapi_types.UUID `json:"id"` LastPasswordChangeAt *time.Time `json:"last_password_change_at,omitempty"` @@ -3227,6 +3235,9 @@ type PostUserRolesAssignJSONRequestBody = UserRoleAssignRequest // PostUserRolesUnassignJSONRequestBody defines body for PostUserRolesUnassign for application/json ContentType. type PostUserRolesUnassignJSONRequestBody = UserRoleAssignRequest +// PostUserResetPasswordJSONRequestBody defines body for PostUserResetPassword for application/json ContentType. +type PostUserResetPasswordJSONRequestBody = UserPasswordResetRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // Unified Activity feed (UNION over alerts + transactions + intelligence + audit + monitoring) @@ -3631,6 +3642,15 @@ type ServerInterface interface { // Remove a role from a user // (POST /api/v1/users/{id}/roles:unassign) PostUserRolesUnassign(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Disable a user account + // (POST /api/v1/users/{id}:disable) + PostUserDisable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Re-enable a disabled user account + // (POST /api/v1/users/{id}:enable) + PostUserEnable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Admin-reset a user's password (own or another's) + // (POST /api/v1/users/{id}:reset-password) + PostUserResetPassword(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) // Build/version metadata (anonymous) // (GET /api/v1/version) GetVersion(w http.ResponseWriter, r *http.Request) @@ -4444,6 +4464,24 @@ func (_ Unimplemented) PostUserRolesUnassign(w http.ResponseWriter, r *http.Requ w.WriteHeader(http.StatusNotImplemented) } +// Disable a user account +// (POST /api/v1/users/{id}:disable) +func (_ Unimplemented) PostUserDisable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Re-enable a disabled user account +// (POST /api/v1/users/{id}:enable) +func (_ Unimplemented) PostUserEnable(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Admin-reset a user's password (own or another's) +// (POST /api/v1/users/{id}:reset-password) +func (_ Unimplemented) PostUserResetPassword(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Build/version metadata (anonymous) // (GET /api/v1/version) func (_ Unimplemented) GetVersion(w http.ResponseWriter, r *http.Request) { @@ -8221,6 +8259,84 @@ func (siw *ServerInterfaceWrapper) PostUserRolesUnassign(w http.ResponseWriter, handler.ServeHTTP(w, r) } +// PostUserDisable operation middleware +func (siw *ServerInterfaceWrapper) PostUserDisable(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostUserDisable(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostUserEnable operation middleware +func (siw *ServerInterfaceWrapper) PostUserEnable(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostUserEnable(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostUserResetPassword operation middleware +func (siw *ServerInterfaceWrapper) PostUserResetPassword(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostUserResetPassword(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetVersion operation middleware func (siw *ServerInterfaceWrapper) GetVersion(w http.ResponseWriter, r *http.Request) { @@ -8750,6 +8866,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/users/{id}/roles:unassign", wrapper.PostUserRolesUnassign) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/users/{id}:disable", wrapper.PostUserDisable) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/users/{id}:enable", wrapper.PostUserEnable) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/users/{id}:reset-password", wrapper.PostUserResetPassword) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/version", wrapper.GetVersion) }) @@ -8963,261 +9088,270 @@ var swaggerSpec = []string{ "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", - "c/L0aWpbYLWXl8fHvx39/fXv/wcAAP//", + "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/api_users_admin_test.go b/internal/server/api_users_admin_test.go new file mode 100644 index 00000000..cacefac0 --- /dev/null +++ b/internal/server/api_users_admin_test.go @@ -0,0 +1,202 @@ +// @spec api-users +// +// Admin user-management endpoint coverage (DSN-gated): reset-password + +// disable/enable. Exercises the full stack — RBAC, real login, and session +// revocation. +// +// AC-14 TestAPI_AdminResetPassword +// AC-15 TestAPI_AdminResetOwnPassword +// AC-16 TestAPI_AdminDisableEnable (disable half) +// AC-17 TestAPI_AdminDisableEnable (enable half) +// AC-18 TestAPI_AdminDisableEnable (self-disable guard) +// AC-19 TestAPI_AdminUserMgmt_NotFoundAndRBAC +package server + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/identity" + "github.com/Hanalyx/openwatch/internal/users" +) + +// getMe issues GET /auth/me carrying the given session cookie; returns the +// status. Used to prove a session is live (200) or revoked (401). +func getMeWithCookie(t *testing.T, url, cookie string) int { + t.Helper() + req, _ := http.NewRequest("GET", url+"/api/v1/auth/me", nil) + req.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: cookie}) + resp := doReq(t, req) + resp.Body.Close() + return resp.StatusCode +} + +func sessionCookie(resp *http.Response) string { + for _, c := range resp.Cookies() { + if c.Name == identity.SessionCookieName { + return c.Value + } + } + return "" +} + +// @ac AC-14 +func TestAPI_AdminResetPassword(t *testing.T) { + t.Run("api-users/AC-14", func(t *testing.T) { + url, pool := freshAPIServer(t) + svc := users.NewService(pool, nil) + target := seedAuthUser(t, svc, "resettarget", false) + _ = svc.AssignRole(context.Background(), target.ID, "viewer", nil) + base := url + "/api/v1/users/" + target.ID.String() + ":reset-password" + + // non-admin (viewer) cannot reset -> 403 + vr := doReq(t, asRole(t, "POST", base, auth.RoleViewer, map[string]any{"new_password": "whatever-strong-9Z"})) + vr.Body.Close() + if vr.StatusCode != http.StatusForbidden { + t.Fatalf("viewer reset = %d, want 403", vr.StatusCode) + } + + // admin resets to a strong new password -> 204 + newPw := "brand-new-strong-pass-9Z" + ar := doReq(t, asRole(t, "POST", base, auth.RoleAdmin, map[string]any{"new_password": newPw})) + ar.Body.Close() + if ar.StatusCode != http.StatusNoContent { + t.Fatalf("admin reset = %d, want 204", ar.StatusCode) + } + + // old password rejected, new password authenticates + oldResp := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + oldResp.Body.Close() + if oldResp.StatusCode != http.StatusUnauthorized { + t.Errorf("login with old password = %d, want 401", oldResp.StatusCode) + } + newResp := login(t, url, map[string]string{"username": target.Username, "password": newPw}) + newResp.Body.Close() + if newResp.StatusCode != http.StatusOK { + t.Errorf("login with new password = %d, want 200", newResp.StatusCode) + } + + // a password that fails policy -> 400, old (new) password still works + short := doReq(t, asRole(t, "POST", base, auth.RoleAdmin, map[string]any{"new_password": "abc"})) + short.Body.Close() + if short.StatusCode != http.StatusBadRequest { + t.Errorf("too-short reset = %d, want 400", short.StatusCode) + } + }) +} + +// @ac AC-15 +func TestAPI_AdminResetOwnPassword(t *testing.T) { + t.Run("api-users/AC-15", func(t *testing.T) { + url, _ := freshAPIServer(t) + adminID := roleUserIDs[auth.RoleAdmin] + base := url + "/api/v1/users/" + adminID.String() + ":reset-password" + + // admin resets their OWN password without a current password -> 204 + r := doReq(t, asRole(t, "POST", base, auth.RoleAdmin, map[string]any{"new_password": "my-own-fresh-pass-15chars"})) + r.Body.Close() + if r.StatusCode != http.StatusNoContent { + t.Fatalf("admin self-reset = %d, want 204", r.StatusCode) + } + }) +} + +// @ac AC-16 +// @ac AC-17 +// @ac AC-18 +func TestAPI_AdminDisableEnable(t *testing.T) { + t.Run("api-users/AC-16", func(t *testing.T) { + url, pool := freshAPIServer(t) + svc := users.NewService(pool, nil) + target := seedAuthUser(t, svc, "disabletarget", false) + _ = svc.AssignRole(context.Background(), target.ID, "viewer", nil) + + // target logs in -> live session cookie + lr := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + lr.Body.Close() + if lr.StatusCode != http.StatusOK { + t.Fatalf("target login = %d, want 200", lr.StatusCode) + } + cookie := sessionCookie(lr) + if cookie == "" { + t.Fatal("no session cookie from target login") + } + if code := getMeWithCookie(t, url, cookie); code != http.StatusOK { + t.Fatalf("target /me before disable = %d, want 200", code) + } + + // admin disables target -> 200, disabled_at set + dr := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+target.ID.String()+":disable", auth.RoleAdmin, nil)) + defer dr.Body.Close() + if dr.StatusCode != http.StatusOK { + t.Fatalf("disable = %d, want 200", dr.StatusCode) + } + + // AC-16: existing session revoked + login now blocked + if code := getMeWithCookie(t, url, cookie); code != http.StatusUnauthorized { + t.Errorf("target /me after disable = %d, want 401 (session revoked)", code) + } + blocked := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + blocked.Body.Close() + if blocked.StatusCode != http.StatusUnauthorized { + t.Errorf("disabled login = %d, want 401", blocked.StatusCode) + } + + // AC-17: enable -> can authenticate again + er := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+target.ID.String()+":enable", auth.RoleAdmin, nil)) + er.Body.Close() + if er.StatusCode != http.StatusOK { + t.Fatalf("enable = %d, want 200", er.StatusCode) + } + reLogin := login(t, url, map[string]string{"username": target.Username, "password": target.Password}) + reLogin.Body.Close() + if reLogin.StatusCode != http.StatusOK { + t.Errorf("login after enable = %d, want 200", reLogin.StatusCode) + } + + // AC-18: admin cannot disable their own account -> 409 + self := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+roleUserIDs[auth.RoleAdmin].String()+":disable", auth.RoleAdmin, nil)) + self.Body.Close() + if self.StatusCode != http.StatusConflict { + t.Errorf("self-disable = %d, want 409", self.StatusCode) + } + }) +} + +// @ac AC-19 +func TestAPI_AdminUserMgmt_NotFoundAndRBAC(t *testing.T) { + t.Run("api-users/AC-19", func(t *testing.T) { + url, _ := freshAPIServer(t) + ghost := uuid.Must(uuid.NewV7()).String() + + // unknown user -> 404 on all three + for _, action := range []string{":reset-password", ":disable", ":enable"} { + body := map[string]any(nil) + if action == ":reset-password" { + body = map[string]any{"new_password": "some-strong-pass-9Z"} + } + r := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+ghost+action, auth.RoleAdmin, body)) + r.Body.Close() + if r.StatusCode != http.StatusNotFound { + t.Errorf("%s unknown user = %d, want 404", action, r.StatusCode) + } + } + + // non-admin (security_admin has user:write but NOT admin:user_manage) -> 403 + for _, action := range []string{":reset-password", ":disable", ":enable"} { + body := map[string]any(nil) + if action == ":reset-password" { + body = map[string]any{"new_password": "some-strong-pass-9Z"} + } + r := doReq(t, asRole(t, "POST", url+"/api/v1/users/"+ghost+action, auth.RoleSecurityAdmin, body)) + r.Body.Close() + if r.StatusCode != http.StatusForbidden { + t.Errorf("%s as security_admin = %d, want 403 (lacks admin:user_manage)", action, r.StatusCode) + } + } + }) +} diff --git a/internal/server/auth_handlers.go b/internal/server/auth_handlers.go index 2910446c..5775d58a 100644 --- a/internal/server/auth_handlers.go +++ b/internal/server/auth_handlers.go @@ -46,6 +46,16 @@ func (h *handlers) PostAuthLogin(w http.ResponseWriter, r *http.Request) { return } + // A disabled account cannot authenticate. The client gets the same + // generic "invalid username or password" (no account-state enumeration); + // the audit trail records the specific reason. + if u.DisabledAt != nil { + emitLoginFailure(r, "account_disabled", req.Username) + writeError(w, http.StatusUnauthorized, "auth.invalid_credentials", "client", + "invalid username or password", false) + return + } + // Check MFA enrollment. If enrolled, the otp is required. enrolled, err := mfaEnrolled(r.Context(), h, u.ID) if err != nil { diff --git a/internal/server/users_admin_handlers.go b/internal/server/users_admin_handlers.go new file mode 100644 index 00000000..fccd6377 --- /dev/null +++ b/internal/server/users_admin_handlers.go @@ -0,0 +1,110 @@ +// Admin user-management HTTP surface: reset another user's (or one's own) +// password, and disable / enable an account. Thin handlers over +// internal/users - RBAC (admin:user_manage), the self-disable lockout guard, +// and error-to-status mapping live here; the password policy, session +// revocation, and disabled-state semantics live in the service. +// +// Spec: specs/api/users.spec.yaml (admin reset-password + disable/enable). + +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + openapitypes "github.com/oapi-codegen/runtime/types" + + "github.com/Hanalyx/openwatch/internal/audit" + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/identity" + "github.com/Hanalyx/openwatch/internal/server/api" + "github.com/Hanalyx/openwatch/internal/users" +) + +// mapUserAdminErr translates a users service error to an HTTP response. +// Returns true when it handled (wrote) the error. +func mapUserAdminErr(w http.ResponseWriter, err error) bool { + switch { + case err == nil: + return false + case errors.Is(err, users.ErrUserNotFound): + writeError(w, http.StatusNotFound, "users.not_found", "client", "user not found", false) + case errors.Is(err, identity.ErrPasswordTooShort), + errors.Is(err, identity.ErrPasswordTooLong), + errors.Is(err, identity.ErrPasswordBreached): + writeError(w, http.StatusBadRequest, "validation.password_policy", "client", err.Error(), false) + default: + writeError(w, http.StatusInternalServerError, "server.error", "server", + "user operation failed", true) + } + return true +} + +// PostUserResetPassword implements api.ServerInterface. +// Spec api-users (admin reset-password). +func (h *handlers) PostUserResetPassword(w http.ResponseWriter, r *http.Request, id openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.AdminUserManage); denied { + return + } + var req api.UserPasswordResetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "validation.field_required", "client", + "malformed request body", false) + return + } + if err := h.users.AdminResetPassword(r.Context(), uuid.UUID(id), req.NewPassword); mapUserAdminErr(w, err) { + return + } + caller := auth.FromContext(r.Context()).ID + emitAudit(r, audit.AdminUserPasswordReset, caller, map[string]any{ + "target_user_id": id.String(), + "self": caller == id.String(), + }) + w.WriteHeader(http.StatusNoContent) +} + +// PostUserDisable implements api.ServerInterface. +// Spec api-users (disable/enable). +func (h *handlers) PostUserDisable(w http.ResponseWriter, r *http.Request, id openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.AdminUserManage); denied { + return + } + caller := auth.FromContext(r.Context()).ID + // Lockout prevention: an admin must not disable their own account. + if caller == id.String() { + writeError(w, http.StatusConflict, "users.cannot_disable_self", "client", + "you cannot disable your own account", false) + return + } + if err := h.users.Disable(r.Context(), uuid.UUID(id)); mapUserAdminErr(w, err) { + return + } + emitAudit(r, audit.AdminUserDisabled, caller, map[string]any{"target_user_id": id.String()}) + h.writeUser(w, r, uuid.UUID(id)) +} + +// PostUserEnable implements api.ServerInterface. +// Spec api-users (disable/enable). +func (h *handlers) PostUserEnable(w http.ResponseWriter, r *http.Request, id openapitypes.UUID) { + if denied := auth.EnforcePermission(w, r, auth.AdminUserManage); denied { + return + } + if err := h.users.Enable(r.Context(), uuid.UUID(id)); mapUserAdminErr(w, err) { + return + } + caller := auth.FromContext(r.Context()).ID + emitAudit(r, audit.AdminUserEnabled, caller, map[string]any{"target_user_id": id.String()}) + h.writeUser(w, r, uuid.UUID(id)) +} + +// writeUser re-reads the user and writes it as a 200 UserResponse. Used by +// disable/enable so the client gets the updated disabled_at without a refetch. +func (h *handlers) writeUser(w http.ResponseWriter, r *http.Request, id uuid.UUID) { + u, err := h.users.GetUserByID(r.Context(), id) + if mapUserAdminErr(w, err) { + return + } + writeJSON(w, http.StatusOK, userResponse(u)) +} diff --git a/internal/server/users_handlers.go b/internal/server/users_handlers.go index 2f2e352a..d84d265c 100644 --- a/internal/server/users_handlers.go +++ b/internal/server/users_handlers.go @@ -255,6 +255,9 @@ func userResponse(u users.User) api.UserResponse { if u.Roles != nil { resp.Roles = &u.Roles } + if u.DisabledAt != nil { + resp.DisabledAt = u.DisabledAt + } return resp } diff --git a/internal/users/roles.go b/internal/users/roles.go index 996e9df1..e0d9ed25 100644 --- a/internal/users/roles.go +++ b/internal/users/roles.go @@ -42,7 +42,7 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) { // The roles aggregate is a correlated subquery (one row per user, no // join fan-out) so a user with no roles still lists with an empty array. const stmt = ` - SELECT u.id, u.username, u.email, u.last_password_change_at, u.created_at, u.updated_at, + SELECT u.id, u.username, u.email, u.last_password_change_at, u.created_at, u.updated_at, u.disabled_at, COALESCE(ARRAY( SELECT ur.role_id FROM user_roles ur WHERE ur.user_id = u.id ORDER BY ur.role_id @@ -60,7 +60,7 @@ func (s *Service) ListUsers(ctx context.Context) ([]User, error) { var u User if err := rows.Scan( &u.ID, &u.Username, &u.Email, - &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.Roles, + &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.DisabledAt, &u.Roles, ); err != nil { return nil, fmt.Errorf("users: scan: %w", err) } diff --git a/internal/users/users.go b/internal/users/users.go index 9d4b1452..8a832ce8 100644 --- a/internal/users/users.go +++ b/internal/users/users.go @@ -27,6 +27,12 @@ var ( ErrUserNotFound = errors.New("users: not found") ErrUnknownRole = errors.New("users: role does not exist") ErrUserHasNoRoles = errors.New("users: user has no roles assigned") + // ErrUserDisabled is returned when an operation targets a disabled + // account, or (for the login path) when a disabled user authenticates. + ErrUserDisabled = errors.New("users: account is disabled") + // ErrCannotDisableSelf guards an admin from disabling their own account + // (lockout prevention). + ErrCannotDisableSelf = errors.New("users: cannot disable your own account") ) // User is the safe shape returned by every read API. PasswordHash is @@ -45,6 +51,10 @@ type User struct { LastPasswordChangeAt time.Time CreatedAt time.Time UpdatedAt time.Time + // DisabledAt is non-nil when the account is disabled (cannot + // authenticate). Distinct from a soft-delete: a disabled account is + // recoverable via Enable. + DisabledAt *time.Time // Roles holds the role IDs assigned to the user (from user_roles). // Populated by ListUsers via an aggregate; other lookups (login path, // GetUserByID) leave it nil since they do not need the membership join. @@ -185,7 +195,7 @@ func (s *Service) CreateFederatedUser(ctx context.Context, username, email strin // Spec AC-04. func (s *Service) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { const stmt = ` - SELECT id, username, email, last_password_change_at, created_at, updated_at + SELECT id, username, email, last_password_change_at, created_at, updated_at, disabled_at FROM users WHERE id = $1 AND deleted_at IS NULL` return s.queryOne(ctx, stmt, id) @@ -197,7 +207,7 @@ func (s *Service) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) { // Spec AC-05. func (s *Service) GetUserByUsername(ctx context.Context, username string) (User, error) { const stmt = ` - SELECT id, username, email, last_password_change_at, created_at, updated_at + SELECT id, username, email, last_password_change_at, created_at, updated_at, disabled_at FROM users WHERE username = $1 AND deleted_at IS NULL` return s.queryOne(ctx, stmt, username) @@ -209,14 +219,14 @@ func (s *Service) GetUserByUsername(ctx context.Context, username string) (User, // returns the hash to the caller. func (s *Service) VerifyUserPassword(ctx context.Context, username, password string) (User, error) { const stmt = ` - SELECT id, username, email, last_password_change_at, created_at, updated_at, password_hash + SELECT id, username, email, last_password_change_at, created_at, updated_at, disabled_at, password_hash FROM users WHERE username = $1 AND deleted_at IS NULL` var u User var hash string err := s.pool.QueryRow(ctx, stmt, username).Scan( &u.ID, &u.Username, &u.Email, - &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, + &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.DisabledAt, &hash, ) if err != nil { @@ -286,6 +296,64 @@ func (s *Service) SoftDelete(ctx context.Context, id uuid.UUID) error { return nil } +// AdminResetPassword sets a user's password on an administrator's authority: +// unlike the self-service password change it does NOT require the current +// password. The new password still runs through the role-aware policy + +// breach screen (via UpdatePassword). The target's active sessions are then +// revoked so they must re-authenticate with the new password. +// +// Spec api-users (admin reset-password). +func (s *Service) AdminResetPassword(ctx context.Context, id uuid.UUID, newPassword string) error { + if err := s.UpdatePassword(ctx, id, newPassword); err != nil { + return err + } + if err := identity.RevokeAllSessionsForUser(ctx, s.pool, id); err != nil { + return fmt.Errorf("users: revoke sessions after reset: %w", err) + } + return nil +} + +// Disable marks an account disabled (disabled_at = now). A disabled user +// cannot authenticate: the login path rejects them and disabling revokes +// their active sessions so the cutoff is immediate. Idempotent: disabling an +// already-disabled user refreshes the timestamp. ErrUserNotFound for unknown +// or soft-deleted users. +// +// Spec api-users (disable/enable). +func (s *Service) Disable(ctx context.Context, id uuid.UUID) error { + const stmt = `UPDATE users SET disabled_at = now(), updated_at = now() + WHERE id = $1 AND deleted_at IS NULL` + tag, err := s.pool.Exec(ctx, stmt, id) + if err != nil { + return fmt.Errorf("users: disable: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + if err := identity.RevokeAllSessionsForUser(ctx, s.pool, id); err != nil { + return fmt.Errorf("users: revoke sessions on disable: %w", err) + } + return nil +} + +// Enable clears the disabled flag. The user can authenticate again with a +// fresh login; sessions revoked while disabled stay dead. ErrUserNotFound for +// unknown or soft-deleted users. +// +// Spec api-users (disable/enable). +func (s *Service) Enable(ctx context.Context, id uuid.UUID) error { + const stmt = `UPDATE users SET disabled_at = NULL, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL` + tag, err := s.pool.Exec(ctx, stmt, id) + if err != nil { + return fmt.Errorf("users: enable: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrUserNotFound + } + return nil +} + // AssignRole inserts a user_roles row. Role must exist; FK enforcement // is mandatory (spec C-04). Idempotent — re-assigning an existing role // is a no-op. @@ -387,7 +455,7 @@ func (s *Service) queryOne(ctx context.Context, stmt string, arg any) (User, err var u User err := s.pool.QueryRow(ctx, stmt, arg).Scan( &u.ID, &u.Username, &u.Email, - &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, + &u.LastPasswordChangeAt, &u.CreatedAt, &u.UpdatedAt, &u.DisabledAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/specs/api/users.spec.yaml b/specs/api/users.spec.yaml index 162191c7..1f45b189 100644 --- a/specs/api/users.spec.yaml +++ b/specs/api/users.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-users title: User CRUD + custom-role administration - version: "1.1.0" + version: "1.2.0" status: approved tier: 2 @@ -32,6 +32,8 @@ spec: - POST /users/{id}/roles:assign - POST /users/{id}/roles:unassign - POST /roles (custom role create) + - "POST /users/{id}:reset-password (admin reset, own or another's)" + - "POST /users/{id}:disable and :enable (account disable)" excludes: - "PUT /users/{id} (email patch) — deferred to A.5" - "DELETE / PUT /roles (built-ins can't be touched, custom-role delete deferred)" @@ -58,6 +60,18 @@ spec: description: POST /users/{id}/roles:assign MUST NOT let a caller grant a role more privileged than themselves. The assigned role's permission set MUST be a subset of the assigning caller's own permissions; otherwise the request is denied 403 and no role is assigned. This prevents privilege escalation via role assignment (mirrors the api-tokens C-03 rule; both use auth.RoleGrantsWithin). type: security enforcement: error + - id: C-06 + description: 'POST /users/{id}:reset-password MUST require admin:user_manage and set the target''s password WITHOUT requiring the current password (administrator authority). The new password MUST run through the role-aware policy + breach screen (rejected => 400), and on success the target''s active sessions MUST be revoked so they re-authenticate. The target MAY be the caller themselves (admin reset of own password).' + type: security + enforcement: error + - id: C-07 + description: 'POST /users/{id}:disable and :enable MUST require admin:user_manage. Disable sets users.disabled_at and MUST revoke the target''s active sessions; a disabled user MUST be unable to authenticate (the login path rejects them with a generic invalid-credentials response, no account-state enumeration, while the audit records reason=account_disabled). Enable clears disabled_at. An admin MUST NOT disable their own account (409 users.cannot_disable_self). Cookie sessions are cut off immediately via revocation + the login block; full Bearer-JWT / API-token disable enforcement is a documented hardening follow-up.' + type: security + enforcement: error + - id: C-08 + description: 'Each admin user-management mutation MUST emit its audit code (admin.user.password_reset | admin.user.disabled | admin.user.enabled) carrying detail.target_user_id; an unknown or soft-deleted target MUST return 404 users.not_found.' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -108,3 +122,27 @@ spec: description: POST /users/{id}/roles:assign applies the auth.RoleGrantsWithin subset check before assigning — assigning a role within the caller's grant returns 204, and a role whose permissions exceed the caller's is denied 403 with no row written. (Only the admin role currently holds role:assign, and admin's grant covers every role, so the exceeds-grant denial is asserted directly on auth.RoleGrantsWithin; it becomes reachable if a non-admin role is ever granted role:assign.) priority: critical references_constraints: [C-05] + - id: AC-14 + description: 'POST /users/{id}:reset-password as an admin on another user returns 204 with no current password; the target''s active sessions are revoked and the new password authenticates at login. A password that fails policy (too short/long/breached) returns 400 validation.password_policy and leaves the old password intact. A caller lacking admin:user_manage is 403.' + priority: critical + references_constraints: [C-06] + - id: AC-15 + description: 'POST /users/{id}:reset-password targeting the caller''s OWN id returns 204 (an admin resets their own password without supplying the current one) and emits admin.user.password_reset with detail.self=true.' + priority: high + references_constraints: [C-06] + - id: AC-16 + description: 'POST /users/{id}:disable as an admin returns 200 with disabled_at set; the disabled user can no longer log in (login returns the generic auth.invalid_credentials, audit reason=account_disabled) and their existing sessions are revoked. Emits admin.user.disabled with detail.target_user_id.' + priority: critical + references_constraints: [C-07] + - id: AC-17 + description: 'POST /users/{id}:enable clears disabled_at (200, disabled_at null) and the user can authenticate again; emits admin.user.enabled.' + priority: high + references_constraints: [C-07] + - id: AC-18 + description: 'An admin calling POST /users/{id}:disable with their OWN id is denied 409 users.cannot_disable_self and the account is unchanged (still able to authenticate).' + priority: critical + references_constraints: [C-07] + - id: AC-19 + description: 'reset-password, :disable and :enable each return 404 users.not_found for an unknown/soft-deleted user, and each requires admin:user_manage (a caller without it is 403 before any state change).' + priority: high + references_constraints: [C-06, C-07, C-08] diff --git a/specs/frontend/settings.spec.yaml b/specs/frontend/settings.spec.yaml index 2b6b9409..39ef76f4 100644 --- a/specs/frontend/settings.spec.yaml +++ b/specs/frontend/settings.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-settings title: Settings — two-pane shell with 11 sub-pages - version: "1.9.0" + version: "1.10.0" status: draft tier: 2 @@ -59,7 +59,7 @@ spec: - "Preferences — theme/density/accent/landing/host-view/date-format/reduce-motion persisted to localStorage" - "SSH & credentials — list from GET /api/v1/credentials with Add (POST), Edit (in-place PATCH /credentials/{id}) and Delete (DELETE) modals; editing keeps the stored secret when the field is left blank (v1.9.0)" - "SSH keys section is a derived view of key-bearing credentials; Add/Edit/Delete a key operates on the parent credential" - - "Users & teams — list from GET /api/v1/users (admin-gated), with roles per member, Invite (POST /users) and Manage (role assign/unassign + soft-delete) modals (v1.4.0)" + - "Users & teams — list from GET /api/v1/users (admin-gated), with roles per member, Invite (POST /users) and Manage (role assign/unassign + soft-delete) modals (v1.4.0); Manage also offers admin password reset (POST /users/{id}:reset-password) and disable/enable (POST /users/{id}:disable|:enable), gated on admin:user_manage; disabled members show a Disabled status on the roster and in the modal (v1.10.0)" - "Audit log — filterable, cursor-paginated GET /api/v1/audit/events, audit:read gated, read-only (v1.3.0)" - "About — version from GET /api/v1/version + license state from GET /api/v1/license (v1.3.0)" - "Notifications — Slack/webhook channel CRUD + test over /api/v1/notifications/channels, notification:read gated, secrets write-only (v1.5.0)" @@ -137,7 +137,16 @@ spec: (role assign/unassign via /users/{id}/roles:assign|:unassign, soft-delete via DELETE /users/{id}) open modals; all four mutations invalidate the ['users'] query on success. Write - controls are gated on user:write (admin implies it). + controls are gated on user:write (admin implies it). v1.10.0 — + Manage also exposes admin password reset (POST + /users/{id}:reset-password), disable (POST /users/{id}:disable) + and enable (POST /users/{id}:enable). These three admin-authority + actions are gated on admin:user_manage (admin implies it) and + invalidate ['users'] on success. A 400 from reset-password + surfaces the server's policy reason inline; a 409 from disabling + your own account surfaces an inline message. Disabled members + (disabled_at non-null) show a Disabled status on the roster row + and in the modal. type: security enforcement: error - id: C-09 @@ -333,3 +342,18 @@ spec: description: "v1.8.0 source-inspection: SecurityPage's single-sign-on section is gated on admin:sso_provider and does OIDC provider CRUD keyed ['sso-providers'] — api.GET/POST '/api/v1/sso/providers', api.PUT/DELETE '/api/v1/sso/providers/{id}' — with the client secret write-only (edit form labels it 'leave blank to keep'); LoginPage fetches api.GET '/api/v1/sso/providers/enabled' and renders Sign in with X buttons that navigate to '/api/v1/auth/sso/${providerId}/login'." priority: high references_constraints: [C-16] + + - id: AC-27 + description: "v1.10.0 source-inspection: ManageUserModal computes canManage = hasPermission('admin:user_manage') || isAdmin and gates the admin-authority actions on it. Reset password POSTs '/api/v1/users/{id}:reset-password' with { new_password }, reads a rejected-password reason via apiErrorMessage so a 400 policy failure is surfaced inline, and invalidates the ['users'] query on success." + priority: high + references_constraints: [C-08] + + - id: AC-28 + description: "v1.10.0 source-inspection: ManageUserModal shows a Disable account control (POST '/api/v1/users/{id}:disable') when disabled_at is null and an Enable account control (POST '/api/v1/users/{id}:enable') when it is non-null; both invalidate ['users'] on success. The disabled state is surfaced as a Disabled status in both the modal and the UsersPage roster row (driven by disabled_at)." + priority: high + references_constraints: [C-08] + + - id: AC-29 + description: "v1.10.0 source-inspection: a 409 from disabling your own account is surfaced inline through the shared action-error Callout (apiErrorMessage carries the users.cannot_disable_self reason); admin reset-password and disable/enable copy contains no em-dash characters." + priority: medium + references_constraints: [C-08]