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
gooseruns embedded migrations on startup (configurable off) and viahango migrate.tenants,users,api_keysexist with the locked schema; users are tenant-scoped with roleowner|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 aPrincipal{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). /readyzgains 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.
| Route | platform | owner | admin | member |
|---|---|---|---|---|
POST /v1/admin/tenants | allow | deny | deny | deny |
POST /v1/admin/tenants/{id}/users | allow (bootstrap owner only) | allow (own tenant) | allow (own tenant) | deny |
POST /v1/users/{id}/api-keys | allow (bootstrap only) | allow | allow (target member or self) | allow (self only) |
DELETE /v1/api-keys/{id} | deny | allow (any key in tenant) | allow (any key in tenant) | allow (own keys only) |
GET /v1/me | allow (platform identity) | allow | allow | allow |
GET /v1/audit-events | deny | allow (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 NULLand 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 onpgxpool.Config.ConnConfig.Tracer, so every query becomes a CLIENT span with the SQL statement and connection attributes;otelpgx.WithIncludeQueryParametersstays off (parameters can contain tenant data).internal/telemetry.ContextLogger(ctx)returns a*slog.Loggerpre-bound withhango.tenant.idandhango.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.idandhango.tenant.trace_llm_contenton 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.failurescounter with areasonattribute (unknown_key,revoked,expired,bad_hash,pre_auth_rate_limited),hango.auth.prelimit.rejectionscounter,hango.db.migrations.appliedgauge set at startup,hango.audit.eventscounter (attributesaction,outcome) andhango.audit.write_failurescounter. Phase 14 alerts on the auth-failure rate; the counter’sreasondimension is what makes a brute-force attempt distinguishable from a misconfigured client. /readyzDB 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(theembed.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,jsonbcodec 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—TestMainthat starts or reuses the named testcontainerspostgrescontainer (or usesHANGO_TEST_DB_DSNwhen set), creates and migrateshango_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-testtesting.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, coalescedlast_used_atupdate.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_DSNis read only by themain_test.gohelpers, never byconfig.Load; it is documented inREADME.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
- Add deps:
github.com/jackc/pgx/v5,github.com/pressly/goose/v3. - Create
migrations/with00001_identity.sql(goose-- +goose Up/-- +goose Downsections) andmigrations.goexporting//go:embed *.sqlasFS. - Write
internal/store/pg/pool.go: parseHANGO_DATABASE_URL, setMaxConns,MaxConnLifetime,HealthCheckPeriod; return*pgxpool.Pool. - Write
internal/store/pg/migrate.go:goose.SetBaseFS(migrations.FS),goose.SetDialect("postgres"),goose.Up(stdlibDB, ".")opened viastdlib.OpenDBFromPool. - Write
internal/store/context.go:Scope,WithScope,ScopeFromContextreturningErrNoTenantwhen absent or equal touuid.Nil()(stdlibuuid.Nilis a function in Go 1.27, so it is called, not compared as a value). Ids are minted withuuid.NewV7. Unit test both branches.Rolehas no valid zero value: the constants areowner,adminandmember, the empty role is invalid, andScopeFromContextreturnsErrNoTenantfor a scope whose role is invalid, so a hand-built scope that forgot its role fails closed instead of reading tenant-wide. - Write
internal/store/pg/scoped.go: ascopedstruct holding the querier and the scope. ImplementUsers()andAPIKeys()returning sub-stores that close over the scope. Every SQL string includestenant_id = $Nbound from the scope — no method accepts a tenant parameter. - Write the tenant admin store (unscoped, platform-only):
CreateTenant,GetTenantBySlug,ListTenants. - Write
internal/auth/key.go:GenerateKeyreads 32 bytes fromcrypto/rand, base58-encodes, formatshango_{slug8}_{body};HashKeyreturns the 8-char lookup (first 8 chars of the base58 body) and the SHA-256 hex of the whole plaintext. Compare withsubtle.ConstantTimeCompare. - Write
APIKeyStore.Create/List/Revoke(scoped) and an unscopedResolveKey(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. - 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 plushango.auth.prelimit.rejections. - Write
internal/server/auth_middleware.go: pre-auth limiter, extract bearer,ResolveKeyby lookup, verify hash withsubtle.ConstantTimeCompare, reject revoked/expired, buildPrincipal, putstore.Scopeinto the request context, updatelast_used_atat most once perHANGO_KEY_LAST_USED_INTERVALper key through asafego-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. - Write the bootstrap step in
internal/app: on start, if no platform user exists andHANGO_BOOTSTRAP_ADMIN_KEYis 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 intoWithTenantTx), log the key’s lookup prefix only, and mark bootstrap complete. Idempotent across restarts. - Write
internal/server/authz.goas the route→role matrix above plusAuthorize(principal, route, target) error, and write the admin handlers on top of it.POST /v1/admin/tenantsrequiresKindPlatform; 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 callsSetAuditActionwithtenant.create,user.create,api_key.createorapi_key.revoke. - 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`.
- 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`.
- Write
internal/store/pg/main_test.go:TestMainreturns early under-short; otherwise it usesHANGO_TEST_DB_DSNwhen set, else starts or reuses the named testcontainerspostgrescontainer, then createshango_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. - Write
tenancy_test.go: create tenants A and B with a user and key in each, plus two users (ownerandmember) inside tenant A; for every tenant-owned table, insert a row under A and assert reads under B’s scope return zero rows andErrNotFoundon get-by-id, and assert that A’smembercannot read or mutate a row created by another user while A’sownerandadmincan. 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 areusers,api_keysandaudit_events. - Wire
internal/app, add the DB readiness check, update compose/env/docs, run the full gate (go test ./..., which now includes the integration tests) andgo 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 NULLeverywhere, tenant-leading indexes,store.For(ctx), SHA-256 (not bcrypt) for high-entropy keys, lookup prefix for O(1) key resolution, goose withembed.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
TestMainand why the 20-line container helper is copied per package instead of living in a shared test package. - goclaw comparison:
QueryScope.WhereClauseappended per call, tenant in ctx passed per method,MasterTenantIDdefault tenant, free-form TEXTuser_idwith 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.failuresreason 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 thehango.tenant.idon 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_idpredicate from one query and watch the isolation test fail; revoke a key, then find theapi_key.revokerow and open its trace from thetrace_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 -vshows one passing case per tenant-owned table. -
ScopeFromContexton a bare context returnsErrNoTenant; a unit test asserts it. - Creating a tenant, a user and a key via the admin API, then calling
GET /v1/mewith 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
memberrevoking another user’s key (403) and creating a key for another user (403). - Intra-tenant cases in
tenancy_test.go: amembercannot read or mutate a row created by another user in the same tenant;ownerandadmincan. - After bootstrap, the
HANGO_BOOTSTRAP_ADMIN_KEYvalue 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_atis 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_eventsrows with actionstenant.create,user.create,api_key.create,api_key.revoke, each carrying a non-emptytrace_idmatching the trace of the request that produced it (integration test). - A denied mutation (a
memberrevoking another user’s key) writes a row withoutcome = 'denied'under the caller’s tenant; aGETwrites no row. - With the audit store forced to fail, the mutating request still returns its normal status and
hango.audit.write_failuresincrements (test). -
GET /v1/audit-eventsreturns the whole tenant forowner/adminand only the caller’s ownactor_idrows for amember; 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/hangolists notestcontainerspackage: 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 upapplies migrations on a fresh volume;/readyzreports the DB check.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
A future query forgets tenant_id | Medium x Critical | Cross-tenant test fails, or review catches raw SQL outside scoped | All 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 hatch | Medium x High | Other packages call it | Keep 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 CI | Medium x Medium | Integration tests time out | Guard 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 apart | Medium x Low | One package’s tests use a different image or migration path | Each 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 database | Medium x Medium | Flaky truncate-related failures | Per-package database created in TestMain; no shared truncate step |
| Bootstrap admin key leaks via logs or compose file | Low x Critical | Key visible in docker compose logs | Never 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 data | High x High | Intra-tenant case in the isolation suite fails | Creator-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 mutation | Certain x Low | audit_events grows with request volume | Only 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 row | Medium x Medium | Rows with an empty action | The 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 bodies | Medium x High | Key material or message text in an audit row | detail 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 tenancy | n/a x Critical | Temptation 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).