Skip to content
Persistence and tenancy core

Persistence and tenancy core

Overview

Add Postgres via pgx v5, embedded goose migrations, and the identity and tenancy spine: tenants, users, api_keys, plus the append-only audit_events table and the middleware that fills it. Introduce the scoped store — a store handle constructed from a request context that fails closed when the tenant is missing, so no query can be written without tenant_id. Add API-key authentication middleware, a bootstrap admin key, and the admin API that creates tenants, users and keys. Stand up the testcontainers harness and the cross-tenant isolation test pattern that every later table must follow.

Requirements

Functional

  • goose runs embedded migrations on startup (configurable off) and via hango migrate.
  • tenants, users, api_keys exist with the locked schema; users are tenant-scoped with role owner|admin|member.
  • API keys are generated as hango_{prefix}_{random}, stored as SHA-256 hex plus an indexed 8-char lookup prefix; the plaintext is returned exactly once at creation.
  • Auth middleware resolves Authorization: Bearer <key> to a Principal{TenantID, UserID, Role, KeyID} and puts it in the request context; unknown, revoked or expired keys return 401.
  • Bootstrap admin key is one-shot: on first start, if no platform user exists, hango creates a platform user row plus one `api_keys` row from `HANGO_BOOTSTRAP_ADMIN_KEY` and records that bootstrap completed. From then on the env value itself no longer authenticates — only the persisted key row does, so the root credential is revocable, rotatable and attributable to a user id. Restarting with a different env value after bootstrap logs a warning and changes nothing.
  • The platform principal's capability set is explicit and narrow: create tenants, create the first `owner` user inside a tenant, create that user's first key, read and set `tenant_quotas` (phase 13), read aggregate usage (phase 13). It may not read `agents`, `sessions`, `messages`, `runs`, `run_events`, `agent_files` or approvals. "May not read tenant data" in the locked decisions means exactly this list; bootstrap writes are the documented exception.
  • Admin API: POST /v1/admin/tenants, POST /v1/admin/tenants/{id}/users, POST /v1/users/{id}/api-keys, GET /v1/me, DELETE /v1/api-keys/{id} (revoke).
  • /readyz gains a database ping check.
  • **Audit everything.** An append-only `audit_events` table records every mutating request the API serves and (from phase 15) every approval decision. A row carries who acted, what they acted on, whether it was allowed, and the `request_id`, `trace_id` and `span_id` of the request that produced it, so an audit row joins to its trace in Tempo, its logs in Loki and its run events by `trace_id`. `detail jsonb` carries a redacted summary only: no secrets, no key material, no prompt or message bodies.
  • An **audit middleware** in `internal/server` records every `POST`, `PUT`, `PATCH` and `DELETE` after the handler returns. The actor comes from the `Principal`; the action, target kind and target id come from a value the handler sets with `server.SetAuditAction(c, action, targetKind, targetID)`; the outcome is derived from the status code (2xx → `ok`, 401/403 → `denied`, anything else → `error`); request, trace and span ids come from the active span. Action names are dotted and closed: `tenant.create`, `user.create`, `api_key.create`, `api_key.revoke`, `agent.create`, `agent.update`, `agent.delete`, `session.create`, `session.delete`, `run.create`, `run.cancel`, `file.write`, `file.delete`, `quota.update`, `approval.decide`, `approval.expire`.
  • The audit write always goes through the scoped store. Tenant creation first inserts the `tenants` row, which is not tenant-owned, then builds a scope for the new tenant id and writes the owner user, the first key and the `tenant.create` audit row under it. Phase 16 turns that scope into `WithTenantTx(newTenantID)`, so no pre-scope write ever needs the system pool. A failed audit write is logged and counted on `hango.audit.write_failures`; it never fails the request the user made.
  • `GET /v1/audit-events?since=&action=&target_id=` is read-only and returns the caller's tenant's events: `owner` and `admin` see the whole tenant, a `member` sees only rows whose `actor_id` is their own user id. There is no write, update or delete endpoint for audit rows.
  • `tenant_id` is never null. A platform action that creates a tenant is attributed to the tenant it created; a denied cross-tenant attempt is attributed to the caller's tenant, which is where a reviewer will look for it.
  • Per-route authorization matrix for this phase, enforced in one place (`internal/server/authz.go`) and table-tested route by route.
