Storage¶
connectforge has two stores that pass the same conformance suite.
| Store | Package | Use |
|---|---|---|
| In memory | store/memstore |
Tests and examples; the reference for behaviour |
| SQL | sqlstore |
SQLite and PostgreSQL through Ent, on your own database |
A store implements connectforge.Store, which is ConnectionStore,
CredentialStore, GrantStore and BindingStore. The SQL store and
memstore also implement Leaser.
The SQL store¶
import "github.com/plexusone/connectforge/sqlstore"
store, err := sqlstore.New(db, sqlstore.Postgres,
sqlstore.WithTxHook(func(ctx context.Context, tx *sql.Tx) error {
return setTenant(ctx, tx) // for example, SET LOCAL for row-level security
}),
)
if err != nil {
return err
}
if err := store.Migrate(ctx); err != nil { // idempotent
return err
}
tables := store.Tables() // for your tenancy policy
- You own the database.
Newtakes an open*sql.DB, and you close it. The package imports no database driver; use the one you already use. - The schema is the module's. Ent code lives in
sqlstore/ent. You do not regenerate or merge it into your own Ent client; you share the database, not the code generation. - Tables are prefixed
connectforge_:connections,credentials,grants,bindingsandleases. - Every owned table has
owner_id(text), and every query filters on it. - Ciphertext is stored as bytes. No column holds plaintext.
Migrate¶
Migrate creates or updates the schema to match this version of the module.
Run it with a role allowed to change the schema, and not through the
transaction hook. A change that alters stored data is called out in the
changelog.
SQLite¶
SQLite needs foreign keys on (Ent requires it to migrate) and either one open connection or immediate transactions, so that concurrent writers queue instead of failing:
db, err := sql.Open("sqlite", "file:cf.db?_txlock=immediate&_pragma=foreign_keys(1)&_pragma=busy_timeout(10000)")
db.SetMaxOpenConns(1)
Concurrency¶
Races are decided by the database, not by locks in the process:
UpdateConnectionis a conditional update on the connection's revision. The loser getsErrConflictand retries (the service retries a few times).SwapCredentialinstalls version n+1 only if the active version is n; a unique index on (owner, connection, version) decides concurrent swaps.- A lease is a row with an expiry, taken by a conditional insert or update, so refresh can be single-flight across processes. Expiry uses the store's clock, so processes sharing a database should have synchronized clocks.
Row-level security¶
On PostgreSQL, enable row-level security on the owned tables so the database
refuses another tenant's rows even if a query forgot its filter. Key the
policy on owner_id, and set the tenant for each transaction in the hook:
func setTenant(ctx context.Context, tx *sql.Tx) error {
tenant, ok := tenantFrom(ctx)
if !ok {
return errors.New("no tenant in context") // fail closed
}
// set_config(..., true) is SET LOCAL, scoped to this transaction.
_, err := tx.ExecContext(ctx, "SELECT set_config('app.current_tenant', $1, true)", tenant)
return err
}
ALTER TABLE connectforge_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE connectforge_connections FORCE ROW LEVEL SECURITY;
CREATE POLICY connectforge_connections_tenant ON connectforge_connections
USING (owner_id = current_setting('app.current_tenant', true))
WITH CHECK (owner_id = current_setting('app.current_tenant', true));
Repeat for each table Tables() returns. The lease table has no owner and is
not part of the policy.
Test as an ordinary role
Superusers and the table owner bypass row-level security. Test your policy with an application role that owns nothing, or it will appear to work.
The in-memory store¶
memstore.New() returns a store and leaser that live as long as the process.
memstore.NewWithClock takes a clock so a test can advance lease expiry.
Nothing is persisted.
Testing your own store¶
store/storetest is the conformance suite. Call it with a constructor and it
checks owner isolation on every method, compare-and-swap under concurrency,
the transition table, cascade on delete, ordering and that returned records are
copies: