Help us shape the future of Go! Take the 2026 Go Developer Survey

multiapp

package
v0.21.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 35 Imported by: 1

README

Multi-App Framework

The multiapp package enables multiple apps to share backend infrastructure while keeping each app's data in its own PostgreSQL schema. Apps can be deployed in two modes:

  • Multi-app mode: Multiple apps share a server, routed by X-App-ID header
  • Single-app mode: One app runs on dedicated infrastructure

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                           HTTP Request                          │
│                     X-App-ID: app1                              │
│                     Authorization: Bearer <jwt>                 │
└─────────────────────────────────┬───────────────────────────────┘
                                  │
┌─────────────────────────────────▼───────────────────────────────┐
│                          multiapp.Server                        │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  appContextMiddleware                                     │  │
│  │  - Extracts X-App-ID header                               │  │
│  │  - Looks up registered app                                │  │
│  │  - Sets AppContext in request context                     │  │
│  │  - Routes to app's router                                 │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────┬───────────────────────────────┘
                                  │
          ┌───────────────────────┼───────────────────────┐
          │                       │                       │
          ▼                       ▼                       ▼
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   App1     │     │   App2     │     │   App3    │
│   AppBackend    │     │   AppBackend    │     │   AppBackend    │
├─────────────────┤     ├─────────────────┤     ├─────────────────┤
│ Schema:         │     │ Schema:         │     │ Schema:         │
│ app_app1   │     │ app_app2   │     │ app_app3  │
├─────────────────┤     ├─────────────────┤     ├─────────────────┤
│ Own users       │     │ Own users       │     │ Own users       │
│ Own orgs        │     │ Own orgs        │     │ Own orgs        │
│ Own data        │     │ Own data        │     │ Own data        │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Key Concepts

Schema-Per-App Isolation

Each app gets its own PostgreSQL schema (e.g., app_app1). This provides:

  • Separate tables per app, chosen per transaction with SET LOCAL search_path (see Database isolation)
  • Independent migrations per app
  • Shared database infrastructure, with one database login per app
PostgreSQL Database
├── app_app1/          # App1 schema
│   ├── users
│   ├── organizations
│   ├── courses
│   └── ...
├── app_app2/          # App2 schema
│   ├── users
│   ├── organizations
│   ├── dashboards
│   └── ...
└── app_app3/         # App3 schema
    ├── users
    ├── organizations
    ├── proofs
    └── ...
Database isolation

A schema name alone does not keep apps apart: search_path only picks which schema unqualified names resolve to, and SQL running for one app (or injected into it) can name another app's tables directly, app_other.users. Switching roles inside one shared pool (SET LOCAL ROLE) does not help either, because SQL in the same transaction can run RESET ROLE or SET ROLE to get back out. multiapp therefore relies on PostgreSQL privileges, with one database login per app.

Each app has its own database login by default. Config.AppDatabases maps every app slug to the app's own credentials, and the server opens a separate pool for each app. RegisterApp refuses an app without one (ErrAppDatabaseLoginRequired). An AppAwareDB opened with an app's login serves that app's schema only: ForSchema, EntClientFactory and EntConnHook refuse a second schema with ErrSharedDatabaseLogin.

server, err := multiapp.NewServer(multiapp.Config{
    Mode:                 multiapp.MultiAppMode,
    MigrationRole:        "sf_owner", // the owner role, for the isolation check
    AppDatabases: map[string]multiapp.AppDatabase{
        "app1": {URL: os.Getenv("APP1_DATABASE_URL")},
        "app2": {URL: os.Getenv("APP2_DATABASE_URL")},
    },
})

The same rule applies in single-app mode: the one app connects with its own login, and migrations run with the owner login in a separate step.

Migrations run as a separate step, with a separate migration (owner) login. The owner login owns the app schemas and creates their tables; tables it creates reach the app's login through default privileges set at provisioning. App servers do not hold it: run multiapp.Migrate(ctx, ownerURL, backends...) before the servers start, from a process that is given the owner's secret and nothing else to do, such as the app binary's own migrate command (the example's main.go migrate) run as a one-off task. Migrate runs every migration on every call, so write migrations to be idempotent (CREATE TABLE IF NOT EXISTS, and so on). The multiappdb command cannot run them, since they are Go code compiled into the app.

The servers then leave Config.MigrationDatabaseURL empty. RegisterApp creates no schema and runs no migration: it requires the app's schema to exist and its login to have USAGE on it, refusing the app with ErrAppSchemaMissing otherwise, and the isolation check runs as usual. Set Config.MigrationRole to the owner role's name (no credentials), so the check also verifies that no app login is a member of it or can act as it.

In-process migrations, for local development only. Setting MigrationDatabaseURL makes RegisterApp create each app's schema and run its Migrations with the owner login when the app registers. NewServer refuses it (ErrInProcessMigrationsNotAllowed) unless AllowInProcessMigrations is also set, and logs a warning when it is: the server process then holds the owner's credentials, and any code that takes it over can use the owner's privileges on every app schema.

Running migrations on AWS. Package the migration step as a one-off Amazon ECS task: a task definition that runs the app image's migrate command, with its own task role and only the owner's secret (Secrets Manager or an RDS IAM token for the owner role) injected. Run it with aws ecs run-task (or a deployment pipeline step) and wait for it to stop with exit code 0 before updating the app service. The app service's task definition gets only the app's own database secret, and MigrationRole as plain configuration.

The server checks isolation at startup. RegisterApp and Run call CheckDatabaseIsolation (also available as Server.VerifyDatabaseIsolation, and from the multiappdb verify command), which connects with each app's login and fails, naming the app and the problem but never a password, unless:

  • the login is not a superuser and has neither BYPASSRLS nor CREATEROLE;
  • it is not another app's login, owns no app schema, and is not a member of another app's role, the owner (migration) role, a superuser or BYPASSRLS role, pg_read_all_data or pg_write_all_data;
  • it has USAGE on its own schema and neither USAGE nor CREATE on any other app's schema; reading a table there, creating a table there, and SET ROLE to another app's role or the owner role all fail with permission denied (the probes run in transactions that are rolled back);
  • it cannot create objects in schema public, which every app's search_path includes;
  • its default search_path does not reach another app's schema.

If you serve Server.Router() yourself instead of calling Run, call VerifyDatabaseIsolation before serving.

Shared login, for local development only. Setting AllowSharedDatabaseLogin lets apps without an AppDatabases entry share the MigrationDatabaseURL login. The database then does not keep those apps apart, and the server logs a warning at startup and for each such app. Because the apps on it already hold the owner login, it implies AllowInProcessMigrations: it needs MigrationDatabaseURL, and every app registered, with or without its own login, is migrated in process. SkipIsolationCheck turns the check off and is accepted only together with AllowSharedDatabaseLogin.

Provisioning the logins

multiapp.RoleProvisioning generates idempotent SQL for an app's login, and ApplyRoleProvisioning runs it in one transaction with an admin login. The multiappdb command wraps both:

# Print the SQL for review (it never contains a password).
go run ./cmd/multiappdb sql --with-owner --owner sf_owner \
    --app app1 --role app_app1_login

# Apply it; the admin DSN and the password come from environment variables.
go run ./cmd/multiappdb apply --admin-url-env ADMIN_DATABASE_URL \
    --create-owner --owner sf_owner --owner-password-env OWNER_DB_PASSWORD \
    --app app1 --role app_app1_login --password-env APP1_DB_PASSWORD

# Check the deployed logins.
go run ./cmd/multiappdb verify --owner sf_owner \
    --login app1=APP1_DATABASE_URL --login app2=APP2_DATABASE_URL

