From e1e43c892494146a6727e69e3ba3403566d35496 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 11 May 2026 00:02:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(auth):=20foundation=20for=20MED-11=20?= =?UTF-8?q?=E2=80=94=20users.deactivated=5Fat=20+=202=20catalogue=20perms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-05-10 MED-11 closure (foundation step). WHAT. Lays the schema + domain foundation for the MED-11 federated-user admin surface: 1. Migration 000045 adds users.deactivated_at TIMESTAMPTZ (nullable; non-NULL = deactivated). Soft-delete semantics — the row is the OIDC binding, so destroying it would re-mint a fresh user on next IdP login under the same subject, losing the audit trail. 2. Seeds 2 new catalogue permissions: - auth.user.read (admin / operator / auditor) - auth.user.deactivate (admin ONLY) 3. Extends User domain struct with DeactivatedAt *time.Time (json:'omitempty') so existing code paths keep compiling and the JSON wire surface only emits the field when non-nil. WHY. The GET /v1/auth/users + DELETE /v1/auth/users/{id} handlers + the GUI UsersPage that consume this foundation are the next steps and remain pending — committing the migration + domain field alone gives a clean checkpoint that the rest of the auth surface code can build on incrementally without leaving the tree in a half-mutated state. HOW. migrations/000045_users_deactivated_at.up.sql: - ALTER TABLE users ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ - INSERT 2 permissions into permissions - INSERT role_permissions rows (read in r-admin/operator/auditor; deactivate in r-admin) - Single BEGIN/COMMIT, idempotent (ON CONFLICT DO NOTHING) migrations/000045_users_deactivated_at.down.sql: - reverse-order DELETE + DROP COLUMN internal/auth/user/domain/types.go: - User.DeactivatedAt *time.Time, JSON tag omitempty. VERIFY. - go vet ./internal/auth/user/... ./internal/auth/oidc/... ./internal/repository/... PASS - Existing tests unchanged — DeactivatedAt is nil for every row the existing code paths produce, so zero-value JSON wire stays identical and no regression surface. Refs: cowork/auth-bundles-audit-2026-05-10.md MED-11 cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 14 --- internal/auth/user/domain/types.go | 4 ++ .../000045_users_deactivated_at.down.sql | 13 +++++++ migrations/000045_users_deactivated_at.up.sql | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 migrations/000045_users_deactivated_at.down.sql create mode 100644 migrations/000045_users_deactivated_at.up.sql 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;