Security model and practices.
All application tables live in the app schema. Tenant isolation is enforced at the database level using PostgreSQL Row-Level Security.
Each RLS-protected table has a policy:
CREATE POLICY organization_isolation_policy ON app.<table>
USING (organization_id = current_setting('app.current_organization')::UUID);Enable RLS on tables that contain tenant-scoped data.
- Organization middleware extracts the organization slug from the request subdomain (via
X-Forwarded-HostorHostheader) - The organization is loaded and placed into
context.Contextviadomain.ContextWithOrganization() rlsfx.DB.Tx()starts a pgx transaction and executesSET LOCAL app.current_organization = '<org-id>'- All SQLC-generated queries within that transaction are automatically filtered by the RLS policy
SET LOCALscoping ensures the variable is cleared when the transaction ends- Nested transactions use PostgreSQL savepoints. The inner
Tx()saves the parent's RLS value, sets its own, and restores the parent's value after commit.
The full multi-tenant request lifecycle from HTTP request to RLS-filtered query:
- HTTP request arrives with tenant identifier in the
X-Organization-Slugheader (or subdomain viaX-Forwarded-Host) WithOrganization()middleware (core/transport/http/middleware) extracts the slug. Routes listed inSkipPaths(e.g.,/healthz,/readyz) bypass this middleware.- Organization lookup — the middleware calls an
OrganizationLoader(implemented by the app's persistence layer) using*pgxpool.Pooldirectly (outside RLS, since this table establishes tenant context) - Organization stored in context —
domain.ContextWithOrganization()places the organization incontext.Context, making it available to all downstream layers - Handler receives request — extracts path params, binds request body, calls the appropriate service method
- Service calls repository — the repository depends on
*rlsfx.DBfor tenant-scoped tables rlsfx.DB.Tx()starts a transaction — reads the organization from context, executesSET LOCAL app.current_organization = '<org-id>'to activate the RLS policy for this transaction- SQLC-generated queries execute — PostgreSQL's RLS policy automatically filters rows by
organization_id = current_setting('app.current_organization')::UUID - Transaction commits —
SET LOCALscoping clears the session variable automatically
This flow ensures that tenant isolation is enforced at the database level, not in application code. A missing or incorrect organization in context causes rlsfx.DB.Tx() to fail before any query executes.
- RLS is only active inside
rlsfx.DB.Tx()transactions. Non-RLS repos use*pgxpool.Pooldirectly. - If
Tx()is called without an organization in context, it returns an error without starting a transaction. - The dependency type declares intent:
*rlsfx.DB= RLS-enforced,*pgxpool.Pool= direct access. - DO NOT use
*pgxpool.Poolto query RLS-protected tables — this bypasses tenant isolation entirely. Choose the dependency type based on whether the table has an RLS policy. - Non-RLS repos participate in ambient transactions via
psqlfx.TxFromContext()for transaction composability. - Tables that establish tenant context (e.g., organizations) sit outside RLS. They are queried before any RLS transaction exists (middleware resolves slug → org). See ARCHITECTURE.md — RLS boundary for the full pattern.
type RLS struct {
Schema string // e.g., "app"
Field string // e.g., "current_organization"
}The schema and field combine to form the PostgreSQL session variable name: app.current_organization.
The framework supports passwordless authentication via login codes and JWT tokens. Implementation details:
Login flow pattern:
- Client sends email to initiate login
- Server generates a short alphanumeric code (using
crypto/rand) - Code is stored with an expiry
- Client submits email + code to verify
- On success, server returns a signed JWT
JWT tokens:
- Signed with HMAC-SHA256 using
lestrrat-go/jwx/v3 - Configurable subject, issuer, and expiry
- Signing key: minimum 32 characters, configured via secret store
auth:
jwt_signing_key: "secret://jwt-signing-key" # resolved via secret store
jwt_expiry: "24h"
code_expiry: "10m"Configuration values can reference secrets by using the secret:// prefix:
database:
password: "secret://db-password"
auth:
jwt_signing_key: "secret://jwt-signing-key"Or via environment variables:
DATABASE_PASSWORD=secret://db-passwordDuring configuration loading, the secretLookuper intercepts values with the secret:// prefix:
- Strip the
secret://prefix to get the secret name - Call
secretstore.Service.GetSecretValue(secretName)to resolve the actual value - Return the resolved value in place of the
secret://reference
If no secretstore.Service is provided (e.g., in development), secret:// values are not resolved and the raw string is used as-is.
type Service interface {
GetSecretValue(secretName string) (string, error)
}The interface is intentionally minimal. Implementations can back onto AWS Secrets Manager, Vault, or any other secret backend. The configuration layer doesn't know or care which backend is used.
The secretstore package provides built-in adapters. Applications select the adapter per environment in their configuration wiring.
| Adapter | Package | Resolves from | Use case |
|---|---|---|---|
EnvService |
core/secretstore |
Environment variables | Development, testing, CI |
EnvService converts secret names to environment variable keys: hyphens become underscores, names are uppercased. An optional prefix scopes lookups (e.g., prefix "SECRET" maps "db-password" → SECRET_DB_PASSWORD).
Production adapters (AWS Secrets Manager, Vault, etc.) implement the same Service interface and are wired in the application's AsFx() method based on environment.
- Development/Testing: Use
secretstore.NewEnvService("")withsecret://references. Set secrets as environment variables. - Production: Use a production adapter (e.g., AWS Secrets Manager) with the same
secret://references.