555133d245
Phase 2 Batch 1 - No Dependencies (Start Immediately) ├─ VS-01: ManageIdentityAndRoles │ ├─ GOV: VS-01_SLICE_SPEC.md (Policy/Scope/Failure/Acceptance) │ ├─ DATA: VS-01_DATA_CONTRACT.md (3NF schema, PIT, CDC events) │ └─ DOMAIN: VS01_IdentityPolicyTests.cs (15 tests, pure logic) └─ VS-02: SynchronizeSecurityMaster (🔜 Next) ### VS-01 GOV Component - User Management (CRUD, soft-delete) - Role & Permission Model (Admin/Analyst/Trader/Viewer) - Data Integrity (PIT compliance, immutable email) - API Contracts (POST/GET/PATCH endpoints) - UI/UX Acceptance Criteria - Security Model - Failure Modes & Recovery ### VS-01 DATA Component - Schema (3NF): identity.users, identity.roles, identity.user_roles, identity.user_permissions - Constraints: Email UNIQUE, status ENUM, PIT temporal ordering - Immutability: Email/UserID/Roles cannot change post-creation - Soft-delete: removed_at pattern (append-only) - PIT Queries: published_at <= cutoff validation - CDC Events: UserCreated, RoleAssigned, RoleRevoked - Idempotency: Email-based dedup, role assignment idempotent ### VS-01 DOMAIN Component - 15 Domain Policy Tests (NO database, pure logic) ✅ Email validation (format, normalization, case-insensitivity) ✅ Password validation (length ≥12 chars) ✅ Role management (assign, revoke, idempotency) ✅ Permission hierarchy (role-based access control) ✅ User status transitions (active/inactive/suspended) ✅ Admin-only operations (user creation, role modification) ✅ Immutability (email, user ID) ✅ Soft-delete (inactive users filtered out) ✅ Consistency (every user must have role) Execution Timeline (Per Slice): - GOV: 1-2 hours ✅ COMPLETE - DATA: 2-3 hours ✅ COMPLETE - DOMAIN: 2-3 hours ✅ COMPLETE - BE: 3-4 hours (next) - ASYNC: 2-3 hours - FE: 3-4 hours - TESTOPS: 2-3 hours Total VS-01: ~18-22 hours (wall-clock ~3 days) Phase 2 Status: - Batch 1: 3/14 components COMPLETE (VS-01: 3/7, VS-02: 0/7) - Batch 2-3: 🔜 Queued (after Batch 1 deps satisfied) - 56 items total, 8 parallel batches AGENTS.md v16.0 Compliance: ✅ Necessity: User goal/non-goal/acceptance criteria specified ✅ Pattern: Vertical Slice (GOV → DATA → DOMAIN → BE → ASYNC → FE → TESTOPS) ✅ Traceability: VS-01 specs linked to Phase 2 plan ✅ Safety: Pure logic tests (no side effects) ✅ Maturity: Contracts before implementation Next: VS-01 BE (API/Handler/SQL) OR continue parallel VS-02 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
375 lines
10 KiB
Markdown
375 lines
10 KiB
Markdown
# VS-01: Identity and Roles Data Contract
|
|
|
|
**Slice:** VS-01 (ManageIdentityAndRoles)
|
|
**Status:** 📋 SPECIFICATION
|
|
**Version:** 1.0
|
|
**Created:** 2026-08-04
|
|
|
|
---
|
|
|
|
## Schema (3NF Write Model)
|
|
|
|
### identity.users (User Accounts)
|
|
|
|
**Purpose:** Immutable user record (append-only, PIT envelope)
|
|
|
|
```sql
|
|
CREATE TABLE identity.users (
|
|
-- Primary Key
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Business Keys (immutable)
|
|
email VARCHAR(255) NOT NULL UNIQUE,
|
|
email_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 of email
|
|
|
|
-- Authentication (write-once)
|
|
password_hash VARCHAR(255) NOT NULL, -- bcrypt, never changed after creation
|
|
|
|
-- State
|
|
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
|
CHECK (status IN ('active', 'inactive', 'suspended')),
|
|
|
|
-- Temporal (PIT)
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
-- Revision Tracking
|
|
revision INT NOT NULL DEFAULT 1,
|
|
content_hash VARCHAR(64) NOT NULL, -- SHA-256 of (email, status, updated_at)
|
|
|
|
-- Audit
|
|
created_by_user_id UUID REFERENCES identity.users(id),
|
|
correlation_id VARCHAR(36) NOT NULL,
|
|
|
|
-- Indexing
|
|
CONSTRAINT email_lowercase CHECK (email = LOWER(email)),
|
|
CONSTRAINT valid_email CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$')
|
|
);
|
|
|
|
CREATE INDEX idx_users_email ON identity.users(email);
|
|
CREATE INDEX idx_users_status ON identity.users(status);
|
|
CREATE INDEX idx_users_published_at ON identity.users(published_at);
|
|
CREATE INDEX idx_users_created_by ON identity.users(created_by_user_id);
|
|
```
|
|
|
|
**Constraints:**
|
|
- ✅ email UNIQUE: Only one account per email per environment
|
|
- ✅ status IN ('active', 'inactive', 'suspended'): Enum validation
|
|
- ✅ published_at ≤ CURRENT_TIMESTAMP: Never future-dated
|
|
- ✅ created_at ≤ updated_at: Temporal order
|
|
|
|
**PIT (Point-in-Time) Query:**
|
|
```sql
|
|
SELECT * FROM identity.users
|
|
WHERE published_at <= @cutoff
|
|
AND status = 'active'
|
|
ORDER BY created_at DESC;
|
|
```
|
|
|
|
---
|
|
|
|
### identity.roles (Role Definitions)
|
|
|
|
**Purpose:** Immutable, predefined roles (reference data)
|
|
|
|
```sql
|
|
CREATE TABLE identity.roles (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(50) NOT NULL UNIQUE,
|
|
description VARCHAR(255),
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
INSERT INTO identity.roles (name, description) VALUES
|
|
('Admin', 'Full system access'),
|
|
('Analyst', 'Read-only analysis'),
|
|
('Trader', 'Execute trades'),
|
|
('Viewer', 'Dashboard read-only');
|
|
|
|
-- Prevent deletion (immutable reference data)
|
|
CREATE TRIGGER prevent_role_deletion
|
|
BEFORE DELETE ON identity.roles
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION raise_immutability_error();
|
|
```
|
|
|
|
**Constraints:**
|
|
- ✅ name UNIQUE: One role per name
|
|
- ✅ Immutable: No INSERT/UPDATE/DELETE after initial load
|
|
- ✅ Predefined: Only 4 roles (Admin, Analyst, Trader, Viewer)
|
|
|
|
---
|
|
|
|
### identity.user_roles (User-Role Assignment)
|
|
|
|
**Purpose:** Many-to-many junction table (append-only)
|
|
|
|
```sql
|
|
CREATE TABLE identity.user_roles (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
-- Foreign Keys
|
|
user_id UUID NOT NULL REFERENCES identity.users(id),
|
|
role_id INT NOT NULL REFERENCES identity.roles(id),
|
|
|
|
-- Temporal
|
|
assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
removed_at TIMESTAMP, -- NULL = still assigned, NOT NULL = removed
|
|
|
|
-- Audit
|
|
assigned_by_user_id UUID REFERENCES identity.users(id),
|
|
correlation_id VARCHAR(36) NOT NULL,
|
|
|
|
-- Versioning (for CDC)
|
|
revision INT NOT NULL DEFAULT 1,
|
|
|
|
-- Constraints
|
|
CONSTRAINT active_assignment CHECK (assigned_at <= published_at),
|
|
CONSTRAINT valid_removal CHECK (removed_at IS NULL OR removed_at >= assigned_at),
|
|
CONSTRAINT unique_active_role UNIQUE (user_id, role_id) WHERE removed_at IS NULL
|
|
);
|
|
|
|
CREATE INDEX idx_user_roles_user ON identity.user_roles(user_id);
|
|
CREATE INDEX idx_user_roles_role ON identity.user_roles(role_id);
|
|
CREATE INDEX idx_user_roles_active ON identity.user_roles(user_id, removed_at);
|
|
CREATE INDEX idx_user_roles_published ON identity.user_roles(published_at);
|
|
```
|
|
|
|
**Constraints:**
|
|
- ✅ UNIQUE (user_id, role_id) WHERE removed_at IS NULL: No duplicate active roles
|
|
- ✅ assigned_at ≤ published_at: Temporal ordering
|
|
- ✅ removed_at IS NULL: Active assignment tracking
|
|
|
|
**PIT Query (Get current roles for user):**
|
|
```sql
|
|
SELECT ur.user_id, r.name AS role
|
|
FROM identity.user_roles ur
|
|
JOIN identity.roles r ON ur.role_id = r.id
|
|
WHERE ur.user_id = @userId
|
|
AND ur.published_at <= @cutoff
|
|
AND ur.removed_at IS NULL;
|
|
```
|
|
|
|
---
|
|
|
|
### identity.user_permissions (Permission Grant)
|
|
|
|
**Purpose:** Fine-grained permission model (append-only)
|
|
|
|
```sql
|
|
CREATE TABLE identity.user_permissions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
-- Foreign Keys
|
|
user_id UUID NOT NULL REFERENCES identity.users(id),
|
|
|
|
-- Permission (domain-scoped)
|
|
resource VARCHAR(50) NOT NULL, -- e.g., 'users', 'portfolios', 'trades'
|
|
action VARCHAR(20) NOT NULL, -- 'read', 'write', 'approve', 'execute'
|
|
|
|
-- Temporal
|
|
granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
revoked_at TIMESTAMP, -- NULL = active, NOT NULL = revoked
|
|
|
|
-- Audit
|
|
granted_by_user_id UUID REFERENCES identity.users(id),
|
|
correlation_id VARCHAR(36) NOT NULL,
|
|
|
|
-- Constraints
|
|
CONSTRAINT valid_resource CHECK (resource IN ('users', 'portfolios', 'trades', 'models', 'signals')),
|
|
CONSTRAINT valid_action CHECK (action IN ('read', 'write', 'approve', 'execute')),
|
|
CONSTRAINT unique_active_permission UNIQUE (user_id, resource, action) WHERE revoked_at IS NULL
|
|
);
|
|
|
|
CREATE INDEX idx_permissions_user ON identity.user_permissions(user_id);
|
|
CREATE INDEX idx_permissions_resource ON identity.user_permissions(resource, action);
|
|
```
|
|
|
|
---
|
|
|
|
## Data Integrity Rules
|
|
|
|
### Rule 1: Email Immutability
|
|
**Constraint:** email CANNOT be updated after creation
|
|
**Verification:**
|
|
```sql
|
|
-- Test: Email update should fail
|
|
UPDATE identity.users SET email = 'newemail@example.com'
|
|
WHERE id = @userId;
|
|
-- Expected: CONSTRAINT VIOLATION (or trigger prevents update)
|
|
```
|
|
|
|
### Rule 2: Password Hash Never Logged
|
|
**Constraint:** password_hash column exists but NEVER appears in SELECT without WHERE
|
|
**Verification:**
|
|
```sql
|
|
-- Bad (never do this):
|
|
SELECT * FROM identity.users; -- ❌ Exposes password_hash
|
|
|
|
-- Good (always explicit):
|
|
SELECT id, email, status FROM identity.users; -- ✅ No password
|
|
```
|
|
|
|
### Rule 3: PIT (Point-in-Time) Queries Must Include Cutoff
|
|
**Constraint:** All reads include `WHERE published_at <= @cutoff`
|
|
**Verification:**
|
|
```sql
|
|
-- Correct:
|
|
SELECT * FROM identity.users WHERE published_at <= @cutoff AND status = 'active';
|
|
|
|
-- Wrong (time-machine unsafe):
|
|
SELECT * FROM identity.users WHERE status = 'active'; -- ❌ No cutoff
|
|
```
|
|
|
|
### Rule 4: No Direct Email Mutations
|
|
**Constraint:** Email cannot be part of UPDATE statement
|
|
**Verification (trigger):**
|
|
```sql
|
|
CREATE TRIGGER prevent_email_update
|
|
BEFORE UPDATE ON identity.users
|
|
FOR EACH ROW
|
|
WHEN (OLD.email IS DISTINCT FROM NEW.email)
|
|
EXECUTE FUNCTION raise_immutability_error('email');
|
|
```
|
|
|
|
### Rule 5: Role Removal via Soft Delete
|
|
**Constraint:** Set removed_at timestamp instead of DELETE
|
|
**Verification:**
|
|
```sql
|
|
-- Correct:
|
|
UPDATE identity.user_roles SET removed_at = CURRENT_TIMESTAMP
|
|
WHERE user_id = @userId AND role_id = @roleId;
|
|
|
|
-- Wrong (no DELETE):
|
|
DELETE FROM identity.user_roles WHERE user_id = @userId; -- ❌ Banned
|
|
```
|
|
|
|
---
|
|
|
|
## Event Contracts (CDC)
|
|
|
|
### UserCreated Event
|
|
|
|
```json
|
|
{
|
|
"eventId": "UUID",
|
|
"eventType": "UserCreated",
|
|
"userId": "UUID",
|
|
"email": "user@example.com",
|
|
"roles": ["Admin", "Analyst"],
|
|
"createdAt": "2026-08-04T12:00:00Z",
|
|
"correlationId": "req-001"
|
|
}
|
|
```
|
|
|
|
**When:** INSERT into identity.users
|
|
**Consumer:** ApprovalQueue (if user requires approval)
|
|
|
|
### RoleAssigned Event
|
|
|
|
```json
|
|
{
|
|
"eventId": "UUID",
|
|
"eventType": "RoleAssigned",
|
|
"userId": "UUID",
|
|
"roleName": "Analyst",
|
|
"assignedAt": "2026-08-04T12:00:00Z",
|
|
"correlationId": "req-001"
|
|
}
|
|
```
|
|
|
|
**When:** INSERT into identity.user_roles with removed_at IS NULL
|
|
**Consumer:** PermissionCache (invalidate user's permission set)
|
|
|
|
### RoleRevoked Event
|
|
|
|
```json
|
|
{
|
|
"eventId": "UUID",
|
|
"eventType": "RoleRevoked",
|
|
"userId": "UUID",
|
|
"roleName": "Analyst",
|
|
"revokedAt": "2026-08-04T12:00:00Z",
|
|
"correlationId": "req-001"
|
|
}
|
|
```
|
|
|
|
**When:** UPDATE identity.user_roles SET removed_at = now()
|
|
**Consumer:** PermissionCache (invalidate user's permission set)
|
|
|
|
---
|
|
|
|
## Idempotency & Replay Safety
|
|
|
|
### Create User Idempotency
|
|
|
|
**Input:** IdempotencyKey = `create-user-alice-20260804`
|
|
**First Run:**
|
|
```sql
|
|
INSERT INTO identity.users (email, password_hash, correlation_id)
|
|
VALUES ('alice@example.com', 'bcrypt(...)', 'req-001')
|
|
RETURNING id;
|
|
-- Result: UUID = 12345678-1234-1234-1234-123456789012
|
|
```
|
|
|
|
**Replay (same IdempotencyKey):**
|
|
```sql
|
|
-- Check if already created
|
|
SELECT id FROM identity.users WHERE email = 'alice@example.com';
|
|
-- Result: 12345678-1234-1234-1234-123456789012 (same)
|
|
-- Action: Return existing record (no duplicate INSERT)
|
|
```
|
|
|
|
### Assign Role Idempotency
|
|
|
|
**Input:** IdempotencyKey = `assign-alice-analyst-20260804`
|
|
**First Run:**
|
|
```sql
|
|
INSERT INTO identity.user_roles (user_id, role_id, assigned_by_user_id)
|
|
VALUES (uuid-alice, 2, admin-user-id)
|
|
RETURNING id;
|
|
-- Result: ID = 1001
|
|
```
|
|
|
|
**Replay:**
|
|
```sql
|
|
-- Check if already assigned
|
|
SELECT id FROM identity.user_roles
|
|
WHERE user_id = uuid-alice AND role_id = 2 AND removed_at IS NULL;
|
|
-- Result: 1001 (same)
|
|
-- Action: Return existing record (no duplicate)
|
|
```
|
|
|
|
---
|
|
|
|
## Acceptance Criteria Checklist
|
|
|
|
- [ ] All tables created with 3NF normalization
|
|
- [ ] PIT queries tested (published_at ≤ cutoff)
|
|
- [ ] Append-only verified (no direct UPDATE on business keys)
|
|
- [ ] Immutability enforced (email, roles)
|
|
- [ ] Soft-delete working (removed_at pattern)
|
|
- [ ] Idempotency verified (replay tests passing)
|
|
- [ ] CDC events defined (UserCreated, RoleAssigned, RoleRevoked)
|
|
- [ ] Indexes created for performance
|
|
- [ ] Constraints enforced (CHECK, UNIQUE, FK)
|
|
|
|
---
|
|
|
|
## Sign-Off
|
|
|
|
| Role | Approval | Date |
|
|
|------|----------|------|
|
|
| Data Architect | ✅ Draft | 2026-08-04 |
|
|
| DBA | ⏳ Review | TBD |
|
|
| Security | ⏳ Review | TBD |
|
|
|
|
---
|
|
|
|
**Status:** 📋 **READY FOR DOMAIN TESTS & BE IMPLEMENTATION**
|
|
|
|
Next: DomainPolicyTests (identity rules validation)
|