Chuyển đến nội dung

Persistence và lõi tenancy

Tổng quan

Thêm Postgres qua pgx v5, các migration goose được nhúng sẵn, và xương sống định danh cùng tenancy: tenants, users, api_keys, cộng với bảng chỉ-thêm audit_events và middleware ghi vào bảng đó. Giới thiệu scoped store — một store handle được dựng từ request context và fail closed (từ chối an toàn) khi thiếu tenant, nên không thể viết được câu query nào mà thiếu tenant_id. Thêm middleware xác thực bằng API key, một bootstrap admin key, và admin API tạo tenant, user và key. Dựng harness testcontainers và mẫu test cô lập giữa các tenant (cross-tenant isolation) mà mọi bảng về sau đều phải theo.

Yêu cầu

Chức năng

  • goose chạy các migration được nhúng khi khởi động (có thể tắt qua cấu hình) và qua hango migrate.
  • tenants, users, api_keys tồn tại với schema đã chốt; user thuộc phạm vi tenant với role owner|admin|member.
  • API key được sinh theo dạng hango_{prefix}_{random}, lưu dưới dạng SHA-256 hex cộng một lookup prefix 8 ký tự có index; plaintext chỉ được trả về đúng một lần lúc tạo.
  • Auth middleware phân giải Authorization: Bearer <key> thành Principal{TenantID, UserID, Role, KeyID} và đặt nó vào request context; key không tồn tại, đã bị revoke hoặc đã hết hạn trả 401.
  • Bootstrap admin key chỉ dùng một lần: ở lần khởi động đầu tiên, nếu chưa có platform user nào, hango tạo một dòng platform user cộng một dòng `api_keys` từ `HANGO_BOOTSTRAP_ADMIN_KEY` và ghi nhận rằng bootstrap đã hoàn tất. Từ đó trở đi, bản thân giá trị env không còn xác thực nữa — chỉ dòng key đã lưu mới xác thực, nên credential gốc có thể revoke, rotate và quy được về một user id. Khởi động lại với giá trị env khác sau bootstrap sẽ ghi một cảnh báo và không thay đổi gì.
  • Tập khả năng của platform principal được nêu rõ và hẹp: tạo tenant, tạo user `owner` đầu tiên trong một tenant, tạo key đầu tiên của user đó, đọc và đặt `tenant_quotas` (Phase 13), đọc usage tổng hợp (Phase 13). Nó không được đọc `agents`, `sessions`, `messages`, `runs`, `run_events`, `agent_files` hay approval. "Không được đọc dữ liệu tenant" trong các quyết định đã chốt có nghĩa đúng bằng danh sách này; các thao tác ghi của bootstrap là ngoại lệ đã được ghi nhận.
  • 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 có thêm một check ping database.
  • **Audit mọi thứ.** Bảng `audit_events` chỉ-thêm ghi lại mọi request thay đổi dữ liệu mà API phục vụ và (từ Phase 15) mọi quyết định approval. Mỗi dòng mang thông tin ai thực hiện, tác động lên cái gì, có được phép hay không, cùng `request_id`, `trace_id` và `span_id` của request tạo ra nó, nên một dòng audit nối được tới trace trong Tempo, log trong Loki và các run event theo `trace_id`. `detail jsonb` chỉ mang một bản tóm tắt đã được che: không secret, không vật liệu key, không nội dung prompt hay message.
  • Một **audit middleware** trong `internal/server` ghi lại mọi `POST`, `PUT`, `PATCH` và `DELETE` sau khi handler trả về. Actor lấy từ `Principal`; action, target kind và target id lấy từ một giá trị mà handler đặt bằng `server.SetAuditAction(c, action, targetKind, targetID)`; outcome suy ra từ status code (2xx thành `ok`, 401/403 thành `denied`, còn lại thành `error`); request, trace và span id lấy từ span đang hoạt động. Tên action có dạng dấu chấm và là một tập đóng: `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`.
  • Việc ghi audit luôn đi qua scoped store. Tạo tenant trước hết chèn dòng `tenants` (không thuộc sở hữu tenant nào), rồi dựng một scope cho tenant id mới và ghi user owner, key đầu tiên và dòng audit `tenant.create` dưới scope đó. Phase 16 biến scope đó thành `WithTenantTx(newTenantID)`, nên không có thao tác ghi trước-scope nào cần tới system pool. Một lần ghi audit thất bại được log và đếm trên `hango.audit.write_failures`; nó không bao giờ làm hỏng request của người dùng.
  • `GET /v1/audit-events?since=&action=&target_id=` chỉ đọc và trả về các sự kiện của tenant người gọi: `owner` và `admin` thấy toàn bộ tenant, `member` chỉ thấy các dòng có `actor_id` là user id của chính mình. Không có endpoint ghi, cập nhật hay xóa cho các dòng audit.
  • `tenant_id` không bao giờ null. Một hành động của platform tạo ra tenant được quy cho tenant mà nó vừa tạo; một nỗ lực cross-tenant bị từ chối được quy cho tenant của người gọi, là nơi người rà soát sẽ tìm.
  • Ma trận phân quyền theo từng route cho giai đoạn này, được thực thi ở một nơi (`internal/server/authz.go`) và được test dạng bảng từng route một.
