NextMyDay/docs/specs/spec-004-identity-sessions.md
Claus Lohmar 57610cdbae docs: add the project plan, M0/M0.1 specs, agent guide and README
- PROJECT_PLAN.md: the decision log of record through D-042 and the M0.1 slice
- docs/specs: SPEC-002…009 — routing · door · identity/sessions · /setup ·
  enroll/login · roles/chain/recovery · module registry · the web layer & PWA
  (plus docs/specs/README.md, the index)
- AGENT.md: the working rules — commit AND push at every submilestone (gate
  green), Conventional Commits, never commit secrets
- README.md: the quickstart, the first-run/SSH-tunnel steps and the
  localhost/loopback caveat (SPEC-009 §13 Q-3): with an https base URL the
  CSRF/session cookies are Secure, so reach the app at localhost/127.0.0.1
- config.example.yaml: the settings template (D-035)
2026-10-14 10:53:44 +00:00

164 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.