Skip to content

Datastore (GORM + Connection Pools)

Frame's datastore layer provides pooled database connections and migration management on top of GORM.

Overview

  • datastore.Manager manages named pools.
  • datastore/pool provides GORM-backed connections and tuning.
  • datastore/migration supports 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 / BeforeCreate copies access_id from JWT claims when the column is empty.
  • BaseRepository treats access_id as immutable (with tenant_id / partition_id) so updates cannot retarget attribution.
  • Empty access_id is allowed (service accounts, system jobs).
  • Do not add Keto tuples or RLS clauses keyed only by access_id unless 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...)