For each app, the SQL:

  • creates the login role LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION (an existing role keeps its SUPERUSER/BYPASSRLS attributes, which only a superuser can change, so the startup check reports them instead);
  • creates the app schema owned by the owner role and revokes PUBLIC's access to it and to its tables, sequences and functions;
  • grants the login USAGE on its own schema and SELECT, INSERT, UPDATE, DELETE on its tables (SELECT only with ReadOnly), sequence and function privileges, and the same as default privileges on what the owner creates later;
  • revokes CREATE on schema public from PUBLIC, and EXECUTE on the owner's future functions from PUBLIC;
  • grants SELECT on the shared tables (by default DefaultSharedReadTables; see below), and sets the login's search_path to its schema and the shared schema.

Names are validated (lowercase letters, digits and underscores) and quoted. Passwords are never generated or logged: they come from a PasswordSource (PasswordFromEnv, or your secret manager), and ALTER ROLE ... PASSWORD carries a SCRAM-SHA-256 verifier computed client-side, so the plaintext never reaches the server or its statement log. The admin login that applies the SQL must be able to act as the owner role (a superuser, or a member of it).

Shared SystemForge tables. By default an app's login gets read-only access (SELECT) to the identity tables in multiapp.DefaultSharedReadTables, which an app reads to authorize requests in process (what authz mode rbac reads):

Table Why an app reads it
sf_principals who the caller is
sf_organizations the caller's organizations
sf_principal_memberships organization membership and role
sf_app_roles app role definitions
sf_resource_grants per-resource grants

RoleProvisioning.SharedReadTables changes the list: nil (the default) means DefaultSharedReadTables; multiapp.NoSharedReadTables (a non-nil empty list) grants none, for an app that does not read the identity tables; any other list replaces the default, so append to DefaultSharedReadTables to add a table. The multiappdb flags match: no flag grants the default list, --no-shared-tables grants none, and --shared-table (repeatable) replaces the list, with --default-shared-tables adding the defaults back.

Tables holding secrets or driving sign-in are never granted to apps and stay with the identity service's own login: credentials, OAuth app secrets, authorization codes and tokens, principal (API key) token hashes, SCIM tokens, login states and sessions, invites and manager claims, signup and external identities. multiapp.RestrictedSharedTables lists them, and provisioning refuses a SharedReadTables entry that names one. Grants are skipped for tables that do not exist yet, so apply the provisioning again after the identity migrations create them.

Amazon RDS. The same SQL works on RDS for PostgreSQL; apply it with the master user, which must be granted the owner role first (GRANT sf_owner TO <master user>), because it is not a superuser. App logins can use IAM database authentication instead of a password: pass --member-of rds_iam (or MemberOf: []string{"rds_iam"}), omit the password, and give the app an AppDatabase.PoolConfig whose BeforeConnect sets a fresh IAM authentication token as the password. Alternatively, keep each app's password in AWS Secrets Manager and read it at startup into that app's AppDatabase.URL, or into a PasswordSource when provisioning.

What database logins do not contain

Per-app logins stop one app's SQL, including injected SQL, from reaching another app's schema. They do not contain remote code execution in multi-app mode: all apps run in one process, so code that takes over that process can read every app's credentials, and the migration login's, from memory and connect as any of them. For apps that hold sensitive data, use single-app mode: run each app as its own binary or container, given only its own database secret (migrations run as a separate step, so the owner login is not in the process at all).

Self-Contained Apps

Each app has its own:

  • Users and authentication
  • Organizations and memberships
  • Business data
  • API routes

No external dependencies (like SystemAuth) are required. SystemAuth integration is optional for SSO and centralized management.

Integration Pattern

Apps integrate with multiapp by implementing the AppBackend interface and providing a factory function that accepts an Ent client:

┌────────────────────────────────────────────────────────────────┐
│                     Deployment Modes                           │
├─────────────────────────┬──────────────────────────────────────┤
│   Standalone Mode       │        Multi-App Mode                │
│   (dedicated infra)     │        (shared infra)                │
├─────────────────────────┼──────────────────────────────────────┤
│   cmd/server/main.go    │   systemforge-multi server             │
│          │              │          │                           │
│          ▼              │          ▼                           │
│   NewServer(cfg)        │   multiapp.Backend                   │
│          │              │          │                           │
│          ▼              │          ▼                           │
│   NewServerWithOptions  │   NewServerWithEntClient             │
│   (creates own DB)      │   (uses schema-isolated DB)          │
│          │              │          │                           │
│          └──────────────┴──────────┘                           │
│                         │                                      │
│                         ▼                                      │
│              newServerInternal(cfg, client)                    │
│              (shared: JWT, router, authz, storage, routes)     │
└────────────────────────────────────────────────────────────────┘

Implementing AppBackend

Step 1: Refactor Existing Server

Extract shared initialization into an internal function:

// internal/api/server.go

// NewServerWithOptions - standalone mode, creates own DB
func NewServerWithOptions(cfg *config.Config, connectDB bool) (*Server, error) {
    var client *ent.Client
    if connectDB {
        // Create database connection
        db, err := sql.Open("postgres", cfg.Database.DSN())
        if err != nil {
            return nil, err
        }
        drv := entsql.OpenDB(dialect.Postgres, db)
        client = ent.NewClient(ent.Driver(drv))

        // Run migrations
        if err := client.Schema.Create(context.Background()); err != nil {
            return nil, err
        }
    }
    return newServerInternal(cfg, client)
}

// NewServerWithEntClient - multi-app mode, uses provided client
func NewServerWithEntClient(cfg *config.Config, client *ent.Client) (*Server, error) {
    return newServerInternal(cfg, client)
}

// newServerInternal - shared initialization logic
func newServerInternal(cfg *config.Config, client *ent.Client) (*Server, error) {
    // JWT service, router, middleware, authz, BFF, storage, routes...
    // All the shared setup code
}
Step 2: Create AppBackend Adapter
// internal/multiapp/backend.go

package multiapp

import (
    "github.com/grokify/myapp/ent"
    "github.com/grokify/myapp/internal/api"
    cfmultiapp "github.com/plexusone/systemforge/multiapp"
)

type Backend struct {
    cfg    *config.Config
    deps   cfmultiapp.Dependencies
    db     *ent.Client
    server *api.Server
}

func NewBackend(cfg *config.Config) *Backend {
    return &Backend{cfg: cfg}
}

func (b *Backend) Slug() string { return "myapp" }
func (b *Backend) Name() string { return "My App" }

func (b *Backend) Routes(deps cfmultiapp.Dependencies) chi.Router {
    b.deps = deps

    // Create Ent client with schema isolation
    b.db = createEntClient(deps.DB)

    // Run migrations
    b.db.Schema.Create(context.Background())

    // Create server using existing API package
    server, _ := api.NewServerWithEntClient(b.cfg, b.db)
    b.server = server

    return server.Router().(chi.Router)
}

func (b *Backend) OnRegister(ctx context.Context, cfg *cfmultiapp.AppConfig) error {
    return nil
}

func (b *Backend) OnShutdown(ctx context.Context) error {
    return b.server.Close()
}
Step 3: Create Schema-Isolated Ent Client
// deps.DB is the app's SchemaDB, connected with the app's own login.
// StdDB opens a database/sql pool on that login with search_path set to
// the app's schema; open it once and close it on shutdown.
func createEntClient(ctx context.Context, schemaDB *cfmultiapp.SchemaDB) (*ent.Client, error) {
    db, err := schemaDB.StdDB(ctx)
    if err != nil {
        return nil, err
    }
    return ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db))), nil
}

Usage

