2. Persistence và Tenancy
Persistence và Tenancy
Giai đoạn 2 cho hango một bộ nhớ và một cánh cổng. Nó lưu tenant, user và API key trong Postgres, mỗi request đều phải xuất trình một key, và mọi query mà request thực hiện đều bị khóa vào tenant của người gọi. Nó cũng mở đầu một audit trail: một dòng cho mỗi thay đổi mà bất kỳ ai thực hiện, kèm trace id của request đã tạo ra thay đổi đó.
Bạn sẽ học được gì
- Multi-tenancy là gì, và vì sao hango đặt tenant lên store handle thay vì truyền nó vào từng method.
- Một API key được tạo, lưu và kiểm tra như thế nào mà không bao giờ phải lưu chính key đó.
- Vì sao authorization có hai trục, và vì sao hai trục nằm ở hai nơi khác nhau.
- Audit trail, trace và log trả lời ba câu hỏi khác nhau như thế nào, và một
trace_idnối chúng lại ra sao. - Cách test isolation trên một Postgres thật, và quy tắc mà mọi bảng về sau đều phải theo.
Các từ dùng trong bài này
| Thuật ngữ | Ý nghĩa |
|---|---|
| Tenant | Một khách hàng tách biệt của hango, ví dụ một gia đình. Dữ liệu của nó không bao giờ được hiện ra với tenant khác. |
| Multi-tenancy | Một hango đang chạy phục vụ nhiều tenant. Multi-tenancy “logic” nghĩa là các tenant dùng chung một database và mỗi dòng ghi rõ tenant nào sở hữu nó. |
| Tenant-owned table | Một bảng mà các dòng thuộc về một tenant. Nó có cột tenant_id. |
| Scope | Ai đang hành động và trong tenant nào: tenant id, user id và role. |
| Fail closed | Khi có gì đó thiếu hoặc không rõ ràng thì từ chối. Ngược lại, fail open nghĩa là “cho phép khi không chắc”. |
| Migration | Một file SQL được đánh số, dùng để thay đổi schema của database. Được áp dụng theo thứ tự, mỗi file một lần. |
| Principal | Người gọi đã được xác thực của một request: user nào, tenant nào, key nào. |
| Hash | Một hàm một chiều. Bạn kiểm tra được một key có khớp với hash hay không, nhưng không thể suy ngược ra key từ hash. |
| Audit trail | Một bản ghi chỉ thêm vào (append-only) về ai đã thay đổi gì, và họ có được phép hay không. |
| Testcontainers | Một thư viện khởi động một Docker container thật (ở đây là Postgres) từ một test Go. |
| Advisory lock | Một khóa có tên mà Postgres cấp cho ứng dụng. Ai giữ nó thì những bên khác phải chờ. Nó không khóa bảng nào và không khóa dòng nào. |
| Token bucket | Một bộ giới hạn tốc độ. Mỗi client có một bucket chứa token, mỗi request tốn một token, và bucket được nạp lại với tốc độ đều đặn. Bucket cạn nghĩa là từ chối. |
| Entropy | Mức độ không đoán trước được của một giá trị. 256 bit nghĩa là 2^256 giá trị có khả năng như nhau, quá nhiều để đoán. |
| Base58 | Một cách viết byte thành văn bản bằng chữ số và chữ cái, bỏ các ký tự dễ nhầm 0, O, I và l. |
| Foreign key | Một cột bắt buộc phải trỏ tới một dòng có thật ở bảng khác, nên database từ chối dòng mà dòng cha của nó không tồn tại. |
| Unique index, partial index | Unique index từ chối hai dòng có cùng một giá trị. Partial index chỉ bao phủ những dòng khớp một điều kiện, ví dụ “chỉ platform tenant”. |
| Row lock | Một khóa trên một dòng. select ... for no key update khiến các transaction khác phải chờ trước khi khóa cùng dòng đó, nhưng vẫn cho phép các lệnh insert chỉ tham chiếu tới nó qua foreign key. |
| Constant-time comparison | So sánh hai bí mật trong cùng một khoảng thời gian dù có bao nhiêu byte đầu giống nhau, nên thời gian chạy không để lộ gì về bí mật. |
| Trace, span | Trace là bản ghi của một request. Span là một bước có đo thời gian bên trong nó. Tempo lưu trace và Loki lưu log. |
Vì sao tenancy đến sớm thế này
Mọi giai đoạn sau đều lưu thứ gì đó: agent, session, run, file, memory. Nếu ranh giới tenant đến sau khi các bảng đó đã tồn tại, từng bảng một phải được xem lại, và chỉ cần quên một WHERE tenant_id = ... là dữ liệu bị rò giữa các gia đình. Xây ranh giới trước thì mỗi bảng mới sinh ra đã nằm bên trong nó, và một test sẽ hỏng ngay ngày ai đó quên.
Phiên bản nhỏ nhất
Đây là toàn bộ ý tưởng trong vài dòng. Tenant không phải là một tham số của các query method. Nó được gắn vào handle ngay khi handle được tạo, và việc tạo handle sẽ thất bại nếu không có tenant.
type Scope struct{ TenantID uuid.UUID }
type ctxKey struct{}
func WithScope(ctx context.Context, s Scope) context.Context {
return context.WithValue(ctx, ctxKey{}, s)
}
type Users struct {
db *pgxpool.Pool
tenant uuid.UUID
}
// For is the only way to get a Users handle, and it needs a tenant.
func For(ctx context.Context, db *pgxpool.Pool) (*Users, error) {
s, ok := ctx.Value(ctxKey{}).(Scope)
if !ok || s.TenantID == uuid.Nil() {
return nil, errors.New("no tenant") // fail closed
}
return &Users{db: db, tenant: s.TenantID}, nil
}
// Get cannot forget the tenant: there is no way to call it without one.
func (u *Users) Get(ctx context.Context, id uuid.UUID) (string, error) {
var email string
err := u.db.QueryRow(ctx,
`select email from users where tenant_id = $1 and id = $2`, u.tenant, id).Scan(&email)
return email, err
}Người gọi không thể truyền sai tenant, vì nó không bao giờ truyền tenant. Người gọi không thể quên tenant, vì không có tenant thì nó không bao giờ nhận được handle. Mọi thứ còn lại của giai đoạn 2 đều lớn lên từ đây.
Cách hango được nối dây
Khởi động
internal/app giờ làm nhiều việc hơn trước khi server khởi động:
config.Load -> telemetry.Init -> server.New -> pg.Open -> migrate -> bootstrap -> MountAPI -> servepg.Open dựng một pgx connection pool có cài sẵn tracing và ping database. migrate áp dụng mọi file SQL trong migrations/ chưa được chạy, trừ khi HANGO_MIGRATE_ON_START là false (mặc định là true). bootstrap tạo platform operator ở lần khởi động đầu tiên. MountAPI đăng ký readiness check database và các route của API. Khi HANGO_DATABASE_URL không được đặt, hango bỏ qua cả bốn bước này và chỉ phục vụ /healthz và /readyz, nhờ đó giữ được đặc tính “chạy ở đâu cũng được” của giai đoạn 1.
URL của database chứa mật khẩu, nên nó không bao giờ được ghi vào log hay đưa vào thông báo lỗi. Log khởi động chỉ nói database_enabled=true.
Migration
Schema nằm trong migrations/00001_identity.sql, được nhúng vào binary bằng embed.FS và được goose áp dụng. Vì các file nằm trong binary, image không cần thêm file nào và schema luôn khớp với code. hango đưa cho goose một Postgres advisory lock (goose.WithSessionLocker), nên hai replica khởi động cùng lúc sẽ không cùng chạy một file. goose không khóa trừ khi bạn yêu cầu: một bản đầu của giai đoạn này đã khẳng định có khóa trong một comment mà không truyền locker, và code review đã bắt được lỗi đó. Giờ ba lệnh hango migrate khởi động cùng lúc trên một database trống cho ra một applied=1 và hai applied=0. hango migrate áp dụng các migration rồi thoát, dùng khi bạn muốn migrate trước một lần deploy thay vì lúc khởi động.
Bốn bảng:
| Bảng | Thuộc về tenant | Nó chứa gì |
|---|---|---|
tenants | không | slug, name, kind (platform hoặc customer), status |
users | có | email, role (owner, admin, member) |
api_keys | có | lookup, key_hash, last_used_at, revoked_at, expires_at |
audit_events | có | ai, key nào, hành động gì, lên đối tượng nào, kết quả, trace_id |
Mọi tenant-owned table đều có tenant_id NOT NULL, và mọi index dùng để liệt kê đều bắt đầu bằng tenant_id. Index unique api_keys.lookup thì không, vì nó phải hoạt động trước khi biết tenant. Quy tắc thứ nhất nghĩa là không thể tồn tại một dòng không có chủ. Quy tắc thứ hai nghĩa là “các dòng của tenant này” luôn là một đoạn index nhanh, không bao giờ là full scan, nên một tenant lớn không làm chậm tenant khác.
Scoped store
internal/store chứa interface, type, lỗi và các hàm hỗ trợ scope (WithScope, ScopeFromContext). internal/store/pg là bản cài đặt trên Postgres, và là package duy nhất được phép import pgx. Một test (storeboundary_test.go) sẽ hỏng nếu package nào khác import nó, nên mọi query lên tenant-owned table trong codebase đều nằm trong một thư mục, nơi test isolation bao phủ chúng.
Store gốc có ba cánh cửa:
Tenants()quản lý bảngtenants, bảng không thuộc về tenant nào. Chỉ platform dùng nó.Auth().ResolveKey(lookup)tìm một key trước khi biết tenant, vì đó chính là cách tenant được biết.For(ctx)trả về một handleScopedgắn với scope trên context. User, key và các dòng audit chỉ truy cập được qua nó.
ScopeFromContext trả về ErrNoTenant khi scope bị thiếu, tenant id là nil, hoặc role không thuộc ba role hợp lệ. Một scope dựng tay mà quên một trường sẽ không nhận được gì.
Bên trong một tenant, một member thấy ít hơn một owner. Store thu hẹp List và Revoke về các dòng của chính member đó. Get cố ý vẫn là toàn tenant: lớp authorization cần tải dòng lên để nói với member “forbidden” thay vì “not found”.
API key
Một key trông như hango_family_7Q2... (43 hoặc 44 ký tự base58 sau thẻ tenant). Nó được tạo từ 32 byte ngẫu nhiên, nên có 256 bit entropy. hango lưu hai thứ:
lookup: 8 ký tự đầu của phần ngẫu nhiên, có một index unique. Phân giải một key chỉ là một lần tra index, dù có bao nhiêu key đi nữa.key_hash: SHA-256 của toàn bộ key, được so sánh trong thời gian hằng số (constant time).
Vì sao SHA-256 mà không phải bcrypt? bcrypt chậm có chủ đích, để bảo vệ mật khẩu của con người vốn ngắn và dễ đoán. Một key ngẫu nhiên 256 bit thì không thể đoán, nên sự chậm chạp chẳng mang lại gì và sẽ khiến mỗi request tốn thêm hàng chục mili giây. Key dạng plaintext chỉ xuất hiện một lần, trong response đã tạo ra nó, và không ở đâu khác: không trong database, không trong log, không trong span.
Authentication middleware
Với mỗi API request, authenticator chạy các bước sau theo thứ tự:
- Pre-auth limiter. Một token bucket cho mỗi IP của client. Chỉ các lần thất bại mới tốn token. Khi bucket cạn, request nhận
429trước cả khi có query database nào, nên một cơn lũ key sai không thể làm nặng database, dù nó thử bao nhiêu key khác nhau. Một key đã xác thực trong mười phút gần nhất vẫn đi qua được bucket cạn, nên một client xấu đứng sau cùng một địa chỉ (một router gia đình, một ingress) không thể khóa ngoài một chiếc điện thoại đang dùng bình thường. Limiter giữ tối đa 65.536 bucket, nên một cơn lũ từ nhiều địa chỉ cũng không làm phình bộ nhớ của nó. - Tra key theo
lookupcủa nó. - So sánh hash, rồi kiểm tra key chưa bị revoke, chưa hết hạn, và user cùng tenant đều đang active.
- Khi thành công, đặt
Principalvà store scope vào context, đồng thời đặt tenant id và user id lên span, vào log và vào mọi span con. - Ghi
last_used_atở chế độ nền, tối đa một lần mỗi key mỗi phút.
Mọi thất bại đều trả cùng một 401 missing or invalid API key. Lý do (unknown_key, revoked, expired, …) đi vào metric hango.auth.failures và log, không bao giờ tới client, để kẻ tấn công không biết được key nào tồn tại.
Hai trục của authorization
“Người gọi này có được làm việc này không?” chứa hai câu hỏi riêng biệt:
- Nó với tới được tenant nào? Store trả lời câu này. Một scoped handle chỉ thấy tenant của chính nó.
- Nó được làm gì bên trong tenant đó?
internal/server/authz.gotrả lời câu này, từ một bảng duy nhất có một dòng cho mỗi route và một cột cho mỗi role.
| Route | Platform | Owner | Admin | Member |
|---|---|---|---|---|
POST /v1/admin/tenants | có | không | không | không |
POST /v1/admin/tenants/{id}/users | chỉ owner đầu tiên | có | không phải owner | không |
POST /v1/users/{id}/api-keys | cho một owner, khi tenant chưa có key owner nào còn dùng được | có | cho chính mình hoặc một member | cho chính mình |
DELETE /v1/api-keys/{id} | không | có | có | key của chính mình |
GET /v1/me | có | có | có | có |
GET /v1/audit-events | không | toàn tenant | toàn tenant | các dòng của chính mình |
Cột Platform áp dụng cho các customer tenant. Bên trong platform tenant của chính nó, platform user là owner của tenant đó và dùng cột Owner, nên nó có thể rotate và revoke key của chính mình và đọc audit trail của chính mình.
Một đối tượng ở tenant khác luôn là 404, không bao giờ là 403. Một 403 sẽ xác nhận rằng id đó tồn tại ở đâu đó. Trộn hai trục vào một lần kiểm tra là cách rò rỉ xảy ra: một kiểm tra role mà cũng cố làm luôn việc kiểm tra tenant rồi sẽ bỏ sót một trường hợp mà không ai nghĩ tới. Giữ hai trục tách biệt nghĩa là mỗi cái đều test riêng được, và một lỗi ở cái này vẫn bị cái kia bắt.
Platform operator và bootstrap key
Phải có ai đó tạo tenant đầu tiên. Đó là platform operator, user owner của tenant duy nhất có kind là platform. Một unique partial index chỉ cho phép tối đa một tenant như vậy.
Ở lần khởi động đầu tiên trên một database trống, hango đọc HANGO_BOOTSTRAP_ADMIN_KEY và, trong một transaction, tạo platform tenant, user [email protected] và một dòng key chứa hash của key đó. Sau đó biến này bị bỏ qua chừng nào platform còn một key dùng được, nên việc revoke hoặc rotate key của platform qua API sẽ được giữ nguyên qua các lần khởi động lại.
Nếu platform không còn key dùng được nào (mọi key đều bị revoke hoặc hết hạn), hango không thể quản trị được nữa. Để phục hồi, đặt một HANGO_BOOTSTRAP_ADMIN_KEY mới và khởi động lại: lúc khởi động, hango lưu key mới cho platform owner. Nó không bao giờ hồi sinh một key đã bị revoke, nên khởi động lại với giá trị cũ sẽ ghi một cảnh báo và không thay đổi gì.
Vì sao là một dòng chứ không phải một kiểm tra đơn giản kiểu “nếu header bằng biến môi trường”? Vì một dòng có thể bị revoke, có thể hết hạn, có last_used_at, xuất hiện trong audit trail và đi qua đúng cùng một đường code như mọi key khác. So sánh với biến môi trường sẽ là một đường xác thực thứ hai, đặc biệt, bỏ qua tất cả những thứ đó, và revoke nó sẽ cần một lần deploy lại.
Quyền của platform bên trong một customer tenant được giữ ở mức tối thiểu mà việc onboarding cần: user đầu tiên của tenant, với vai trò owner, và một key cho một owner khi tenant chưa có key owner nào còn dùng được. Quy tắc thứ hai bao phủ onboarding và phục hồi: nếu các owner làm mất hoặc revoke mọi key, platform có thể cấp cho một trong số họ một key mới. Khi một key owner còn dùng được, tenant tự quản lý chính mình, và platform nhận 403 nếu nó thử tạo key cho một user do owner thêm vào. Mỗi kiểm tra là “đã có cái nào chưa?” rồi tới một lần ghi, và hai request cùng lúc có thể cùng thấy “chưa”. Vì vậy handler chạy kiểm tra và lần ghi trong một transaction, transaction này khóa dòng tenant trước (select ... for no key update); request thứ hai phải chờ, rồi thấy dòng của request thứ nhất. for no key update là khóa yếu nhất vẫn bắt request thứ hai phải chờ, nên nó không chặn các lần insert chỉ tham chiếu tới tenant qua foreign key.
Khi platform hành động bên trong một customer tenant, span và dòng log của request mang hango.acting_tenant.id bên cạnh hango.tenant.id. hango.tenant.id vẫn là tenant của chính platform, tức danh tính của người gọi, còn attribute acting cho bạn biết nó đã chạm tới tenant nào.
Audit trail
Mọi POST, PUT, PATCH và DELETE trên API từ một người gọi đã xác thực đều ghi một dòng audit_events sau khi handler của nó trả về. Handler nói nó đã làm gì bằng SetAuditAction(r, "api_key.revoke", "api_key", id), và gọi hàm này trước bước kiểm tra authorization, nên một nỗ lực bị từ chối cũng được ghi lại cùng hành động của nó. Middleware quyết định kết quả từ status của response: 2xx là ok, 401 hoặc 403 là denied, mọi thứ khác là error. Các request GET không ghi gì.
Hai quy tắc giữ cho trail đáng tin. Nó là append-only: store không có câu lệnh UPDATE hay DELETE nào cho bảng này, và không route nào thay đổi nó; các test kiểm tra cả hai điều đó. Và một lần ghi audit thất bại không bao giờ làm hỏng request của người dùng: nó được ghi log và đếm trên hango.audit.write_failures, metric mà bạn có thể đặt cảnh báo.
Cách xem server đang làm gì
Khởi động stack với một bootstrap key:
echo "HANGO_BOOTSTRAP_ADMIN_KEY=hango_platform_$(openssl rand -base64 48 | tr -dc '1-9A-HJ-NP-Za-km-z' | head -c 44)" >> .env
make compose-up
set -a; . ./.env; set +a
K=$HANGO_BOOTSTRAP_ADMIN_KEYTạo một tenant, owner của nó và key của owner:
TID=$(curl -s -H "Authorization: Bearer $K" -d '{"slug":"family","name":"Family"}' localhost:8080/v1/admin/tenants | jq -r .id)
UID_=$(curl -s -H "Authorization: Bearer $K" -d '{"email":"[email protected]","role":"owner"}' localhost:8080/v1/admin/tenants/$TID/users | jq -r .id)
OWNER=$(curl -s -H "Authorization: Bearer $K" -d '{"name":"phone"}' localhost:8080/v1/users/$UID_/api-keys | jq -r .key)
curl -i -H "Authorization: Bearer $OWNER" localhost:8080/v1/me
curl -s -H "Authorization: Bearer $OWNER" localhost:8080/v1/audit-events | jq '.events[] | {action, outcome, trace_id}'Chép X-Trace-Id của response /v1/me vào Grafana → Explore → Tempo. Dưới server span GET /v1/me, bạn thấy một CLIENT span cho mỗi query và mỗi lần pool acquire, mỗi span kèm câu lệnh SQL của nó. Mọi span sau bước xác thực đều mang hango.tenant.id; riêng lần tra key thì không, vì nó chạy trước khi biết tenant.
Audit, trace và log: ba câu hỏi, một chìa khóa
| Tín hiệu | Câu hỏi nó trả lời | Ở đâu |
|---|---|---|
| Dòng audit | Ai đã thay đổi gì, và có được phép không? | GET /v1/audit-events, được giữ lâu bằng thời gian bạn giữ database |
| Trace | Hệ thống đã làm gì, và mỗi bước mất bao lâu? | Tempo |
| Log | Code đã nói gì trong lúc làm việc đó? | Loki |
Mỗi thứ riêng lẻ chỉ là một câu trả lời một phần. Dòng audit mang trace_id của request đã tạo ra nó, và mọi dòng log mang cùng id đó, nên một id biến ba thứ thành một câu chuyện: đọc dòng audit, dán trace_id của nó vào Tempo để xem các query, rồi lọc Loki theo trace_id="..." để đọc những gì code đã ghi log.
Cách debug lớp này
Một request trả về 401
Client luôn thấy cùng một thông báo, nên hãy nhìn phía server. Trong Grafana, metric hango_auth_failures_total có label reason. Trong Loki, lọc theo request_id của request và dòng authentication failed cho thấy lý do: missing_key (không có bearer token), malformed_key (không có dạng của một key hango, thường do gõ nhầm), unknown_key (đúng dạng nhưng không có key nào như vậy), bad_hash (đúng tiền tố, sai phần còn lại), revoked, expired hoặc inactive. Một 429 được đếm với reason pre_auth_rate_limited và là do pre-auth limiter: cùng một địa chỉ đã gửi quá nhiều key sai; mặc định nó cho phép một burst 10 lần thất bại và nạp lại 20 mỗi phút (HANGO_PREAUTH_BURST, HANGO_PREAUTH_RATE_PER_MINUTE).
Một request chậm hoặc lỗi database
Mở trace của request trong Tempo. Các span con của pgx cho thấy từng câu lệnh, mất bao lâu và lỗi của nó. Một span pool.acquire dài nghĩa là mọi connection đều đang bận: hãy tăng HANGO_DB_MAX_CONNS hoặc tìm các query chậm đang giữ connection.
hango không khởi động
Một HANGO_DATABASE_URL sai làm khởi động dừng với parse database url: invalid connection string (bản thân URL không bao giờ được in ra) hoặc connect to database: .... Một migration thất bại in ra lỗi của goose và tên file. /readyz trả 503 với "database":"fail" khi Postgres biến mất sau lúc khởi động.
Bạn nghi có lỗi isolation
Tái hiện nó thành một case trong internal/store/pg/tenancy_test.go, rồi xác nhận trong Tempo rằng span query của request mang đúng hango.tenant.id mà bạn mong đợi.
Test trên database thật
Các đảm bảo về isolation nằm ở tầng SQL, nên chúng được test trên một Postgres thật, không phải đồ giả. internal/store/pg/pgtest khởi động một container postgres:17-alpine bằng testcontainers và dùng lại nó giữa các package và các lần chạy. Mỗi test package có database riêng (hango_test_pg, hango_test_server, hango_test_app), được xóa và tạo lại ở đầu mỗi lần chạy, nên các package chạy song song không bao giờ thấy dòng của nhau. go test -short ./... bỏ qua mọi test database, và đặt HANGO_TEST_DB_DSN sẽ trỏ các test tới một server bạn đã chạy sẵn.
Go không có TestMain dùng chung cho cả repo: mỗi package cần database có một TestMain một dòng gọi pgtest.Main. Helper này là một package bình thường, không phải file _test.go, nên chia sẻ được, và chỉ các file test mới import nó. go list -deps ./cmd/hango không hiện package testcontainers nào, nên thư viện container không bao giờ lọt vào binary.
Trên Rancher Desktop, testcontainers cần DOCKER_HOST=unix://$HOME/.rd/docker.sock và TESTCONTAINERS_RYUK_DISABLED=true.
Bảng isolation
tenancy_test.go giữ một entry cho mỗi tenant-owned table. Mỗi entry biết cách tạo một dòng và, khi bảng hỗ trợ, đọc nó theo id, liệt kê các dòng và thay đổi một dòng. Với mỗi entry, test kiểm tra:
- Cross-tenant: dưới bất kỳ role nào của tenant B, dòng của tenant A không được liệt kê, get-by-id là
ErrNotFound, và một thay đổi làErrNotFound. - Cross-user: member của tenant A không liệt kê hay thay đổi được dòng thuộc về owner của A; owner và admin của A thì được.
Quy tắc cho mọi giai đoạn sau: một tenant-owned table mới thêm một entry vào đây, trong cùng pull request tạo ra bảng đó.
Đánh đổi
- Scoped store so với Postgres row-level security (RLS) so với một schema hoặc database cho mỗi tenant. RLS thực thi ranh giới ngay trong Postgres, mạnh hơn, nhưng nó cần một session variable được đặt ở mỗi lần lấy connection và dễ bị vô tình qua mặt bằng một connection superuser. Schema riêng cho từng tenant nhân số migration lên theo số tenant. Một scoped store kèm bảng test thì đơn giản, hiện rõ trong code review và đủ nhanh cho các gia đình. RLS có thể được thêm sau như bức tường thứ hai mà không đổi store interface.
- SQL viết tay so với sqlc. sqlc sinh code có kiểu từ SQL, nhưng mọi query ở đây đều cần điều kiện tenant từ handle và một vài query được dựng từ các bộ lọc. SQL viết tay trong một package giữ cho điều đó hiện rõ. Hãy xem lại khi có hàng chục query. Giai đoạn 2.1 đã xem lại sớm, để học sqlc khi store còn nhỏ: xem Chuyển store sang sqlc.
- Testcontainers so với SQLite trong test. SQLite sẽ nhanh hơn, nhưng các lỗi mà giai đoạn này canh chừng (thiếu một điều kiện, một unique index, một partial index, hành vi của
on delete) là hành vi của Postgres. Một test pass trên database khác chứng minh được rất ít. - Một package
pgtestdùng chung so với chép helper vào từng package. Kế hoạch dự kiến một bản chép cho mỗi package. Một package dùng chung nhỏ dướiinternal/store/pggiữ một bản duy nhất, và kiểm tra binary ở trên chứng minh nó không lọt vào production. - Ghi audit đồng bộ. Ghi dòng sau handler thêm vài mili giây cho mỗi thay đổi. Đổi lại, dòng đó tồn tại trước request kế tiếp, giúp trail dễ test. Thay đổi hiếm so với lượt đọc, và lượt đọc không ghi gì.
goclaw và deepagents làm việc này thế nào
goclaw (~/ws/personal/goclaw) cũng là multi-tenant, nhưng tenant đi theo cách khác. Một QueryScope (internal/store/scope.go) tạo ra một WhereClause mà mỗi query tự nối vào bằng tay, và tenant được đọc từ context bên trong từng method. Một MasterTenantID mặc định được điền vào khi không có tenant nào được đặt, tức là fail open. User id là text tự do, không có foreign key tới bảng users. API key được cache năm phút (newAPIKeyCache(ms, 5*time.Minute)), nên một key bị revoke vẫn dùng được tới năm phút, và có ba đường xác thực riêng biệt. Vì mỗi query tự thêm bộ lọc của mình, về sau goclaw cần thêm một lớp visibility filter riêng để giữ chúng nhất quán.
langchain deepagents hoàn toàn không có tenancy. Ý tưởng gần nhất là namespace factory của StoreBackend, thêm tiền tố (user_identity, "filesystem") vào các file được lưu.
hango khác biệt có chủ đích: scope nằm trên handle nên không query nào bỏ qua được, thiếu tenant thì fail closed thay vì lùi về một giá trị mặc định, hango sở hữu user của mình với foreign key thật, chỉ có một đường xác thực, và một key được kiểm tra với database ở mỗi request, nên revoke có hiệu lực ngay lập tức.
Bài tập
- Cố tình phá isolation. Bài tập này dùng các file query và
make generatetừ Chuyển store sang sqlc, nên hãy làm sau bài học đó. Tronginternal/store/pg/queries/api_keys.sql(từ giai đoạn 2.1; làapikey.goở giai đoạn 2), đổiwhere tenant_id = @tenant_id and id = @idcủaGetAPIKeythànhwhere @tenant_id::uuid is not null and id = @id, chạymake generate, rồi chạygo test ./internal/store/pg/ -run TestCrossTenant -v. Xem caseapi_keysthất bại, rồi khôi phục lại. - Theo dõi một thay đổi qua cả ba tín hiệu. Revoke một key bằng
curl -X DELETE -H "Authorization: Bearer $OWNER" localhost:8080/v1/api-keys/<id>, tìm dòngapi_key.revoketrong/v1/audit-events, dántrace_idcủa nó vào Tempo, rồi tìm cùng request đó trong Loki. - Làm limiter kích hoạt. Gửi 11 request, mỗi request dùng một key bịa khác nhau, và xem request thứ 11 trả
429. Sau đó gọi/v1/mevới$OWNER, key vừa xác thực cách đây ít lâu: nó vẫn hoạt động. Rồi kiểm trahango_auth_prelimit_rejections_totaltrong Grafana. - Thêm một bảng. Phác thảo một bảng
notescótenant_id, viết migration của nó và thêm entry của nó vàotenantOwnedTables. Test đã buộc bạn viết những store method nào?