- 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)
164 lines
8.4 KiB
Markdown
164 lines
8.4 KiB
Markdown
# 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.
|