Routeplatformowneradminmember
POST /v1/admin/tenantsallowdenydenydeny
POST /v1/admin/tenants/{id}/usersallow (bootstrap owner only)allow (own tenant)allow (own tenant)deny
POST /v1/users/{id}/api-keysallow (bootstrap only)allowallow (target member or self)allow (self only)
DELETE /v1/api-keys/{id}denyallow (any key in tenant)allow (any key in tenant)allow (own keys only)
GET /v1/meallow (platform identity)allowallowallow
GET /v1/audit-eventsdenyallow (whole tenant)allow (whole tenant)allow (own actor_id only)
  • Intra-tenant rule for every later phase: a `member` sees and mutates only rows they created; `owner` and `admin` see and mutate everything inside their tenant. Roles never cross tenants.
  • Pre-authentication rate limiting: a small IP- plus lookup-prefix-keyed limiter runs *before* key resolution, sized for failure volume (default 20/min per key, burst 10), so an unknown key cannot buy an unbounded number of hash computations, goroutines and `last_used_at` writes. The authenticated per-key limiter arrives in phase 13 and replaces nothing here.
  • `last_used_at` writes are coalesced: the middleware updates at most once per key per `HANGO_KEY_LAST_USED_INTERVAL` (default 60s) from an in-memory last-write map, so a hot key produces one write a minute instead of one per request.
  • `tenants` gains `trace_llm_content boolean not null default false`. The auth middleware puts it on the request span as `hango.tenant.trace_llm_content` wherever it sets the other tenant attributes, so the collector can strip content for opted-out tenants on the Langfuse leg too.

Non-functional

  • Every tenant-owned table has tenant_id uuid NOT NULL and a tenant-leading index.
  • store.For(ctx) returns an error when no tenant is in context; no store method takes a tenant argument, so omission is impossible.
  • Integration test wiring is decided once, here, and every later phase follows it: integration tests are ordinary tests that run under plain `go test ./...` and skip under `-short`. Go has no repo-wide `TestMain`: each package is its own test binary and process. So every package with integration tests owns a `main_test.go` whose `TestMain` starts or reuses one named testcontainers `postgres` container (reuse by name, so all packages share one server), creates its **own** database `hango_test_`, migrates it and drops it at exit. When `HANGO_TEST_DB_DSN` is set (CI service container), the helper uses that server instead of starting a container. The helper is about 20 lines and is copied per package rather than shared, because a shared test-only package would break the package cap. testcontainers is imported only from `_test.go` files, so it is never linked into the production binary. No test truncates a table another package may be using.
  • Key comparison uses crypto/subtle.ConstantTimeCompare; keys never appear in logs.
  • `audit_events` is append-only by construction: the store exposes `Append` and `List` and nothing else, there is no `UPDATE` or `DELETE` statement anywhere in the package, and the table is `tenant_id`-scoped exactly like every other tenant-owned table. Retention is not addressed in v1; phase 16 records the partition-or-archive follow-up.
  • Ids are UUID v7 everywhere (`uuid.NewV7`, stdlib `uuid` in Go 1.27) so primary keys are time-ordered and index locality follows insert order; `uuid.Nil()` is a function in the stdlib package, not a variable, and every emptiness check calls it.

Architecture

// internal/store/context.go
type ctxKey struct{}

type Scope struct {
	TenantID uuid.UUID
	UserID   uuid.UUID
	Role     Role // "" is invalid: there is no default role
}

func WithScope(ctx context.Context, s Scope) context.Context
func ScopeFromContext(ctx context.Context) (Scope, error) // fail-closed: error when absent, zero tenant or invalid role

// internal/store/store.go
type Store interface { // unscoped root; only platform operations
	Tenants() TenantAdminStore
	For(ctx context.Context) (Scoped, error) // builds a tenant-bound handle from ctx
	Ping(ctx context.Context) error
	Close()
}

type Scoped interface { // every method is implicitly tenant-scoped
	Scope() Scope
	Users() UserStore
	APIKeys() APIKeyStore
	AuditEvents() AuditStore // Updated: Validation Session 1 - audit everything
}