Routeplatformowneradminmember
POST /v1/admin/tenantsallowdenydenydeny
POST /v1/admin/tenants/{id}/usersallow (chỉ bootstrap owner)allow (tenant của mình)allow (tenant của mình)deny
POST /v1/users/{id}/api-keysallow (chỉ bootstrap)allowallow (target là member hoặc chính mình)allow (chỉ chính mình)
DELETE /v1/api-keys/{id}denyallow (mọi key trong tenant)allow (mọi key trong tenant)allow (chỉ key của mình)
GET /v1/meallow (platform identity)allowallowallow
GET /v1/audit-eventsdenyallow (toàn tenant)allow (toàn tenant)allow (chỉ actor_id của mình)
  • Quy tắc trong nội bộ tenant cho mọi giai đoạn sau: một `member` chỉ thấy và chỉ sửa các dòng do mình tạo; `owner` và `admin` thấy và sửa mọi thứ trong tenant của họ. Role không bao giờ vượt qua ranh giới tenant.
  • Giới hạn tốc độ trước xác thực: một limiter nhỏ theo khóa IP cộng lookup prefix chạy *trước* khi phân giải key, được định cỡ theo khối lượng thất bại (mặc định 20/phút mỗi key, burst 10), để một key không tồn tại không thể "mua" số lần tính hash, goroutine và lần ghi `last_used_at` không giới hạn. Limiter theo từng key đã xác thực đến ở Phase 13 và không thay thế gì ở đây.
  • Các lần ghi `last_used_at` được gộp lại: middleware chỉ cập nhật tối đa một lần mỗi key trong mỗi `HANGO_KEY_LAST_USED_INTERVAL` (mặc định 60s) dựa trên một map thời điểm ghi cuối trong bộ nhớ, nên một key được dùng dày chỉ gây một lần ghi mỗi phút thay vì một lần mỗi request.
  • `tenants` có thêm `trace_llm_content boolean not null default false`. Auth middleware đặt nó lên request span dưới tên `hango.tenant.trace_llm_content` ở mọi nơi nó đặt các attribute tenant khác, để collector loại bỏ nội dung cho các tenant đã opt-out cả trên nhánh Langfuse.

