- 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)
8.4 KiB
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
activeidentity per user (enforced by a partial unique index + service checks). guidis unique and never recycled; a revoked GUID is tombstoned (D-008).- The admin is
user_id = 1and itsmanager_user_idisNULL(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:
ctx = audit.WithMeta(ctx, audit.Meta{ActorUserID, ClientIP, CorrelationID})
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):
- Two service types over one
auth.Core(IdentitiesService/SessionsService) — one Go type can't hold two same-named methods. Interfaces match §5. Identities.ByGUIDalso returnsrevokedrows, soResolver.GUID()maps a tombstoned GUID to not found (→ 404) while staying auditable.Users.Create(creatorID=0)= the bootstrap path — only whileusersis empty, only forrole=admin. SPEC-005 drives it.- Additive helpers not in §5:
users.Count/ByEmail,identities.WithinEnrollmentWindow, the TOTP seam (PutTOTPSecret/TOTPSecret/MarkTOTPVerified). - Code defaults (config comes later, D-035): session TTL 24h · enrollment window 72h.
- Enums not enumerated in §4:
users.status ∈ {active, disabled};credential_idasBLOB;transportsas comma-text. TOTP sealed with AES-256-GCM. - Migration
0002_passkey_opt_out.sql(D-041, 2026-10-14) addsusers.passkey_opt_out INTEGER NOT NULL DEFAULT 0— append-only,0001_inituntouched.UsersgainsPasskeyOptOut(read) andSetPasskeyOptOut(write, auditeduser.passkey_opt_out);Usercarries 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/routingtests still pass.