Standalone Deployment (Unchanged)
# Existing command works as before
go run ./cmd/server
Multi-App Deployment
package main

import (
    "os"

    "github.com/grokify/app1/internal/multiapp"
    cfmultiapp "github.com/plexusone/systemforge/multiapp"
)

func main() {
    server, _ := cfmultiapp.NewServer(cfmultiapp.Config{
        Mode:          cfmultiapp.MultiAppMode,
        MigrationRole: "sf_owner", // migrations ran beforehand with Migrate
        AppDatabases: map[string]cfmultiapp.AppDatabase{
            "app1": {URL: os.Getenv("APP1_DATABASE_URL")},
        },
        RedisURL: "redis://localhost:6379", // optional
    })

    // Register apps
    server.RegisterApp(multiapp.NewBackend(nil))
    // server.RegisterApp(app2.NewBackend(nil))
    // server.RegisterApp(app3.NewBackend(nil))

    server.Run(":8080")
}
Testing Multi-App Mode
# Request to App1
curl -H "X-App-ID: app1" http://localhost:8080/api/courses

# Request to App2
curl -H "X-App-ID: app2" http://localhost:8080/api/dashboards

Context Flow

The multiapp framework provides several context values:

// In your handlers:
func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // App context (from X-App-ID routing)
    appCtx := multiapp.AppContextFromContext(ctx)
    appCtx.AppID          // "app1"
    appCtx.DatabaseSchema // "app_app1"
    appCtx.Features       // ["auth", "tenancy"]

    // JWT claims (from auth middleware)
    claims := middleware.ClaimsFromContext(ctx)
    claims.PrincipalID    // user UUID
    claims.OrganizationID // org UUID

    // Full context (combines all)
    fc := multiapp.FullContextFromContext(ctx)
    fc.HasApp()           // true in multi-app mode
    fc.IsAuthenticated()  // true if JWT valid
}

Tokens Are Bound to an App

The X-App-ID header chooses the app, and a client controls that header. So that a token issued for one app cannot be used against another, AuthMiddleware requires a token on an app route to be addressed to that app. By default the required audience is the app's AppID; mint tokens for it with:

pair, err := jwtService.GenerateTokenPairWithOptions(userID, email, name,
    jwt.TokenOptions{Audience: []string{appCtx.AppID}})

Set MiddlewareConfig.AppAudiences to derive a different audience per app. Set AllowCrossAppTokens only when one token is meant to work in every app; tokens are then checked against the JWT service's own Config.Audience.

Tokens Are DPoP-Bound

AuthMiddleware requires DPoP (RFC 9449): a token must be bound to a key (cnf.jkt, minted with jwt.TokenOptions{DPoPThumbprint: ...}) and come with Authorization: DPoP <token> and a fresh proof by that key for the request, carrying the token's hash. Configure it with a replay cache shared by every instance and the external base URL clients use:

auth, err := multiapp.AuthMiddleware(multiapp.MiddlewareConfig{
    JWTService:  jwtService,
    RequireAuth: true,
    DPoP: middleware.DPoPConfig{
        ReplayCache: middleware.NewRedisReplayCache(redisClient, "dpop-jti:"),
        BaseURL:     "https://apps.example.com",
    },
})

The proof's htu must equal DPoP.BaseURL plus the request path as this server sees it. Behind a proxy that strips a path prefix, include the prefix in BaseURL (for example https://example.com/api). Unbound bearer tokens are refused unless DPoP.AllowBearer is set, an explicit opt-in for legacy clients; a DPoP-bound token is never accepted as Bearer.

Optional: SystemAuth Integration

Apps can optionally integrate with SystemAuth for:

  • Single Sign-On (SSO) across apps
  • Centralized user management
  • Cross-app analytics

This is enabled by setting a federation_id on users when they authenticate via SystemAuth. See the SystemAuth Integration Guide for details.

Documentation

Overview

Package multiapp provides infrastructure for running multiple apps on shared or dedicated backend infrastructure. Each app implements the AppBackend interface and has its own users, organizations, and data, in its own schema.

Apps can be deployed in two modes:

  • Multi-app mode: Multiple apps share a server, routed by X-App-ID header
  • Single-app mode: One app runs on dedicated infrastructure

SystemAuth integration is optional - apps work without any external dependencies.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoAppContext is returned when app context is required but not present.
	ErrNoAppContext = errors.New("multiapp: no app context in request")

	// ErrFeatureNotEnabled is returned when a required feature is not enabled.
	ErrFeatureNotEnabled = errors.New("multiapp: feature not enabled for this app")

	// ErrNotAuthenticated is returned when authentication is required but not present.
	ErrNotAuthenticated = errors.New("multiapp: authentication required")

	// ErrAppNotFound is returned when the requested app is not registered.
	ErrAppNotFound = errors.New("multiapp: app not found")

	// ErrAppAlreadyRegistered is returned when trying to register a duplicate app.
	ErrAppAlreadyRegistered = errors.New("multiapp: app already registered")

	// ErrInvalidSchemaName is returned when a schema name is invalid.
	ErrInvalidSchemaName = errors.New("multiapp: invalid schema name")

	// ErrMigrationFailed is returned when database migrations fail.
	ErrMigrationFailed = errors.New("multiapp: migration failed")
)

Sentinel errors for the multiapp package.

View Source
var DefaultSharedReadTables = []string{
	"sf_principals",
	"sf_organizations",
	"sf_principal_memberships",
	"sf_app_roles",
	"sf_resource_grants",
}

DefaultSharedReadTables are the shared SystemForge identity tables an app reads when it authorizes requests in process against the identity database: who a principal is, which organizations it belongs to, and the app roles and resource grants it holds (the tables authz mode rbac reads). RoleProvisioning grants SELECT on them by default, when SharedReadTables is nil.

Tables that hold secrets or drive sign-in are deliberately absent and must stay with the identity service's own login: credentials, OAuth app secrets, authorization codes and tokens, principal (API key) token hashes, SCIM tokens, login states and sessions, invites, signup and external identities.

View Source
var ErrAppDatabaseLoginRequired = errors.New("multiapp: app has no database login of its own")

ErrAppDatabaseLoginRequired is returned when an app is registered without a database login of its own and the shared login is not allowed.

View Source
var ErrAppSchemaMissing = errors.New("multiapp: app schema does not exist")

ErrAppSchemaMissing is returned by RegisterApp when the server runs no migrations (no MigrationDatabaseURL) and the app's schema does not exist or its login cannot see it.

View Source
var ErrCacheMiss = errors.New("cache miss")

ErrCacheMiss is returned when a key is not found in the cache.

View Source
var ErrInProcessMigrationsNotAllowed = errors.New("multiapp: MigrationDatabaseURL puts the migration (owner) login in the server process")

ErrInProcessMigrationsNotAllowed is returned by NewServer when MigrationDatabaseURL is set without AllowInProcessMigrations.

View Source
var ErrJWTServiceRequired = errors.New("multiapp: MiddlewareConfig.JWTService is required")

ErrJWTServiceRequired is returned by AuthMiddleware without a MiddlewareConfig.JWTService.

View Source
var ErrSharedDatabaseLogin = errors.New("multiapp: one database login cannot serve several app schemas")

ErrSharedDatabaseLogin is returned when a pool connected with one app's login is asked to serve another app's schema.

View Source
var NoSharedReadTables = []string{}

NoSharedReadTables is a SharedReadTables value that grants no shared table.