Phi chức năng

  • Mọi bảng thuộc sở hữu tenant đều có tenant_id uuid NOT NULL và một index bắt đầu bằng tenant.
  • store.For(ctx) trả về lỗi khi không có tenant trong context; không có method nào của store nhận đối số tenant, nên việc bỏ sót là bất khả.
  • Cách nối dây integration test được quyết định một lần, tại đây, và mọi giai đoạn sau tuân theo: integration test là các test thông thường chạy dưới `go test ./...` bình thường và bị bỏ qua khi có `-short`. Go không có `TestMain` cho toàn repo: mỗi package là một test binary và một tiến trình riêng. Vì vậy mọi package có integration test đều sở hữu một `main_test.go` có `TestMain` khởi động hoặc tái dùng một container testcontainers `postgres` có tên (tái dùng theo tên, để mọi package chia sẻ một server), tạo database **riêng** `hango_test_`, migrate nó và xóa nó khi thoát. Khi `HANGO_TEST_DB_DSN` được đặt (service container của CI), helper dùng server đó thay vì khởi động container. Helper dài khoảng 20 dòng và được sao chép theo từng package thay vì dùng chung, vì một package chỉ-dành-cho-test dùng chung sẽ phá giới hạn số package. testcontainers chỉ được import từ các file `_test.go`, nên không bao giờ được link vào binary production. Không test nào truncate một bảng mà package khác có thể đang dùng.
  • So sánh key dùng crypto/subtle.ConstantTimeCompare; key không bao giờ xuất hiện trong log.
  • `audit_events` chỉ-thêm theo cấu tạo: store chỉ lộ `Append` và `List` và không gì khác, không có câu lệnh `UPDATE` hay `DELETE` nào trong package, và bảng được giới hạn theo `tenant_id` giống hệt mọi bảng thuộc tenant khác. Chính sách lưu giữ (retention) không được đề cập ở v1; Phase 16 ghi nhận việc theo dõi tiếp theo là phân vùng hoặc lưu trữ.
  • Id là UUID v7 ở mọi nơi (`uuid.NewV7`, `uuid` của stdlib trong Go 1.27) để khóa chính được sắp theo thời gian và tính cục bộ của index đi theo thứ tự chèn; `uuid.Nil()` là một hàm trong package stdlib, không phải một biến, và mọi phép kiểm tra rỗng đều gọi nó.

Kiến trúc

// 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() được cài vào pgxpool.Config.ConnConfig.Tracer, nên mọi query trở thành một span CLIENT với câu lệnh SQL và các attribute kết nối; otelpgx.WithIncludeQueryParameters giữ ở trạng thái tắt (tham số có thể chứa dữ liệu của tenant).
  • internal/telemetry.ContextLogger(ctx) trả về một *slog.Logger đã gắn sẵn hango.tenant.id và hango.user.id; auth middleware cài nó vào request context, nên mọi dòng log và span ở phía dưới đều mang tenant mà người gọi không cần nhớ.
  • Auth middleware đặt hango.tenant.id, hango.user.id, hango.role, hango.api_key.id và hango.tenant.trace_llm_content lên request span; nó không bao giờ ghi key, lookup prefix hay hash.
  • Metric (các tên được thêm vào internal/telemetry/metrics_names.go): counter hango.auth.failures với attribute reason (unknown_key, revoked, expired, bad_hash, pre_auth_rate_limited), counter hango.auth.prelimit.rejections, gauge hango.db.migrations.applied được đặt lúc khởi động, counter hango.audit.events (attribute action, outcome) và counter hango.audit.write_failures. Phase 14 cảnh báo dựa trên tỷ lệ lỗi xác thực; chiều reason của counter là thứ giúp phân biệt một nỗ lực brute-force với một client cấu hình sai.
  • Check ping DB của /readyz chạy trong một span riêng để một database chậm hiện ra như một span, chứ không chỉ là một lỗi 503.

Các file liên quan

Tạo mới

  • migrations/00001_identity.sql, migrations/migrations.go (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 (cấu hình pgxpool, thiết lập codec jsonb), 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 khởi động hoặc tái dùng container testcontainers postgres có tên (hoặc dùng HANGO_TEST_DB_DSN khi được đặt), tạo và migrate hango_test_pg, đưa pool cho các test của package và xóa database khi thoát. Mọi package về sau có integration test (internal/server, internal/run, …) có một bản sao riêng của file này. Không có testing.go nào không phải test.
  • internal/store/pg/tenancy_test.go — bảng test cô lập giữa các tenant, mỗi bảng thuộc tenant một case.
  • internal/auth/key.go, internal/auth/key_test.go, internal/auth/principal.go.
  • internal/server/auth_middleware.go — limiter trước xác thực, tách bearer, phân giải key, đưa scope vào, cập nhật last_used_at có gộp.
  • internal/server/prelimit.go, internal/server/prelimit_test.go — limiter IP/prefix trước xác thực.
  • internal/server/authz.go, internal/server/authz_test.go — ma trận phân quyền theo route dưới dạng dữ liệu cùng test dạng bảng của nó.
  • internal/server/handlers_admin.go, internal/server/handlers_keys.go.
  • `internal/store/audit.go` (`AuditEvent`, `AuditStore`, `AuditFilter`, các hằng số tên action), `internal/store/pg/audit.go`, `internal/store/pg/audit_test.go`.
  • `internal/server/audit.go` (`SetAuditAction` cộng middleware ghi), `internal/server/audit_test.go`, `internal/server/handlers_audit.go` (`GET /v1/audit-events`).
  • docs/learning/02-persistence-and-tenancy.md.

Sửa đổi

  • 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 chỉ được đọc bởi các helper main_test.go, không bao giờ bởi config.Load; nó được ghi tài liệu trong README.md.
  • internal/app/app.go — dựng pool, chạy migration, dựng store, đăng ký check readiness của DB, mount các route group đã xác thực.
  • internal/server/server.go — các route group: public, platform-admin, tenant-authed.
  • compose.yaml, .env.example, Makefile (make migrate, make test-integration), README.md, docs/system-architecture.md.

Các bước triển khai

  1. Thêm dependency: github.com/jackc/pgx/v5, github.com/pressly/goose/v3.
  2. Tạo migrations/ với 00001_identity.sql (các phần -- +goose Up / -- +goose Down của goose) và migrations.go xuất //go:embed *.sql dưới tên FS.
  3. Viết internal/store/pg/pool.go: parse HANGO_DATABASE_URL, đặt MaxConns, MaxConnLifetime, HealthCheckPeriod; trả về *pgxpool.Pool.
  4. Viết internal/store/pg/migrate.go: goose.SetBaseFS(migrations.FS), goose.SetDialect("postgres"), goose.Up(stdlibDB, ".") được mở qua stdlib.OpenDBFromPool.
  5. Viết internal/store/context.go: Scope, WithScope, ScopeFromContext trả về ErrNoTenant khi thiếu hoặc bằng uuid.Nil() (uuid.Nil của stdlib là một hàm trong Go 1.27, nên được gọi chứ không so sánh như một giá trị). Id được tạo bằng uuid.NewV7. Unit test cả hai nhánh. Role không có giá trị zero hợp lệ: các hằng số là owner, admin và member, role rỗng là không hợp lệ, và ScopeFromContext trả về ErrNoTenant cho một scope có role không hợp lệ, để một scope dựng tay mà quên role sẽ fail closed thay vì đọc được cả tenant.
  6. Viết internal/store/pg/scoped.go: một struct scoped giữ querier và scope. Cài Users() và APIKeys() trả về các sub-store đóng gói (close over) scope. Mọi chuỗi SQL đều có tenant_id = $N được bind từ scope — không method nào nhận tham số tenant.
  7. Viết tenant admin store (không scope, chỉ cho platform): CreateTenant, GetTenantBySlug, ListTenants.
  8. Viết internal/auth/key.go: GenerateKey đọc 32 byte từ crypto/rand, mã hóa base58, định dạng hango_{slug8}_{body}; HashKey trả về lookup 8 ký tự (8 ký tự đầu của body base58) và SHA-256 hex của toàn bộ plaintext. So sánh bằng subtle.ConstantTimeCompare.
  9. Viết APIKeyStore.Create/List/Revoke (có scope) và một ResolveKey(ctx, lookup) không scope chỉ được auth middleware dùng — đây là query duy nhất chạy hợp lệ trước khi biết tenant, và nó trả về tenant để scope theo.
  10. Viết internal/server/prelimit.go: một limiter chia shard, được quét TTL, khóa theo (client IP, lookup prefix), chạy trước mọi bước phân giải key và từ chối bằng 429 cộng hango.auth.prelimit.rejections.
  11. Viết internal/server/auth_middleware.go: limiter trước xác thực, tách bearer, ResolveKey theo lookup, kiểm tra hash bằng subtle.ConstantTimeCompare, từ chối key đã revoke/hết hạn, dựng Principal, đặt store.Scope vào request context, cập nhật last_used_at tối đa một lần mỗi HANGO_KEY_LAST_USED_INTERVAL cho mỗi key qua một goroutine kiểu safego (được giới thiệu đúng nghĩa ở Phase 6; ở đây một goroutine thường có recover là chấp nhận được và được đánh dấu). Không có đường so sánh giá trị env: bootstrap key xác thực như một dòng key được lưu thông thường sau lần khởi động đầu.
  12. Viết bước bootstrap trong internal/app: khi khởi động, nếu chưa có platform user và HANGO_BOOTSTRAP_ADMIN_KEY được đặt, tạo platform user và dòng key của nó trong một transaction dưới một scope dựng cho tenant của platform user (cùng mẫu mà Phase 16 biến thành WithTenantTx), chỉ log lookup prefix của key, và đánh dấu bootstrap hoàn tất. Idempotent qua các lần khởi động lại.
  13. Viết internal/server/authz.go là ma trận route sang role ở trên cộng Authorize(principal, route, target) error, và viết các admin handler dựa trên nó. POST /v1/admin/tenants yêu cầu KindPlatform; tạo key là chính mình hoặc role cao hơn; revoke là owner/admin cho mọi key trong tenant và chỉ chính mình cho member. Trả plaintext key đúng một lần trong response tạo cùng ghi chú "store this now". Mỗi handler này gọi SetAuditAction với tenant.create, user.create, api_key.create hoặc api_key.revoke.
  14. Viết `internal/store/audit.go` và `internal/store/pg/audit.go`: kiểu `AuditEvent`, tập đóng các hằng số tên action, chỉ `Append` và `List`, mọi câu lệnh mang `tenant_id = $n` từ scope, và không có câu lệnh `UPDATE` hay `DELETE` nào trong file. `List` áp dụng các bộ lọc `since`, `action` và `target_id` và, với scope `member`, thêm `actor_id = scope.UserID`.
  15. Viết `internal/server/audit.go`: `SetAuditAction(r, action, targetKind, targetID)` điền một khai báo mà middleware đã đặt lên request context dưới dạng con trỏ trước khi gọi handler (không thể đổi request context từ bên trong handler), và middleware chạy handler, rồi — chỉ với `POST`, `PUT`, `PATCH` và `DELETE` — dựng dòng từ principal, khai báo, status của response lấy từ writer ghi nhận status của Phase 1 (2xx thành `ok`, 401/403 thành `denied`, còn lại thành `error`) và `request_id`, `trace_id` cùng `span_id` của span đang hoạt động, rồi append nó. Với việc tạo tenant, handler đã dựng một scope cho tenant vừa tạo (yêu cầu ở trên), và middleware ghi dòng dưới scope đó; với một nỗ lực cross-tenant bị từ chối thì nó ghi dưới tenant của người gọi. Lỗi ghi được log và đếm trên `hango.audit.write_failures` và không bao giờ được truyền ra response. Sau đó viết `handlers_audit.go` cho `GET /v1/audit-events` chỉ đọc.
  16. Viết internal/store/pg/main_test.go: TestMain thoát sớm dưới -short; ngược lại nó dùng HANGO_TEST_DB_DSN khi được đặt, nếu không thì khởi động hoặc tái dùng container testcontainers postgres có tên, rồi tạo hango_test_pg, chạy migration vào đó, chạy test và xóa database. Giữ ở khoảng 20 dòng để việc sao chép vào các package sau vẫn rẻ.
  17. Viết tenancy_test.go: tạo tenant A và B, mỗi tenant có một user và một key, cộng hai user (owner và member) bên trong tenant A; với mỗi bảng thuộc tenant, chèn một dòng dưới A và khẳng định các lần đọc dưới scope của B trả về không dòng nào và ErrNotFound khi get-by-id, và khẳng định member của A không thể đọc hay sửa một dòng do user khác tạo trong khi owner và admin của A thì có thể. Cấu trúc nó thành một bảng các closure theo từng bảng, với cả case cross-tenant lẫn case cross-user trong cùng tenant, để các Phase 4, 5, 6, 8, 13, 15 chỉ việc thêm entry. Các entry của riêng bảng này cho giai đoạn này là users, api_keys và audit_events.
  18. Nối dây internal/app, thêm check readiness của DB, cập nhật compose/env/docs, chạy toàn bộ cổng kiểm (go test ./..., nay đã gồm các integration test) và go test -short ./... để xác nhận đường bỏ qua.

Dàn ý tài liệu học

docs/learning/02-persistence-and-tenancy.md phải có:

  • Khái niệm: multi-tenancy logic, scoping fail-closed, vì sao scope thuộc về store handle chứ không thuộc từng method.
  • Các lựa chọn thiết kế: tenant_id NOT NULL ở mọi nơi, index bắt đầu bằng tenant, store.For(ctx), SHA-256 (không phải bcrypt) cho các key có entropy cao, lookup prefix để phân giải key theo O(1), goose với embed.FS.
  • Đánh đổi: scoped store so với RLS so với schema/database riêng cho mỗi tenant; SQL viết tay so với sqlc; testcontainers so với SQLite trong test; vì sao Go không có TestMain cho toàn repo và vì sao helper container 20 dòng được sao chép theo từng package thay vì nằm trong một package test dùng chung.
  • So sánh với goclaw: QueryScope.WhereClause được nối thêm cho mỗi lời gọi, tenant trong ctx được truyền cho từng method, tenant mặc định MasterTenantID, user_id dạng TEXT tự do không có FK, cache key 5 phút, ba đường xác thực song song.
  • Cách debug tầng này: một lỗi 401 được định vị bằng cách lọc Loki theo hango.auth.failures reason cộng request id; một query chậm hoặc lỗi được định vị trong Tempo bằng cách mở request span và đọc các span pgx con của nó (câu lệnh, thời lượng, lỗi); một nghi vấn lỗi cô lập được tái hiện bằng test cross-tenant và xác nhận trong Tempo bằng cách kiểm tra hango.tenant.id trên span query.
  • So sánh với deepagents: không có tenancy gì cả; phép tương tự gần nhất là namespace factory của StoreBackend (user_identity, "filesystem").
  • Vì sao hango đi khác: scope nằm trên handle (ctx truyền theo từng lời gọi của goclaw là lý do nó cần một visibility_filter.go), hango sở hữu user với FK thật, một đường xác thực.
  • Mẫu test cross-tenant và quy tắc rằng mọi bảng mới thêm một case — cả cross-tenant lẫn cross-user trong cùng tenant.
  • Mô hình phân quyền hai trục: tenancy được thực thi trong store, role và phạm vi theo người tạo nằm trong `authz.go`, và vì sao trộn hai thứ vào một tầng là cách rò rỉ xảy ra.
  • Vì sao credential gốc phải là một dòng dữ liệu chứ không phải một phép so sánh env.
  • Audit trail so với trace so với log: cái nào trả lời câu hỏi nào, và `trace_id` nối chúng trong Grafana ra sao. Dòng audit trả lời "ai đã đổi gì, và có được phép không", trace trả lời "hệ thống đã làm gì và mỗi bước mất bao lâu", log trả lời "code đã nói gì khi đang làm"; `trace_id` là khóa duy nhất biến ba câu trả lời không đầy đủ thành một câu chuyện liền mạch.
  • Bài tập: xóa mệnh đề tenant_id khỏi một query và xem test cô lập thất bại; revoke một key, rồi tìm dòng api_key.revoke và mở trace của nó từ trace_id.
  • Ngân sách thời gian: 16h triển khai + 2h viết learning doc.

Tiêu chí thành công

  • go build ./... && go vet ./... && go test ./... pass khi Docker đang chạy.
  • go test ./internal/store/pg/ -run TestCrossTenant -v cho thấy một case pass cho mỗi bảng thuộc tenant.
  • ScopeFromContext trên một context trống trả về ErrNoTenant; một unit test khẳng định điều này.
  • Tạo một tenant, một user và một key qua admin API, rồi gọi GET /v1/me với key được trả về, sẽ trả về user và tenant đó.
  • Một key của tenant A dùng trên tài nguyên của tenant B trả 404, không phải 403 (không lộ sự tồn tại).
  • Key đã revoke và đã hết hạn trả 401.
  • Test ma trận authz bao phủ mọi route trong bảng ở trên cho platform/owner/admin/member, gồm cả việc một member revoke key của user khác (403) và tạo key cho user khác (403).
  • Các case trong cùng tenant ở tenancy_test.go: một member không thể đọc hay sửa một dòng do user khác trong cùng tenant tạo; owner và admin thì có thể.
  • Sau bootstrap, giá trị HANGO_BOOTSTRAP_ADMIN_KEY chỉ được chấp nhận vì dòng key của nó tồn tại; revoke dòng đó trả 401 ở request kế tiếp (test).
  • Các đợt request chưa xác thực dồn dập bị giới hạn trước khi phân giải key: N+1 request key không tồn tại từ một IP tạo ra N lần tính hash và một 429 (test), và last_used_at được ghi tối đa một lần mỗi khoảng cho mỗi key (test).
  • Tạo một tenant, một user và một key rồi revoke key đó tạo ra bốn dòng audit_events với các action tenant.create, user.create, api_key.create, api_key.revoke, mỗi dòng mang một trace_id không rỗng khớp với trace của request tạo ra nó (integration test).
  • Một thao tác thay đổi dữ liệu bị từ chối (một member revoke key của user khác) ghi một dòng với outcome = 'denied' dưới tenant của người gọi; một GET không ghi dòng nào.
  • Khi audit store bị ép thất bại, request thay đổi dữ liệu vẫn trả status bình thường và hango.audit.write_failures tăng (test).
  • GET /v1/audit-events trả toàn tenant cho owner/admin và chỉ các dòng có actor_id của chính người gọi cho một member; không có route nào cập nhật hay xóa một dòng audit (khẳng định trên bảng route).
  • go test ./... chạy các integration test trên các database theo từng package; go test -short ./... bỏ qua chúng; bộ test của hai package có thể chạy song song mà không gây nhiễu nhau.
  • go list -deps ./cmd/hango không liệt kê package testcontainers nào: binary production không link thư viện test container.
  • Trong Grafana, trace của một request cho thấy các span pgx CLIENT con cho mỗi query, và mọi span cùng dòng log trên trace đó đều mang hango.tenant.id.
  • docker compose up áp dụng migration trên một volume mới; /readyz báo check DB.

Bằng chứng, 2026-10-15: go build, go vet, go test -race ./... (Docker, ba package database chạy song song) và go test -short ./... đều pass; golangci-lint run báo 0 vấn đề; go list -deps ./cmd/hango không có package testcontainers. docker compose up --build trên một volume mới đã áp dụng 1 migration và bootstrap platform; /readyz trả {"checks":{"database":"ok"},"ready":true}; khởi động lại là idempotent. Tempo cho thấy các span pgx CLIENT mang hango.tenant.id dưới span của request (lần tra key trước auth không mang, theo thiết kế), và dòng log request của Loki mang hango_tenant_id và hango_user_id. Ba lần chạy hango migrate đồng thời trên một database trống cho ra một applied=1 và hai applied=0. helm lint --strict và một dry run phía server trên rancher-desktop đều pass.

Các sai lệch, được quyết định trong lúc triển khai và review:

  • Pre-auth limiter được khóa chỉ theo IP của client, không theo IP kèm lookup. Khi khóa theo lookup, kẻ tấn công gửi mỗi lần một key bịa khác nhau sẽ có một bucket mới cho mỗi request, nên số lần tra database là không giới hạn, và ai biết lookup của một key thật có thể làm cạn bucket của key đó. Một key đã xác thực trong mười phút gần nhất được đi qua dù bucket rỗng, để một client xấu dùng chung địa chỉ không khóa được một client đang dùng tốt; map bucket bị chặn ở 16 x 4096.
  • Platform chỉ được tạo key cho một owner, và chỉ khi tenant chưa có key owner nào còn dùng được (chưa bị revoke, chưa hết hạn, owner còn active). Điều này bao phủ việc onboarding và khôi phục một tenant bị khóa ngoài. Các kiểm tra “user đầu tiên” và “không có key owner nào dùng được” cùng các thao tác ghi của chúng chạy trong một transaction giữ khóa for no key update trên dòng tenant, nên các request đồng thời không thể cùng vượt qua.
  • Bên trong tenant của chính mình, platform user được coi là owner của tenant đó, nên nó có thể rotate và revoke key của chính mình. Khi nó không còn key dùng được, lúc khởi động sẽ lưu một HANGO_BOOTSTRAP_ADMIN_KEY mới cho nó; một key đã revoke không bao giờ được hồi sinh.
  • Khi platform thao tác trong một tenant của khách hàng, span và dòng log mang hango.acting_tenant.id.
  • Một nỗ lực bị từ chối của platform được audit trong tenant platform; chỉ nỗ lực được cho phép mới được ghi vào tenant mà nó đã thao tác.
  • Test helper là một package dùng chung, internal/store/pg/pgtest, chỉ được import bởi các file test, thay vì một bản sao cho mỗi package. Một bản sao duy nhất không thể lệch nhau, và go list -deps ./cmd/hango chứng minh nó nằm ngoài binary.
  • Dockerfile giờ sao chép migrations/, thứ mà bước build image cần cho embed.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngDấu hiệuCách xử lý đã quyết định trước
Một query về sau quên tenant_idMedium x CriticalTest cross-tenant thất bại, hoặc review bắt được SQL thô nằm ngoài scopedMọi SQL thuộc tenant nằm trong internal/store/pg sau scoped; một test cấp package grep các package anh em tìm cách dùng pgx và thất bại nếu tìm thấy
ResolveKey (không scope) trở thành lối thoát tổng quátMedium x HighPackage khác gọi nóGiữ nó không export ra ngoài pg, trừ một interface AuthLookup chỉ auth middleware dùng; được ghi trong learning doc
testcontainers chậm hoặc không có trong CIMedium x MediumIntegration test hết giờChặn integration test bằng cách bỏ qua testing.Short(); tái dùng container theo tên nghĩa là một server cho mỗi máy, không phải một cho mỗi package; CI có thể đặt HANGO_TEST_DB_DSN tới một service container và helper bỏ qua việc khởi động container
Các helper main_test.go được sao chép dần lệch nhauMedium x LowTest của một package dùng image hoặc đường migration khácMỗi bản sao dài khoảng 20 dòng và mở đầu bằng cùng một comment nêu tên container; learning doc giải thích vì sao nó được sao chép chứ không dùng chung
Integration test của hai package tranh nhau một databaseMedium x MediumLỗi chập chờn liên quan truncateDatabase theo từng package được tạo trong TestMain; không có bước truncate dùng chung
Bootstrap admin key rò qua log hoặc file composeLow x CriticalKey hiện trong docker compose logsKhông bao giờ log key hay bất kỳ bearer nào; .env.example đi kèm giá trị giữ chỗ; log khởi động chỉ ghi lookup prefix; key có thể revoke vì bootstrap lưu nó như một dòng key thông thường
Một member đọc hoặc phá dữ liệu của user khácHigh x HighCase cùng tenant trong bộ test cô lập thất bạiKhả năng nhìn theo người tạo cho member, khả năng nhìn toàn tenant cho owner/admin, được thực thi trong authz.go và trong các predicate của store, được chặn bởi các case test cùng tenant
Việc ghi audit làm gấp đôi khối lượng ghi của mọi thao tác thay đổiCertain x Lowaudit_events lớn dần theo lượng requestChỉ các request thay đổi dữ liệu được audit, dòng nhỏ và ít index, và việc ghi là best-effort sau response; retention là một việc theo dõi đã được ghi của Phase 16 chứ không phải một đường xóa âm thầm
Một handler quên SetAuditAction, tạo ra một dòng không quy đượcMedium x MediumCác dòng có action rỗngMiddleware log và đếm một request thay đổi dữ liệu không khai báo action, và tiêu chí thành công của từng giai đoạn nêu đúng các action mà handler của giai đoạn đó phải đặt
detail tích tụ secret hoặc nội dung promptMedium x HighVật liệu key hoặc văn bản message trong một dòng auditdetail được handler dựng từ một allowlist field tường minh, và danh sách IsContentAttr của Phase 1 cộng một test trên các fixture audit giữ nội dung ở ngoài
Nghiên cứu gợi ý hoãn tenancyn/a x CriticalCám dỗ “thêm tenant_id sau”Quyết định đã chốt: tenant_id ngay trên bảng đầu tiên; phần dư thừa do goclaw vá muộn là bằng chứng được trích dẫn

Rollback

goose down về 00000 xóa các bảng định danh. Hoàn tác giai đoạn này để nguyên server của Phase 1 vì DB pool, migration và auth middleware được dựng trong internal/app sau một cờ cấu hình duy nhất (HANGO_DATABASE_URL rỗng thì các route định danh không được mount).