# SPEC-004 — identity & sessions (`internal/core/auth`, `internal/core/users`) | Field | Value | |---|---| | **Status** | **Implemented** (2026-10-13) — see §8; `build`/`vet`/`test`/`-race` green · static CGO-free binary | | **Created** | 2026-10-13 | | **Author** | OpenArchitect | | **Target milestone** | **M0** | | **Governing decisions** | **D-007** · **D-008** · **D-013** · **D-020** · **D-034** · `PROJECT_PLAN.md` §6 | | **Related** | SPEC-003 (door) · SPEC-005 (`/setup`) · SPEC-006 (enroll/login) | --- ## 1. Goal The **data model and core services** for users, identities, sessions, and the audit trail — the foundation the `/setup`, enroll/login, roles, and recovery slices build on. --- ## 2. Non-goals - The HTTP door → SPEC-003. The flows → SPEC-005/006/007. Module data → modules own it. --- ## 3. The three anchors (D-013) | Anchor | Where | Rotates? | |---|---|---| | **`user_id`** | internal, stable; **the first user = 1** | **no** | | **`guid`** | the access username in the path | **yes** (recovery) | | **`email`** | contact + recovery authority | no | *Stable user ≠ rotating identity.* Credentials hang off an **identity**, never the user. --- ## 4. Schema (SQLite · WAL · foreign keys ON) | Table | Key columns | |---|---| | `users` | `user_id` PK · `email` UNIQUE NOT NULL · `display_name` · `role` (`admin`\|`director`\|`manager`\|`team`) · `manager_user_id` FK NULL (NULL only for the admin) · `passkey_opt_out` BOOL DEFAULT 0 · `status` · `created_at` | | `identities` | `identity_id` PK · `user_id` FK · `guid` UNIQUE (≥128-bit) · `status` (`pending_enrollment`\|`active`\|`revoked`) · `created_at` · `enrolled_at` · `revoked_at` | | `webauthn_credentials` | `credential_id` PK · `identity_id` FK · `public_key` · `sign_count` · `transports` · `aaguid` · `created_at` | | `totp_secrets` | `identity_id` FK · `secret_encrypted` (A9) · `verified_at` | | `sessions` | `session_id` PK (opaque random) · `identity_id` FK · `user_id` · `created_at` · `expires_at` · `revoked_at` · `client_ip` · `user_agent` | | `recovery_requests` | `request_id` PK · `user_id` FK · `status` (`pending`\|`approved`\|`denied`\|`expired`) · `requested_at` · `decided_by` FK · `decided_at` · `client_ip` | | `audit_log` | `id` PK · `at` · `actor_user_id` · `action` · `subject` · `detail` · `client_ip` · `location` (D-031) · `correlation_id` | **Invariants** - **Exactly one `active` identity per user** (enforced by a partial unique index + service checks). - `guid` is **unique and never recycled**; a revoked GUID is **tombstoned** (D-008). - **The admin is `user_id = 1`** and its `manager_user_id` is `NULL` (D-020, D-034). - The **enrollment window** is bounded: `now − identity.created_at ≤ enroll_window` (A1). --- ## 5. Core services (contracts) **Audit metadata rides the `context.Context`** — the door attaches it before calling any service: ```go ctx = audit.WithMeta(ctx, audit.Meta{ActorUserID, ClientIP, CorrelationID}) ``` ```go type Users interface { Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID int64, err error) Get(ctx context.Context, id int64) (User, error) SetRole(ctx context.Context, id int64, role Role) error // ripple-down model (D-034) Manager(ctx context.Context, id int64) (Optional[UserID], error) // the chain } type Identities interface { Mint(ctx context.Context, userID int64) (IdentityID int64, guid string, err error) // pending_enrollment Activate(ctx context.Context, id int64) error // pending -> active Rotate(ctx context.Context, userID int64) (guid string, err error) // recovery: revoke old, mint new Revoke(ctx context.Context, id int64) error // revoke identity AND its sessions ByGUID(ctx context.Context, guid string) (Identity, error) // tombstoned -> not-found semantics } type Sessions interface { Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error) Validate(ctx context.Context, sessionID string) (Session, error) // rejects empty · expired · revoked [A2] Rotate(ctx context.Context, sessionID string) (Session, error) // on login Revoke(ctx context.Context, sessionID string) error RevokeAll(ctx context.Context, identityID int64) error GC(ctx context.Context, now time.Time) (int, error) } type Audit interface { // event: bootstrap · enroll · login · logout · recovery request/approve/deny · revoke Record(ctx context.Context, e Event) } ``` Every mutating service call writes an audit row, taking **actor · client IP · correlation** from the context (A11). --- ## 6. Session lifecycle - **Mint** on successful enroll/login → an opaque random id; cookie set by the door (SPEC-003). - **Validate** on every request: the row must exist, **not expired**, **not revoked**, and the **session id must be non-empty** (A2). - **Rotate** on login (fixation defence). **Revoke** on logout; **RevokeAll** when an identity is rotated or revoked (D-007). - **GC** expired rows on a schedule — but **never** the principal's last valid session. --- ## 7. Acceptance criteria | # | Criterion | |---|---| | AC-1 | Two `active` identities for one user is **impossible** (index + service) | | AC-2 | `guid` is unique; a revoked GUID is never reissued (tombstoned) | | AC-3 | `Sessions.Validate("")` → error (empty id rejected, A2) | | AC-4 | An expired/revoked session → error; `RevokeAll` kills every session of an identity | | AC-5 | `Identities.Rotate` revokes the old identity **and** all its sessions, and mints a new GUID | | AC-6 | Creating a user with `role = admin` when user 1 exists is **rejected** (one admin, D-034/§7.1) | | AC-7 | Every service mutation writes an `audit_log` row (A11) | | AC-8 | `totp_secrets.secret_encrypted` is never stored or logged in plaintext (A9) | --- ## 8. Implementation notes (accepted rulings) Built 2026-10-13. `go build` · `go vet` · `go test` (incl. `-race`) green; `CGO_ENABLED=0` build is a **static** binary; `modernc.org/sqlite` in the dep graph, **no** `mattn/go-sqlite3`. AC-1…AC-8 all pass. **Architect rulings on the builder's open questions:** | # | Question | Ruling | |---|---|---| | **R-1** | Services can't populate `client_ip`/`correlation_id` (no context in §5). | **Thread `context.Context` + `audit.Meta`** (§5 rewritten). The door attaches the meta. **Must land before SPEC-003.** | | **R-2** | The `CHECK ((role='admin') = (manager_user_id IS NULL))` couples role and manager. | **Confirmed intended** — the one admin is the root (§7.1/§5.3). | | **R-3** | `RevokeAll` records an **anonymous** actor. | **Resolved by R-1** — the actor comes from the context (approver/operator/system). | | **R-4** | *(raised while building SPEC-003)* `auth.Mint/Rotate/Revoke` hard-code the **owning user** as actor. | **Use the context actor** (creator/approver); record the affected user as the **subject**. Adjust the identity services accordingly. | **Accepted deviations (Class B, reversible):** 1. Two service types over one `auth.Core` (`IdentitiesService` / `SessionsService`) — one Go type can't hold two same-named methods. Interfaces match §5. 2. `Identities.ByGUID` also returns `revoked` rows, so `Resolver.GUID()` maps a tombstoned GUID to *not found* (→ 404) while staying auditable. 3. `Users.Create(creatorID=0)` = the **bootstrap** path — only while `users` is empty, only for `role=admin`. SPEC-005 drives it. 4. Additive helpers not in §5: `users.Count`/`ByEmail`, `identities.WithinEnrollmentWindow`, the TOTP seam (`PutTOTPSecret`/`TOTPSecret`/`MarkTOTPVerified`). 5. Code defaults (config comes later, D-035): session TTL **24h** · enrollment window **72h**. 6. Enums not enumerated in §4: `users.status ∈ {active, disabled}`; `credential_id` as `BLOB`; `transports` as comma-text. TOTP sealed with **AES-256-GCM**. 7. **Migration `0002_passkey_opt_out.sql`** (D-041, 2026-10-14) adds `users.passkey_opt_out INTEGER NOT NULL DEFAULT 0` — append-only, `0001_init` untouched. `Users` gains `PasskeyOptOut` (read) and `SetPasskeyOptOut` (write, audited `user.passkey_opt_out`); `User` carries the field. It is a **user** preference, so it survives identity rotation (D-008). > **Packages added:** `internal/core/store` · `internal/core/audit` · `internal/core/users` · > `internal/core/auth`. `internal/core/routing` tests still pass.