// internal/store/audit.go — Updated: Validation Session 1 - audit everything
type AuditEvent struct {
	ID, TenantID              uuid.UUID
	OccurredAt                time.Time
	ActorKind                 string // platform|user|system
	ActorID, APIKeyID         uuid.UUID
	Action, TargetKind        string
	TargetID                  uuid.UUID
	RequestID, TraceID, SpanID string
	Outcome                   string // ok|denied|error
	Detail                    json.RawMessage
}

type AuditStore interface { // append-only: no Update, no Delete
	Append(ctx context.Context, e AuditEvent) error
	List(ctx context.Context, f AuditFilter) ([]AuditEvent, error)
}

// internal/server/audit.go — Updated: Validation Session 1 - audit everything
// The handler declares what it did; the middleware decides the outcome and writes the row.
func SetAuditAction(r *http.Request, action, targetKind string, targetID uuid.UUID)

// internal/store/pg/scoped.go
type scoped struct {
	q     pgxQuerier // pool or tx
	scope store.Scope
}
// every SQL literal in this package ends with "AND tenant_id = $n" bound from s.scope.TenantID

// internal/auth/key.go
type Principal struct {
	Kind     Kind // KindPlatform | KindUser
	TenantID uuid.UUID
	UserID   uuid.UUID
	Role     store.Role
	KeyID    uuid.UUID
}

func GenerateKey(tenantSlug string) (plaintext string, lookup string, hash string, err error)
func HashKey(plaintext string) (lookup string, hash string)
-- migrations/00001_identity.sql
create table tenants (
  id uuid primary key,
  slug text not null unique,
  name text not null,
  status text not null default 'active',
  trace_llm_content boolean not null default false, -- Red team: F13 — per-tenant LLM content opt-in
  created_at timestamptz not null default now()
);

create table users (
  id uuid primary key,
  tenant_id uuid not null references tenants(id) on delete cascade,
  email text not null,
  role text not null check (role in ('owner','admin','member')),
  status text not null default 'active',
  created_at timestamptz not null default now(),
  unique (tenant_id, email)
);
create index users_tenant_created_idx on users (tenant_id, created_at desc);

create table api_keys (
  id uuid primary key,
  tenant_id uuid not null references tenants(id) on delete cascade,
  user_id uuid not null references users(id) on delete cascade,
  name text not null,
  lookup text not null unique,          -- 8-char indexed prefix
  key_hash text not null,               -- sha-256 hex of the full key
  last_used_at timestamptz,
  revoked_at timestamptz,
  expires_at timestamptz,
  created_at timestamptz not null default now()
);
create index api_keys_tenant_user_idx on api_keys (tenant_id, user_id);

-- Updated: Validation Session 1 - audit everything.
-- Same migration as the identity spine, so no later migration is renumbered.
create table audit_events (
  id uuid primary key,                        -- uuid v7
  tenant_id uuid not null references tenants(id) on delete cascade,
  occurred_at timestamptz not null default now(),
  actor_kind text not null check (actor_kind in ('platform','user','system')),
  actor_id uuid references users(id),
  api_key_id uuid references api_keys(id) on delete set null,
  action text not null,                       -- dotted, e.g. api_key.revoke
  target_kind text not null,
  target_id uuid,
  request_id text not null default '',
  trace_id text not null default '',
  span_id text not null default '',
  outcome text not null check (outcome in ('ok','denied','error')),
  detail jsonb not null default '{}'::jsonb   -- redacted: no secrets, no prompt bodies
);
create index audit_events_tenant_time_idx on audit_events (tenant_id, occurred_at desc);
create index audit_events_tenant_target_idx on audit_events (tenant_id, target_kind, target_id);

