Datastore (GORM + Connection Pools)¶
Frame's datastore layer provides pooled database connections and migration management on top of GORM.
Overview¶
datastore.Managermanages named pools.datastore/poolprovides GORM-backed connections and tuning.datastore/migrationsupports migration patches.tenancy/package provides pluggable RLS enforcement via providers.
Quick Start¶
_, svc := frame.NewService(
frame.WithDatastore(),
)
// default pool
if db := svc.DatastoreManager().DB(ctx, false); db != nil {
_ = db.Exec("select 1").Error
}
Configure via Environment¶
Set DATABASE_URL (and optional REPLICA_DATABASE_URL) in config. Frame auto-wires pools.
export DATABASE_URL=postgres://user:pass@host:5432/dbname?sslmode=disable
Multiple Pools¶
_, svc := frame.NewService(
frame.WithDatastoreConnectionWithName("primary", dsn, false),
frame.WithDatastoreConnectionWithName("replica", dsnReplica, true),
)
primary := svc.DatastoreManager().DBWithPool(ctx, "primary", false)
Migrations¶
When DO_MIGRATION=true, Frame creates a migration pool and runs migrations.
err := svc.DatastoreManager().Migrate(ctx, pool, "./migrations", &MyModel{})
Tuning¶
Use config or pool options:
- Max open connections
- Max idle connections
- Max connection lifetime
- Prepared statements
Tenancy¶
Tenancy enforcement lives in the top-level tenancy/ package. The
default Postgres provider installs Row-Level Security policies on every
model that satisfies tenancy.Tenanted (which data.BaseModel does
out of the box), and binds per-request tenancy state to each database
connection through pgxpool acquire/release hooks. Application code
never references tenant_id or partition_id directly.
When enforcement applies¶
| Context | FailOpen (default) | Hybrid / FailClosed (Secure Profile) |
|---|---|---|
| No / empty claims | Match-all, no error | Error ErrClaimsRequired |
Bare WithSkipEnforcement |
Match-all | Error ErrSkipNotPermitted |
Claims with TenantID |
RLS filters | RLS filters |
| Partition-only (no TenantID) | Unsafe bind | Error ErrTenantIDRequired |
WithSystemPrincipal + TenantID |
Bind principal scope | Bind principal scope |
Framework Migrate |
Match-all (elevated) | Match-all via unforgeable marker |
App Reason:"migration" alone |
N/A | Denied without allowlist/marker |
Secure Profile (recommended production):
frame.WithSecureProfile(), // Hybrid + TrustTenancyOnly + no internal Skip + …
// or piece-wise:
frame.WithTenancySecurityMode(tenancy.ModeHybrid),
frame.WithLegacyInternalSkip(false),
Env: FRAME_TENANCY_SECURITY_MODE=hybrid|fail_closed|fail_open.
Stock default remains fail-open for compatibility. See Transparent Multi-Tenant Isolation.
Partition IDs must not contain , (CSV encoding in the session GUC).
Call claims.Validate() (or let the provider do it on acquire) to catch
this early. Claims.Normalize() / WithClaims / ClaimsFromAuth trim
whitespace and dedupe partition IDs.
Policies use both USING and WITH CHECK (reads and writes).
Access ID (write attribution only)¶
access_id is the membership grant used for the current session (which
Access record the user entered through). Frame uses it for write
attribution, not for read isolation or Keto:
| Concern | Field / plane |
|---|---|
| Cross-tenant / branch isolation | tenant_id + partition_id (RLS) + Plane 1 ReBAC |
| Capabilities in that partition | Plane 2 (FunctionChecker) |
| Per-resource ACLs | Plane 3 |
| Who created the row | created_by (profile_id) |
| Which membership stamped the row | access_id |
Behaviour:
- On create,
BaseModel.GenID/BeforeCreatecopiesaccess_idfrom JWT claims when the column is empty. BaseRepositorytreatsaccess_idas immutable (withtenant_id/partition_id) so updates cannot retarget attribution.- Empty
access_idis allowed (service accounts, system jobs). - Do not add Keto tuples or RLS clauses keyed only by
access_idunless product requirements change; partition membership remains the data plane.
Interactive user tokens should carry access_id from login/consent when a
grant exists; do not invent Keto paths of the form tenant/partition/access.
Connection poolers (PgBouncer)¶
Session GUCs require session affinity to the Postgres backend:
- Direct Postgres: supported
- PgBouncer
pool_mode=session: supported - PgBouncer
pool_mode=transaction/statement: not safe with this provider
Keep Frame's pool small (DATABASE_MAX_OPEN_CONNECTIONS) and let
PgBouncer cap total backends when used.
Wiring¶
_, svc := frame.NewService(
frame.WithDatastore(), // installs default Postgres adapter + RLS provider
)
// Preferred: service-aware defaults (honours Secure Profile binder):
ix, err := svc.ConnectDefaultInterceptors(ctx, authenticator)
// …
// Or manually after auth:
options := connect.WithInterceptors(
authInterceptor,
tenancy.NewClaimsInterceptorWithBinder(svc.ClaimsBinder()),
)
Building / extending tenancy claims¶
// Claims are derived from security.AuthenticationClaims by default.
got := tenancy.ClaimsFromContext(ctx)
// For service-on-behalf-of flows, extend with additional partitions:
ctx = tenancy.WithExtraPartitions(ctx, "branch-2", "branch-3")
// For job workers, Frame queue consumers reconstruct
// security.AuthenticationClaims from publisher AsMetadata() (including
// multi-partition partition_ids). That enables RLS via ClaimsFromAuth
// when tenant/partition are present. You can also bind storage claims
// explicitly:
ctx = tenancy.WithClaims(ctx, &tenancy.Claims{
TenantID: "T1",
PartitionIDs: []string{"P1", "P2"},
AccessID: "A1",
})
// Migrations: Frame pool.Migrate elevates automatically (framework marker).
// Do not use bare WithSkipEnforcement in Hybrid — use SystemPrincipal:
ctx = tenancy.WithSystemPrincipal(ctx, tenancy.SystemPrincipal{
ServiceName: "my-svc", // must be on WithSystemPrincipalAllowGlobal
Reason: "admin_export", // logs only
AllowGlobal: true,
})
Performance: prefer the interceptor over auth-claim fallback¶
tenancy.ClaimsFromContext has a three-tier fallback:
1. Explicit *tenancy.Claims bound via tenancy.WithClaims (fastest — no allocation).
2. Derived from security.AuthenticationClaims if present (allocates a fresh *Claims every call).
3. nil if neither is bound.
The Postgres tenancy provider's connection-acquire hook calls
ClaimsFromContext on every connection acquired from the pool. For
high-throughput services, register tenancy.NewClaimsInterceptor()
after your authentication interceptor so the derived claims are bound
once per request:
options := connect.WithInterceptors(
authInterceptor,
tenancy.NewClaimsInterceptor(), // pre-binds Claims so the hot path is path 1
)
Without the interceptor, requests with auth claims still work correctly — they just pay an extra allocation per connection acquire.
Job workers: write-path tenancy¶
data.BaseModel.BeforeCreate reads from security.AuthenticationClaims
(via security.ClaimsFromContext) to populate TenantID,
PartitionID, and AccessID automatically. Job workers that build
tenancy.Claims from queue metadata without also pushing
security.AuthenticationClaims into the context must populate those
fields manually before calling repo.Create:
entity := &MyModel{
BaseModel: data.BaseModel{TenantID: msg.TenantID, PartitionID: msg.PartitionID},
// ... other fields
}
err := repo.Create(ctx, entity)
Alternatively, push an AuthenticationClaims into the context so the
BaseModel hook fires automatically:
auth := &security.AuthenticationClaims{
TenantID: msg.TenantID, PartitionID: msg.PartitionID,
}
ctx := auth.ClaimsToContext(ctx)
// repo.Create(ctx, entity) now picks up TenantID/PartitionID from auth.
One-shot calls are the encouraged path¶
Repositories continue to call pool.DB(ctx, _) — tenancy is applied
transparently. For multi-statement atomicity, use raw GORM:
db := dbPool.DB(ctx, false)
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&e1).Error; err != nil { return err }
if err := tx.Create(&e2).Error; err != nil { return err }
return nil
})
The *gorm.DB is local to the closure — transactions are never
threaded through context.Context.
Opting a model out¶
Embed tenancy.UnscopedMarker on tables that should not have RLS
installed (lookup tables, migration metadata):
type LookupTable struct {
ID string `gorm:"primaryKey"`
tenancy.UnscopedMarker
}
Custom provider¶
Swap the default provider via frame.WithTenancyProvider. Implementing
a new tenancy scheme is a matter of writing a tenancy.Provider plus,
if a new database is involved, a dialect.DialectAdapter.
IMPORTANT: Postgres superuser bypasses RLS¶
Postgres SUPERUSER and roles with the BYPASSRLS attribute bypass
Row-Level Security policies entirely, even with FORCE ROW LEVEL
SECURITY. This is a Postgres design choice and applies regardless
of frame's wiring.
In production, services MUST connect to Postgres as a non-superuser
role without BYPASSRLS. If you connect as a superuser (which is the
default in many local-dev images), RLS will be silently disabled and
every query will return rows from every tenant.
Recommended production setup:
1. Create a dedicated application role (e.g., app_user) that is NOT
a superuser and does NOT have BYPASSRLS.
2. Grant that role the privileges it needs on the application schema.
3. Connect from frame using that role's credentials.
4. Use a separate, privileged role only for migrations and operator
tasks that must bypass RLS.
Frame's testcontainer-based integration tests work around this by
creating a non-superuser role inside the test setup; see
tenancy/postgres/provider_test.go for the pattern.
API Reference (Key)¶
manager.NewManager(ctx)Manager.AddPool(ctx, name, pool)Manager.DB(ctx, readOnly)Manager.DBWithPool(ctx, name, readOnly)Manager.Migrate(ctx, pool, dir, models...)Manager.SaveMigration(ctx, pool, patches...)