View Source
var RestrictedSharedTables = []string{
	"sf_client_assertion_jtis",
	"sf_credentials",
	"sf_external_identities",
	"sf_invites",
	"sf_login_sessions",
	"sf_login_states",
	"sf_manager_claims",
	"sf_manager_invites",
	"sf_oauth_app_secrets",
	"sf_oauth_auth_codes",
	"sf_oauth_tokens",
	"sf_principal_tokens",
	"sf_scim_tokens",
	"sf_signup_invites",
	"sf_signup_waitlist",
}

RestrictedSharedTables are the shared SystemForge tables an app login is never granted, even when SharedReadTables names them: they hold credentials, secrets, tokens, sign-in sessions and state, invitations and claim codes, and external identities. RoleProvisioning refuses a SharedReadTables entry listed here.

Functions

func AppIDFromContext

func AppIDFromContext(ctx context.Context) string

AppIDFromContext returns the app ID from context, or empty string if not present.

func AppIDFromSessionMetadata

func AppIDFromSessionMetadata(session *bff.Session) string

AppIDFromSessionMetadata extracts app ID from session metadata.

func AppLogger

func AppLogger(next http.Handler) http.Handler

AppLogger returns middleware that adds app context to structured logging. This should be used after appContextMiddleware.

func AppSessionMetadata

func AppSessionMetadata(appID, appSlug string) map[string]string

AppSessionMetadata returns metadata for storing app context in session. This can be added to bff.Session.Metadata for app tracking.

func AppSlugFromContext

func AppSlugFromContext(ctx context.Context) string

AppSlugFromContext returns the app slug from context, or empty string if not present.

func AppSlugFromSessionMetadata

func AppSlugFromSessionMetadata(session *bff.Session) string

AppSlugFromSessionMetadata extracts app slug from session metadata.

func ApplyOwnerRole added in v0.21.0

func ApplyOwnerRole(ctx context.Context, db TxBeginner, owner string, password PasswordSource) error

ApplyOwnerRole creates the migration (owner) login on db and sets its password from password when it is not nil.

func ApplyRoleProvisioning added in v0.21.0

func ApplyRoleProvisioning(ctx context.Context, db TxBeginner, p RoleProvisioning, password PasswordSource) error

ApplyRoleProvisioning runs p's SQL in one transaction on db, which must be an admin (or migration-capable) login, then sets the role's password from password when it is not nil. Leave password nil for roles that sign in without one, such as IAM database authentication.

func AuthMiddleware

func AuthMiddleware(cfg MiddlewareConfig) (func(http.Handler) http.Handler, error)

AuthMiddleware returns middleware that validates access tokens and sets authentication context. This works with the app context middleware.

It requires DPoP-bound tokens with a valid proof (see MiddlewareConfig.DPoP); unbound bearer tokens are refused unless DPoP.AllowBearer is set. It returns an error without a JWT service or a replay cache.

The middleware chain should be:

  1. appContextMiddleware (set by Server in multi-app mode)
  2. AuthMiddleware (validates the token and its proof, sets claims)
  3. Your handlers

func CheckDatabaseIsolation added in v0.21.0

func CheckDatabaseIsolation(ctx context.Context, apps []AppDatabaseLogin, ownerRoles ...string) error

CheckDatabaseIsolation verifies that each app login in apps (those with a Pool) is confined to its own schema:

  • it is not a superuser and has neither BYPASSRLS nor CREATEROLE;
  • it is not the login of another app, does not own any app schema, and is not a member of another app's role, of an owner role (the owners of the app schemas and ownerRoles, the migration logins), of a superuser or BYPASSRLS role, or of pg_read_all_data or pg_write_all_data;
  • it has USAGE on its own schema;
  • it has neither USAGE nor CREATE on another app's schema, and reading a table there, creating a table there, and SET ROLE to another app's role or an owner role all fail with permission denied (the probes run in transactions that are rolled back);
  • it cannot create objects in schema public, which every app's search_path includes;
  • its default search_path does not name or reach another app's schema.

It returns an *IsolationError listing every violation, or another error when the check itself could not run.

func DatabaseSchemaFromContext

func DatabaseSchemaFromContext(ctx context.Context) string

DatabaseSchemaFromContext returns the database schema from context, or empty string if not present.

func EnrichContext

func EnrichContext(ctx context.Context, appCtx *AppContext) context.Context

EnrichContext adds app context to an existing context that may already have authentication context from session middleware. This is useful when you need to add app context to a context that was created outside of the multi-app middleware.

func GetSetting

func GetSetting(ctx context.Context, key string) any

GetSetting retrieves a setting value for the current app. Returns nil if the setting doesn't exist or no app context is present.

func HasAppContext

func HasAppContext(ctx context.Context) bool

HasAppContext checks if an app context is present in the context.

func HasFeature

func HasFeature(ctx context.Context, feature string) bool

HasFeature checks if a feature is enabled for the current app.

func InjectRLS

func InjectRLS() func(http.Handler) http.Handler

InjectRLS returns middleware that sets RLS context from JWT claims. Use this after AuthMiddleware if you need RLS but didn't enable SetRLSContext.

func Migrate added in v0.21.0

func Migrate(ctx context.Context, migrationDatabaseURL string, backends ...AppBackend) error

Migrate creates the schemas of backends and runs their Migrations with the migration (owner) login at migrationDatabaseURL. It is the production way to migrate: run it as a separate step before the servers start, from a process given the owner's secret (for example the app binary's own migrate command, run as a one-off task), and leave Config.MigrationDatabaseURL empty in the servers. Every migration runs on every call, so migrations must be idempotent.

func OwnerRoleSQL added in v0.21.0

func OwnerRoleSQL(owner string) ([]string, error)

OwnerRoleSQL returns statements that create the migration (owner) login if it is missing: LOGIN, with no superuser, role creation, database creation, replication or RLS bypass. Set its password with SetRolePassword.

func RequireApp

func RequireApp() func(http.Handler) http.Handler

RequireApp returns middleware that requires app context to be present. Use this for routes that must have an app context.

func RequireAuth

func RequireAuth(ctx context.Context) (uuid.UUID, error)

RequireAuth is a middleware helper that ensures the request is authenticated. Returns the principal ID if authenticated, or an error if not.

func RequireAuthentication

func RequireAuthentication() func(http.Handler) http.Handler

RequireAuthentication returns middleware that requires valid authentication.

func RequireFeature

func RequireFeature(feature string) func(http.Handler) http.Handler

RequireFeature returns middleware that requires a specific feature to be enabled.

func RequirePermission

func RequirePermission(permission string) func(http.Handler) http.Handler

RequirePermission returns middleware that requires a specific permission.

func RequireRole

func RequireRole(role string) func(http.Handler) http.Handler

RequireRole returns middleware that requires a specific role.

func SchemaFromAppSlug

func SchemaFromAppSlug(slug string) string

SchemaFromAppSlug returns the database schema name for an app slug. By convention, app schemas are named "app_{slug}".

func SessionKeyWithApp

func SessionKeyWithApp(appID, sessionID string) string

SessionKeyWithApp returns a session store key that includes the app ID. This ensures sessions are isolated per-app in shared session stores.

func SetRolePassword added in v0.21.0

func SetRolePassword(ctx context.Context, db Execer, role string, password PasswordSource) error

SetRolePassword sets role's password from password. The statement carries a SCRAM-SHA-256 verifier computed here, never the password, so server statement logs do not record it; errors never include it either.

func ValidateAppAccess

func ValidateAppAccess(ctx context.Context) error

ValidateAppAccess reports whether the request has an app context. It does not check that the caller's token belongs to that app: the app context comes from the X-App-ID header. Bind tokens to apps with MiddlewareConfig.AppAudiences.

func ValidateAppFeature

func ValidateAppFeature(ctx context.Context, feature string) error

ValidateAppFeature validates that a feature is enabled for the current app.

