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

8.4 KiB
Raw Blame History

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:

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):

  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.