Instrumentation

  • otelpgx.NewTracer() installed on pgxpool.Config.ConnConfig.Tracer, so every query becomes a CLIENT span with the SQL statement and connection attributes; otelpgx.WithIncludeQueryParameters stays off (parameters can contain tenant data).
  • internal/telemetry.ContextLogger(ctx) returns a *slog.Logger pre-bound with hango.tenant.id and hango.user.id; the auth middleware installs it into the request context, so every downstream log line and span carries the tenant without the caller remembering.
  • Auth middleware sets hango.tenant.id, hango.user.id, hango.role, hango.api_key.id and hango.tenant.trace_llm_content on the request span; it never records the key, the lookup prefix or the hash.
  • Metrics (names added to internal/telemetry/metrics_names.go): hango.auth.failures counter with a reason attribute (unknown_key, revoked, expired, bad_hash, pre_auth_rate_limited), hango.auth.prelimit.rejections counter, hango.db.migrations.applied gauge set at startup, hango.audit.events counter (attributes action, outcome) and hango.audit.write_failures counter. Phase 14 alerts on the auth-failure rate; the counter’s reason dimension is what makes a brute-force attempt distinguishable from a misconfigured client.
  • /readyz DB ping runs in its own span so a slow database is visible as a span, not just a 503.

Related Code Files

Create

  • migrations/00001_identity.sql, migrations/migrations.go (the embed.FS).
  • internal/store/context.go, internal/store/store.go, internal/store/types.go (Tenant, User, APIKey, Role), internal/store/errors.go (ErrNotFound, ErrConflict, ErrNoTenant).
  • internal/store/pg/pool.go (pgxpool config, jsonb codec setup), internal/store/pg/migrate.go (goose), internal/store/pg/store.go, internal/store/pg/scoped.go, internal/store/pg/tenant.go, internal/store/pg/user.go, internal/store/pg/apikey.go.
  • internal/store/pg/main_test.go — TestMain that starts or reuses the named testcontainers postgres container (or uses HANGO_TEST_DB_DSN when set), creates and migrates hango_test_pg, exposes the pool to the package’s tests and drops the database at exit. Every later package with integration tests (internal/server, internal/run, …) gets its own copy of this file. There is no non-test testing.go.
  • internal/store/pg/tenancy_test.go — the cross-tenant isolation test table, one case per tenant-owned table.
  • internal/auth/key.go, internal/auth/key_test.go, internal/auth/principal.go.
  • internal/server/auth_middleware.go — pre-auth limiter, bearer extraction, key resolution, scope injection, coalesced last_used_at update.
  • internal/server/prelimit.go, internal/server/prelimit_test.go — the pre-authentication IP/prefix limiter.
  • internal/server/authz.go, internal/server/authz_test.go — the per-route authorization matrix as data plus its table test.
  • internal/server/handlers_admin.go, internal/server/handlers_keys.go.
  • `internal/store/audit.go` (`AuditEvent`, `AuditStore`, `AuditFilter`, the action-name constants), `internal/store/pg/audit.go`, `internal/store/pg/audit_test.go`.
  • `internal/server/audit.go` (`SetAuditAction` plus the recording middleware), `internal/server/audit_test.go`, `internal/server/handlers_audit.go` (`GET /v1/audit-events`).
  • docs/learning/02-persistence-and-tenancy.md.

Modify

  • internal/config/config.go — HANGO_DATABASE_URL, HANGO_DB_MAX_CONNS, HANGO_MIGRATE_ON_START, HANGO_BOOTSTRAP_ADMIN_KEY, HANGO_KEY_LAST_USED_INTERVAL, HANGO_PREAUTH_RATE_PER_MINUTE, HANGO_PREAUTH_BURST. HANGO_TEST_DB_DSN is read only by the main_test.go helpers, never by config.Load; it is documented in README.md.
  • internal/app/app.go — build pool, run migrations, build store, register the DB readiness check, mount authed route groups.
  • internal/server/server.go — route groups: public, platform-admin, tenant-authed.
  • compose.yaml, .env.example, Makefile (make migrate, make test-integration), README.md, docs/system-architecture.md.