func WithAppContext

func WithAppContext(ctx context.Context, appCtx *AppContext) context.Context

WithAppContext returns a new context with the app context attached.

func WithRLSFromClaims

func WithRLSFromClaims(ctx context.Context) context.Context

WithRLSFromClaims adds RLS context from JWT claims. This bridges session/middleware claims to rls context.

Types

type AppAwareDB

type AppAwareDB struct {
	// contains filtered or unexported fields
}

AppAwareDB is a connection pool for one database login, serving app schemas over it.

A pool connected with an app's own login serves that app's schema only: ForSchema, the Ent helpers and EntConnHook refuse a second schema, since switching schemas inside one login (search_path, or SET LOCAL ROLE, which SQL in the transaction can reset) does not keep apps apart. Open the pool of the migration (owner) login with AsMigrationLogin, and a pool every app shares, for local development only, with AllowSharedLogin. See "Database isolation" in the package README.

func NewAppAwareDB

func NewAppAwareDB(databaseURL string, opts ...DBOption) (*AppAwareDB, error)

NewAppAwareDB opens a connection pool for databaseURL and checks that it connects.

func NewAppAwareDBWithConfig added in v0.21.0

func NewAppAwareDBWithConfig(ctx context.Context, config *pgxpool.Config, opts ...DBOption) (*AppAwareDB, error)

NewAppAwareDBWithConfig opens a connection pool from config, for example one whose BeforeConnect sets a short-lived IAM authentication token as the password, and checks that it connects.

func (*AppAwareDB) Close

func (db *AppAwareDB) Close() error

Close closes the database connection pool.

func (*AppAwareDB) CreateSchema

func (db *AppAwareDB) CreateSchema(ctx context.Context, schemaName string) error

CreateSchema creates a new PostgreSQL schema for an app.

func (*AppAwareDB) DropSchema

func (db *AppAwareDB) DropSchema(ctx context.Context, schemaName string) error

DropSchema drops a PostgreSQL schema and all its contents. Use with caution - this deletes all data in the schema.

func (*AppAwareDB) ForSchema

func (db *AppAwareDB) ForSchema(schemaName string) (*SchemaDB, error)

ForSchema returns a SchemaDB scoped to a specific schema. The name is used in SQL, so it must be a plain identifier (lowercase letters, digits, and underscores); anything else is refused. Unless the pool was opened with AsMigrationLogin or AllowSharedLogin, it serves one schema only, and asking for a second returns ErrSharedDatabaseLogin.

func (*AppAwareDB) GetSchemaSize

func (db *AppAwareDB) GetSchemaSize(ctx context.Context, schemaName string) (int64, error)

GetSchemaSize returns the size of a schema in bytes.

func (*AppAwareDB) ListSchemas

func (db *AppAwareDB) ListSchemas(ctx context.Context) ([]string, error)

ListSchemas returns all app schemas in the database.

func (*AppAwareDB) Pool

func (db *AppAwareDB) Pool() *pgxpool.Pool

Pool returns the underlying connection pool.

type AppBackend

type AppBackend interface {
	// Slug returns the unique app identifier (e.g., "app1").
	// This is used for routing, database schema naming, and configuration.
	Slug() string

	// Name returns the human-readable display name (e.g., "App1").
	Name() string

	// EntSchemas returns the Ent schemas for this app's database tables.
	// These are created in the app's isolated database schema.
	EntSchemas() []ent.Schema

	// Routes returns the HTTP routes for this app.
	// Routes are mounted under the app's context with no prefix needed.
	// The Dependencies provide access to the database, cache, and logger.
	Routes(deps Dependencies) chi.Router

	// Migrations returns app-specific database migrations.
	// These run in the app's isolated database schema.
	Migrations() []Migration

	// OnRegister is called when the app is registered with the server.
	// Use this for app-specific initialization.
	OnRegister(ctx context.Context, cfg *AppConfig) error

	// OnShutdown is called when the server is shutting down.
	// Use this to clean up app-specific resources.
	OnShutdown(ctx context.Context) error
}

AppBackend is the interface that all app backends must implement. This enables composition of multiple apps into a single server or standalone deployment on dedicated infrastructure.

Each app is self-contained with its own users, organizations, and data. No external dependencies (like SystemAuth) are required.

type AppClaims

type AppClaims struct {
	*jwt.Claims

	// AppID is the app this token was issued for.
	// In multi-app mode, tokens are scoped to a specific app.
	AppID string `json:"app_id,omitempty"`

	// AppSlug is the URL-safe app identifier.
	AppSlug string `json:"app_slug,omitempty"`
}

AppClaims extends jwt.Claims with app-specific fields for multi-app mode. This is used when you need to include app context in JWT tokens.

func AppClaimsFromContext

func AppClaimsFromContext(ctx any) *AppClaims

AppClaimsFromContext extracts app-aware claims from context. If the request has both app context and JWT claims, it combines them.

func NewAppClaims

func NewAppClaims(claims *jwt.Claims) *AppClaims

NewAppClaims creates AppClaims from existing jwt.Claims.

func NewAppClaimsForApp

func NewAppClaimsForApp(claims *jwt.Claims, appID, appSlug string) *AppClaims

NewAppClaimsForApp creates AppClaims with app context.

func (*AppClaims) WithApp

func (c *AppClaims) WithApp(appID, appSlug string) *AppClaims

WithApp adds app context to the claims.

type AppConfig

type AppConfig struct {
	// AppID is the unique identifier for this app (usually same as Slug).
	AppID string

	// Slug is the URL-safe identifier used for routing and schema naming.
	Slug string

	// DatabaseSchema is the PostgreSQL schema name for this app (e.g., "app_app1").
	DatabaseSchema string

	// Features lists enabled features for this app.
	Features []string

	// Settings contains app-specific configuration values.
	Settings map[string]any
}

AppConfig contains configuration for an app instance.

type AppContext

type AppContext struct {
	// AppID is the unique identifier for this app.
	AppID string

	// AppSlug is the URL-safe identifier.
	AppSlug string

	// AppName is the human-readable display name.
	AppName string

	// DatabaseSchema is the PostgreSQL schema for this app.
	DatabaseSchema string

	// Features lists enabled features for this app.
	Features []string

	// Settings contains app-specific configuration.
	Settings map[string]any
}

AppContext contains information about the current app for a request. This is injected by the app context middleware in multi-app mode.

func AppContextFromContext

func AppContextFromContext(ctx context.Context) *AppContext

AppContextFromContext extracts the app context from the request context. Returns nil if no app context is present (e.g., in single-app mode without middleware).

type AppDatabase added in v0.21.0

type AppDatabase struct {
	// URL is a PostgreSQL connection string with the app's own
	// credentials, for example read from a secret manager at startup.
	URL string

	// PoolConfig, when set, is used instead of URL: for example a config
	// whose BeforeConnect sets a short-lived IAM authentication token as
	// the password.
	PoolConfig *pgxpool.Config
}

AppDatabase is an app's own database login.

type AppDatabaseLogin added in v0.21.0

type AppDatabaseLogin struct {
	// Slug names the app in violations.
	Slug string

	// Schema is the app's schema.
	Schema string

	// Pool is connected with the app's own login. Leave it nil for an app
	// on the shared development login: its schema is still checked as one
	// the other apps must not reach, but its login is not checked.
	Pool *pgxpool.Pool
}

AppDatabaseLogin is one app's schema and, when the app has a database login of its own, the pool connected with it.

type AppSession

type AppSession struct {
	*bff.Session

	// AppID is the app this session belongs to.
	AppID string

	// AppSlug is the URL-safe app identifier.
	AppSlug string
}

