diff --git a/internal/auth/user/domain/types.go b/internal/auth/user/domain/types.go index 93aa346..7255545 100644 --- a/internal/auth/user/domain/types.go +++ b/internal/auth/user/domain/types.go @@ -38,6 +38,10 @@ type User struct { WebAuthnCredentials []byte `json:"webauthn_credentials,omitempty"` // JSONB; reserved for v3, always `[]` in Bundle 2 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + // Audit 2026-05-10 MED-11 — soft-delete column. + // Non-nil = deactivated; nil = active. The deactivate path + // cascade-revokes sessions in the same tx via the service layer. + DeactivatedAt *time.Time `json:"deactivated_at,omitempty"` } // Validation errors. Service layer maps these to HTTP 400. diff --git a/migrations/000045_users_deactivated_at.down.sql b/migrations/000045_users_deactivated_at.down.sql new file mode 100644 index 0000000..b0dbdd1 --- /dev/null +++ b/migrations/000045_users_deactivated_at.down.sql @@ -0,0 +1,13 @@ +-- Down for 000045 — remove the deactivated_at column + 2 user perms. +BEGIN; + +DELETE FROM role_permissions + WHERE permission IN ('auth.user.read', 'auth.user.deactivate'); + +DELETE FROM permissions + WHERE name IN ('auth.user.read', 'auth.user.deactivate'); + +ALTER TABLE users + DROP COLUMN IF EXISTS deactivated_at; + +COMMIT; diff --git a/migrations/000045_users_deactivated_at.up.sql b/migrations/000045_users_deactivated_at.up.sql new file mode 100644 index 0000000..700ee63 --- /dev/null +++ b/migrations/000045_users_deactivated_at.up.sql @@ -0,0 +1,39 @@ +-- 000045_users_deactivated_at.up.sql +-- Audit 2026-05-10 MED-11 closure: federated-user admin surface. +-- +-- Adds the deactivated_at column to users so the admin DELETE-by-id +-- path can soft-delete a federated identity without destroying the +-- row (the row is the OIDC binding — destroying it would re-mint a +-- fresh user on the next IdP login under the same subject, losing +-- the audit trail). Also seeds two new catalogue permissions: +-- +-- auth.user.read — list / get a user. Seeded into r-admin, +-- r-operator, r-auditor. +-- auth.user.deactivate — set deactivated_at + cascade-revoke +-- sessions. Seeded into r-admin ONLY. +-- +-- Idempotent. Single transaction. + +BEGIN; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ; + +INSERT INTO permissions (name) VALUES + ('auth.user.read'), + ('auth.user.deactivate') +ON CONFLICT (name) DO NOTHING; + +-- Read is broad (admin / operator / auditor). +INSERT INTO role_permissions (role_id, permission, scope_type, scope_id) VALUES + ('r-admin', 'auth.user.read', 'global', NULL), + ('r-operator', 'auth.user.read', 'global', NULL), + ('r-auditor', 'auth.user.read', 'global', NULL) +ON CONFLICT DO NOTHING; + +-- Deactivate is admin-only. +INSERT INTO role_permissions (role_id, permission, scope_type, scope_id) VALUES + ('r-admin', 'auth.user.deactivate', 'global', NULL) +ON CONFLICT DO NOTHING; + +COMMIT;