Implementation Steps

  1. Add deps: github.com/jackc/pgx/v5, github.com/pressly/goose/v3.
  2. Create migrations/ with 00001_identity.sql (goose -- +goose Up / -- +goose Down sections) and migrations.go exporting //go:embed *.sql as FS.
  3. Write internal/store/pg/pool.go: parse HANGO_DATABASE_URL, set MaxConns, MaxConnLifetime, HealthCheckPeriod; return *pgxpool.Pool.
  4. Write internal/store/pg/migrate.go: goose.SetBaseFS(migrations.FS), goose.SetDialect("postgres"), goose.Up(stdlibDB, ".") opened via stdlib.OpenDBFromPool.
  5. Write internal/store/context.go: Scope, WithScope, ScopeFromContext returning ErrNoTenant when absent or equal to uuid.Nil() (stdlib uuid.Nil is a function in Go 1.27, so it is called, not compared as a value). Ids are minted with uuid.NewV7. Unit test both branches. Role has no valid zero value: the constants are owner, admin and member, the empty role is invalid, and ScopeFromContext returns ErrNoTenant for a scope whose role is invalid, so a hand-built scope that forgot its role fails closed instead of reading tenant-wide.
  6. Write internal/store/pg/scoped.go: a scoped struct holding the querier and the scope. Implement Users() and APIKeys() returning sub-stores that close over the scope. Every SQL string includes tenant_id = $N bound from the scope — no method accepts a tenant parameter.
  7. Write the tenant admin store (unscoped, platform-only): CreateTenant, GetTenantBySlug, ListTenants.
  8. Write internal/auth/key.go: GenerateKey reads 32 bytes from crypto/rand, base58-encodes, formats hango_{slug8}_{body}; HashKey returns the 8-char lookup (first 8 chars of the base58 body) and the SHA-256 hex of the whole plaintext. Compare with subtle.ConstantTimeCompare.
  9. Write APIKeyStore.Create/List/Revoke (scoped) and an unscoped ResolveKey(ctx, lookup) used only by the auth middleware — it is the one query that legitimately runs before a tenant is known, and it returns the tenant to scope by.
  10. Write internal/server/prelimit.go: a sharded, TTL-swept limiter keyed by (client IP, lookup prefix) that runs before any key resolution and rejects with 429 plus hango.auth.prelimit.rejections.
  11. Write internal/server/auth_middleware.go: pre-auth limiter, extract bearer, ResolveKey by lookup, verify hash with subtle.ConstantTimeCompare, reject revoked/expired, build Principal, put store.Scope into the request context, update last_used_at at most once per HANGO_KEY_LAST_USED_INTERVAL per key through a safego-style goroutine (introduced properly in phase 6; here a plain goroutine with recover is acceptable and flagged). There is no env-value comparison path: the bootstrap key authenticates as an ordinary persisted key row after first start.
  12. Write the bootstrap step in internal/app: on start, if no platform user exists and HANGO_BOOTSTRAP_ADMIN_KEY is set, create the platform user and its key row inside one transaction under a scope built for the platform user’s tenant (the same pattern phase 16 turns into WithTenantTx), log the key’s lookup prefix only, and mark bootstrap complete. Idempotent across restarts.
  13. Write internal/server/authz.go as the route→role matrix above plus Authorize(principal, route, target) error, and write the admin handlers on top of it. POST /v1/admin/tenants requires KindPlatform; key creation is self-or-higher-role; revocation is owner/admin for any key in the tenant and self-only for a member. Return the plaintext key exactly once in the create response with a "store this now" note. Each of these handlers calls SetAuditAction with tenant.create, user.create, api_key.create or api_key.revoke.
  14. Write `internal/store/audit.go` and `internal/store/pg/audit.go`: the `AuditEvent` type, the closed set of action-name constants, `Append` and `List` only, every statement carrying `tenant_id = $n` from the scope, and no `UPDATE` or `DELETE` statement in the file. `List` applies the `since`, `action` and `target_id` filters and, for a `member` scope, `actor_id = scope.UserID`.
  15. Write `internal/server/audit.go`: `SetAuditAction(r, action, targetKind, targetID)` fills a declaration that the middleware put on the request context as a pointer before calling the handler (a request context cannot be changed from inside the handler), and the middleware runs the handler, then — for `POST`, `PUT`, `PATCH` and `DELETE` only — builds the row from the principal, the declaration, the response status from the phase-1 status-recording writer (2xx → `ok`, 401/403 → `denied`, else → `error`) and the active span's `request_id`, `trace_id` and `span_id`, and appends it. For tenant creation, the handler has already built a scope for the created tenant (requirement above), and the middleware writes the row under that scope; for a denied cross-tenant attempt it writes under the caller's tenant. A write error is logged and counted on `hango.audit.write_failures` and is never propagated to the response. Then write `handlers_audit.go` for the read-only `GET /v1/audit-events`.
  16. Write internal/store/pg/main_test.go: TestMain returns early under -short; otherwise it uses HANGO_TEST_DB_DSN when set, else starts or reuses the named testcontainers postgres container, then creates hango_test_pg, runs migrations into it, runs the tests and drops the database. Keep it to about 20 lines so copying it into later packages stays cheap.
  17. Write tenancy_test.go: create tenants A and B with a user and key in each, plus two users (owner and member) inside tenant A; for every tenant-owned table, insert a row under A and assert reads under B’s scope return zero rows and ErrNotFound on get-by-id, and assert that A’s member cannot read or mutate a row created by another user while A’s owner and admin can. Structure it as a table of per-table closures with both the cross-tenant and the intra-tenant cross-user case so phases 4, 5, 6, 8, 13, 15 just append entries. The table’s own entries for this phase are users, api_keys and audit_events.
  18. Wire internal/app, add the DB readiness check, update compose/env/docs, run the full gate (go test ./..., which now includes the integration tests) and go test -short ./... to confirm the skip path.