AppSession extends bff.Session with app-specific fields. This provides app-scoped sessions for multi-app deployments.

func CreateAppSession

func CreateAppSession(
	appID, appSlug string,
	userID uuid.UUID,
	accessToken, refreshToken string,
	accessExpiry, refreshExpiry time.Duration,
) (*AppSession, error)

CreateAppSession creates a new app-scoped session.

func NewAppSession

func NewAppSession(session *bff.Session, appID, appSlug string) *AppSession

NewAppSession creates an app-scoped session from a BFF session.

type Cache

type Cache interface {
	// Get retrieves a value from the cache.
	Get(ctx context.Context, key string) ([]byte, error)

	// Set stores a value in the cache with optional TTL.
	Set(ctx context.Context, key string, value []byte, ttlSeconds int) error

	// Delete removes a value from the cache.
	Delete(ctx context.Context, key string) error

	// WithPrefix returns a Cache that prefixes all keys.
	WithPrefix(prefix string) Cache
}

Cache provides a cache interface for apps. Implementations can use Redis, in-memory, or other backends.

type Config

type Config struct {
	// Mode determines how apps are routed (multi or single).
	Mode ServerMode

	// AppDatabases holds each app's own database login, keyed by app
	// slug. RegisterApp refuses an app without one unless
	// AllowSharedDatabaseLogin is set. Each login gets its own pool,
	// serves its app's schema only, and must pass the isolation check.
	AppDatabases map[string]AppDatabase

	// MigrationDatabaseURL is the migration (owner) login, for running
	// migrations in the server process: RegisterApp then uses it to create
	// each app's schema and run the app's Migrations. It is never handed
	// to app code. It is accepted only with AllowInProcessMigrations (or
	// AllowSharedDatabaseLogin). Leave it empty in production and run
	// Migrate as a separate step, such as a one-off task given the owner's
	// secret; RegisterApp then expects each app's schema to exist, refuses
	// an app whose schema is missing (ErrAppSchemaMissing), and does not
	// run Migrations.
	MigrationDatabaseURL string

	// AllowInProcessMigrations lets the server hold the migration login,
	// MigrationDatabaseURL, and create schemas and run migrations when an
	// app registers. Any code that takes over the process can then use
	// the owner's privileges on every app schema. For local development
	// only; the server logs a warning when it is set.
	AllowInProcessMigrations bool

	// MigrationRole names the migration (owner) role when
	// MigrationDatabaseURL is empty, so the isolation check can verify
	// that no app login is a member of it or can act as it. It holds no
	// credentials. With MigrationDatabaseURL, the role is read from that
	// login and this field is ignored. Optional, but set it in
	// production.
	MigrationRole string

	// AllowSharedDatabaseLogin lets apps without an AppDatabases entry
	// use the MigrationDatabaseURL login, shared by every such app. The
	// database then does not keep those apps apart: each can read and
	// write the others' tables. It needs MigrationDatabaseURL and implies
	// AllowInProcessMigrations, since the apps on it already hold the
	// owner login. For local development only; the server logs a warning
	// when it is set.
	AllowSharedDatabaseLogin bool

	// SkipIsolationCheck turns the database isolation check off. It is
	// accepted only together with AllowSharedDatabaseLogin. Apps on the
	// shared login are never checked as subjects either way.
	SkipIsolationCheck bool

	// RedisURL is the Redis connection string for caching and sessions.
	// Optional - if empty, an in-memory cache is used.
	RedisURL string

	// Logger is the logger to use. If nil, a default logger is created.
	Logger *slog.Logger
}

Config contains configuration for creating a new server.

Every app connects to the database with a login of its own, given in AppDatabases, in single-app and multi-app mode alike. Schema and table changes run with a separate migration (owner) login, as a deploy step of their own (Migrate) in production, so the server process never holds the owner's credentials. See "Database isolation" in the package README.

type DBOption added in v0.21.0

type DBOption func(*dbOptions)

DBOption configures an AppAwareDB.

func AllowSharedLogin added in v0.21.0

func AllowSharedLogin() DBOption

AllowSharedLogin lets app code use one pool, and so one database login, for several app schemas. The database then does not keep the apps apart: any of them can read and write the others' tables with a schema-qualified name. Use it for local development only.

func AsMigrationLogin added in v0.21.0

func AsMigrationLogin() DBOption

AsMigrationLogin marks the pool as the migration (owner) login, which creates every app schema and runs migrations in them, so it may serve any number of schemas. Never hand it to app code.

type Dependencies

type Dependencies struct {
	// DB provides database access scoped to the app's schema.
	DB *SchemaDB

	// Cache provides cache access with app-specific key prefix.
	Cache Cache

	// Logger is configured with app context (app slug, etc.).
	Logger *slog.Logger

	// Config contains app-specific configuration.
	Config *AppConfig
}

Dependencies provides access to shared infrastructure for app handlers. All dependencies are scoped to the app's context.

type EntClientFactory

type EntClientFactory struct {
	// contains filtered or unexported fields
}

EntClientFactory creates Ent clients scoped to app schemas. This bridges the multiapp SchemaDB with Ent's database layer.

func NewEntClientFactory

func NewEntClientFactory(db *AppAwareDB) *EntClientFactory

NewEntClientFactory creates a factory for app-scoped Ent clients.

func (*EntClientFactory) EntDriverForSchema

func (f *EntClientFactory) EntDriverForSchema(ctx context.Context, schema string) (*entsql.Driver, error)

EntDriverForSchema returns an Ent SQL driver scoped to a schema. This is a convenience wrapper around StdDBForSchema.

Usage:

drv, err := factory.EntDriverForSchema(ctx, "app_app1")
client := ent.NewClient(ent.Driver(drv))

func (*EntClientFactory) PgxConnWithSchema

func (f *EntClientFactory) PgxConnWithSchema(ctx context.Context, schema string) (*pgx.Conn, error)

PgxConnWithSchema opens a dedicated, unpooled pgx connection with schema set, for raw pgx operations outside of Ent; close it when done. The schema name must be a plain identifier; anything else is refused, and so is a second schema on a factory over an app's own login.

func (*EntClientFactory) StdDBForSchema

func (f *EntClientFactory) StdDBForSchema(ctx context.Context, schema string) (*sql.DB, error)

StdDBForSchema returns a *sql.DB configured for a specific schema. Every connection of the returned pool has search_path set to the app's schema, and it connects with the factory's login, so pass the AppAwareDB of the app's own login: like AppAwareDB.ForSchema, a factory over an app login serves one schema only. The schema name must be a plain identifier; anything else is refused.

Usage with Ent:

factory := multiapp.NewEntClientFactory(appAwareDB)
db, err := factory.StdDBForSchema(ctx, "app_app1")
if err != nil {
    return err
}
drv := entsql.OpenDB(dialect.Postgres, db)
client := ent.NewClient(ent.Driver(drv))

type EntConfig

type EntConfig struct {
	// Schema is the PostgreSQL schema name for this app.
	Schema string

	// Debug enables Ent debug logging.
	Debug bool
}

EntConfig provides configuration for Ent client creation.

type EntConnHook

type EntConnHook struct {
	// contains filtered or unexported fields
}

EntConnHook sets an application's schema on database/sql transactions, from the AppContext in the request context.

func NewEntConnHook

func NewEntConnHook(db *AppAwareDB) *EntConnHook

NewEntConnHook creates a new Ent connection hook. db is the AppAwareDB of the login the transactions run with: like AppAwareDB.ForSchema, the hook refuses an app context naming a second schema unless db was opened with AllowSharedLogin, so one app's login never serves another app's requests.

