Plan 2 — Identity Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A person can log in, prove which organization they are acting in with a signed claim, and be revoked on their next request — replacing the spoofable X-Org-Id header Phase 0 carried forward.
Architecture: A global login shell (users, session_family, refresh_token) sits outside tenancy and outside RLS; everything tenant-visible (organization_member, roles, company profile, activity) stays org-scoped and RLS-protected. Authentication produces a 15-minute JWT whose org claim is the sole source of tenant context, validated on every request against one liveness lookup. Organization provisioning is a step registry that later plans append to, not a fixed script.
Tech Stack: Go 1.26 · Gin · pgx v5 · goose · testcontainers-go · golang.org/x/crypto/argon2 · github.com/golang-jwt/jwt/v5 · github.com/pquerna/otp · github.com/redis/go-redis/v9
Specs:
docs/superpowers/specs/2026-09-20-session-and-auth-design.md— authoritative for everything in Tasks 1–12.docs/superpowers/specs/2026-09-20-erp-architecture-design.md— §4.2 identity model, §4.3 authorization, §4.6 company profile, §11A statutory compliance, §20.1.7 activity log, §20.2 provisioning.
Global Constraints
Section titled “Global Constraints”- Go floor is 1.26.0. CI derives its version from
go.mod; never restate a version. - Every business table carries
org_id UUID NOT NULL, hasENABLEandFORCE ROW LEVEL SECURITY, and a policy namedorg_isolation. The Phase 0 drift test atinternal/kernel/db/rlscoverage_test.goenforces this and will fail the build otherwise. - An exemption is a decision:
db/rls_exempt.txt(noorg_idat all) anddb/org_id_nullable.txt(nullableorg_id) each require a written justification in the file. - Dependencies point downward only:
modules/→platform/→kernel/. Enforced byinternal/kernel/layering/layering_test.go. - Money is
NUMERIC(19,4)andmoney.Amount. Neverfloat64. The sqlc override already exists insqlc.yaml; an OpenAPI monetary field needs the matching oapi-codegen override. - Every request runs inside a transaction, including reads, because
SET LOCAL app.current_orgis transaction-scoped. Usedb.InTx. - Errors are RFC 7807 problem+json. Handlers record an
*apperr.Problemwithc.Error(...)and never write an error body. - Times are UTC through
kernel/clock. Never calltime.Now()in code under test. - Records stay in Malaysia (ITA s.82(8)) — no external identity provider, no offshore processor.
- Record-bearing free text is English or Bahasa Malaysia (§11A.3).
zhis a UI locale only. - Audited actions write to
audit_logon the samepgx.Txas the change. - Git commits end with:
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
File Structure
Section titled “File Structure”New migrations (db/migrations/)
Section titled “New migrations (db/migrations/)”| File | Responsibility |
|---|---|
00007_audit_hash_chain.sql |
Adds prev_hash / row_hash to audit_log. Carried out of Phase 0 by §11A.5. |
00008_identity_global.sql |
users, session_family, refresh_token, login_attempt. RLS-exempt — the only tables in the system without org_id. |
00009_membership.sql |
organization_member, party. Org-scoped, RLS-protected. |
00010_roles.sql |
role, role_permission, member_role, and organization.perm_version. |
00011_company_profile.sql |
company_profile, 1:1 with organization. |
00012_activity_log.sql |
The user-facing feed. Distinct from audit_log. |
00013_provisioning.sql |
org_provisioning_step — what has been provisioned, per org, per step. |
New Go packages
Section titled “New Go packages”| Path | Responsibility |
|---|---|
internal/platform/identity/password/ |
Argon2id hashing and verification. One responsibility, no imports beyond x/crypto. |
internal/platform/identity/token/ |
Mint and verify access JWTs. Knows claims and keys; knows nothing about the database. |
internal/platform/identity/session/ |
Login, refresh rotation, reuse detection, revocation. Owns session_family and refresh_token. |
internal/platform/identity/totp/ |
TOTP enrolment, verification, recovery codes. |
internal/platform/identity/throttle/ |
Login backoff per email and per IP. |
internal/platform/identity/member/ |
Membership, roles, permissions, perm_version. |
internal/platform/identity/liveness/ |
The per-request lookup: Redis with a Postgres fallback. |
internal/platform/company/ |
Company profile. Separate package because it is a ledger dependency, not an identity concern. |
internal/platform/activity/ |
The activity feed writer and reader. |
internal/platform/provisioning/ |
The ordered, idempotent step registry and the readiness report. |
internal/modules/identity/ |
HTTP handlers for auth, sessions, members, profile. The first modules/ package in the repo. |
Modified
Section titled “Modified”| Path | Change |
|---|---|
internal/kernel/httpx/middleware.go |
Delete Tenant()’s header body and HeaderOrgID. Add Authenticate, rewrite Tenant, add Authorize. |
internal/kernel/audit/audit.go |
Hash chaining. |
internal/kernel/layering/layering_test.go |
New rule: no internal/modules/ package references an RLS-exempt table. |
db/rls_exempt.txt |
Four new entries, each justified. |
api/openapi.yaml |
Auth, session, member, profile and readiness endpoints. |
Task 1: Make the audit trail tamper-evident
Section titled “Task 1: Make the audit trail tamper-evident”Carried out of Phase 0 by §11A.5. REVOKE UPDATE, DELETE stops the application; it does not stop anyone holding superuser. Chaining each row to its predecessor makes an alteration detectable, which is what turns a log into evidence.
Files:
- Create:
db/migrations/00007_audit_hash_chain.sql - Modify:
internal/kernel/audit/audit.go - Test:
internal/kernel/audit/chain_test.go
Interfaces:
- Consumes:
audit.Entry,audit.Write(ctx, tx, clock.Clock, Entry) error(unchanged signature),tenancy.OrgFromContext. - Produces:
audit.VerifyChain(ctx context.Context, tx pgx.Tx) (*audit.ChainBreak, error)— returnsnil, nilwhen the chain is intact, and a non-nil*ChainBreak{RowID uuid.UUID, Position int64}naming the first row that does not match.
The chain is per organization, not global: rows for org A must not be linkable to org B’s, and RLS means a verifier can only ever see its own tenant’s rows anyway.
- Step 1: Write the failing test
package audit_test
import ( "context" "testing" "time"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/kernel/eventbus" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/jackc/pgx/v5")
func chainSetup(t *testing.T) (*db.DB, *testdb.Handles, context.Context, uuid.UUID) { t.Helper() h := testdb.Start(t) org := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) require.NoError(t, err) d, err := db.Open(context.Background(), h.AppDSN, eventbus.New()) require.NoError(t, err) t.Cleanup(d.Close) return d, h, tenancy.WithOrg(context.Background(), org), org}
func writeN(t *testing.T, d *db.DB, ctx context.Context, n int) { t.Helper() c := clock.Fixed(time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC)) for i := 0; i < n; i++ { require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return audit.Write(ctx, tx, c, audit.Entry{ EntityKind: "expense_claim", EntityID: uuid.New(), Action: "created", }) })) }}
func TestVerifyChainAcceptsAnUntamperedTrail(t *testing.T) { d, _, ctx, _ := chainSetup(t) writeN(t, d, ctx, 5)
var brk *audit.ChainBreak require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error brk, err = audit.VerifyChain(ctx, tx) return err })) require.Nil(t, brk, "an untampered chain must verify")}
func TestVerifyChainDetectsAnAlteredRow(t *testing.T) { d, h, ctx, org := chainSetup(t) writeN(t, d, ctx, 5)
// Tamper as only a superuser could: the app role holds no UPDATE grant, // so this uses the admin pool deliberately. This is the exact attack the // chain exists to detect, and a test that cannot perform it proves nothing. var victim uuid.UUID require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT id FROM audit_log WHERE org_id=$1 ORDER BY occurred_at, id OFFSET 2 LIMIT 1`, org).Scan(&victim)) _, err := h.Admin.Exec(context.Background(), `UPDATE audit_log SET action='deleted' WHERE id=$1`, victim) require.NoError(t, err)
var brk *audit.ChainBreak require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error brk, err = audit.VerifyChain(ctx, tx) return err })) require.NotNil(t, brk, "an altered row must break the chain") require.Equal(t, victim, brk.RowID, "the break must name the altered row, not a later one")}
func TestVerifyChainDetectsAnExcisedRow(t *testing.T) { d, h, ctx, org := chainSetup(t) writeN(t, d, ctx, 5)
var victim uuid.UUID require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT id FROM audit_log WHERE org_id=$1 ORDER BY occurred_at, id OFFSET 2 LIMIT 1`, org).Scan(&victim)) _, err := h.Admin.Exec(context.Background(), `DELETE FROM audit_log WHERE id=$1`, victim) require.NoError(t, err)
var brk *audit.ChainBreak require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error brk, err = audit.VerifyChain(ctx, tx) return err })) require.NotNil(t, brk, "deleting a row must break the chain of the row that followed it")}
func TestChainsAreIndependentPerOrganization(t *testing.T) { d, h, ctxA, _ := chainSetup(t) orgB := uuid.New() _, err := h.Admin.Exec(context.Background(), `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Beta')`, orgB) require.NoError(t, err) ctxB := tenancy.WithOrg(context.Background(), orgB)
writeN(t, d, ctxA, 3) writeN(t, d, ctxB, 3)
for _, ctx := range []context.Context{ctxA, ctxB} { var brk *audit.ChainBreak require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error brk, err = audit.VerifyChain(ctx, tx) return err })) require.Nil(t, brk, "interleaved writes from another org must not break this org's chain") }}- Step 2: Run the test and watch it fail
Run: go test ./internal/kernel/audit/ -run TestVerifyChain -v
Expected: FAIL — undefined: audit.VerifyChain and undefined: audit.ChainBreak.
- Step 3: Write the migration
Create db/migrations/00007_audit_hash_chain.sql:
-- +goose Up
-- prev_hash is the row_hash of this org's previous audit row; row_hash covers-- the payload AND prev_hash, which is what makes the chain a chain rather than-- a column of independent checksums.ALTER TABLE audit_log ADD COLUMN prev_hash BYTEA;ALTER TABLE audit_log ADD COLUMN row_hash BYTEA;
-- Ordering within an org must be total and stable, or verification is-- non-deterministic. occurred_at alone is not: two rows in the same-- transaction share it. seq gives the tiebreak and the traversal order.ALTER TABLE audit_log ADD COLUMN seq BIGSERIAL;CREATE UNIQUE INDEX audit_log_org_seq_idx ON audit_log (org_id, seq);
-- +goose DownDROP INDEX audit_log_org_seq_idx;ALTER TABLE audit_log DROP COLUMN seq;ALTER TABLE audit_log DROP COLUMN row_hash;ALTER TABLE audit_log DROP COLUMN prev_hash;- Step 4: Implement chaining and verification
Append to internal/kernel/audit/audit.go, and change Write’s INSERT to the version below. canonical is the single definition of what is hashed — Write and VerifyChain must call the same function, or the chain will “break” on untampered data.
// canonical renders the hashed payload of one audit row. Write and VerifyChain// MUST both use it: two spellings of "the bytes we hash" is how a chain starts// reporting breaks on honest data and gets switched off.func canonical(orgID uuid.UUID, entityKind string, entityID uuid.UUID, action string, actorID *uuid.UUID, before, after []byte, occurredAt time.Time) []byte { var b bytes.Buffer b.WriteString(orgID.String()) b.WriteByte(0x1f) b.WriteString(entityKind) b.WriteByte(0x1f) b.WriteString(entityID.String()) b.WriteByte(0x1f) b.WriteString(action) b.WriteByte(0x1f) if actorID != nil { b.WriteString(actorID.String()) } b.WriteByte(0x1f) b.Write(before) b.WriteByte(0x1f) b.Write(after) b.WriteByte(0x1f) b.WriteString(occurredAt.UTC().Format(time.RFC3339Nano)) return b.Bytes()}
func rowHash(prev []byte, payload []byte) []byte { h := sha256.New() h.Write(prev) h.Write([]byte{0x1e}) h.Write(payload) return h.Sum(nil)}
// ChainBreak names the first row whose stored hash does not match a recomputation.type ChainBreak struct { RowID uuid.UUID Position int64}
// VerifyChain walks this organization's audit rows in order and returns the// first break, or nil when the chain is intact. RLS confines it to the caller's// tenant, so it can only ever verify what the caller is entitled to read.func VerifyChain(ctx context.Context, tx pgx.Tx) (*ChainBreak, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return nil, fmt.Errorf("audit: %w", tenancy.ErrNoOrg) } rows, err := tx.Query(ctx, ` SELECT id, seq, entity_kind, entity_id, action, actor_id, COALESCE(before::text,''), COALESCE(after::text,''), occurred_at, prev_hash, row_hash FROM audit_log WHERE org_id = $1 ORDER BY seq`, orgID) if err != nil { return nil, fmt.Errorf("audit verify: %w", err) } defer rows.Close()
var expectedPrev []byte for rows.Next() { var ( id uuid.UUID seq int64 kind, action string entityID uuid.UUID actorID *uuid.UUID before, after string occurredAt time.Time prevHash, storedRH []byte ) if err := rows.Scan(&id, &seq, &kind, &entityID, &action, &actorID, &before, &after, &occurredAt, &prevHash, &storedRH); err != nil { return nil, fmt.Errorf("audit verify scan: %w", err) } if !bytes.Equal(prevHash, expectedPrev) { return &ChainBreak{RowID: id, Position: seq}, nil } want := rowHash(prevHash, canonical(orgID, kind, entityID, action, actorID, []byte(before), []byte(after), occurredAt)) if !bytes.Equal(want, storedRH) { return &ChainBreak{RowID: id, Position: seq}, nil } expectedPrev = storedRH } return nil, rows.Err()}Replace the body of Write with:
func Write(ctx context.Context, tx pgx.Tx, c clock.Clock, e Entry) error { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return fmt.Errorf("audit: %w", tenancy.ErrNoOrg) } beforeJSON, err := marshalOrEmpty(e.Before) if err != nil { return fmt.Errorf("audit before: %w", err) } afterJSON, err := marshalOrEmpty(e.After) if err != nil { return fmt.Errorf("audit after: %w", err) }
// The predecessor is read FOR UPDATE inside this same transaction, so two // concurrent writers cannot both chain onto the same prev_hash and produce // a fork. This serializes audit writes per org, which is acceptable: the // trail is not the hot path, and a forked chain is unverifiable. var prev []byte err = tx.QueryRow(ctx, ` SELECT row_hash FROM audit_log WHERE org_id = $1 ORDER BY seq DESC LIMIT 1 FOR UPDATE`, orgID).Scan(&prev) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return fmt.Errorf("audit predecessor: %w", err) }
occurredAt := c.Now() rh := rowHash(prev, canonical(orgID, e.EntityKind, e.EntityID, e.Action, e.ActorID, beforeJSON, afterJSON, occurredAt))
_, err = tx.Exec(ctx, ` INSERT INTO audit_log (org_id, entity_kind, entity_id, action, actor_id, before, after, occurred_at, prev_hash, row_hash) VALUES ($1,$2,$3,$4,$5, NULLIF($6,'')::jsonb, NULLIF($7,'')::jsonb, $8,$9,$10)`, orgID, e.EntityKind, e.EntityID, e.Action, e.ActorID, string(beforeJSON), string(afterJSON), occurredAt, prev, rh) if err != nil { return fmt.Errorf("audit write: %w", err) } return nil}
// marshalOrEmpty returns the JSON encoding of v, or an empty slice for nil, so// that "no before state" hashes identically every time.func marshalOrEmpty(v any) ([]byte, error) { if v == nil { return []byte{}, nil } return json.Marshal(v)}Add to the import block: bytes, crypto/sha256, encoding/json, errors, time.
- Step 5: Run the tests and verify they pass
Run: go test ./internal/kernel/audit/ -v
Expected: PASS, including the pre-existing Phase 0 audit tests. Output must be pristine.
- Step 6: Run the full suite — this migration touches a table every package uses
Run: go test ./...
Expected: all packages PASS. In particular internal/kernel/db and internal/gen must still pass; if internal/gen fails, regenerate with make generate and commit the result.
- Step 7: Commit
git add db/migrations/00007_audit_hash_chain.sql internal/kernel/audit/git commit -m "Chain audit rows so tampering is detectable
REVOKE UPDATE, DELETE stops the application; it does not stop superuser.Each row now carries prev_hash and row_hash, per organization, andVerifyChain names the first row that does not reconcile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 2: The global login shell, and the rule that contains its RLS exemption
Section titled “Task 2: The global login shell, and the rule that contains its RLS exemption”Per §2.1 and §2.2 of the session design. users, session_family, refresh_token and login_attempt have no meaningful org_id: a login is not tenant-scoped. Phase 0’s drift test requires org_id on every table, so this task grants four exemptions — quadrupling the contents of db/rls_exempt.txt — and pays for them with a new machine-checked rule.
Files:
- Create:
db/migrations/00008_identity_global.sql - Modify:
db/rls_exempt.txt,internal/kernel/layering/layering_test.go - Test:
internal/kernel/layering/layering_test.go(new test function)
Interfaces:
-
Produces: the four tables below, and
TestModulesNeverReferenceRLSExemptTablesin the layering package. -
Step 1: Write the failing test
Append to internal/kernel/layering/layering_test.go:
// An RLS-exempt table has no tenant isolation at the database layer. That is// tolerable only while access to it stays inside the platform package that// owns it: a module package reaching one directly is a cross-tenant read// waiting to be written. The exemption in db/rls_exempt.txt is granted on// this condition, so the condition is enforced rather than remembered.func TestModulesNeverReferenceRLSExemptTables(t *testing.T) { root := repoRoot(t) exempt := readExemptTables(t, filepath.Join(root, "db", "rls_exempt.txt")) require.NotEmpty(t, exempt, "no exempt tables parsed; the reader is not reaching the file")
// goose's own bookkeeping is not a table any application code should // reference, but it is also not an identity table — excluded so the test // reports real findings rather than a false positive on a migration tool. delete(exempt, "goose_db_version") require.NotEmpty(t, exempt, "only goose_db_version parsed; expected the identity tables")
modulesRoot := filepath.Join(root, "internal", "modules") if _, err := os.Stat(modulesRoot); os.IsNotExist(err) { t.Skip("no modules yet") }
violations := map[string][]string{} var filesScanned int err := filepath.WalkDir(modulesRoot, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || !strings.HasSuffix(path, ".go") { return nil } src, err := os.ReadFile(path) require.NoError(t, err) filesScanned++ rel, err := filepath.Rel(root, path) require.NoError(t, err) for table := range exempt { // Word-boundary match: "users" must not fire on "org_users_view". if regexp.MustCompile(`\b` + regexp.QuoteMeta(table) + `\b`).Match(src) { violations[rel] = append(violations[rel], table) } } return nil }) require.NoError(t, err) require.Positive(t, filesScanned, "no module files scanned; the walker is not reaching the source tree")
require.Empty(t, violations, "packages under internal/modules must not reference RLS-exempt tables directly — "+ "go through the owning platform package: %v", violations)}
// readExemptTables parses db/rls_exempt.txt: one bare table name per line,// blank lines and # comments ignored.func readExemptTables(t *testing.T, path string) map[string]struct{} { t.Helper() raw, err := os.ReadFile(path) require.NoError(t, err) out := map[string]struct{}{} for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } out[line] = struct{}{} } return out}Add regexp to the import block.
- Step 2: Run it and watch it skip, then fail for the right reason
Run: go test ./internal/kernel/layering/ -run TestModulesNeverReference -v
Expected: FAIL — only goose_db_version parsed; expected the identity tables. This is the anti-vacuity guard doing its job: before the migration exists there is nothing to enforce, and a silent PASS would be a lie.
- Step 3: Write the migration
Create db/migrations/00008_identity_global.sql:
-- +goose Up
-- These four tables are the global login shell. They carry no org_id because a-- login is not tenant-scoped: one human is one users row across every tenant-- they work in. They are listed in db/rls_exempt.txt, and the layering drift-- test forbids any package under internal/modules from touching them.
CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email CITEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, email_verified_at TIMESTAMPTZ, totp_secret_enc BYTEA, totp_enabled_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE TABLE session_family ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL, last_used_at TIMESTAMPTZ NOT NULL, -- Set at login and never extended. A session that can be renewed forever is -- a credential with no end date. absolute_expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, revoked_reason TEXT, device_label TEXT, ip INET, user_agent TEXT);CREATE INDEX session_family_user_idx ON session_family (user_id, last_used_at DESC);
CREATE TABLE refresh_token ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), family_id UUID NOT NULL REFERENCES session_family (id) ON DELETE CASCADE, -- The raw token is never stored. A database dump must not yield live -- sessions. token_hash BYTEA NOT NULL UNIQUE, issued_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL);CREATE INDEX refresh_token_family_idx ON refresh_token (family_id, issued_at DESC);
-- Throttling state. Rows are transient and pruned; this is not a record.CREATE TABLE login_attempt ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email CITEXT NOT NULL, ip INET NOT NULL, succeeded BOOLEAN NOT NULL, attempted_at TIMESTAMPTZ NOT NULL);CREATE INDEX login_attempt_email_idx ON login_attempt (email, attempted_at DESC);CREATE INDEX login_attempt_ip_idx ON login_attempt (ip, attempted_at DESC);
GRANT SELECT, INSERT, UPDATE, DELETE ON users, session_family, refresh_token, login_attempt TO erp_app;
-- +goose DownDROP TABLE login_attempt;DROP TABLE refresh_token;DROP TABLE session_family;DROP TABLE users;CITEXT needs its extension. Add to the top of the Up block, before CREATE TABLE users:
CREATE EXTENSION IF NOT EXISTS citext;- Step 4: Justify the exemptions
Append to db/rls_exempt.txt:
# The global login shell. A login is not tenant-scoped: one human is one users# row across every organization they work in, and a session family is the# login rather than the organization (see# docs/superpowers/specs/2026-09-20-session-and-auth-design.md section 2).# These tables therefore have no org_id to isolate on.## The exemption is granted on one condition, enforced by# TestModulesNeverReferenceRLSExemptTables in internal/kernel/layering: no# package under internal/modules may reference these tables. Access goes# through internal/platform/identity, which is the only code that knows how to# reach them safely. Everything tenant-visible — organization_member, roles,# company_profile — is org-scoped and fully RLS-protected, and is NOT listed# here.userssession_familyrefresh_tokenlogin_attempt- Step 5: Run the tests and verify they pass
Run: go test ./internal/kernel/layering/ ./internal/kernel/db/ -v
Expected: PASS. The RLS coverage test must pass because the four tables are exempted, not because it stopped looking — confirm TestExemptionFilesNameRealTables still passes, since it asserts every name in the file is a table that actually exists.
- Step 6: Commit
git add db/migrations/00008_identity_global.sql db/rls_exempt.txt internal/kernel/layering/git commit -m "Add the global login shell, and fence its RLS exemption
users, session_family, refresh_token and login_attempt have no org_id: alogin is not tenant-scoped. That exemption quadruples rls_exempt.txt, soit is paid for with an enforced rule — no package under internal/modulesmay reference an RLS-exempt table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 3: Password hashing
Section titled “Task 3: Password hashing”Files:
- Create:
internal/platform/identity/password/password.go - Test:
internal/platform/identity/password/password_test.go - Modify:
go.mod(promotegolang.org/x/cryptofrom indirect)
Interfaces:
-
Produces:
password.Hash(plain string) (string, error)— returns a self-describing PHC string.password.Verify(plain, encoded string) (bool, error)— constant-time.password.ErrMalformed— sentinel for an unparseable encoded hash.
-
Step 1: Write the failing test
package password_test
import ( "strings" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/identity/password")
func TestHashThenVerifyAccepts(t *testing.T) { h, err := password.Hash("correct horse battery staple") require.NoError(t, err) ok, err := password.Verify("correct horse battery staple", h) require.NoError(t, err) require.True(t, ok)}
func TestVerifyRejectsAWrongPassword(t *testing.T) { h, err := password.Hash("correct horse battery staple") require.NoError(t, err) ok, err := password.Verify("Correct horse battery staple", h) require.NoError(t, err) require.False(t, ok, "verification must be case-sensitive and exact")}
func TestHashingIsSaltedSoIdenticalPasswordsDiffer(t *testing.T) { a, err := password.Hash("same") require.NoError(t, err) b, err := password.Hash("same") require.NoError(t, err) require.NotEqual(t, a, b, "two hashes of one password must differ, or the salt is not random")
// Both must still verify: differing output is only correct if it is salt, // not corruption. for _, h := range []string{a, b} { ok, err := password.Verify("same", h) require.NoError(t, err) require.True(t, ok) }}
func TestEncodedHashIsSelfDescribing(t *testing.T) { h, err := password.Hash("x") require.NoError(t, err) // PHC format: parameters travel with the hash, so raising them later does // not invalidate existing passwords. require.True(t, strings.HasPrefix(h, "$argon2id$v=19$"), "got %q", h) require.Equal(t, 6, len(strings.Split(h, "$")), "expected $argon2id$v$params$salt$hash")}
func TestVerifyRejectsMalformedInputRatherThanPanicking(t *testing.T) { for _, bad := range []string{"", "notahash", "$argon2id$v=19$broken", "$bcrypt$v=1$a$b$c"} { _, err := password.Verify("x", bad) require.ErrorIs(t, err, password.ErrMalformed, "input %q", bad) }}
func TestEmptyPasswordIsRejectedAtHashTime(t *testing.T) { _, err := password.Hash("") require.Error(t, err, "an empty password must not produce a usable hash")}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/password/ -v
Expected: FAIL — package does not exist.
- Step 3: Implement
// Package password hashes and verifies user passwords with Argon2id.//// Encoded hashes are PHC strings, so the parameters travel with the hash: the// cost can be raised later without invalidating passwords already stored.package password
import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "strings"
"golang.org/x/crypto/argon2")
var ErrMalformed = errors.New("password: malformed encoded hash")
// Parameters. OWASP's second recommended Argon2id configuration (19 MiB, t=2,// p=1) — chosen over a tuned-on-this-machine figure deliberately, because a// number tuned on a developer laptop is meaningless on the VPS this runs on.// Raising these later is safe: existing hashes carry their own parameters.const ( timeCost uint32 = 2 memoryKiB uint32 = 19 * 1024 parallelism uint8 = 1 saltLen = 16 keyLen uint32 = 32)
func Hash(plain string) (string, error) { if plain == "" { return "", errors.New("password: empty") } salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("password: salt: %w", err) } key := argon2.IDKey([]byte(plain), salt, timeCost, memoryKiB, parallelism, keyLen) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, memoryKiB, timeCost, parallelism, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil}
func Verify(plain, encoded string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, ErrMalformed } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { return false, ErrMalformed } var m, t uint32 var p uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { return false, ErrMalformed } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false, ErrMalformed } want, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false, ErrMalformed } got := argon2.IDKey([]byte(plain), salt, t, m, p, uint32(len(want))) // Constant time: a timing difference here leaks how much of a guess was // correct. return subtle.ConstantTimeCompare(got, want) == 1, nil}- Step 4: Run the tests and verify they pass
Run: go test ./internal/platform/identity/password/ -v
Expected: PASS, 6 tests. Then go mod tidy to promote golang.org/x/crypto to a direct requirement.
- Step 5: Commit
git add internal/platform/identity/password/ go.mod go.sumgit commit -m "Hash passwords with Argon2id
PHC-encoded so parameters travel with the hash and the cost can beraised later without invalidating stored passwords.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 4: Access tokens
Section titled “Task 4: Access tokens”Per §3.1 of the session design. HS256 with a kid header so a key can be rotated without invalidating every live token: the verifier accepts any key in its set, the minter signs with one.
Files:
- Create:
internal/platform/identity/token/token.go - Test:
internal/platform/identity/token/token_test.go - Modify:
internal/kernel/config/config.go,.env.example
Interfaces:
-
Consumes:
clock.Clock. -
Produces:
token.Claims{UserID, OrgID, MemberID uuid.UUID; PermVersion int64; SessionID uuid.UUID; ID string}token.NewSigner(keys map[string]([]byte), activeKID string, c clock.Clock) (*token.Signer, error)(*Signer).Mint(Claims) (string, error)— 15-minute expiry.(*Signer).Verify(raw string) (Claims, error)token.ErrExpired,token.ErrInvalid,token.ErrUnknownKey
-
Step 1: Write the failing test
package token_test
import ( "testing" "time"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/identity/token")
var base = time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)
func signer(t *testing.T, c clock.Clock) *token.Signer { t.Helper() s, err := token.NewSigner(map[string][]byte{ "k1": []byte("0123456789abcdef0123456789abcdef"), }, "k1", c) require.NoError(t, err) return s}
func sampleClaims() token.Claims { return token.Claims{ UserID: uuid.New(), OrgID: uuid.New(), MemberID: uuid.New(), SessionID: uuid.New(), PermVersion: 7, }}
func TestMintThenVerifyRoundTripsEveryClaim(t *testing.T) { s := signer(t, clock.Fixed(base)) in := sampleClaims() raw, err := s.Mint(in) require.NoError(t, err)
out, err := s.Verify(raw) require.NoError(t, err) require.Equal(t, in.UserID, out.UserID) require.Equal(t, in.OrgID, out.OrgID, "org is the sole source of tenant context; losing it is a tenancy bug") require.Equal(t, in.MemberID, out.MemberID) require.Equal(t, in.SessionID, out.SessionID) require.Equal(t, in.PermVersion, out.PermVersion) require.NotEmpty(t, out.ID, "every token needs a jti")}
func TestTokenExpiresAfterFifteenMinutes(t *testing.T) { s := signer(t, clock.Fixed(base)) raw, err := s.Mint(sampleClaims()) require.NoError(t, err)
// One second before expiry: still good. ok := signerAt(t, base.Add(15*time.Minute-time.Second)) _, err = ok.Verify(raw) require.NoError(t, err)
// One second after: refused. late := signerAt(t, base.Add(15*time.Minute+time.Second)) _, err = late.Verify(raw) require.ErrorIs(t, err, token.ErrExpired)}
func signerAt(t *testing.T, at time.Time) *token.Signer { t.Helper() return signer(t, clock.Fixed(at))}
func TestTamperedTokenIsRefused(t *testing.T) { s := signer(t, clock.Fixed(base)) raw, err := s.Mint(sampleClaims()) require.NoError(t, err)
tampered := raw[:len(raw)-4] + "AAAA" _, err = s.Verify(tampered) require.ErrorIs(t, err, token.ErrInvalid)}
func TestVerifierAcceptsARetiredKeyWhileTheMinterUsesTheNewOne(t *testing.T) { // Rotation: tokens minted under k1 must keep verifying after k2 becomes // active, or every logged-in user is thrown out at the moment of rotation. old, err := token.NewSigner(map[string][]byte{ "k1": []byte("0123456789abcdef0123456789abcdef"), }, "k1", clock.Fixed(base)) require.NoError(t, err) raw, err := old.Mint(sampleClaims()) require.NoError(t, err)
rotated, err := token.NewSigner(map[string][]byte{ "k1": []byte("0123456789abcdef0123456789abcdef"), "k2": []byte("fedcba9876543210fedcba9876543210"), }, "k2", clock.Fixed(base)) require.NoError(t, err)
_, err = rotated.Verify(raw) require.NoError(t, err, "a token minted under a retired-but-present key must still verify")}
func TestTokenSignedWithAnUnknownKeyIsRefused(t *testing.T) { foreign, err := token.NewSigner(map[string][]byte{ "kx": []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), }, "kx", clock.Fixed(base)) require.NoError(t, err) raw, err := foreign.Mint(sampleClaims()) require.NoError(t, err)
ours := signer(t, clock.Fixed(base)) _, err = ours.Verify(raw) require.ErrorIs(t, err, token.ErrUnknownKey)}
func TestNewSignerRejectsAShortKey(t *testing.T) { _, err := token.NewSigner(map[string][]byte{"k1": []byte("tooshort")}, "k1", clock.Fixed(base)) require.Error(t, err, "an HS256 key shorter than 32 bytes must be refused, not accepted quietly")}
func TestNewSignerRejectsAnActiveKIDNotInTheSet(t *testing.T) { _, err := token.NewSigner(map[string][]byte{ "k1": []byte("0123456789abcdef0123456789abcdef"), }, "k2", clock.Fixed(base)) require.Error(t, err)}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/token/ -v
Expected: FAIL — package does not exist.
- Step 3: Add the dependency
Run: go get github.com/golang-jwt/jwt/v5@latest
- Step 4: Implement
// Package token mints and verifies access tokens.//// HS256: one binary issues and verifies its own tokens, so asymmetric keys// would buy key distribution nobody needs. The kid header carries which key// signed a token, so a key can be rotated while tokens minted under the// previous one keep verifying until they expire.//// This package knows claims and keys. It does not know the database: whether// the session behind a valid token is still live is a separate question,// answered per request by internal/platform/identity/liveness.package token
import ( "errors" "fmt" "time"
"github.com/golang-jwt/jwt/v5" "github.com/google/uuid"
"github.com/mcom/erp/internal/kernel/clock")
var ( ErrExpired = errors.New("token: expired") ErrInvalid = errors.New("token: invalid") ErrUnknownKey = errors.New("token: unknown key id"))
// Lifetime is short by design: the damage window of a leaked access token.// Revocation does not wait for it (see liveness), but expiry is the backstop.const Lifetime = 15 * time.Minute
const minKeyLen = 32
type Claims struct { UserID uuid.UUID OrgID uuid.UUID MemberID uuid.UUID SessionID uuid.UUID PermVersion int64 ID string}
type Signer struct { keys map[string][]byte activeKID string clock clock.Clock}
func NewSigner(keys map[string][]byte, activeKID string, c clock.Clock) (*Signer, error) { if len(keys) == 0 { return nil, errors.New("token: no keys") } for kid, k := range keys { if len(k) < minKeyLen { return nil, fmt.Errorf("token: key %q is %d bytes, need at least %d", kid, len(k), minKeyLen) } } if _, ok := keys[activeKID]; !ok { return nil, fmt.Errorf("token: active kid %q is not in the key set", activeKID) } return &Signer{keys: keys, activeKID: activeKID, clock: c}, nil}
func (s *Signer) Mint(c Claims) (string, error) { now := s.clock.Now() if c.ID == "" { c.ID = uuid.NewString() } t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": c.UserID.String(), "org": c.OrgID.String(), "mem": c.MemberID.String(), "sid": c.SessionID.String(), "pv": c.PermVersion, "jti": c.ID, "iat": now.Unix(), "exp": now.Add(Lifetime).Unix(), }) t.Header["kid"] = s.activeKID raw, err := t.SignedString(s.keys[s.activeKID]) if err != nil { return "", fmt.Errorf("token: sign: %w", err) } return raw, nil}
func (s *Signer) Verify(raw string) (Claims, error) { parsed, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { // Refusing the algorithm named in the header is what stops the // classic "alg: none" and RS256-key-confusion attacks. return nil, ErrInvalid } kid, _ := t.Header["kid"].(string) k, ok := s.keys[kid] if !ok { return nil, ErrUnknownKey } return k, nil }, jwt.WithValidMethods([]string{"HS256"}), jwt.WithTimeFunc(s.clock.Now), ) if err != nil { switch { case errors.Is(err, jwt.ErrTokenExpired): return Claims{}, ErrExpired case errors.Is(err, ErrUnknownKey): return Claims{}, ErrUnknownKey default: return Claims{}, fmt.Errorf("%w: %v", ErrInvalid, err) } } m, ok := parsed.Claims.(jwt.MapClaims) if !ok { return Claims{}, ErrInvalid } out := Claims{} for dst, key := range map[*uuid.UUID]string{ &out.UserID: "sub", &out.OrgID: "org", &out.MemberID: "mem", &out.SessionID: "sid", } { s, _ := m[key].(string) id, err := uuid.Parse(s) if err != nil { return Claims{}, fmt.Errorf("%w: claim %s", ErrInvalid, key) } *dst = id } pv, ok := m["pv"].(float64) if !ok { return Claims{}, fmt.Errorf("%w: claim pv", ErrInvalid) } out.PermVersion = int64(pv) out.ID, _ = m["jti"].(string) return out, nil}- Step 5: Add configuration
In internal/kernel/config/config.go, add to the Config struct and Load:
// JWTKeys is "kid:base64secret" pairs, comma separated. More than one // entry is a rotation in progress: the first is active, the rest still // verify. Kept as a single variable so rotation is one deploy, not two. JWTKeys stringRead it with os.Getenv("JWT_KEYS") and return an error when it is empty — an unset signing key must fail at boot, not at the first login. Add JWT_KEYS= with an example value to .env.example; the Phase 0 env drift test at internal/kernel/config/envexample_test.go will fail the build if you forget.
- Step 6: Run the tests and verify they pass
Run: go test ./internal/platform/identity/token/ ./internal/kernel/config/ -v
Expected: PASS, 8 token tests plus the config drift test.
- Step 7: Commit
git add internal/platform/identity/token/ internal/kernel/config/ .env.example go.mod go.sumgit commit -m "Mint and verify access tokens
HS256 with a kid header so a key rotates without logging everyone out.Verification pins the algorithm, which is what refuses alg:none andkey-confusion tokens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 5: Sessions — login, rotation, and reuse detection
Section titled “Task 5: Sessions — login, rotation, and reuse detection”The heart of the plan. Per §4 of the session design, including the 10-second grace window: strict single-use rotation logs users out for having two tabs open or one flaky connection, and a design that does that gets switched off in production.
Files:
- Create:
internal/platform/identity/session/session.go - Test:
internal/platform/identity/session/session_test.go
Interfaces:
- Consumes:
clock.Clock,password.Verify,pgx.Tx. - Produces:
session.Pair{Refresh string; FamilyID uuid.UUID; UserID uuid.UUID}— the raw refresh token is returned once, never readable again.session.Login(ctx, tx, c, email, plain string, meta Meta) (Pair, error)session.Refresh(ctx, tx, c, rawRefresh string) (Pair, error)session.Meta{IP netip.Addr; UserAgent, DeviceLabel string}- Sentinels:
ErrBadCredentials,ErrSessionExpired,ErrReplayed,ErrRevoked
- Constants:
session.IdleWindow = 14 * 24 * time.Hour,session.AbsoluteWindow = 90 * 24 * time.Hour,session.GraceWindow = 10 * time.Second
Login and Refresh take a pgx.Tx because they must commit with whatever else the request does — notably the audit row. They do not open their own transaction.
- Step 1: Write the failing test
package session_test
import ( "context" "net/netip" "testing" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db/testdb" "github.com/mcom/erp/internal/platform/identity/password" "github.com/mcom/erp/internal/platform/identity/session")
var t0 = time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
func meta() session.Meta { return session.Meta{ IP: netip.MustParseAddr("203.0.113.7"), UserAgent: "Mozilla/5.0", DeviceLabel: "Ziyang's laptop", }}
// sessionSetup returns the app pool and a seeded user. These tables are global// and RLS-exempt, so no tenant context is needed — that is the point of Task 2.func sessionSetup(t *testing.T) (*pgxpool.Pool, string) { t.Helper() h := testdb.Start(t) email := "ziyang@example.com" hash, err := password.Hash("correct horse battery staple") require.NoError(t, err) _, err = h.Admin.Exec(context.Background(), `INSERT INTO users (email, password_hash, email_verified_at) VALUES ($1,$2,now())`, email, hash) require.NoError(t, err) return h.App, email}
// inTx runs fn in a transaction against the app pool and commits it. Sessions// are global, so this deliberately does not use db.InTx, which requires tenant// context.func inTx[T any](t *testing.T, pool *pgxpool.Pool, fn func(tx pgx.Tx) (T, error)) (T, error) { t.Helper() ctx := context.Background() tx, err := pool.Begin(ctx) require.NoError(t, err) out, err := fn(tx) if err != nil { _ = tx.Rollback(ctx) return out, err } require.NoError(t, tx.Commit(ctx)) return out, nil}
func login(t *testing.T, pool *pgxpool.Pool, email string, at time.Time) session.Pair { t.Helper() p, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Login(context.Background(), tx, clock.Fixed(at), email, "correct horse battery staple", meta()) }) require.NoError(t, err) return p}
func TestLoginIssuesARefreshTokenForTheRightUser(t *testing.T) { pool, email := sessionSetup(t) p := login(t, pool, email, t0) require.NotEmpty(t, p.Refresh) require.NotEqual(t, uuid.Nil, p.FamilyID) require.NotEqual(t, uuid.Nil, p.UserID)}
func TestLoginRejectsAWrongPassword(t *testing.T) { pool, email := sessionSetup(t) _, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Login(context.Background(), tx, clock.Fixed(t0), email, "wrong", meta()) }) require.ErrorIs(t, err, session.ErrBadCredentials)}
func TestLoginRejectsAnUnknownEmailWithTheSameError(t *testing.T) { pool, _ := sessionSetup(t) _, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Login(context.Background(), tx, clock.Fixed(t0), "nobody@example.com", "x", meta()) }) // Same sentinel as a wrong password: distinguishing them tells an attacker // which addresses are registered. require.ErrorIs(t, err, session.ErrBadCredentials)}
func TestRefreshRotatesTheTokenAndKeepsTheFamily(t *testing.T) { pool, email := sessionSetup(t) first := login(t, pool, email, t0)
second, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(time.Hour)), first.Refresh) }) require.NoError(t, err) require.NotEqual(t, first.Refresh, second.Refresh, "the token must rotate") require.Equal(t, first.FamilyID, second.FamilyID, "rotation stays within one family")}
func TestReplayingAConsumedTokenRevokesTheWholeFamily(t *testing.T) { pool, email := sessionSetup(t) first := login(t, pool, email, t0)
second, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(time.Hour)), first.Refresh) }) require.NoError(t, err)
// Replay the consumed token well outside the grace window. replayAt := clock.Fixed(t0.Add(time.Hour + time.Minute)) _, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, replayAt, first.Refresh) }) require.ErrorIs(t, err, session.ErrReplayed)
// And the legitimate successor must now be dead too: a replay means the // family is compromised, not that one token is. _, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, replayAt, second.Refresh) }) require.ErrorIs(t, err, session.ErrRevoked)}
func TestConcurrentRefreshInsideTheGraceWindowSucceeds(t *testing.T) { pool, email := sessionSetup(t) first := login(t, pool, email, t0)
at := clock.Fixed(t0.Add(time.Hour)) a, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, at, first.Refresh) }) require.NoError(t, err)
// The second tab presents the same token 3 seconds later. It must receive // the pair already minted, NOT trip reuse detection — otherwise a flaky // connection logs the user out. b, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(time.Hour+3*time.Second)), first.Refresh) }) require.NoError(t, err) require.Equal(t, a.Refresh, b.Refresh, "inside the grace window, the same successor is returned")
// And the family must still be usable afterwards. _, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(2*time.Hour)), a.Refresh) }) require.NoError(t, err)}
func TestRefreshIsRejectedAfterFourteenIdleDays(t *testing.T) { pool, email := sessionSetup(t) first := login(t, pool, email, t0)
_, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(session.IdleWindow+time.Minute)), first.Refresh) }) require.ErrorIs(t, err, session.ErrSessionExpired)}
func TestRefreshIsRejectedPastTheAbsoluteCapHoweverActive(t *testing.T) { pool, email := sessionSetup(t) cur := login(t, pool, email, t0)
// Refresh every 10 days: never idle, so only the absolute cap can stop it. at := t0 for i := 0; i < 9; i++ { at = at.Add(10 * 24 * time.Hour) next, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(at), cur.Refresh) }) if err != nil { require.ErrorIs(t, err, session.ErrSessionExpired) require.Greater(t, at.Sub(t0), session.AbsoluteWindow, "the session died before the absolute cap, at %s", at.Sub(t0)) return } cur = next } t.Fatalf("session survived %s of continuous use; the absolute cap is not enforced", at.Sub(t0))}
func TestAnUnknownTokenIsRejectedWithoutSayingWhy(t *testing.T) { pool, _ := sessionSetup(t) _, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0), "not-a-real-token") }) require.ErrorIs(t, err, session.ErrRevoked)}
func TestTheRawRefreshTokenIsNeverStored(t *testing.T) { pool, email := sessionSetup(t) p := login(t, pool, email, t0)
var n int require.NoError(t, pool.QueryRow(context.Background(), `SELECT count(*) FROM refresh_token WHERE encode(token_hash,'escape') = $1`, p.Refresh).Scan(&n)) require.Zero(t, n, "the raw token must not appear in the database in any form")}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/session/ -v
Expected: FAIL — package does not exist.
- Step 3: Implement
// Package session owns login and refresh-token rotation.//// A session family is the login, not the organization: a user authenticates// once and may then act in any organization they are a member of. Switching// organizations mints a new access token against the same family and never// re-authenticates.//// The refresh token is an opaque random value, not a JWT, because it must be// single-use and revocable — exactly what a stateless token is designed to// prevent.package session
import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "net/netip" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/identity/password")
const ( // IdleWindow: a session ends after this long without use. IdleWindow = 14 * 24 * time.Hour // AbsoluteWindow: fixed at login and never extended, so a session cannot // become a credential with no end date. AbsoluteWindow = 90 * 24 * time.Hour // GraceWindow: a consumed token remains acceptable this long, returning // the successor already minted for it. Without this, two browser tabs or // one retried request on a flaky connection trips reuse detection and // logs the user out. GraceWindow = 10 * time.Second
tokenBytes = 32)
var ( ErrBadCredentials = errors.New("session: bad credentials") ErrSessionExpired = errors.New("session: expired") ErrReplayed = errors.New("session: refresh token replayed") ErrRevoked = errors.New("session: revoked"))
type Meta struct { IP netip.Addr UserAgent string DeviceLabel string}
type Pair struct { Refresh string FamilyID uuid.UUID UserID uuid.UUID}
func hashToken(raw string) []byte { sum := sha256.Sum256([]byte(raw)) return sum[:]}
func newToken() (string, error) { b := make([]byte, tokenBytes) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("session: random: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil}
func Login(ctx context.Context, tx pgx.Tx, c clock.Clock, email, plain string, m Meta) (Pair, error) { var ( userID uuid.UUID hash string ) err := tx.QueryRow(ctx, `SELECT id, password_hash FROM users WHERE email = $1`, email). Scan(&userID, &hash) if errors.Is(err, pgx.ErrNoRows) { // Deliberately the same error as a wrong password. Distinguishing the // two turns the login form into a directory of registered addresses. // The cost is one wasted Argon2 verification on an unknown address, // which also keeps the timing similar. _, _ = password.Verify(plain, decoyHash) return Pair{}, ErrBadCredentials } if err != nil { return Pair{}, fmt.Errorf("session: load user: %w", err) } ok, err := password.Verify(plain, hash) if err != nil || !ok { return Pair{}, ErrBadCredentials }
now := c.Now() var familyID uuid.UUID err = tx.QueryRow(ctx, ` INSERT INTO session_family (user_id, created_at, last_used_at, absolute_expires_at, device_label, ip, user_agent) VALUES ($1,$2,$2,$3,$4,$5,$6) RETURNING id`, userID, now, now.Add(AbsoluteWindow), nullIfEmpty(m.DeviceLabel), ipOrNil(m.IP), nullIfEmpty(m.UserAgent)).Scan(&familyID) if err != nil { return Pair{}, fmt.Errorf("session: create family: %w", err) } raw, err := issue(ctx, tx, familyID, now) if err != nil { return Pair{}, err } return Pair{Refresh: raw, FamilyID: familyID, UserID: userID}, nil}
// decoyHash is a real Argon2id hash of a value nobody knows, verified against// when the email is unknown so that the unknown-address path costs roughly what// the known-address path costs.const decoyHash = `$argon2id$v=19$m=19456,t=2,p=1$ZGVjb3lzYWx0ZGVjb3lzYQ$` + `YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE`
func issue(ctx context.Context, tx pgx.Tx, familyID uuid.UUID, now time.Time) (string, error) { raw, err := newToken() if err != nil { return "", err } _, err = tx.Exec(ctx, ` INSERT INTO refresh_token (family_id, token_hash, issued_at, expires_at) VALUES ($1,$2,$3,$4)`, familyID, hashToken(raw), now, now.Add(IdleWindow)) if err != nil { return "", fmt.Errorf("session: issue token: %w", err) } return raw, nil}
func Refresh(ctx context.Context, tx pgx.Tx, c clock.Clock, raw string) (Pair, error) { now := c.Now()
var ( tokenID, familyID, userID uuid.UUID usedAt *time.Time expiresAt time.Time absoluteExpiresAt time.Time lastUsedAt time.Time revokedAt *time.Time ) // FOR UPDATE on the family row serializes concurrent refreshes of the same // session, which is what makes the grace window observable rather than a // race between two writers. err := tx.QueryRow(ctx, ` SELECT rt.id, f.id, f.user_id, rt.used_at, rt.expires_at, f.absolute_expires_at, f.last_used_at, f.revoked_at FROM refresh_token rt JOIN session_family f ON f.id = rt.family_id WHERE rt.token_hash = $1 FOR UPDATE OF f`, hashToken(raw)). Scan(&tokenID, &familyID, &userID, &usedAt, &expiresAt, &absoluteExpiresAt, &lastUsedAt, &revokedAt) if errors.Is(err, pgx.ErrNoRows) { // An unknown token is indistinguishable from a revoked one, on // purpose: the caller learns only that it does not work. return Pair{}, ErrRevoked } if err != nil { return Pair{}, fmt.Errorf("session: load token: %w", err) } if revokedAt != nil { return Pair{}, ErrRevoked } if now.After(absoluteExpiresAt) || now.Sub(lastUsedAt) > IdleWindow { return Pair{}, ErrSessionExpired }
if usedAt != nil { if now.Sub(*usedAt) <= GraceWindow { // Same successor, not a new one: two tabs must converge on one // token, or they rotate each other into a replay. successor, err := successorOf(ctx, tx, tokenID) if err != nil { return Pair{}, err } return Pair{Refresh: successor, FamilyID: familyID, UserID: userID}, nil } if err := revokeFamily(ctx, tx, familyID, now, "refresh token replayed"); err != nil { return Pair{}, err } return Pair{}, ErrReplayed } if now.After(expiresAt) { return Pair{}, ErrSessionExpired }
next, err := newToken() if err != nil { return Pair{}, err } if _, err := tx.Exec(ctx, `UPDATE refresh_token SET used_at = $2, successor_hash = $3 WHERE id = $1`, tokenID, now, hashToken(next)); err != nil { return Pair{}, fmt.Errorf("session: consume token: %w", err) } if _, err := tx.Exec(ctx, ` INSERT INTO refresh_token (family_id, token_hash, issued_at, expires_at) VALUES ($1,$2,$3,$4)`, familyID, hashToken(next), now, now.Add(IdleWindow)); err != nil { return Pair{}, fmt.Errorf("session: issue successor: %w", err) } if _, err := tx.Exec(ctx, `UPDATE session_family SET last_used_at = $2 WHERE id = $1`, familyID, now); err != nil { return Pair{}, fmt.Errorf("session: touch family: %w", err) } return Pair{Refresh: next, FamilyID: familyID, UserID: userID}, nil}The grace window needs the successor’s raw value, which is never stored. Resolving that is the point of successorOf: the consumed row records the hash of its successor, and the successor’s raw value is re-derivable only by the caller who received it. So the grace path cannot return the raw token from the database. Implement successorOf to mint nothing and instead return the raw value held for the grace period in a short-lived column:
ALTER TABLE refresh_token ADD COLUMN successor_hash BYTEA;-- grace_token holds the successor's RAW value for GraceWindow only, so a-- concurrent refresh can be answered with the same token instead of minting a-- second one. It is cleared by the pruning job in Task 17 and is the single-- place a raw token rests at all.ALTER TABLE refresh_token ADD COLUMN grace_token TEXT;Put both columns in 00008_identity_global.sql directly rather than a later migration, and write grace_token alongside successor_hash in the consume step, then:
func successorOf(ctx context.Context, tx pgx.Tx, tokenID uuid.UUID) (string, error) { var raw *string if err := tx.QueryRow(ctx, `SELECT grace_token FROM refresh_token WHERE id = $1`, tokenID).Scan(&raw); err != nil { return "", fmt.Errorf("session: successor: %w", err) } if raw == nil { // The grace copy is gone, so this is a replay of a token whose // successor has already been cleaned up. return "", ErrReplayed } return *raw, nil}
func revokeFamily(ctx context.Context, tx pgx.Tx, familyID uuid.UUID, now time.Time, reason string) error { _, err := tx.Exec(ctx, `UPDATE session_family SET revoked_at = $2, revoked_reason = $3 WHERE id = $1 AND revoked_at IS NULL`, familyID, now, reason) if err != nil { return fmt.Errorf("session: revoke family: %w", err) } return nil}
func nullIfEmpty(s string) *string { if s == "" { return nil } return &s}
func ipOrNil(a netip.Addr) *string { if !a.IsValid() { return nil } s := a.String() return &s}- Step 4: Run the tests and verify they pass
Run: go test ./internal/platform/identity/session/ -v
Expected: PASS, 10 tests. TestRefreshIsRejectedPastTheAbsoluteCapHoweverActive is the one most likely to expose an off-by-one; it must fail with ErrSessionExpired at some point past day 90 and not before.
- Step 5: Commit
git add db/migrations/00008_identity_global.sql internal/platform/identity/session/git commit -m "Add sessions with rotation and reuse detection
A session family is the login, not the organization. Refresh tokens areopaque and stored hashed; a replay revokes the whole family.
The 10-second grace window is the part that matters in practice: strictsingle-use rotation logs a user out for having two tabs open, and adesign that does that gets switched off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 6: Session listing and revocation
Section titled “Task 6: Session listing and revocation”Per §4.3 of the session design. The honest answer to “I lost my phone” is a list the user can act on.
Files:
- Create:
internal/platform/identity/session/revoke.go,internal/platform/identity/session/revoke_test.go
Interfaces:
- Consumes: the Task 5 tables and
clock.Clock. - Produces:
session.List(ctx, tx, userID uuid.UUID) ([]session.Summary, error)session.Summary{ID uuid.UUID; DeviceLabel, UserAgent, IP string; CreatedAt, LastUsedAt time.Time; Current bool}session.Revoke(ctx, tx, c, userID, familyID uuid.UUID, reason string) errorsession.RevokeAllExcept(ctx, tx, c, userID, keepFamilyID uuid.UUID, reason string) error
Revoke takes userID as well as familyID and scopes its UPDATE by both. These tables have no RLS, so the WHERE user_id = $1 is the authorization check — omitting it lets any authenticated user revoke any session in the system by guessing a UUID.
- Step 1: Write the failing test
func TestListReturnsOnlyThisUsersSessions(t *testing.T) { pool, email := sessionSetup(t) other := seedUser(t, pool, "someone.else@example.com") mine := login(t, pool, email, t0) _ = loginAs(t, pool, "someone.else@example.com", t0)
got, err := inTx(t, pool, func(tx pgx.Tx) ([]session.Summary, error) { return session.List(context.Background(), tx, mine.UserID) }) require.NoError(t, err) require.Len(t, got, 1, "a user must not see another user's sessions") require.Equal(t, mine.FamilyID, got[0].ID) require.Equal(t, "Ziyang's laptop", got[0].DeviceLabel) require.NotEqual(t, other, uuid.Nil)}
func TestRevokeKillsTheSessionImmediately(t *testing.T) { pool, email := sessionSetup(t) p := login(t, pool, email, t0)
_, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, session.Revoke(context.Background(), tx, clock.Fixed(t0.Add(time.Minute)), p.UserID, p.FamilyID, "user revoked") }) require.NoError(t, err)
_, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(2*time.Minute)), p.Refresh) }) require.ErrorIs(t, err, session.ErrRevoked)}
func TestRevokeRefusesAFamilyBelongingToAnotherUser(t *testing.T) { pool, email := sessionSetup(t) victim := login(t, pool, email, t0) attacker := seedUser(t, pool, "attacker@example.com")
// These tables carry no RLS, so scoping by user_id IS the authorization // check. If this test passes trivially, the WHERE clause is missing. _, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, session.Revoke(context.Background(), tx, clock.Fixed(t0), attacker, victim.FamilyID, "should not happen") }) require.Error(t, err)
_, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(time.Minute)), victim.Refresh) }) require.NoError(t, err, "the victim's session must still work")}
func TestRevokeAllExceptLeavesTheCurrentSessionAlone(t *testing.T) { pool, email := sessionSetup(t) phone := login(t, pool, email, t0) laptop := login(t, pool, email, t0.Add(time.Minute))
_, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, session.RevokeAllExcept(context.Background(), tx, clock.Fixed(t0.Add(2*time.Minute)), laptop.UserID, laptop.FamilyID, "password changed") }) require.NoError(t, err)
_, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(3*time.Minute)), phone.Refresh) }) require.ErrorIs(t, err, session.ErrRevoked)
_, err = inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Refresh(context.Background(), tx, clock.Fixed(t0.Add(3*time.Minute)), laptop.Refresh) }) require.NoError(t, err, "changing your password must not log you out of the session you did it from")}
func TestListExcludesRevokedSessions(t *testing.T) { pool, email := sessionSetup(t) phone := login(t, pool, email, t0) _ = login(t, pool, email, t0.Add(time.Minute))
_, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, session.Revoke(context.Background(), tx, clock.Fixed(t0.Add(2*time.Minute)), phone.UserID, phone.FamilyID, "user revoked") }) require.NoError(t, err)
got, err := inTx(t, pool, func(tx pgx.Tx) ([]session.Summary, error) { return session.List(context.Background(), tx, phone.UserID) }) require.NoError(t, err) require.Len(t, got, 1)}Add the two helpers to session_test.go:
func seedUser(t *testing.T, pool *pgxpool.Pool, email string) uuid.UUID { t.Helper() hash, err := password.Hash("correct horse battery staple") require.NoError(t, err) var id uuid.UUID require.NoError(t, pool.QueryRow(context.Background(), `INSERT INTO users (email, password_hash, email_verified_at) VALUES ($1,$2,now()) RETURNING id`, email, hash).Scan(&id)) return id}
func loginAs(t *testing.T, pool *pgxpool.Pool, email string, at time.Time) session.Pair { t.Helper() p, err := inTx(t, pool, func(tx pgx.Tx) (session.Pair, error) { return session.Login(context.Background(), tx, clock.Fixed(at), email, "correct horse battery staple", meta()) }) require.NoError(t, err) return p}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/session/ -run 'TestList|TestRevoke' -v
Expected: FAIL — undefined: session.List, session.Revoke, session.RevokeAllExcept, session.Summary.
- Step 3: Implement
package session
// Summary is one live session as a user sees it in their own settings.type Summary struct { ID uuid.UUID DeviceLabel string UserAgent string IP string CreatedAt time.Time LastUsedAt time.Time Current bool}
func List(ctx context.Context, tx pgx.Tx, userID uuid.UUID) ([]Summary, error) { rows, err := tx.Query(ctx, ` SELECT id, COALESCE(device_label,''), COALESCE(user_agent,''), COALESCE(host(ip),''), created_at, last_used_at FROM session_family WHERE user_id = $1 AND revoked_at IS NULL ORDER BY last_used_at DESC`, userID) if err != nil { return nil, fmt.Errorf("session: list: %w", err) } defer rows.Close()
var out []Summary for rows.Next() { var s Summary if err := rows.Scan(&s.ID, &s.DeviceLabel, &s.UserAgent, &s.IP, &s.CreatedAt, &s.LastUsedAt); err != nil { return nil, fmt.Errorf("session: list scan: %w", err) } out = append(out, s) } return out, rows.Err()}
// Revoke ends one session.//// The user_id predicate is not a convenience: session_family carries no RLS,// so this WHERE clause is the entire authorization check. Widening it to// `WHERE id = $2` alone would let any authenticated caller end any session in// the system.func Revoke(ctx context.Context, tx pgx.Tx, c clock.Clock, userID, familyID uuid.UUID, reason string) error { tag, err := tx.Exec(ctx, ` UPDATE session_family SET revoked_at = $3, revoked_reason = $4 WHERE id = $2 AND user_id = $1 AND revoked_at IS NULL`, userID, familyID, c.Now(), reason) if err != nil { return fmt.Errorf("session: revoke: %w", err) } if tag.RowsAffected() == 0 { // Not found, already revoked, or not yours — reported identically, so // a caller cannot probe for other users' session ids. return ErrRevoked } return nil}
func RevokeAllExcept(ctx context.Context, tx pgx.Tx, c clock.Clock, userID, keep uuid.UUID, reason string) error { _, err := tx.Exec(ctx, ` UPDATE session_family SET revoked_at = $3, revoked_reason = $4 WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL`, userID, keep, c.Now(), reason) if err != nil { return fmt.Errorf("session: revoke others: %w", err) } return nil}- Step 4: Run the tests and verify they pass
Run: go test ./internal/platform/identity/session/ -v
Expected: PASS, 15 tests.
- Step 5: Commit
git add internal/platform/identity/session/git commit -m "List and revoke sessions
Scoped by user_id, which on an RLS-exempt table is the authorizationcheck itself rather than a convenience.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 7: TOTP second factor
Section titled “Task 7: TOTP second factor”Optional per user in Phase 1 (§3.3). The secret is encrypted at rest with a key held outside the database, so a database dump alone does not yield working second factors.
Files:
- Create:
internal/platform/identity/totp/totp.go,internal/platform/identity/totp/totp_test.go - Modify:
internal/kernel/config/config.go,.env.example
Interfaces:
-
Produces:
totp.Enrol(issuer, account string) (secret string, uri string, err error)totp.Validate(secret, code string, at time.Time) booltotp.Seal(secret string, key []byte) ([]byte, error)/totp.Open(sealed []byte, key []byte) (string, error)totp.GenerateRecoveryCodes(n int) (plain []string, hashed []string, err error)totp.ErrBadKey
-
Step 1: Write the failing test
package totp_test
import ( "testing" "time"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/identity/totp")
var key = []byte("0123456789abcdef0123456789abcdef")var at = time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
func TestEnrolProducesAUsableSecretAndURI(t *testing.T) { secret, uri, err := totp.Enrol("Mcom ERP", "ziyang@example.com") require.NoError(t, err) require.NotEmpty(t, secret) require.Contains(t, uri, "otpauth://totp/") require.Contains(t, uri, "Mcom%20ERP")}
func TestAGeneratedCodeValidatesAndAWrongOneDoesNot(t *testing.T) { secret, _, err := totp.Enrol("Mcom ERP", "ziyang@example.com") require.NoError(t, err)
code, err := totp.CodeAt(secret, at) require.NoError(t, err) require.True(t, totp.Validate(secret, code, at)) require.False(t, totp.Validate(secret, "000000", at), "an arbitrary code must not validate")}
func TestACodeFromTheAdjacentWindowIsAccepted(t *testing.T) { secret, _, err := totp.Enrol("Mcom ERP", "ziyang@example.com") require.NoError(t, err) code, err := totp.CodeAt(secret, at.Add(-30*time.Second)) require.NoError(t, err) // One step of clock skew is tolerated; a user reading a code off a phone // that is a few seconds out must not be locked out. require.True(t, totp.Validate(secret, code, at))}
func TestACodeFromLongAgoIsRefused(t *testing.T) { secret, _, err := totp.Enrol("Mcom ERP", "ziyang@example.com") require.NoError(t, err) code, err := totp.CodeAt(secret, at.Add(-10*time.Minute)) require.NoError(t, err) require.False(t, totp.Validate(secret, code, at))}
func TestSealThenOpenRoundTrips(t *testing.T) { secret, _, err := totp.Enrol("Mcom ERP", "ziyang@example.com") require.NoError(t, err) sealed, err := totp.Seal(secret, key) require.NoError(t, err) require.NotContains(t, string(sealed), secret, "the sealed form must not contain the plaintext")
out, err := totp.Open(sealed, key) require.NoError(t, err) require.Equal(t, secret, out)}
func TestOpenWithTheWrongKeyFails(t *testing.T) { secret, _, err := totp.Enrol("Mcom ERP", "ziyang@example.com") require.NoError(t, err) sealed, err := totp.Seal(secret, key) require.NoError(t, err)
_, err = totp.Open(sealed, []byte("ffffffffffffffffffffffffffffffff")) require.Error(t, err, "a database dump without the key must be useless")}
func TestSealRejectsAShortKey(t *testing.T) { _, err := totp.Seal("SECRET", []byte("short")) require.ErrorIs(t, err, totp.ErrBadKey)}
func TestRecoveryCodesAreDistinctAndHashed(t *testing.T) { plain, hashed, err := totp.GenerateRecoveryCodes(8) require.NoError(t, err) require.Len(t, plain, 8) require.Len(t, hashed, 8)
seen := map[string]bool{} for i, p := range plain { require.False(t, seen[p], "recovery codes must be distinct") seen[p] = true require.NotContains(t, hashed[i], p, "the stored form must not contain the code") }}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/totp/ -v
Expected: FAIL — package does not exist.
- Step 3: Add the dependency
Run: go get github.com/pquerna/otp@latest
- Step 4: Implement
// Package totp handles second-factor enrolment and verification.//// The shared secret is sealed with AES-GCM under a key supplied by// configuration and held outside the database, so a database dump on its own// does not yield working second factors.package totp
import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/base64" "encoding/hex" "errors" "fmt" "strings" "time"
"github.com/pquerna/otp" "github.com/pquerna/otp/totp")
var ErrBadKey = errors.New("totp: key must be 32 bytes")
// skew of 1 allows the adjacent 30-second window on each side: a phone a few// seconds out of step must not lock its owner out.const skew = 1
func Enrol(issuer, account string) (string, string, error) { k, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: account}) if err != nil { return "", "", fmt.Errorf("totp: enrol: %w", err) } return k.Secret(), k.URL(), nil}
func CodeAt(secret string, at time.Time) (string, error) { code, err := totp.GenerateCodeCustom(secret, at, totp.ValidateOpts{ Period: 30, Skew: skew, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, }) if err != nil { return "", fmt.Errorf("totp: code: %w", err) } return code, nil}
func Validate(secret, code string, at time.Time) bool { ok, err := totp.ValidateCustom(code, secret, at, totp.ValidateOpts{ Period: 30, Skew: skew, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, }) return err == nil && ok}
func Seal(secret string, key []byte) ([]byte, error) { gcm, err := newGCM(key) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("totp: nonce: %w", err) } return gcm.Seal(nonce, nonce, []byte(secret), nil), nil}
func Open(sealed, key []byte) (string, error) { gcm, err := newGCM(key) if err != nil { return "", err } if len(sealed) < gcm.NonceSize() { return "", errors.New("totp: sealed value too short") } nonce, ct := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():] out, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", fmt.Errorf("totp: open: %w", err) } return string(out), nil}
func newGCM(key []byte) (cipher.AEAD, error) { if len(key) != 32 { return nil, ErrBadKey } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("totp: cipher: %w", err) } return cipher.NewGCM(block)}
// GenerateRecoveryCodes returns n single-use codes and their stored forms.// Plain codes are shown once, at enrolment, and never again.func GenerateRecoveryCodes(n int) ([]string, []string, error) { plain := make([]string, 0, n) hashed := make([]string, 0, n) for i := 0; i < n; i++ { b := make([]byte, 10) if _, err := rand.Read(b); err != nil { return nil, nil, fmt.Errorf("totp: recovery code: %w", err) } code := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)) sum := sha256.Sum256([]byte(code)) plain = append(plain, code) hashed = append(hashed, hex.EncodeToString(sum[:])) } return plain, hashed, nil}
var _ = base64.RawStdEncodingAdd TOTP_ENCRYPTION_KEY to Config and .env.example, read as base64 and required to decode to exactly 32 bytes. The env drift test enforces the .env.example entry.
- Step 5: Run the tests and verify they pass
Run: go test ./internal/platform/identity/totp/ ./internal/kernel/config/ -v
Expected: PASS, 8 totp tests plus config.
- Step 6: Commit
git add internal/platform/identity/totp/ internal/kernel/config/ .env.example go.mod go.sumgit commit -m "Add TOTP second factor with sealed secrets
AES-GCM under a key held outside the database, so a dump alone does notyield working second factors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 8: Login throttling
Section titled “Task 8: Login throttling”Per §6.2. Backoff, not lockout — a hard lockout hands anyone the ability to lock a named finance director out of the system by guessing their password wrong ten times.
Files:
- Create:
internal/platform/identity/throttle/throttle.go,internal/platform/identity/throttle/throttle_test.go
Interfaces:
-
Produces:
throttle.Check(ctx, tx, c, email string, ip netip.Addr) (retryAfter time.Duration, err error)— zero means proceed.throttle.Record(ctx, tx, c, email string, ip netip.Addr, succeeded bool) error
-
Step 1: Write the failing test
func TestFirstAttemptIsNotThrottled(t *testing.T) { pool := throttleSetup(t) d, err := inTx(t, pool, func(tx pgx.Tx) (time.Duration, error) { return throttle.Check(context.Background(), tx, clock.Fixed(t0), "a@example.com", ip) }) require.NoError(t, err) require.Zero(t, d)}
func TestBackoffGrowsWithConsecutiveFailures(t *testing.T) { pool := throttleSetup(t) var last time.Duration for i := 1; i <= 5; i++ { _, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, throttle.Record(context.Background(), tx, clock.Fixed(t0.Add(time.Duration(i)*time.Second)), "a@example.com", ip, false) }) require.NoError(t, err)
d, err := inTx(t, pool, func(tx pgx.Tx) (time.Duration, error) { return throttle.Check(context.Background(), tx, clock.Fixed(t0.Add(time.Duration(i)*time.Second)), "a@example.com", ip) }) require.NoError(t, err) require.Greater(t, d, last, "backoff must grow with each failure (attempt %d)", i) last = d } require.Less(t, last, 30*time.Minute, "backoff must be capped; an uncapped delay is a lockout by another name")}
func TestASuccessfulLoginClearsTheBackoff(t *testing.T) { pool := throttleSetup(t) for i := 0; i < 3; i++ { _, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, throttle.Record(context.Background(), tx, clock.Fixed(t0), "a@example.com", ip, false) }) require.NoError(t, err) } _, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, throttle.Record(context.Background(), tx, clock.Fixed(t0), "a@example.com", ip, true) }) require.NoError(t, err)
d, err := inTx(t, pool, func(tx pgx.Tx) (time.Duration, error) { return throttle.Check(context.Background(), tx, clock.Fixed(t0), "a@example.com", ip) }) require.NoError(t, err) require.Zero(t, d)}
func TestFailuresAgainstOneAccountDoNotThrottleAnother(t *testing.T) { pool := throttleSetup(t) for i := 0; i < 5; i++ { _, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, throttle.Record(context.Background(), tx, clock.Fixed(t0), "victim@example.com", otherIP, false) }) require.NoError(t, err) } // This is the lockout-as-denial-of-service case. A third party hammering // the finance director's address from their own IP must not stop the // finance director logging in from theirs. d, err := inTx(t, pool, func(tx pgx.Tx) (time.Duration, error) { return throttle.Check(context.Background(), tx, clock.Fixed(t0), "victim@example.com", ip) }) require.NoError(t, err) require.Zero(t, d, "an attacker on another IP must not be able to lock this account out")}
func TestBackoffDecaysAfterAQuietPeriod(t *testing.T) { pool := throttleSetup(t) for i := 0; i < 5; i++ { _, err := inTx(t, pool, func(tx pgx.Tx) (struct{}, error) { return struct{}{}, throttle.Record(context.Background(), tx, clock.Fixed(t0), "a@example.com", ip, false) }) require.NoError(t, err) } d, err := inTx(t, pool, func(tx pgx.Tx) (time.Duration, error) { return throttle.Check(context.Background(), tx, clock.Fixed(t0.Add(2*time.Hour)), "a@example.com", ip) }) require.NoError(t, err) require.Zero(t, d, "failures outside the window must not count forever")}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/throttle/ -v
Expected: FAIL — package does not exist.
- Step 3: Implement
// Package throttle slows repeated failed logins.//// Backoff, never lockout. A hard lockout after N failures is a denial of// service against any account whose email address is known: anybody can lock// out a named finance director by guessing wrong ten times. Exponential delay// makes guessing useless without ever making the account unusable by its// owner.package throttle
import ( "context" "fmt" "net/netip" "time"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock")
const ( // Window bounds how far back failures count. Outside it, a user who got // their password wrong last month starts clean. Window = time.Hour // Base doubles per failure past the threshold. Base = 2 * time.Second // Cap keeps the delay from becoming a lockout by another name. Cap = 5 * time.Minute // Free attempts before any delay: fat fingers are not an attack. Free = 3)
// Check returns how long the caller must wait. Zero means proceed.//// Failures are counted for the (email, ip) pair, not for the email alone. An// attacker hammering a known address from their own address must not be able// to delay the real owner logging in from theirs.func Check(ctx context.Context, tx pgx.Tx, c clock.Clock, email string, ip netip.Addr) (time.Duration, error) { now := c.Now() var failures int var lastAt *time.Time err := tx.QueryRow(ctx, ` SELECT count(*), max(attempted_at) FROM login_attempt WHERE email = $1 AND ip = $2 AND succeeded = false AND attempted_at > $3`, email, ip.String(), now.Add(-Window)).Scan(&failures, &lastAt) if err != nil { return 0, fmt.Errorf("throttle: count: %w", err) } if failures <= Free || lastAt == nil { return 0, nil } delay := Base << (failures - Free - 1) if delay > Cap || delay <= 0 { delay = Cap } remaining := lastAt.Add(delay).Sub(now) if remaining < 0 { return 0, nil } return remaining, nil}
// Record writes the outcome. A success clears this pair's recent failures, so// one correct login ends the backoff rather than serving it out.func Record(ctx context.Context, tx pgx.Tx, c clock.Clock, email string, ip netip.Addr, succeeded bool) error { now := c.Now() if succeeded { if _, err := tx.Exec(ctx, `DELETE FROM login_attempt WHERE email = $1 AND ip = $2`, email, ip.String()); err != nil { return fmt.Errorf("throttle: clear: %w", err) } } _, err := tx.Exec(ctx, ` INSERT INTO login_attempt (email, ip, succeeded, attempted_at) VALUES ($1,$2,$3,$4)`, email, ip.String(), succeeded, now) if err != nil { return fmt.Errorf("throttle: record: %w", err) } return nil}Note the deliberate ordering in Record: the success row is inserted after the delete, so a successful login leaves exactly one row rather than none.
- Step 4: Run the tests and verify they pass
Run: go test ./internal/platform/identity/throttle/ -v
Expected: PASS, 5 tests.
- Step 5: Commit
git add internal/platform/identity/throttle/git commit -m "Throttle failed logins with backoff, not lockout
Counted per (email, ip) pair: an attacker hammering a known address fromtheir own IP must not be able to lock the owner out from theirs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 9: Membership and the party model
Section titled “Task 9: Membership and the party model”Per §4.2 and §18.2 item 3. These tables are tenant-scoped, so unlike Task 2 they carry org_id NOT NULL, forced RLS and an org_isolation policy — the Phase 0 drift test will fail the build otherwise.
Files:
- Create:
db/migrations/00009_membership.sql,internal/platform/identity/member/member.go,internal/platform/identity/member/member_test.go
Interfaces:
-
Consumes:
tenancy.OrgFromContext,db.InTx. -
Produces:
member.Record{ID, OrgID, UserID, PartyID uuid.UUID; EmployeeNo string; Active bool}member.ForUser(ctx, tx, userID uuid.UUID) (Record, error)— resolves the caller’s membership in the org already on the context.member.OrgsForUser(ctx, tx, userID uuid.UUID) ([]uuid.UUID, error)— deliberately not tenant-scoped; it answers “which orgs may this user switch to”. It readsorganization_memberthrough the admin-free path described below.member.ErrNotAMember
-
Step 1: Write the failing test
func TestForUserResolvesMembershipInTheContextOrg(t *testing.T) { d, h, ctx, org := memberSetup(t) userID := seedUser(t, h.Admin, "ziyang@example.com") seedMember(t, h.Admin, org, userID, "E-001")
var got member.Record require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error got, err = member.ForUser(ctx, tx, userID) return err })) require.Equal(t, org, got.OrgID) require.Equal(t, "E-001", got.EmployeeNo) require.True(t, got.Active)}
func TestForUserRefusesAUserWhoIsNotAMemberOfThisOrg(t *testing.T) { d, h, ctx, _ := memberSetup(t) outsider := seedUser(t, h.Admin, "outsider@example.com")
err := d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := member.ForUser(ctx, tx, outsider) return err }) require.ErrorIs(t, err, member.ErrNotAMember)}
func TestMembershipOfAnotherOrgIsInvisible(t *testing.T) { // The RLS proof. A membership row in org B must not be readable from a // transaction scoped to org A, even though the row exists. d, h, ctxA, _ := memberSetup(t) orgB := seedOrg(t, h.Admin, "Beta") userID := seedUser(t, h.Admin, "dual@example.com") seedMember(t, h.Admin, orgB, userID, "B-001")
err := d.InTx(ctxA, func(ctx context.Context, tx pgx.Tx) error { _, err := member.ForUser(ctx, tx, userID) return err }) require.ErrorIs(t, err, member.ErrNotAMember)}
func TestForUserRefusesADeactivatedMember(t *testing.T) { d, h, ctx, org := memberSetup(t) userID := seedUser(t, h.Admin, "gone@example.com") seedMember(t, h.Admin, org, userID, "E-002") _, err := h.Admin.Exec(context.Background(), `UPDATE organization_member SET active = false WHERE org_id=$1 AND user_id=$2`, org, userID) require.NoError(t, err)
err = d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := member.ForUser(ctx, tx, userID) return err }) require.ErrorIs(t, err, member.ErrNotAMember, "a dismissed member is not a member")}
func TestEveryMemberHasAParty(t *testing.T) { d, h, ctx, org := memberSetup(t) userID := seedUser(t, h.Admin, "ziyang@example.com") seedMember(t, h.Admin, org, userID, "E-001")
var got member.Record require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error got, err = member.ForUser(ctx, tx, userID) return err })) require.NotEqual(t, uuid.Nil, got.PartyID, "a member must resolve to a party; the ledger pays parties, not logins")}- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/member/ -v
Expected: FAIL — package does not exist.
- Step 3: Write the migration
Create db/migrations/00009_membership.sql:
-- +goose Up
-- party is who the ledger transacts with. In Phase 1 the only role is-- employee, but claims, advances and payouts all bind to a party rather than-- to a login, so that a supplier or a contractor can be paid later without-- reshaping the tables that pay people.CREATE TABLE party ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), kind TEXT NOT NULL CHECK (kind IN ('employee')), -- Record-bearing name: English or Bahasa Malaysia (spec 11A.3). legal_name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());ALTER TABLE party ENABLE ROW LEVEL SECURITY;ALTER TABLE party FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON party USING (org_id = current_setting('app.current_org', true)::uuid);
CREATE TABLE organization_member ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), -- No FK to users: that table is the global login shell and this one is -- tenant-scoped. The reference is validated in application code, which is -- also the only code allowed to touch users at all. user_id UUID NOT NULL, party_id UUID NOT NULL REFERENCES party (id), employee_no TEXT, cost_centre TEXT, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (org_id, user_id));ALTER TABLE organization_member ENABLE ROW LEVEL SECURITY;ALTER TABLE organization_member FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON organization_member USING (org_id = current_setting('app.current_org', true)::uuid);
CREATE INDEX organization_member_user_idx ON organization_member (user_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON party, organization_member TO erp_app;
-- +goose DownDROP TABLE organization_member;DROP TABLE party;- Step 4: Implement
// Package member resolves a login to its tenant-scoped identity.//// users is global; organization_member is tenant-scoped and RLS-protected.// This package is the join between them, and is the only place that join is// allowed to happen.package member
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy")
var ErrNotAMember = errors.New("member: not a member of this organization")
type Record struct { ID uuid.UUID OrgID uuid.UUID UserID uuid.UUID PartyID uuid.UUID EmployeeNo string Active bool}
// ForUser resolves this user's membership in the organization already on the// context. RLS does the isolation: a membership in another org is not merely// filtered out here, it is invisible to the query.//// A deactivated member is reported as ErrNotAMember rather than returned with// Active=false, so that no caller can forget to check the flag.func ForUser(ctx context.Context, tx pgx.Tx, userID uuid.UUID) (Record, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return Record{}, fmt.Errorf("member: %w", tenancy.ErrNoOrg) } var r Record err := tx.QueryRow(ctx, ` SELECT id, org_id, user_id, party_id, COALESCE(employee_no,''), active FROM organization_member WHERE user_id = $1 AND active = true`, userID). Scan(&r.ID, &r.OrgID, &r.UserID, &r.PartyID, &r.EmployeeNo, &r.Active) if errors.Is(err, pgx.ErrNoRows) { return Record{}, ErrNotAMember } if err != nil { return Record{}, fmt.Errorf("member: load: %w", err) } // Belt and braces: RLS already guarantees this, and an assertion here // turns a policy regression into a loud failure instead of a quiet leak. if r.OrgID != orgID { return Record{}, fmt.Errorf("member: row escaped tenant isolation (got %s, want %s)", r.OrgID, orgID) } return r, nil}OrgsForUser is deliberately deferred to Task 12, where switching organizations needs it, because it is the one query that must cross tenants and it belongs next to the code that justifies it.
- Step 5: Run the tests and verify they pass
Run: go test ./internal/platform/identity/member/ ./internal/kernel/db/ -v
Expected: PASS. The RLS coverage drift test must pass without either new table being added to an exemption file.
- Step 6: Commit
git add db/migrations/00009_membership.sql internal/platform/identity/member/git commit -m "Add membership and the party model
organization_member and party are tenant-scoped and RLS-protected, incontrast to the global login shell. Claims and payouts bind to a partyrather than a login so a contractor can be paid later without reshapingthe tables that pay people.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 10: Roles, permissions, and perm_version
Section titled “Task 10: Roles, permissions, and perm_version”Per §4.2 and §4.3 question 1. Permissions are string constants registered by the owning module — there is no central enum, because a central enum is a file every module has to edit and therefore a file that goes stale. Record-level authorization (“is this approver in the chain for this claim”) stays with the owning module and is not built here.
Files:
- Create:
db/migrations/00010_roles.sql,internal/platform/identity/member/permission.go,internal/platform/identity/member/permission_test.go
Interfaces:
-
Produces:
member.RegisterPermission(key, description string)— called from a module’sinit().member.RegisteredPermissions() []member.Permission— sorted, for the role editor.member.Permissions(ctx, tx, memberID uuid.UUID) (map[string]struct{}, error)member.Can(ctx, tx, memberID uuid.UUID, key string) (bool, error)member.PermVersion(ctx, tx) (int64, error)andmember.BumpPermVersion(ctx, tx) errormember.ErrDuplicatePermission
-
Step 1: Write the failing test
func TestRegisteredPermissionsAreSortedAndUnique(t *testing.T) { member.RegisterPermission("zz.test.beta", "Beta") member.RegisterPermission("aa.test.alpha", "Alpha")
got := member.RegisteredPermissions() keys := make([]string, len(got)) for i, p := range got { keys[i] = p.Key } require.True(t, sort.StringsAreSorted(keys), "the role editor renders this list; it must be ordered: %v", keys) require.Contains(t, keys, "aa.test.alpha")}
func TestRegisteringTheSameKeyTwicePanics(t *testing.T) { member.RegisterPermission("dup.test.one", "One") // Two modules claiming one key is a collision that must surface at boot, // not as one module silently shadowing the other's description. require.Panics(t, func() { member.RegisterPermission("dup.test.one", "Again") })}
func TestPermissionsResolveThroughRoles(t *testing.T) { d, h, ctx, org := memberSetup(t) userID := seedUser(t, h.Admin, "ziyang@example.com") m := seedMember(t, h.Admin, org, userID, "E-001") role := seedRole(t, h.Admin, org, "Approver", "expenses.claim.approve", "expenses.claim.view") assignRole(t, h.Admin, org, m, role)
var perms map[string]struct{} require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error perms, err = member.Permissions(ctx, tx, m) return err })) require.Contains(t, perms, "expenses.claim.approve") require.Contains(t, perms, "expenses.claim.view") require.NotContains(t, perms, "accounting.journal.post")}
func TestPermissionsUnionAcrossSeveralRoles(t *testing.T) { d, h, ctx, org := memberSetup(t) userID := seedUser(t, h.Admin, "ziyang@example.com") m := seedMember(t, h.Admin, org, userID, "E-001") assignRole(t, h.Admin, org, m, seedRole(t, h.Admin, org, "Approver", "expenses.claim.approve")) assignRole(t, h.Admin, org, m, seedRole(t, h.Admin, org, "Finance", "accounting.journal.post"))
var perms map[string]struct{} require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error perms, err = member.Permissions(ctx, tx, m) return err })) require.Len(t, perms, 2, "a member holding two roles holds the union of their permissions")}
func TestARoleInAnotherOrgGrantsNothingHere(t *testing.T) { d, h, ctxA, orgA := memberSetup(t) orgB := seedOrg(t, h.Admin, "Beta") userID := seedUser(t, h.Admin, "dual@example.com") mA := seedMember(t, h.Admin, orgA, userID, "A-001") mB := seedMember(t, h.Admin, orgB, userID, "B-001") assignRole(t, h.Admin, orgB, mB, seedRole(t, h.Admin, orgB, "Owner", "accounting.journal.post"))
var perms map[string]struct{} require.NoError(t, d.InTx(ctxA, func(ctx context.Context, tx pgx.Tx) error { var err error perms, err = member.Permissions(ctx, tx, mA) return err })) require.Empty(t, perms, "being an owner of another organization must grant nothing here")}
func TestBumpPermVersionIncrements(t *testing.T) { d, _, ctx, _ := memberSetup(t) var before, after int64 require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error before, err = member.PermVersion(ctx, tx) return err })) require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return member.BumpPermVersion(ctx, tx) })) require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error after, err = member.PermVersion(ctx, tx) return err })) require.Equal(t, before+1, after, "a token minted before an edit must be recognisably stale after it")}Add test helpers seedRole(t, pool, org, name, perms...) uuid.UUID and assignRole(t, pool, org, memberID, roleID) that insert directly with the admin pool.
- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/member/ -run 'Permission|PermVersion|Role' -v
Expected: FAIL — undefined: member.RegisterPermission.
- Step 3: Write the migration
Create db/migrations/00010_roles.sql:
-- +goose Up
-- perm_version is bumped whenever anything that could change a member's-- effective permissions changes. An access token carries the value it was-- minted under, so a stale token is recognisable without a per-request join-- across roles.ALTER TABLE organization ADD COLUMN perm_version BIGINT NOT NULL DEFAULT 1;
CREATE TABLE role ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), name TEXT NOT NULL, -- Seeded roles may be edited but not deleted: an org that deletes Owner -- locks itself out. system BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (org_id, name));ALTER TABLE role ENABLE ROW LEVEL SECURITY;ALTER TABLE role FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON role USING (org_id = current_setting('app.current_org', true)::uuid);
-- permission_key is a free string, not an enum or an FK to a table of known-- permissions: modules register their own keys in Go, and a new module's-- permissions must appear in the role editor without a migration.CREATE TABLE role_permission ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), role_id UUID NOT NULL REFERENCES role (id) ON DELETE CASCADE, permission_key TEXT NOT NULL, UNIQUE (role_id, permission_key));ALTER TABLE role_permission ENABLE ROW LEVEL SECURITY;ALTER TABLE role_permission FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON role_permission USING (org_id = current_setting('app.current_org', true)::uuid);
CREATE TABLE member_role ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), member_id UUID NOT NULL REFERENCES organization_member (id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES role (id) ON DELETE CASCADE, UNIQUE (member_id, role_id));ALTER TABLE member_role ENABLE ROW LEVEL SECURITY;ALTER TABLE member_role FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON member_role USING (org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON role, role_permission, member_role TO erp_app;
-- +goose DownDROP TABLE member_role;DROP TABLE role_permission;DROP TABLE role;ALTER TABLE organization DROP COLUMN perm_version;- Step 4: Implement
package member
import ( "fmt" "sort" "sync")
// Permission is one verb a role can grant.type Permission struct { Key string Description string}
var ( permMu sync.RWMutex permRegistry = map[string]Permission{})
// RegisterPermission declares a permission the owning module understands.// Called from a module's init(), so a new module's permissions appear in the// role editor without anyone editing a central enum — which is the file that// goes stale.//// Registering a key twice panics: two modules claiming one key must surface at// boot, not as one silently shadowing the other.func RegisterPermission(key, description string) { permMu.Lock() defer permMu.Unlock() if _, exists := permRegistry[key]; exists { panic(fmt.Sprintf("member: permission %q registered twice", key)) } permRegistry[key] = Permission{Key: key, Description: description}}
func RegisteredPermissions() []Permission { permMu.RLock() defer permMu.RUnlock() out := make([]Permission, 0, len(permRegistry)) for _, p := range permRegistry { out = append(out, p) } sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out}
// Permissions returns every permission this member holds, unioned across their// roles. RLS confines the query to the context organization, so a role held in// another organization contributes nothing.func Permissions(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) (map[string]struct{}, error) { rows, err := tx.Query(ctx, ` SELECT DISTINCT rp.permission_key FROM member_role mr JOIN role_permission rp ON rp.role_id = mr.role_id WHERE mr.member_id = $1`, memberID) if err != nil { return nil, fmt.Errorf("member: permissions: %w", err) } defer rows.Close() out := map[string]struct{}{} for rows.Next() { var k string if err := rows.Scan(&k); err != nil { return nil, fmt.Errorf("member: permissions scan: %w", err) } out[k] = struct{}{} } return out, rows.Err()}
func Can(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, key string) (bool, error) { perms, err := Permissions(ctx, tx, memberID) if err != nil { return false, err } _, ok := perms[key] return ok, nil}
func PermVersion(ctx context.Context, tx pgx.Tx) (int64, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return 0, fmt.Errorf("member: %w", tenancy.ErrNoOrg) } var v int64 if err := tx.QueryRow(ctx, `SELECT perm_version FROM organization WHERE id = $1`, orgID).Scan(&v); err != nil { return 0, fmt.Errorf("member: perm version: %w", err) } return v, nil}
// BumpPermVersion invalidates every access token minted for this organization.// Call it from every path that could change a member's effective authority:// editing a role, assigning or removing one, deactivating a member.func BumpPermVersion(ctx context.Context, tx pgx.Tx) error { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return fmt.Errorf("member: %w", tenancy.ErrNoOrg) } if _, err := tx.Exec(ctx, `UPDATE organization SET perm_version = perm_version + 1 WHERE id = $1`, orgID); err != nil { return fmt.Errorf("member: bump perm version: %w", err) } return nil}- Step 5: Run the tests and verify they pass
Run: go test ./internal/platform/identity/member/ ./internal/kernel/db/ -v
Expected: PASS. All three new tables must satisfy the RLS drift test with no exemption.
- Step 6: Commit
git add db/migrations/00010_roles.sql internal/platform/identity/member/git commit -m "Add per-org roles and module-registered permissions
Permission keys are registered by the owning module in init(), notlisted in a central enum, so a new module's permissions reach the roleeditor without anyone editing a shared file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 11: The per-request liveness lookup
Section titled “Task 11: The per-request liveness lookup”Per §6 of the session design. A valid signature is not proof that the session behind it is still live. Redis with a Postgres fallback: slower under outage, never fail-open.
Files:
- Create:
internal/platform/identity/liveness/liveness.go,internal/platform/identity/liveness/liveness_test.go - Modify:
internal/kernel/config/config.go,.env.example,go.mod
Interfaces:
-
Consumes:
token.Claims,member.PermVersion. -
Produces:
liveness.Checkerinterface:Check(ctx context.Context, c token.Claims) errorliveness.New(pool *pgxpool.Pool, rdb *redis.Client, ttl time.Duration) *liveness.Cached- Sentinels:
liveness.ErrSessionDead,liveness.ErrMemberInactive,liveness.ErrStalePermissions
-
Step 1: Write the failing test
func TestCheckPassesForALiveSession(t *testing.T) { env := livenessSetup(t) require.NoError(t, env.checker.Check(context.Background(), env.claims))}
func TestCheckFailsOnceTheSessionIsRevoked(t *testing.T) { env := livenessSetup(t) revokeFamily(t, env, "user revoked") // Next request, not fifteen minutes later. This is the whole reason the // lookup exists. require.ErrorIs(t, env.checker.Check(context.Background(), env.claims), liveness.ErrSessionDead)}
func TestCheckFailsOnceTheMemberIsDeactivated(t *testing.T) { env := livenessSetup(t) deactivateMember(t, env) require.ErrorIs(t, env.checker.Check(context.Background(), env.claims), liveness.ErrMemberInactive)}
func TestCheckFailsWhenPermissionsHaveMovedOn(t *testing.T) { env := livenessSetup(t) bumpPermVersion(t, env) err := env.checker.Check(context.Background(), env.claims) require.ErrorIs(t, err, liveness.ErrStalePermissions, "a token minted before a role edit must be refused so the client refreshes")}
func TestCheckStillWorksWithRedisUnavailable(t *testing.T) { env := livenessSetup(t) env.stopRedis() // Degraded, not fail-open: the answer must still be correct. require.NoError(t, env.checker.Check(context.Background(), env.claims))
revokeFamily(t, env, "user revoked") require.ErrorIs(t, env.checker.Check(context.Background(), env.claims), liveness.ErrSessionDead, "with Redis down the check must still refuse a revoked session")}
func TestRevocationIsNotMaskedByACachedPositive(t *testing.T) { env := livenessSetup(t) require.NoError(t, env.checker.Check(context.Background(), env.claims)) // populates the cache revokeFamily(t, env, "user revoked")
// The cache TTL must be short enough, and revocation must invalidate, so // that a cached "alive" cannot outlive the revocation that contradicts it. require.ErrorIs(t, env.checker.Check(context.Background(), env.claims), liveness.ErrSessionDead)}livenessSetup starts a Redis container alongside the Postgres one via testcontainers-go, seeds an org, user, member, role and session family, mints matching claims, and returns a struct exposing checker, claims, and stopRedis(). Put it in the test file.
- Step 2: Run the test and watch it fail
Run: go test ./internal/platform/identity/liveness/ -v
Expected: FAIL — package does not exist.
- Step 3: Add the dependency
Run: go get github.com/redis/go-redis/v9@latest
- Step 4: Implement
// Package liveness answers, once per request, whether a validly signed access// token still corresponds to a live session, an active member, and current// permissions.//// A 15-minute stateless token would leave a dismissed employee holding// approval authority for up to fifteen minutes. In a system whose most// valuable asset is the authority to approve payment, that is the wrong// default, so the token is stateless in FORM but never blindly trusted.//// Redis is a cache in front of Postgres, never the source of truth. When it is// unavailable the check falls through to Postgres: slower, still correct. It// never fails open.package liveness
import ( "context" "errors" "fmt" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgxpool" "github.com/redis/go-redis/v9"
"github.com/mcom/erp/internal/platform/identity/token")
var ( ErrSessionDead = errors.New("liveness: session revoked or expired") ErrMemberInactive = errors.New("liveness: member is not active") ErrStalePermissions = errors.New("liveness: permissions have changed"))
type Checker interface { Check(ctx context.Context, c token.Claims) error}
type Cached struct { pool *pgxpool.Pool rdb *redis.Client ttl time.Duration}
// New builds a checker. ttl bounds how long a positive answer may be reused;// it is deliberately short, because a cached "alive" that outlives the// revocation contradicting it is the failure this package exists to prevent.func New(pool *pgxpool.Pool, rdb *redis.Client, ttl time.Duration) *Cached { if ttl <= 0 || ttl > 10*time.Second { ttl = 5 * time.Second } return &Cached{pool: pool, rdb: rdb, ttl: ttl}}
type state struct { SessionLive bool `json:"s"` MemberActive bool `json:"m"` PermVersion int64 `json:"p"`}
func (c *Cached) Check(ctx context.Context, cl token.Claims) error { st, err := c.load(ctx, cl) if err != nil { return err } switch { case !st.SessionLive: return ErrSessionDead case !st.MemberActive: return ErrMemberInactive case st.PermVersion != cl.PermVersion: return ErrStalePermissions } return nil}
func (c *Cached) load(ctx context.Context, cl token.Claims) (state, error) { key := fmt.Sprintf("liveness:%s:%s", cl.SessionID, cl.MemberID) if c.rdb != nil { if raw, err := c.rdb.Get(ctx, key).Bytes(); err == nil { var st state if json.Unmarshal(raw, &st) == nil { return st, nil } } // Any Redis error — miss, timeout, connection refused — falls through // to Postgres. It is never a reason to admit the request. } st, err := c.fromPostgres(ctx, cl) if err != nil { return state{}, err } if c.rdb != nil { if raw, err := json.Marshal(st); err == nil { _ = c.rdb.Set(ctx, key, raw, c.ttl).Err() } } return st, nil}
// fromPostgres reads the three facts in one round trip. The session and member// rows live in different tenancy regimes — session_family is global, and// organization_member is RLS-protected — so this runs with the tenant setting// applied for the org in the claims.func (c *Cached) fromPostgres(ctx context.Context, cl token.Claims) (state, error) { conn, err := c.pool.Acquire(ctx) if err != nil { return state{}, fmt.Errorf("liveness: acquire: %w", err) } defer conn.Release()
tx, err := conn.Begin(ctx) if err != nil { return state{}, fmt.Errorf("liveness: begin: %w", err) } defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT set_config('app.current_org', $1, true)`, cl.OrgID.String()); err != nil { return state{}, fmt.Errorf("liveness: set tenant: %w", err) }
var st state err = tx.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM session_family WHERE id = $1 AND revoked_at IS NULL AND absolute_expires_at > now()), COALESCE((SELECT active FROM organization_member WHERE id = $2), false), COALESCE((SELECT perm_version FROM organization WHERE id = $3), 0)`, cl.SessionID, cl.MemberID, cl.OrgID). Scan(&st.SessionLive, &st.MemberActive, &st.PermVersion) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return state{}, fmt.Errorf("liveness: query: %w", err) } return st, tx.Commit(ctx)}Add encoding/json to the imports.
Invalidation: every path that revokes a session, deactivates a member or bumps perm_version must also delete the matching Redis keys. Add func (c *Cached) Invalidate(ctx context.Context, sessionID, memberID uuid.UUID) error and call it from session.Revoke, RevokeAllExcept and member.BumpPermVersion’s callers. The TTL is the backstop, not the mechanism.
- Step 5: Run the tests and verify they pass
Run: go test ./internal/platform/identity/liveness/ -v
Expected: PASS, 6 tests. TestCheckStillWorksWithRedisUnavailable is the important one: if it hangs rather than falling through, the Redis client needs an explicit short timeout.
- Step 6: Commit
git add internal/platform/identity/liveness/ internal/kernel/config/ .env.example go.mod go.sumgit commit -m "Check session liveness on every request
A valid signature is not proof the session behind it is still live. Adismissed employee loses approval authority on their next request ratherthan up to fifteen minutes later.
Redis caches; Postgres decides. With Redis down the check is slower andstill correct, and it never fails open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 12: Delete X-Org-Id
Section titled “Task 12: Delete X-Org-Id”This task closes the Phase 0 blocking item. Per §5 and §7 of the session design. When it is done, no code path anywhere reads an organization from a header.
Files:
- Modify:
internal/kernel/httpx/middleware.go,internal/kernel/httpx/server.go - Create:
internal/kernel/httpx/auth.go,internal/kernel/httpx/auth_test.go,internal/platform/identity/member/orgs.go
Interfaces:
- Consumes:
token.Signer,liveness.Checker,member.Record. - Produces:
httpx.Authenticate(v httpx.TokenVerifier, l liveness.Checker) gin.HandlerFunchttpx.Tenant() gin.HandlerFunc— rewritten; reads the verified claims, never a header.httpx.Authorize(key string) gin.HandlerFunchttpx.IdentityFromContext(ctx) (httpx.Identity, bool)whereIdentity{UserID, OrgID, MemberID, SessionID uuid.UUID}member.OrgsForUser(ctx, pool, userID uuid.UUID) ([]member.OrgSummary, error)
httpx is a kernel package and must not import internal/platform — the layering drift test will fail the build. So Authenticate takes interfaces defined in httpx itself:
type TokenVerifier interface{ Verify(raw string) (token.Claims, error) }token.Claims is a platform type, so even this import is forbidden. Define the claim struct in kernel/httpx and have platform/identity/token depend on it, not the other way around — the dependency must point downward. Move Claims to a new kernel package internal/kernel/authn and have both httpx and token import that.
- Step 1: Write the failing test
func TestRequestWithNoTokenIsRefused(t *testing.T) { r := routerWithAuth(t, okVerifier{}, okLiveness{}) w := do(r, "GET", "/v1/thing", "") require.Equal(t, http.StatusUnauthorized, w.Code) require.Equal(t, "application/problem+json", w.Header().Get("Content-Type"))}
func TestXOrgIdHeaderIsIgnoredEntirely(t *testing.T) { // The Phase 0 vulnerability, asserted closed. A caller presenting a valid // token for org A and a header naming org B must act in org A. orgA, orgB := uuid.New(), uuid.New() r := routerWithAuth(t, verifierFor(orgA), okLiveness{})
req := httptest.NewRequest("GET", "/v1/thing", nil) req.Header.Set("Authorization", "Bearer any") req.Header.Set("X-Org-Id", orgB.String()) w := httptest.NewRecorder() r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, orgA.String(), w.Body.String(), "tenant context must come from the signed claim, never from a header")}
func TestExpiredTokenIsRefused(t *testing.T) { r := routerWithAuth(t, failingVerifier{err: token.ErrExpired}, okLiveness{}) w := do(r, "GET", "/v1/thing", "Bearer stale") require.Equal(t, http.StatusUnauthorized, w.Code)}
func TestRevokedSessionIsRefusedEvenWithAValidSignature(t *testing.T) { r := routerWithAuth(t, okVerifier{}, failingLiveness{err: liveness.ErrSessionDead}) w := do(r, "GET", "/v1/thing", "Bearer valid") require.Equal(t, http.StatusUnauthorized, w.Code)}
func TestStalePermissionsProduceA401SoTheClientRefreshes(t *testing.T) { r := routerWithAuth(t, okVerifier{}, failingLiveness{err: liveness.ErrStalePermissions}) w := do(r, "GET", "/v1/thing", "Bearer valid") // 401, not 403: the remedy is a new token, and only 401 tells the client // to go and get one. require.Equal(t, http.StatusUnauthorized, w.Code)}
func TestAuthorizeRefusesAMemberWithoutThePermission(t *testing.T) { r := routerWithPermission(t, "expenses.claim.approve", map[string]struct{}{"expenses.claim.view": {}}) w := do(r, "POST", "/v1/approve", "Bearer valid") require.Equal(t, http.StatusForbidden, w.Code)}
func TestAuthorizeAdmitsAMemberWithThePermission(t *testing.T) { r := routerWithPermission(t, "expenses.claim.approve", map[string]struct{}{"expenses.claim.approve": {}}) w := do(r, "POST", "/v1/approve", "Bearer valid") require.Equal(t, http.StatusOK, w.Code)}And the test that matters most, in internal/kernel/layering/layering_test.go:
func TestNothingReadsAnOrgFromAHeader(t *testing.T) { // Phase 0 shipped a placeholder that trusted X-Org-Id. Deleting it is not // enough; nothing may reintroduce it. This is a grep with a reason. root := repoRoot(t) var found []string var scanned int for _, dir := range []string{"internal", "cmd", "api"} { err := filepath.WalkDir(filepath.Join(root, dir), func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return err } if !strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, ".yaml") { return nil } if strings.HasSuffix(path, "layering_test.go") { return nil // this file names the header in order to forbid it } src, err := os.ReadFile(path) require.NoError(t, err) scanned++ if strings.Contains(strings.ToLower(string(src)), "x-org-id") { rel, _ := filepath.Rel(root, path) found = append(found, rel) } return nil }) require.NoError(t, err) } require.Positive(t, scanned, "no files scanned; the walker is not reaching the source tree") require.Empty(t, found, "the organization comes from a signed token claim. Reintroducing an org header "+ "reopens the Phase 0 cross-tenant hole: %v", found)}- Step 2: Run the tests and watch them fail
Run: go test ./internal/kernel/httpx/ ./internal/kernel/layering/ -v
Expected: FAIL — undefined: httpx.Authenticate, and TestNothingReadsAnOrgFromAHeader fails naming internal/kernel/httpx/middleware.go. That second failure is the Phase 0 placeholder, correctly detected.
- Step 3: Move
Claimsinto the kernel
Create internal/kernel/authn/authn.go:
// Package authn holds the verified-identity types shared by the HTTP edge and// the token minter.//// It lives in kernel rather than platform because kernel/httpx needs these// types and a kernel package importing platform is a layering violation the// drift test fails the build over.package authn
import "github.com/google/uuid"
type Claims struct { UserID uuid.UUID OrgID uuid.UUID MemberID uuid.UUID SessionID uuid.UUID PermVersion int64 ID string}Change platform/identity/token to alias it: type Claims = authn.Claims. Every existing usage keeps compiling.
- Step 4: Delete the placeholder and write the replacement
In internal/kernel/httpx/middleware.go, delete const HeaderOrgID and the entire body of Tenant() including its warning comment. Replace with:
// Tenant puts the organization from the verified token on the request context.//// It reads the claims Authenticate placed there and nothing else. There is no// header: Phase 0 shipped one as a deliberate placeholder, and a validated// header is correct only for as long as every path remembers to validate it.// A signed claim is correct by construction.func Tenant() gin.HandlerFunc { return func(c *gin.Context) { id, ok := IdentityFromContext(c.Request.Context()) if !ok { _ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required")) c.Abort() return } c.Request = c.Request.WithContext(tenancy.WithOrg(c.Request.Context(), id.OrgID)) c.Next() }}Create internal/kernel/httpx/auth.go:
package httpx
type TokenVerifier interface { Verify(raw string) (authn.Claims, error)}
type LivenessChecker interface { Check(ctx context.Context, c authn.Claims) error}
// PermissionLoader returns the permissions held by a member. Defined here so// kernel does not import platform.type PermissionLoader interface { Permissions(ctx context.Context, memberID uuid.UUID) (map[string]struct{}, error)}
type Identity struct { UserID uuid.UUID OrgID uuid.UUID MemberID uuid.UUID SessionID uuid.UUID}
type identityKey struct{}
func IdentityFromContext(ctx context.Context) (Identity, bool) { id, ok := ctx.Value(identityKey{}).(Identity) return id, ok}
// Authenticate verifies the bearer token and confirms the session behind it is// still live. Both halves are required: a valid signature proves the token was// minted by us, not that it still means anything.func Authenticate(v TokenVerifier, l LivenessChecker) gin.HandlerFunc { return func(c *gin.Context) { raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") if raw == "" || raw == c.GetHeader("Authorization") { _ = c.Error(apperr.New(apperr.KindUnauthorized, "a bearer token is required")) c.Abort() return } claims, err := v.Verify(raw) if err != nil { // Never report which of expired, tampered or unknown-key applied: // the remedy is the same and the distinction is only useful to // someone probing. _ = c.Error(apperr.New(apperr.KindUnauthorized, "the token is not valid")) c.Abort() return } if err := l.Check(c.Request.Context(), claims); err != nil { // 401 for all three, including stale permissions: the remedy is a // new token, and only 401 tells a client to go and get one. _ = c.Error(apperr.New(apperr.KindUnauthorized, "the session is no longer valid")) c.Abort() return } c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), identityKey{}, Identity{ UserID: claims.UserID, OrgID: claims.OrgID, MemberID: claims.MemberID, SessionID: claims.SessionID, })) c.Next() }}
// Authorize answers question 1 of spec section 4.3: can this role do this// verb. Question 2 — may they do it to THIS record — belongs to the module// that owns the record and is never answered here.func Authorize(p PermissionLoader, key string) gin.HandlerFunc { return func(c *gin.Context) { id, ok := IdentityFromContext(c.Request.Context()) if !ok { _ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required")) c.Abort() return } perms, err := p.Permissions(c.Request.Context(), id.MemberID) if err != nil { _ = c.Error(err) c.Abort() return } if _, allowed := perms[key]; !allowed { _ = c.Error(apperr.New(apperr.KindForbidden, "you do not have permission to do that")) c.Abort() return } c.Next() }}Wire Authenticate and Tenant into the /v1 group in NewRouter, adding TokenVerifier, Liveness and Permissions to httpx.Deps. Update the comment above NewRouter so it no longer describes an unauthenticated group.
- Step 5: Implement
OrgsForUser
// OrgsForUser lists the organizations this user may act in.//// This is the ONE query in the system that deliberately crosses tenants, and// it is why it lives in its own file with this comment. It cannot run under a// tenant policy, because its whole job is to answer "which tenants?" — so it// runs outside db.InTx, selects only the columns needed to render a switcher,// and never returns tenant data of any kind.func OrgsForUser(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID) ([]OrgSummary, error) { rows, err := pool.Query(ctx, ` SELECT o.id, o.name FROM organization_member m JOIN organization o ON o.id = m.org_id WHERE m.user_id = $1 AND m.active = true ORDER BY o.name`, userID) if err != nil { return nil, fmt.Errorf("member: orgs for user: %w", err) } defer rows.Close() var out []OrgSummary for rows.Next() { var s OrgSummary if err := rows.Scan(&s.ID, &s.Name); err != nil { return nil, fmt.Errorf("member: orgs scan: %w", err) } out = append(out, s) } return out, rows.Err()}This query runs as erp_app, which has no BYPASSRLS, so it reads through the org_isolation policies with no app.current_org set — which matches zero rows. It therefore needs a policy that admits a user’s own membership rows regardless of tenant setting. Add to 00009_membership.sql, replacing the plain policy on organization_member:
-- Two ways to see a membership row: you are inside that tenant, or the row is-- your own. The second is what lets the org switcher work before a tenant is-- chosen, and it exposes nothing but the fact of your own membership.CREATE POLICY org_isolation ON organization_member USING ( org_id = current_setting('app.current_org', true)::uuid OR user_id = NULLIF(current_setting('app.current_user', true), '')::uuid ) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);OrgsForUser must therefore set app.current_user in its own transaction, the same way db.InTx sets app.current_org. The WITH CHECK clause is what stops that second disjunct from also granting write access — the same trap Task 12 of Phase 0 found in the registry pattern.
Add organization to the same treatment so the switcher can read names.
- Step 6: Run the tests and verify they pass
Run: go test ./...
Expected: all packages PASS, including TestNothingReadsAnOrgFromAHeader — which is now the machine-checked proof that the Phase 0 blocker is closed. The RLS drift test must still pass: the new organization_member policy has a qual referencing org_id and a non-empty with_check, which is exactly what the Phase 0 test requires of a policy whose qual admits rows outside the current tenant.
- Step 7: Update the roadmap
In docs/ROADMAP.md, tick the X-Org-Id carry-over item and the Plan 2 sub-item, and delete the “BLOCKING for Plan 2” wording — it no longer blocks anything.
- Step 8: Commit
git add internal/kernel/ internal/platform/identity/member/ db/migrations/00009_membership.sql docs/ROADMAP.mdgit commit -m "Delete X-Org-Id; the organization is a signed claim
Closes the Phase 0 blocker. Tenant() now reads the verified token andnothing else, and a drift test fails the build if any file reintroducesan org header.
Claims move to kernel/authn so the HTTP edge can name them without akernel package importing platform.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 13: The auth HTTP surface
Section titled “Task 13: The auth HTTP surface”The first package under internal/modules/. It may not reference an RLS-exempt table — Task 2’s rule — so every database touch goes through platform/identity.
Files:
- Create:
internal/modules/identity/handlers.go,internal/modules/identity/handlers_test.go,internal/modules/identity/routes.go - Modify:
api/openapi.yaml,cmd/api/main.go
Interfaces:
- Produces:
identity.Register(g *gin.RouterGroup, public *gin.Engine, deps identity.Deps).
Endpoints:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /v1/auth/login |
none | Email + password (+ TOTP when enrolled). Sets the refresh cookie, returns an access token and the user’s orgs. |
| POST | /v1/auth/refresh |
cookie | Rotates. Returns a new access token for the same org. |
| POST | /v1/auth/logout |
cookie | Revokes this family, clears the cookie. |
| POST | /v1/auth/switch-org |
bearer | Mints an access token for another org the user belongs to. |
| GET | /v1/auth/sessions |
bearer | This user’s live sessions. |
| DELETE | /v1/auth/sessions/:id |
bearer | Revoke one. |
- Step 1: Write the failing test
func TestLoginReturnsAnAccessTokenAndSetsTheRefreshCookie(t *testing.T) { env := apiSetup(t) w := env.post("/v1/auth/login", `{"email":"ziyang@example.com","password":"correct horse battery staple"}`) require.Equal(t, http.StatusOK, w.Code)
var body struct { AccessToken string `json:"access_token"` Orgs []struct{ ID, Name string } `json:"organizations"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NotEmpty(t, body.AccessToken) require.Len(t, body.Orgs, 1)
c := refreshCookie(t, w) require.True(t, c.HttpOnly, "the refresh cookie must not be readable from JavaScript") require.True(t, c.Secure) require.Equal(t, http.SameSiteLaxMode, c.SameSite) require.Equal(t, "/v1/auth", c.Path, "the refresh cookie must not ride along on every API call")}
func TestLoginRefusesAWrongPasswordWithProblemJSON(t *testing.T) { env := apiSetup(t) w := env.post("/v1/auth/login", `{"email":"ziyang@example.com","password":"wrong"}`) require.Equal(t, http.StatusUnauthorized, w.Code) require.Equal(t, "application/problem+json", w.Header().Get("Content-Type"))}
func TestTheAccessTokenFromLoginWorksAgainstAProtectedRoute(t *testing.T) { env := apiSetup(t) tok := env.login(t) w := env.get("/v1/auth/sessions", tok) require.Equal(t, http.StatusOK, w.Code)}
func TestSwitchOrgMintsATokenForTheOtherOrg(t *testing.T) { env := apiSetupTwoOrgs(t) tok := env.login(t) w := env.postAuth("/v1/auth/switch-org", fmt.Sprintf(`{"org_id":%q}`, env.orgB), tok) require.Equal(t, http.StatusOK, w.Code)
claims := env.verify(t, accessTokenOf(t, w)) require.Equal(t, env.orgB, claims.OrgID.String())}
func TestSwitchOrgRefusesAnOrgTheUserDoesNotBelongTo(t *testing.T) { env := apiSetup(t) tok := env.login(t) w := env.postAuth("/v1/auth/switch-org", fmt.Sprintf(`{"org_id":%q}`, uuid.NewString()), tok) require.Equal(t, http.StatusForbidden, w.Code, "switching is the one operation that crosses tenants; membership must be re-checked")}
func TestLogoutEndsTheSession(t *testing.T) { env := apiSetup(t) w := env.post("/v1/auth/login", `{"email":"ziyang@example.com","password":"correct horse battery staple"}`) cookie := refreshCookie(t, w)
require.Equal(t, http.StatusNoContent, env.postCookie("/v1/auth/logout", "", cookie).Code) require.Equal(t, http.StatusUnauthorized, env.postCookie("/v1/auth/refresh", "", cookie).Code)}
func TestLoginWritesAnAuditRow(t *testing.T) { env := apiSetup(t) env.login(t) require.Equal(t, 1, env.countAudit(t, "session", "login"), "spec 11A.4: an auditor asking who was logged in when must get an answer")}
func TestAFailedLoginIsAlsoAudited(t *testing.T) { env := apiSetup(t) env.post("/v1/auth/login", `{"email":"ziyang@example.com","password":"wrong"}`) require.Equal(t, 1, env.countAudit(t, "session", "login_failed"))}- Step 2: Run the tests and watch them fail
Run: go test ./internal/modules/identity/ -v
Expected: FAIL — package does not exist.
- Step 3: Implement the handlers
Rules the handlers follow, each of which a reviewer should check:
- Errors are recorded, never written.
c.Error(apperr.New(...))and return;Errors()renders the problem+json. - The refresh cookie is set with
Path: "/v1/auth",HttpOnly,Secure,SameSite=Lax. Scoping the path keeps it off every other API call. - Login, failed login, logout, switch and revoke each write an
audit.Entryon the same transaction as the change, per §11A.4. A failed login has no member, so itsactor_idis nil andentity_idis the user id when known anduuid.Nilwhen not. - Throttling wraps login:
throttle.Checkbefore verifying the password,throttle.Recordafter, both inside the same transaction. switch-orgre-reads membership rather than trusting the token: the token proves who you are, not what you may now join.- Login with
totp_enabled_atset and nocodein the body returns403withtype: "totp-required", not a generic failure, so the client can render the second-factor prompt.
- Step 4: Describe the endpoints in OpenAPI
Add all six to api/openapi.yaml with request and response schemas, and 401/403 responses referencing the existing Problem schema. Run make generate and commit the regenerated internal/gen/api. The Phase 0 generated-artifact drift test in internal/gen/drift_test.go fails the build if the committed output does not match a fresh run.
Do not add an X-Org-Id parameter to any operation. TestNothingReadsAnOrgFromAHeader scans .yaml files too and will fail.
- Step 5: Run the tests and verify they pass
Run: go test ./internal/modules/identity/ ./internal/gen/ ./internal/kernel/layering/ -v
Expected: PASS. The layering test is the one that proves this module never reached for users directly.
- Step 6: Commit
git add internal/modules/identity/ api/openapi.yaml internal/gen/ cmd/api/git commit -m "Add the auth HTTP surface
Login, refresh, logout, switch-org and session management, all reachingthe global tables only through platform/identity — the condition the RLSexemption was granted on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 14: Company profile
Section titled “Task 14: Company profile”Per §4.6. Not a settings screen — a hard dependency of the ledger. Changing base_currency or fiscal_year_starts_on after anything is posted is refused; it is a new entity, not an edit.
Files:
- Create:
db/migrations/00011_company_profile.sql,internal/platform/company/company.go,internal/platform/company/company_test.go
Interfaces:
-
Produces:
company.Profile{OrgID uuid.UUID; LegalName, TradingName, RegistrationNo, CountryCode, BaseCurrency string; FiscalYearStartsOn time.Time; TIN, SSTRegistrationNo, MSICCode string; RegisteredAddress, MailingAddress, DefaultLocale, Timezone string}company.Get(ctx, tx) (Profile, error),company.Create(ctx, tx, c clock.Clock, p Profile) error,company.Update(ctx, tx, c clock.Clock, p Profile) errorcompany.ErrImmutableAfterPosting,company.ErrNotProvisioned
-
Step 1: Write the failing test
func TestCreateThenGetRoundTrips(t *testing.T) { /* create a full profile, read it back, assert every field */ }
func TestBaseCurrencyCannotChangeOnceAnythingIsPosted(t *testing.T) { d, h, ctx, org := companySetup(t) createProfile(t, d, ctx, "MYR") markPosted(t, h.Admin, org) // inserts a row into the ledger-activity marker
p := getProfile(t, d, ctx) p.BaseCurrency = "SGD" err := d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return company.Update(ctx, tx, clock.Fixed(t0), p) }) require.ErrorIs(t, err, company.ErrImmutableAfterPosting, "every base_amount already in the ledger was computed against the old currency")}
func TestFiscalYearStartCannotChangeOnceAnythingIsPosted(t *testing.T) { /* same shape */ }
func TestTradingNameCanChangeAfterPosting(t *testing.T) { // The immutability is specific, not blanket: a company that rebrands must // be able to say so.}
func TestGetOnAnUnprovisionedOrgSaysSoRatherThanReturningZeroes(t *testing.T) { d, _, ctx, _ := companySetup(t) err := d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := company.Get(ctx, tx) return err }) require.ErrorIs(t, err, company.ErrNotProvisioned, "a zero-valued profile would seed a chart of accounts for country ''")}
func TestAnotherOrgsProfileIsInvisible(t *testing.T) { /* RLS proof, same shape as Task 9 */ }
func TestDefaultLocaleMustBeARecordSafeLocale(t *testing.T) { // Spec 11A.3: records must be in English or Bahasa Malaysia. zh is a UI // locale and must not be the default the record fields inherit. d, _, ctx, _ := companySetup(t) p := sampleProfile() p.DefaultLocale = "zh" err := d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return company.Create(ctx, tx, clock.Fixed(t0), p) }) require.Error(t, err)}
func TestCreateWritesAnAuditRow(t *testing.T) { /* 11A.4 */ }- Step 2: Run the tests and watch them fail
Run: go test ./internal/platform/company/ -v
Expected: FAIL — package does not exist.
- Step 3: Write the migration
-- +goose UpCREATE TABLE company_profile ( org_id UUID PRIMARY KEY REFERENCES organization (id), legal_name TEXT NOT NULL, trading_name TEXT, registration_no TEXT, country_code TEXT NOT NULL CHECK (country_code ~ '^[A-Z]{2}$'), base_currency TEXT NOT NULL CHECK (base_currency ~ '^[A-Z]{3}$'), fiscal_year_starts_on DATE NOT NULL, tin TEXT, sst_registration_no TEXT, msic_code TEXT, registered_address TEXT, mailing_address TEXT, logo_file_id UUID, -- Statutory records are English or Bahasa Malaysia (spec 11A.3). zh is a UI -- locale and is deliberately not permitted as the record default. default_locale TEXT NOT NULL DEFAULT 'en' CHECK (default_locale IN ('en','ms')), timezone TEXT NOT NULL DEFAULT 'Asia/Kuala_Lumpur', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());
-- org_id is both the primary key and the tenancy column, as on organization.ALTER TABLE company_profile ENABLE ROW LEVEL SECURITY;ALTER TABLE company_profile FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON company_profile USING (org_id = current_setting('app.current_org', true)::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON company_profile TO erp_app;
-- +goose DownDROP TABLE company_profile;- Step 4: Implement
Update compares the incoming BaseCurrency and FiscalYearStartsOn against the stored row and returns ErrImmutableAfterPosting when either differs and anything has been posted. Phase 1 has no journal table yet, so implement the posting check as:
// hasPostedEntries reports whether this organization's ledger has been used.//// Plan 4 creates journal_entry. Until then nothing can have been posted, so// this returns false — but the check exists now, and the guard it protects is// tested now, because adding it after the ledger has data means auditing every// row written in between. Plan 4's first task replaces this body with a real// EXISTS against journal_entry and must not weaken the guard.func hasPostedEntries(ctx context.Context, tx pgx.Tx) (bool, error) { var exists bool err := tx.QueryRow(ctx, `SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'journal_entry')`).Scan(&exists) if err != nil { return false, fmt.Errorf("company: posting check: %w", err) } if !exists { return false, nil } var any bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM journal_entry)`).Scan(&any); err != nil { return false, fmt.Errorf("company: posting check: %w", err) } return any, nil}The test seeds a stand-in by creating a journal_entry table with one row via the admin pool (markPosted), which exercises the real code path rather than a mock.
- Step 5: Run the tests and verify they pass
Run: go test ./internal/platform/company/ ./internal/kernel/db/ -v
Expected: PASS, 8 tests.
- Step 6: Commit
git add db/migrations/00011_company_profile.sql internal/platform/company/git commit -m "Add the company profile
A ledger dependency rather than a settings screen: base currency andfiscal year start are refused once anything is posted, because everybase_amount already written was computed against them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 15: The activity log
Section titled “Task 15: The activity log”Per §20.1.7. Three logs, three purposes, kept apart: audit_log is compliance (append-only, before/after, hash-chained, same transaction as its change); the access log is telemetry; activity_log is what a user reads in a feed. Merging them produces one table too noisy to audit and too slow to render.
Files:
- Create:
db/migrations/00012_activity_log.sql,internal/platform/activity/activity.go,internal/platform/activity/activity_test.go
Interfaces:
-
Produces:
activity.Event{Subject string; SubjectID uuid.UUID; Verb string; ActorMemberID *uuid.UUID; Summary string; Payload any}activity.Record(ctx, tx, c clock.Clock, e Event) erroractivity.Feed(ctx, tx, f activity.Filter) ([]activity.Item, error)withFilter{Subject string; SubjectID *uuid.UUID; ActorMemberID *uuid.UUID; Limit int; Before *time.Time}
-
Step 1: Write the failing test
func TestRecordThenFeedReturnsTheEvent(t *testing.T) { /* record one, read it back */ }
func TestFeedIsNewestFirstAndRespectsLimit(t *testing.T) { /* 5 events, limit 3, assert order */ }
func TestFeedPaginatesByBefore(t *testing.T) { // Keyset pagination, not OFFSET: a feed that grows while being read must // not show the same row twice or skip one.}
func TestAnotherOrgsActivityIsInvisible(t *testing.T) { /* RLS proof */ }
func TestActivityIsNotTheAuditTrail(t *testing.T) { d, h, ctx, org := activitySetup(t) require.NoError(t, d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return activity.Record(ctx, tx, clock.Fixed(t0), sampleEvent()) })) // Recording activity must NOT write an audit row: the two logs answer // different questions and one is append-only for compliance reasons the // other does not share. var n int require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT count(*) FROM audit_log WHERE org_id=$1`, org).Scan(&n)) require.Zero(t, n)}
func TestActivityRowsAreDeletableUnlikeAuditRows(t *testing.T) { // Deliberate asymmetry, asserted so nobody "fixes" it later: the feed is // operational and prunable (spec 11A.2), the audit trail is not.}- Step 2: Run the tests and watch them fail
Run: go test ./internal/platform/activity/ -v
Expected: FAIL — package does not exist.
- Step 3: Write the migration
-- +goose Up
-- The user-facing feed. Distinct from audit_log in three ways that matter:-- it is summarised rather than before/after, it is NOT append-only, and it is-- prunable. Spec 11A.2: this is operational data, not a statutory record.CREATE TABLE activity_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), subject TEXT NOT NULL, subject_id UUID NOT NULL, verb TEXT NOT NULL, actor_member_id UUID REFERENCES organization_member (id), -- Rendered to a human. Record-bearing text rules do not apply: this is a -- convenience view, and the statutory record is the document it points at. summary TEXT NOT NULL, payload JSONB, occurred_at TIMESTAMPTZ NOT NULL);
-- Ordered by (occurred_at DESC, id DESC) to match the keyset pagination the-- feed uses; OFFSET on a growing feed repeats and skips rows.CREATE INDEX activity_log_feed_idx ON activity_log (org_id, occurred_at DESC, id DESC);CREATE INDEX activity_log_subject_idx ON activity_log (org_id, subject, subject_id, occurred_at DESC);
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;ALTER TABLE activity_log FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON activity_log USING (org_id = current_setting('app.current_org', true)::uuid);
-- Unlike audit_log, DELETE is granted: this table is pruned.GRANT SELECT, INSERT, DELETE ON activity_log TO erp_app;
-- +goose DownDROP TABLE activity_log;- Step 4: Implement, Step 5: Run, Step 6: Commit
Run: go test ./internal/platform/activity/ ./internal/kernel/db/ -v — PASS, 6 tests.
git add db/migrations/00012_activity_log.sql internal/platform/activity/git commit -m "Add the activity feed, kept separate from the audit trail
Three logs, three purposes: audit_log is compliance and append-only,the access log is telemetry, activity_log is what a user reads. Mergingthem gives one table too noisy to audit and too slow to render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 16: Organization provisioning
Section titled “Task 16: Organization provisioning”Per §20.2. The spec lists nine ordered steps, but six of them belong to plans that do not exist yet (country pack seeds, analytic axes, registries, workflow definitions, fiscal periods, number sequences). Building a fixed nine-step script now would mean editing one function in six later plans.
So build the registry, not the script. Steps register themselves; the runner orders, runs and repairs them; later plans add steps without touching this code.
Files:
- Create:
db/migrations/00013_provisioning.sql,internal/platform/provisioning/provisioning.go,internal/platform/provisioning/steps.go,internal/platform/provisioning/provisioning_test.go - Modify:
cmd/migrate/main.go(the CLI backfill shares this code path)
Interfaces:
-
Produces:
provisioning.Stepinterface:Key() string,Order() int,Check(ctx, tx) (bool, error),Run(ctx, tx) errorprovisioning.Register(s Step)— from a module’sinit().provisioning.Run(ctx, tx, c clock.Clock) (Report, error)— idempotent; runs only the steps whoseChecksays they are missing.provisioning.Readiness(ctx, tx) (Report, error)— runs everyCheckand runs nothing.provisioning.Report{Steps []StepState},StepState{Key string; Order int; Done bool; Error string}
-
Step 1: Write the failing test
func TestRunExecutesStepsInOrder(t *testing.T) { var ran []string provisioning.Register(fakeStep{key: "second", order: 20, run: func() { ran = append(ran, "second") }}) provisioning.Register(fakeStep{key: "first", order: 10, run: func() { ran = append(ran, "first") }})
runProvisioning(t) require.Equal(t, []string{"first", "second"}, ran, "order is not decoration: the country pack cannot seed a chart before the company profile names a country")}
func TestRunIsIdempotent(t *testing.T) { calls := 0 provisioning.Register(fakeStep{key: "once", order: 10, run: func() { calls++ }}) runProvisioning(t) runProvisioning(t) require.Equal(t, 1, calls, "re-running must repair, not duplicate")}
func TestRunRepairsOnlyTheMissingStep(t *testing.T) { // The korre failure this exists for: a partial setup must be completable // without risking the parts that succeeded — including an accountant's // edits to a seeded chart. done, missing := 0, 0 provisioning.Register(fakeStep{key: "already", order: 10, present: true, run: func() { done++ }}) provisioning.Register(fakeStep{key: "gap", order: 20, present: false, run: func() { missing++ }}) runProvisioning(t) require.Zero(t, done, "a step already satisfied must not run again") require.Equal(t, 1, missing)}
func TestAFailingStepStopsTheOnesAfterIt(t *testing.T) { later := 0 provisioning.Register(fakeStep{key: "boom", order: 10, err: errors.New("no country pack")}) provisioning.Register(fakeStep{key: "later", order: 20, run: func() { later++ }}) rep, err := runProvisioningErr(t) require.Error(t, err) require.Zero(t, later, "a later step must not run on the assumption an earlier one worked") require.Contains(t, rep.Steps[0].Error, "no country pack")}
func TestReadinessReportsGapsAndChangesNothing(t *testing.T) { ran := 0 provisioning.Register(fakeStep{key: "gap", order: 10, present: false, run: func() { ran++ }}) rep := readiness(t) require.Zero(t, ran, "readiness is a report; it must never repair silently") require.False(t, rep.Steps[0].Done)}
func TestProvisioningIsRecordedPerOrganization(t *testing.T) { // Org A being provisioned must not make org B look provisioned.}
func TestTheCompanyProfileStepComesFirst(t *testing.T) { // The one ordering the spec states explicitly: step 1 gates everything, // because the country code selects the pack that performs later steps. keys := orderedKeys(t) require.Equal(t, "company_profile", keys[0])}
func TestRolesAreSeededAndOwnerCannotBeDeleted(t *testing.T) { // The five seeded roles, and the system flag that stops an org locking // itself out by deleting Owner.}- Step 2: Run the tests and watch them fail
Run: go test ./internal/platform/provisioning/ -v
Expected: FAIL — package does not exist.
- Step 3: Write the migration
-- +goose UpCREATE TABLE org_provisioning_step ( org_id UUID NOT NULL REFERENCES organization (id), step_key TEXT NOT NULL, completed_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (org_id, step_key));ALTER TABLE org_provisioning_step ENABLE ROW LEVEL SECURITY;ALTER TABLE org_provisioning_step FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON org_provisioning_step USING (org_id = current_setting('app.current_org', true)::uuid);GRANT SELECT, INSERT, UPDATE, DELETE ON org_provisioning_step TO erp_app;
-- +goose DownDROP TABLE org_provisioning_step;The table records what ran, but Check is the authority, not the table: a row saying “done” for a chart of accounts somebody then deleted must not stop the repair. The table exists for the readiness report’s completed_at, not as a substitute for looking.
- Step 4: Implement the runner and the two steps Plan 2 owns
Plan 2 registers exactly two steps; the other seven arrive with their plans:
| Order | Key | Registered by |
|---|---|---|
| 10 | company_profile |
Plan 2 (this task) |
| 20 | country_pack |
Plan 4 |
| 30 | analytic_axes |
Plan 4 |
| 40 | roles |
Plan 2 (this task) |
| 50 | org_structure |
Plan 2b |
| 60 | registries |
Plan 6 |
| 70 | workflow_definitions |
Plan 3 |
| 80 | fiscal_periods |
Plan 4 |
| 90 | number_sequences |
Plan 4 |
Reserve the order numbers in a comment block in steps.go so later plans do not collide, and leave gaps of ten so a step can be inserted without renumbering.
The roles step seeds Employee, Approver, Finance, Admin and Owner with system = true. Its Check returns true when all five exist — not when any role exists, or an org that renamed one would never be repaired.
- Step 5: Add the readiness endpoint
GET /v1/org/readiness in internal/modules/identity, behind Authorize("identity.org.administer"). It returns the Report. Per §20.2 the frontend renders gaps with a repair action, so POST /v1/org/provision re-runs Run behind the same permission.
- Step 6: Share the code path with the CLI
cmd/migrate gains a provision --org <uuid> subcommand calling the same provisioning.Run. The spec is blunt about why: a chart of accounts that differs between the CLI and the API is a reconciliation problem rather than a convenience.
- Step 7: Run the tests and verify they pass
Run: go test ./internal/platform/provisioning/ ./internal/modules/identity/ -v
Expected: PASS, 8 tests.
- Step 8: Commit
git add db/migrations/00013_provisioning.sql internal/platform/provisioning/ internal/modules/identity/ cmd/migrate/git commit -m "Provision organizations through a step registry
The spec lists nine ordered steps, six of which belong to plans that donot exist yet. A fixed script would mean editing one function in sixlater plans, so steps register themselves and the runner orders them.
Idempotent, repairable over the API as well as the CLI, and sharing onecode path with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Task 17: Retention — prove sessions are prunable and audit rows are not
Section titled “Task 17: Retention — prove sessions are prunable and audit rows are not”Per §11A.2 and §8 of the session design. The seven-year clock applies to ledger and document records; applying it mechanically to everything with a timestamp would retain session rows for seven years, which is neither required nor desirable. This task makes that distinction explicit and tested.
Files:
- Create:
internal/platform/identity/session/prune.go,internal/kernel/db/retention_test.go - Modify:
cmd/worker/main.go
Interfaces:
-
Produces:
session.Prune(ctx, tx, c clock.Clock) (int64, error)— removes families past their absolute expiry plus a tail, and clearsgrace_tokenolder thanGraceWindow.session.PruneTail = 30 * 24 * time.Hour
-
Step 1: Write the failing test
func TestPruneRemovesLongDeadSessions(t *testing.T) { pool, email := sessionSetup(t) p := login(t, pool, email, t0) n, err := inTx(t, pool, func(tx pgx.Tx) (int64, error) { return session.Prune(context.Background(), tx, clock.Fixed(t0.Add(session.AbsoluteWindow+session.PruneTail+time.Hour))) }) require.NoError(t, err) require.Positive(t, n) require.Zero(t, countFamilies(t, pool, p.UserID))}
func TestPruneLeavesLiveSessionsAlone(t *testing.T) { pool, email := sessionSetup(t) p := login(t, pool, email, t0) _, err := inTx(t, pool, func(tx pgx.Tx) (int64, error) { return session.Prune(context.Background(), tx, clock.Fixed(t0.Add(time.Hour))) }) require.NoError(t, err) require.Equal(t, 1, countFamilies(t, pool, p.UserID))}
func TestPruneClearsTheGraceTokenButNotTheRow(t *testing.T) { // The grace copy is the only place a raw token rests. It must not rest for // longer than the grace window.}
func TestAuditRowsSurvivePruning(t *testing.T) { // The sessions go; the record of who logged in when stays. This is the // asymmetry spec 11A.2 requires, asserted so a later "cleanup" cannot // quietly take the audit trail with it.}And the drift test, in internal/kernel/db/retention_test.go:
// Statutory records may not be deleted by the application; operational data// must be. The grant table is where that distinction is actually enforced, so// that is where it is asserted.func TestOnlyOperationalTablesGrantDeleteToTheAppRole(t *testing.T) { h := testdb.Start(t)
// Tables the application must never delete from. audit_log is the // compliance trail; the ledger tables join this list as they are built. statutory := []string{"audit_log"} // Tables that MUST be prunable, or retention becomes unbounded. operational := []string{"activity_log", "session_family", "refresh_token", "login_attempt"}
for _, table := range statutory { require.False(t, hasPrivilege(t, h, table, "DELETE"), "%s is a statutory record: the application must not be able to delete from it", table) require.False(t, hasPrivilege(t, h, table, "UPDATE"), "%s is append-only", table) } for _, table := range operational { require.True(t, hasPrivilege(t, h, table, "DELETE"), "%s is operational data and must be prunable; spec 11A.2 does not put it under the seven-year clock", table) }}
func hasPrivilege(t *testing.T, h *testdb.Handles, table, priv string) bool { t.Helper() var ok bool require.NoError(t, h.Admin.QueryRow(context.Background(), `SELECT has_table_privilege('erp_app', $1, $2)`, table, priv).Scan(&ok)) return ok}- Step 2: Run the tests and watch them fail
Run: go test ./internal/platform/identity/session/ ./internal/kernel/db/ -run 'Prune|Retention|Operational' -v
Expected: FAIL — undefined: session.Prune, and the retention test fails on the tables that do not exist yet.
- Step 3: Implement and schedule
Implement Prune, and register it in cmd/worker as a daily job. Document in the worker’s comment that this is the only scheduled deletion in the system and that adding a table to it is a retention decision, not a cleanup.
- Step 4: Run the full suite
Run: go test ./...
Expected: every package PASS.
- Step 5: Commit
git add internal/platform/identity/session/ internal/kernel/db/retention_test.go cmd/worker/git commit -m "Prune sessions; prove the audit trail is not prunable
Spec 11A.2's seven-year clock covers ledger and document records. Asession row is operational data, and applying the clock mechanically toeverything with a timestamp would retain logins for seven years.
The distinction is enforced where it actually lives — the grants — so alater cleanup cannot quietly take the audit trail with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"Self-Review
Section titled “Self-Review”Spec coverage. Session design: §2 → Tasks 2, 9; §3 → 3, 4, 7; §4 → 5; §5 → 12, 13; §6 → 8, 10, 11; §7 → 12; §8 → 13, 17; §9 → distributed. Architecture spec: §4.2 → 9, 10; §4.3 → 10, 12; §4.5/§11A.5 → 1; §4.6 → 14; §11A.2 → 17; §11A.3 → 14; §11A.4 → 13; §20.1.7 → 15; §20.2 → 16.
Deliberately not in this plan:
- Org units, positions and the
reports_totree — spec §16 places these in Plan 2b, and grants binding to positions makes 2b a prerequisite for Plan 3. Task 16 reserves provisioning order 50 for it. - Email verification and password reset flows. Both need transactional email, which nothing in the repo sends yet.
users.email_verified_atexists and Task 16’s seeding marks the first owner verified. This is a gap a reviewer should hold me to: an unverifiable email address is a weak account recovery story, and it should be the first item of the next plan that introduces an email transport. - MFA enrolment endpoints. Task 7 builds the primitives and Task 13 handles login with a code; the enrolment screens arrive with the frontend in Plan 10.
- Record-language validation as a drift test (§11A.7). Task 14 constrains
default_localeby CHECK constraint, which covers the profile. The general rule needs a list of record-bearing columns, and that list is meaningless until the claim and ledger tables exist — it belongs to Plan 5.
Type consistency. authn.Claims is defined once in internal/kernel/authn (Task 12) and aliased by token.Claims (Task 4) — a reviewer should check Task 4’s implementer used the alias rather than declaring a second struct, since Task 4 runs first and cannot see Task 12. Controller: carry this into Task 4’s dispatch. member.Record, session.Pair, session.Summary, company.Profile, activity.Event and provisioning.Report each appear in exactly one task’s Produces block.
Known ordering hazard. Task 5 adds successor_hash and grace_token to 00008_identity_global.sql, which Task 2 creates. Task 2’s implementer will not know about them. Either accept the edit to an existing migration file (safe: nothing has been deployed) or have Task 2 create both columns up front. Ruling for the controller: Task 2 creates them, with a comment pointing at Task 5. Carry this into Task 2’s dispatch.