Learning Doc Outline

docs/learning/02-persistence-and-tenancy.md must contain:

  • Concept: logical multi-tenancy, fail-closed scoping, why the scope belongs to the store handle rather than to each method.
  • Design choices: tenant_id NOT NULL everywhere, tenant-leading indexes, store.For(ctx), SHA-256 (not bcrypt) for high-entropy keys, lookup prefix for O(1) key resolution, goose with embed.FS.
  • Trade-offs: scoped store vs RLS vs per-tenant schema/database; hand-written SQL vs sqlc; testcontainers vs SQLite in tests; why Go has no repo-wide TestMain and why the 20-line container helper is copied per package instead of living in a shared test package.
  • goclaw comparison: QueryScope.WhereClause appended per call, tenant in ctx passed per method, MasterTenantID default tenant, free-form TEXT user_id with no FK, 5-minute key cache, three parallel auth paths.
  • How to debug this layer: a 401 is located by filtering Loki for hango.auth.failures reason plus the request id; a slow or failing query is located in Tempo by opening the request span and reading its child pgx spans (statement, duration, error); a suspected isolation bug is reproduced with the cross-tenant test and confirmed in Tempo by checking the hango.tenant.id on the query span.
  • deepagents comparison: no tenancy at all; the closest analogue is StoreBackend’s namespace factory (user_identity, "filesystem").
  • Why hango diverges: scope on the handle (goclaw’s per-call ctx is why it needed a visibility_filter.go), hango owns users with real FKs, one auth path.
  • The cross-tenant test pattern and the rule that every new table adds a case — cross-tenant and intra-tenant cross-user.
  • The two-axis authorization model: tenancy is enforced in the store, role and creator scoping in `authz.go`, and why mixing the two into one layer is how leaks happen.
  • Why the root credential must be a row and not an env comparison.
  • Audit trail vs trace vs log: which answers which question, and how `trace_id` joins them in Grafana. The audit row answers "who changed what, and was it allowed", the trace answers "what did the system do and how long did each step take", the log answers "what did the code say while doing it"; `trace_id` is the single key that turns three partial answers into one narrative.
  • Exercises: delete the tenant_id predicate from one query and watch the isolation test fail; revoke a key, then find the api_key.revoke row and open its trace from the trace_id.
  • Time budget: 16h implementation + 2h learning doc.