func (*EntConnHook) SetSchemaFromContext deprecated

func (h *EntConnHook) SetSchemaFromContext(ctx context.Context, conn *sql.Conn) error

SetSchemaFromContext sets the search_path of conn to the schema of the AppContext in ctx, for the whole session.

Deprecated: the setting stays on the connection after it returns to the database/sql pool, so later use of that connection by code that does not run the hook reads and writes this application's tables. Use SetSchemaInTx, whose search_path ends with the transaction.

func (*EntConnHook) SetSchemaInTx added in v0.21.0

func (h *EntConnHook) SetSchemaInTx(ctx context.Context, tx *sql.Tx) error

SetSchemaInTx scopes tx to the schema of the AppContext in ctx with SET LOCAL search_path, which ends with the transaction, so the connection goes back to the pool without it. Call it first in every transaction an application runs.

type Execer added in v0.21.0

type Execer interface {
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}

Execer runs a statement. pgx.Tx, *pgx.Conn and *pgxpool.Pool satisfy it.

type FullContext

type FullContext struct {
	// App context (from X-App-ID header routing)
	App *AppContext

	// JWT claims (from session/middleware)
	Claims *jwt.Claims

	// RLS context
	TenantID uuid.UUID
	UserID   uuid.UUID

	// Federation context (from SystemAuth, optional)
	FederationID uuid.UUID
}

FullContext represents the complete context available in a multi-app request. It combines app context with authentication context.

func FullContextFromContext

func FullContextFromContext(ctx context.Context) *FullContext

FullContextFromContext extracts all context values into a unified struct. Returns nil for any values that are not present.

func RequireAppAndAuth

func RequireAppAndAuth(ctx context.Context) (*FullContext, error)

RequireAppAndAuth is a middleware helper that ensures both app context and authentication are present. Returns the full context if valid.

func (*FullContext) HasApp

func (fc *FullContext) HasApp() bool

HasApp returns true if the context has app context (multi-app mode).

func (*FullContext) IsAuthenticated

func (fc *FullContext) IsAuthenticated() bool

IsAuthenticated returns true if the context has valid authentication.

func (*FullContext) IsFederated

func (fc *FullContext) IsFederated() bool

IsFederated returns true if the user has a SystemAuth federation ID.

func (*FullContext) OrganizationID

func (fc *FullContext) OrganizationID() *uuid.UUID

OrganizationID returns the current organization context. Returns nil if no organization is selected.

func (*FullContext) PrincipalID

func (fc *FullContext) PrincipalID() uuid.UUID

PrincipalID returns the authenticated principal ID. Returns uuid.Nil if not authenticated.

type IsolationError added in v0.21.0

type IsolationError struct {
	Violations []IsolationViolation
}

IsolationError lists the isolation violations found. Its message names apps, roles and schemas, never a password or connection string.

func (*IsolationError) Error added in v0.21.0

func (e *IsolationError) Error() string

type IsolationViolation added in v0.21.0

type IsolationViolation struct {
	App     string
	Problem string
}

IsolationViolation is one way an app's login reaches beyond its schema.

type MemoryCache

type MemoryCache struct {
	// contains filtered or unexported fields
}

MemoryCache implements Cache using in-memory storage. Useful for testing and single-instance deployments.

func NewMemoryCache

func NewMemoryCache() *MemoryCache

NewMemoryCache creates a new in-memory cache.

func (*MemoryCache) Delete

func (c *MemoryCache) Delete(ctx context.Context, key string) error

Delete removes a value from memory.

func (*MemoryCache) Get

func (c *MemoryCache) Get(ctx context.Context, key string) ([]byte, error)

Get retrieves a value from memory.

func (*MemoryCache) Set

func (c *MemoryCache) Set(ctx context.Context, key string, value []byte, ttlSeconds int) error

Set stores a value in memory.

func (*MemoryCache) WithPrefix

func (c *MemoryCache) WithPrefix(prefix string) Cache

WithPrefix returns a new cache with a key prefix.

type MiddlewareConfig

type MiddlewareConfig struct {
	// JWTService validates JWT tokens (required).
	JWTService *jwt.Service

	// RequireAuth requires authentication for all requests.
	// If false, requests without credentials are allowed through.
	RequireAuth bool

	// SetRLSContext automatically sets RLS context from JWT claims.
	SetRLSContext bool

	// AppAudiences binds a token to the app a request is routed to: when the
	// request has an app context, the token must carry one of the audiences
	// returned for that app, so a token minted for one app cannot be replayed
	// against another by changing the X-App-ID header. Default: the app's
	// AppID, so mint tokens with jwt.TokenOptions{Audience: []string{appID}}.
	AppAudiences func(app *AppContext) []string

	// AllowCrossAppTokens turns app binding off: a request with an app
	// context accepts any token addressed to the JWT service's own
	// Config.Audience, whichever app it was minted for. Set it only when a
	// token is meant to be valid in every app.
	AllowCrossAppTokens bool

	// DPoP configures DPoP (RFC 9449), which AuthMiddleware requires by
	// default: a token must be bound to a key (cnf.jkt) and come with the
	// DPoP scheme and a fresh proof by that key for the request, carrying
	// the token's hash. DPoP.ReplayCache is required (NewRedisReplayCache
	// when several instances serve the apps). Set DPoP.BaseURL to the
	// external base URL clients use, including any path prefix a proxy
	// strips: the proof's htu must equal it plus the request path as this
	// server sees it. DPoP.AllowBearer is the explicit opt-in that also
	// accepts unbound bearer tokens; a DPoP-bound token is never accepted
	// as Bearer.
	DPoP middleware.DPoPConfig

	// OnError is called when authentication fails.
	// If nil, a default error response is sent.
	OnError func(w http.ResponseWriter, r *http.Request, err error)
}

MiddlewareConfig configures the multiapp middleware stack.

type Migration

type Migration struct {
	// Version is the migration version number (must be unique and sequential).
	Version int

	// Name is a short description of the migration.
	Name string

	// Up applies the migration.
	Up func(ctx context.Context, db *SchemaDB) error

	// Down rolls back the migration (optional).
	Down func(ctx context.Context, db *SchemaDB) error
}

Migration represents a database migration for an app.

type PasswordSource added in v0.21.0

type PasswordSource func(ctx context.Context) (string, error)

PasswordSource returns a role's password, for example from a secret manager or the environment. It is called once, when the password is set.

func PasswordFromEnv added in v0.21.0

func PasswordFromEnv(name string) PasswordSource

PasswordFromEnv reads the password from the environment variable name. An unset or empty variable is an error.

type RedisCache

type RedisCache struct {
	// contains filtered or unexported fields
}

RedisCache implements Cache using Redis.

func NewRedisCache

func NewRedisCache(redisURL string) (*RedisCache, error)

NewRedisCache creates a new Redis-backed cache.

func (*RedisCache) Close

func (c *RedisCache) Close() error

Close closes the Redis connection.

func (*RedisCache) Delete

func (c *RedisCache) Delete(ctx context.Context, key string) error

Delete removes a value from Redis.

func (*RedisCache) Get

func (c *RedisCache) Get(ctx context.Context, key string) ([]byte, error)

Get retrieves a value from Redis.

func (*RedisCache) Set

func (c *RedisCache) Set(ctx context.Context, key string, value []byte, ttlSeconds int) error

Set stores a value in Redis.

func (*RedisCache) WithPrefix

func (c *RedisCache) WithPrefix(prefix string) Cache

WithPrefix returns a new cache with a key prefix.

type RoleProvisioning added in v0.21.0

