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
goosechạy các migration được nhúng khi khởi động (có thể tắt qua cấu hình) và quahango migrate.tenants,users,api_keystồn tại với schema đã chốt; user thuộc phạm vi tenant với roleowner|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ànhPrincipal{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). /readyzcó 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.
| Route | platform | owner | admin | member |
|---|---|---|---|---|
POST /v1/admin/tenants | allow | deny | deny | deny |
POST /v1/admin/tenants/{id}/users | allow (chỉ bootstrap owner) | allow (tenant của mình) | allow (tenant của mình) | deny |
POST /v1/users/{id}/api-keys | allow (chỉ bootstrap) | allow | allow (target là member hoặc chính mình) | allow (chỉ chính mình) |
DELETE /v1/api-keys/{id} | deny | allow (mọi key trong tenant) | allow (mọi key trong tenant) | allow (chỉ key của mình) |
GET /v1/me | allow (platform identity) | allow | allow | allow |
GET /v1/audit-events | deny | allow (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 NULLvà 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àopgxpool.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.WithIncludeQueryParametersgiữ ở 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ẵnhango.tenant.idvà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.idvàhango.tenant.trace_llm_contentlê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): counterhango.auth.failuresvới attributereason(unknown_key,revoked,expired,bad_hash,pre_auth_rate_limited), counterhango.auth.prelimit.rejections, gaugehango.db.migrations.appliedđược đặt lúc khởi động, counterhango.audit.events(attributeaction,outcome) và counterhango.audit.write_failures. Phase 14 cảnh báo dựa trên tỷ lệ lỗi xác thực; chiềureasoncủ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
/readyzchạ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 codecjsonb),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—TestMainkhởi động hoặc tái dùng container testcontainerspostgrescó tên (hoặc dùngHANGO_TEST_DB_DSNkhi được đặt), tạo và migratehango_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.gonà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ậtlast_used_atcó 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_DSNchỉ được đọc bởi các helpermain_test.go, không bao giờ bởiconfig.Load; nó được ghi tài liệu trongREADME.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
- Thêm dependency:
github.com/jackc/pgx/v5,github.com/pressly/goose/v3. - Tạo
migrations/với00001_identity.sql(các phần-- +goose Up/-- +goose Downcủa goose) vàmigrations.goxuất//go:embed *.sqldưới tênFS. - Viết
internal/store/pg/pool.go: parseHANGO_DATABASE_URL, đặtMaxConns,MaxConnLifetime,HealthCheckPeriod; trả về*pgxpool.Pool. - Viết
internal/store/pg/migrate.go:goose.SetBaseFS(migrations.FS),goose.SetDialect("postgres"),goose.Up(stdlibDB, ".")được mở quastdlib.OpenDBFromPool. - Viết
internal/store/context.go:Scope,WithScope,ScopeFromContexttrả vềErrNoTenantkhi thiếu hoặc bằnguuid.Nil()(uuid.Nilcủ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ằnguuid.NewV7. Unit test cả hai nhánh.Rolekhông có giá trị zero hợp lệ: các hằng số làowner,adminvàmember, role rỗng là không hợp lệ, vàScopeFromContexttrả vềErrNoTenantcho 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. - Viết
internal/store/pg/scoped.go: một structscopedgiữ querier và scope. CàiUsers()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. - Viết tenant admin store (không scope, chỉ cho platform):
CreateTenant,GetTenantBySlug,ListTenants. - Viết
internal/auth/key.go:GenerateKeyđọc 32 byte từcrypto/rand, mã hóa base58, định dạnghango_{slug8}_{body};HashKeytrả 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ằngsubtle.ConstantTimeCompare. - Viết
APIKeyStore.Create/List/Revoke(có scope) và mộtResolveKey(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. - 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ộnghango.auth.prelimit.rejections. - Viết
internal/server/auth_middleware.go: limiter trước xác thực, tách bearer,ResolveKeytheo lookup, kiểm tra hash bằngsubtle.ConstantTimeCompare, từ chối key đã revoke/hết hạn, dựngPrincipal, đặtstore.Scopevào request context, cập nhậtlast_used_attối đa một lần mỗiHANGO_KEY_LAST_USED_INTERVALcho mỗi key qua một goroutine kiểusafego(đượ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. - 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ànhWithTenantTx), 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. - Viết
internal/server/authz.golà ma trận route sang role ở trên cộngAuthorize(principal, route, target) error, và viết các admin handler dựa trên nó.POST /v1/admin/tenantsyêu cầuKindPlatform; 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ọiSetAuditActionvớitenant.create,user.create,api_key.createhoặcapi_key.revoke. - 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`.
- 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.
- Viết
internal/store/pg/main_test.go:TestMainthoát sớm dưới-short; ngược lại nó dùngHANGO_TEST_DB_DSNkhi được đặt, nếu không thì khởi động hoặc tái dùng container testcontainerspostgrescó tên, rồi tạohango_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ẻ. - 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 (ownervà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àErrNotFoundkhi get-by-id, và khẳng địnhmembercủa A không thể đọc hay sửa một dòng do user khác tạo trong khiownervàadmincủ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_keysvàaudit_events. - 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ớiembed.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ó
TestMaincho 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 địnhMasterTenantID,user_iddạ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.failuresreason 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 trahango.tenant.idtrê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_idkhỏi một query và xem test cô lập thất bại; revoke một key, rồi tìm dòngapi_key.revokevà 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 -vcho thấy một case pass cho mỗi bảng thuộc tenant. -
ScopeFromContexttrê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/mevớ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
memberrevoke 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ộtmemberkhông thể đọc hay sửa một dòng do user khác trong cùng tenant tạo;ownervàadminthì có thể. - Sau bootstrap, giá trị
HANGO_BOOTSTRAP_ADMIN_KEYchỉ đượ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_eventsvới các actiontenant.create,user.create,api_key.create,api_key.revoke, mỗi dòng mang mộttrace_idkhô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
memberrevoke key của user khác) ghi một dòng vớioutcome = 'denied'dưới tenant của người gọi; mộtGETkhô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_failurestăng (test). -
GET /v1/audit-eventstrả toàn tenant choowner/adminvà chỉ các dòng cóactor_idcủa chính người gọi cho mộtmember; 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/hangokhông liệt kê packagetestcontainersnà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;/readyzbá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 updatetrê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_KEYmớ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/hangochứng minh nó nằm ngoài binary. Dockerfilegiờ sao chépmigrations/, thứ mà bước build image cần choembed.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Cách xử lý đã quyết định trước |
|---|---|---|---|
Một query về sau quên tenant_id | Medium x Critical | Test cross-tenant thất bại, hoặc review bắt được SQL thô nằm ngoài scoped | Mọ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át | Medium x High | Package 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 CI | Medium x Medium | Integration 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 nhau | Medium x Low | Test của một package dùng image hoặc đường migration khác | Mỗ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 database | Medium x Medium | Lỗi chập chờn liên quan truncate | Database 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 compose | Low x Critical | Key hiện trong docker compose logs | Khô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ác | High x High | Case cùng tenant trong bộ test cô lập thất bại | Khả 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 đổi | Certain x Low | audit_events lớn dần theo lượng request | Chỉ 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 được | Medium x Medium | Các dòng có action rỗng | Middleware 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 prompt | Medium x High | Vật liệu key hoặc văn bản message trong một dòng audit | detail đượ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 tenancy | n/a x Critical | Cá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).