Success Criteria

  • go build ./... && go vet ./... && go test ./... pass with Docker running.
  • go test ./internal/store/pg/ -run TestCrossTenant -v shows one passing case per tenant-owned table.
  • ScopeFromContext on a bare context returns ErrNoTenant; a unit test asserts it.
  • Creating a tenant, a user and a key via the admin API, then calling GET /v1/me with the returned key, returns that user and tenant.
  • A key from tenant A used against tenant B’s resources returns 404, not 403 (no existence leak).
  • Revoked and expired keys return 401.
  • The authz matrix test covers every route in the table above for platform/owner/admin/member, including a member revoking another user’s key (403) and creating a key for another user (403).
  • Intra-tenant cases in tenancy_test.go: a member cannot read or mutate a row created by another user in the same tenant; owner and admin can.
  • After bootstrap, the HANGO_BOOTSTRAP_ADMIN_KEY value is accepted only because its key row exists; revoking that row returns 401 on the next request (test).
  • Unauthenticated request floods are limited before key resolution: N+1 unknown-key requests from one IP produce N hash computations and one 429 (test), and last_used_at is written at most once per interval per key (test).
  • Creating a tenant, a user and a key and then revoking that key produces four audit_events rows with actions tenant.create, user.create, api_key.create, api_key.revoke, each carrying a non-empty trace_id matching the trace of the request that produced it (integration test).
  • A denied mutation (a member revoking another user’s key) writes a row with outcome = 'denied' under the caller’s tenant; a GET writes no row.
  • With the audit store forced to fail, the mutating request still returns its normal status and hango.audit.write_failures increments (test).
  • GET /v1/audit-events returns the whole tenant for owner/admin and only the caller’s own actor_id rows for a member; there is no route that updates or deletes an audit row (route-table assertion).
  • go test ./... runs the integration tests against per-package databases; go test -short ./... skips them; two packages’ suites can run in parallel without interfering.
  • go list -deps ./cmd/hango lists no testcontainers package: the production binary does not link the test container library.
  • In Grafana, a request trace shows child pgx CLIENT spans for each query, and every span and log line on that trace carries hango.tenant.id.
  • docker compose up applies migrations on a fresh volume; /readyz reports the DB check.

Risk Assessment

RiskLikelihood x ImpactSignalPre-decided response
A future query forgets tenant_idMedium x CriticalCross-tenant test fails, or review catches raw SQL outside scopedAll tenant-owned SQL lives in internal/store/pg behind scoped; a package-level test greps sibling packages for pgx usage and fails if found
ResolveKey (unscoped) becomes a general escape hatchMedium x HighOther packages call itKeep it unexported outside pg except for an AuthLookup interface consumed only by the auth middleware; documented in the learning doc
testcontainers slow or unavailable in CIMedium x MediumIntegration tests time outGuard integration tests with testing.Short() skip; container reuse by name means one server per machine, not one per package; CI may set HANGO_TEST_DB_DSN to a service container and the helper skips the container start
The copied main_test.go helpers drift apartMedium x LowOne package’s tests use a different image or migration pathEach copy is about 20 lines and starts with the same comment naming the container; the learning doc explains why it is copied and not shared
Two packages’ integration tests race on one databaseMedium x MediumFlaky truncate-related failuresPer-package database created in TestMain; no shared truncate step
Bootstrap admin key leaks via logs or compose fileLow x CriticalKey visible in docker compose logsNever log the key or any bearer; .env.example ships a placeholder; startup logs only the lookup prefix; the key is revocable because bootstrap persists it as an ordinary key row
A member reads or destroys another user’s dataHigh x HighIntra-tenant case in the isolation suite failsCreator-scoped visibility for member, full-tenant visibility for owner/admin, enforced in authz.go and in the store predicates, gated by the intra-tenant test cases
Audit writes double the write volume of every mutationCertain x Lowaudit_events grows with request volumeOnly mutating requests are audited, the row is small and index-light, and the write is best-effort behind the response; retention is a documented phase-16 follow-up rather than a silent deletion path
A handler forgets SetAuditAction, producing an unattributed rowMedium x MediumRows with an empty actionThe middleware logs and counts a mutating request that declared no action, and the per-phase success criteria name the exact actions each phase’s handlers must set
detail accumulates secrets or prompt bodiesMedium x HighKey material or message text in an audit rowdetail is built by the handler from an explicit allowlist of fields, and the phase-1 IsContentAttr list plus a test over the audit fixtures keep content out
Research suggests deferring tenancyn/a x CriticalTemptation to “add tenant_id later”Locked decision: tenant_id on table one; goclaw’s retrofit residue is the cited evidence

Rollback

goose down to 00000 drops the identity tables. Reverting the phase leaves phase 1’s server intact because the DB pool, migrations and auth middleware are constructed in internal/app behind a single config flag (HANGO_DATABASE_URL empty → identity routes unmounted).