type RoleProvisioning struct {
	// Schema is the app's schema, normally SchemaFromAppSlug(slug).
	// Required.
	Schema string

	// Role is the app's login role. Required.
	Role string

	// OwnerRole is the migration login: it owns Schema and creates its
	// tables, and the app role receives privileges on the tables it
	// creates through default privileges. It must differ from Role, and
	// the login that applies the SQL must be able to act as it (a
	// superuser, or a member of OwnerRole). Required.
	OwnerRole string

	// Database, when set, is granted CONNECT to Role.
	Database string

	// ReadOnly limits Role to SELECT on its schema's tables. By default
	// it gets SELECT, INSERT, UPDATE and DELETE.
	ReadOnly bool

	// SharedSchema is the schema holding the shared SystemForge tables.
	// Default "public".
	SharedSchema string

	// SharedReadTables are tables in SharedSchema Role may read (SELECT
	// only). Nil, the default, means DefaultSharedReadTables; a non-nil
	// empty slice (NoSharedReadTables) grants none, for an app that does
	// not read the identity tables. A non-empty list replaces the
	// default, so append to DefaultSharedReadTables to add tables. Tables
	// that do not exist yet are skipped, so apply the SQL again after the
	// identity migrations create them.
	SharedReadTables []string

	// MemberOf lists roles Role is made a member of, for example rds_iam
	// for IAM database authentication on Amazon RDS. Never list another
	// app's role or OwnerRole: the isolation check rejects it.
	MemberOf []string
}

RoleProvisioning describes the database login of one app: a role that can log in, use its own schema and nothing else of the other apps.

func (RoleProvisioning) SQL added in v0.21.0

func (p RoleProvisioning) SQL() ([]string, error)

SQL returns the statements that provision the app's login, in order. They are idempotent, so they can be applied again, for example after new shared tables appear. They never contain a password; ApplyRoleProvisioning sets one.

The statements:

  • create Role (if missing) with LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION, and reset LOGIN, NOINHERIT, NOCREATEDB and NOCREATEROLE on an existing one (changing SUPERUSER or BYPASSRLS needs a superuser, so the isolation check reports those instead);
  • create Schema owned by OwnerRole and revoke PUBLIC's access to it;
  • grant Role USAGE on Schema and table, sequence and function privileges on what exists, plus default privileges on what OwnerRole creates later;
  • revoke CREATE on schema public from PUBLIC and EXECUTE on OwnerRole's future functions from PUBLIC;
  • grant SELECT on the shared tables (SharedReadTables, by default DefaultSharedReadTables), and set Role's search_path to Schema and SharedSchema.

type SchemaDB

type SchemaDB struct {
	// contains filtered or unexported fields
}

SchemaDB provides database operations scoped to a specific schema.

func (*SchemaDB) AcquireConn deprecated

func (db *SchemaDB) AcquireConn(ctx context.Context) (*pgxpool.Conn, error)

AcquireConn acquires a connection with the schema's search_path set for the whole session.

Deprecated: the setting stays on the connection after Release, so the pool can hand it, with this schema, to unrelated code. Use Begin, whose search_path ends with the transaction.

func (*SchemaDB) Begin

func (db *SchemaDB) Begin(ctx context.Context) (*SchemaTx, error)

Begin starts a transaction with the schema's search_path.

func (*SchemaDB) BeginTx

func (db *SchemaDB) BeginTx(ctx context.Context, opts pgx.TxOptions) (*SchemaTx, error)

BeginTx starts a transaction with options and the schema's search_path.

func (*SchemaDB) Exec

func (db *SchemaDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)

Exec executes a statement with the schema's search_path, in its own transaction.

func (*SchemaDB) Pool

func (db *SchemaDB) Pool() *pgxpool.Pool

Pool returns the underlying connection pool.

func (*SchemaDB) Query

func (db *SchemaDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)

Query executes a query and returns rows with the schema's search_path. The rows hold a transaction open until they are closed (Close, or Next returning false), so always close them.

func (*SchemaDB) QueryRow

func (db *SchemaDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row

QueryRow executes a query and returns a single row with the schema's search_path. The transaction ends when the row is scanned.

func (*SchemaDB) Schema

func (db *SchemaDB) Schema() string

Schema returns the schema name.

func (*SchemaDB) StdDB

func (db *SchemaDB) StdDB(ctx context.Context) (*sql.DB, error)

StdDB opens a *sql.DB for use with Ent or other database/sql libraries, connected with the same login as this SchemaDB and with search_path set to its schema on every connection. Each call opens a new pool; open it once per app and close it when done. Usage with Ent:

db, err := deps.DB.StdDB(ctx)
if err != nil {
    return err
}
client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))

type SchemaTx

type SchemaTx struct {
	// contains filtered or unexported fields
}

SchemaTx is a transaction scoped to a schema.

func (*SchemaTx) Commit

func (tx *SchemaTx) Commit(ctx context.Context) error

Commit commits the transaction.

func (*SchemaTx) Exec

func (tx *SchemaTx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)

Exec executes a query in the transaction.

func (*SchemaTx) Query

func (tx *SchemaTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)

Query executes a query and returns rows in the transaction.

func (*SchemaTx) QueryRow

func (tx *SchemaTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row

QueryRow executes a query and returns a single row in the transaction.

func (*SchemaTx) Rollback

func (tx *SchemaTx) Rollback(ctx context.Context) error

Rollback rolls back the transaction.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server manages multiple app backends on shared or dedicated infrastructure.

func NewServer

func NewServer(cfg Config) (*Server, error)

NewServer creates a new multi-app server. The server does not require any external dependencies like SystemAuth.

func (*Server) Apps

func (s *Server) Apps() []string

Apps returns the list of registered app slugs.

func (*Server) GetApp

func (s *Server) GetApp(slug string) (AppBackend, bool)

GetApp returns a registered app by slug.

func (*Server) RegisterApp

func (s *Server) RegisterApp(backend AppBackend) error

RegisterApp registers an app backend with the server. It connects with the app's own database login, creates the app's schema and runs its migrations with the migration login when in-process migrations are enabled (otherwise it requires the schema to exist), and checks that the app's login is confined to its schema; any failure refuses the app.

func (*Server) Router

func (s *Server) Router() *chi.Mux

Router returns the underlying chi router for custom middleware.

func (*Server) Run

func (s *Server) Run(addr string) error

Run verifies database isolation, then starts the HTTP server and blocks until shutdown.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully shuts down the server.

func (*Server) VerifyDatabaseIsolation added in v0.21.0

func (s *Server) VerifyDatabaseIsolation(ctx context.Context) error

VerifyDatabaseIsolation runs CheckDatabaseIsolation over every registered app with its own login. RegisterApp and Run call it; call it yourself before serving if you serve Router without Run. It returns nil when SkipIsolationCheck is set.

type ServerMode

type ServerMode string

ServerMode determines how the server handles app routing.

const (
	// MultiAppMode routes requests to apps based on X-App-ID header.
	// Multiple apps share the same server instance.
	MultiAppMode ServerMode = "multi"

	// SingleAppMode runs a single app without header-based routing.
	// The app owns the entire server instance.
	SingleAppMode ServerMode = "single"
)

type TxBeginner added in v0.21.0

type TxBeginner interface {
	Begin(ctx context.Context) (pgx.Tx, error)
}

TxBeginner starts a transaction. *pgx.Conn and *pgxpool.Pool satisfy it.

Directories

Path Synopsis
Package example provides a minimal example of implementing the AppBackend interface.
Package example provides a minimal example of implementing the AppBackend interface.
cmd command
Package main demonstrates running apps in single-app and multi-app modes.
Package main demonstrates running apps in single-app and multi-app modes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL