mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-10 07:16:27 +00:00
refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).
- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
parameter, JSON null arrays, expires_at timezone casts, passkey list
GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
// Package backends assembles a lookup.Provider: it builds the procedure and direct stores for
|
||||
// one database and routes every operation to one of them according to lookup.Config.
|
||||
// It lives apart from package lookup because both backends import lookup.
|
||||
package backends
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/direct"
|
||||
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/procedure"
|
||||
)
|
||||
|
||||
// Options are the settings that are not naming or mode.
|
||||
type Options struct {
|
||||
// DBFactory is called to obtain a fresh *sql.DB when the current one has been closed.
|
||||
// Nil disables reconnecting.
|
||||
DBFactory func() (*sql.DB, error)
|
||||
// UpgradePasswordHash rewrites a legacy cleartext password as bcrypt after a successful
|
||||
// direct-mode login. Off by default.
|
||||
UpgradePasswordHash bool
|
||||
// NoGroupTables skips the group membership table when loading direct-mode policy rules.
|
||||
NoGroupTables bool
|
||||
}
|
||||
|
||||
// New builds a Provider for db. cfg is merged with the defaults and validated; the dialect
|
||||
// is cfg.Dialect or detected from the driver. Every operation's mode is resolved up front so
|
||||
// an impossible combination (procedure mode on a non-Postgres dialect) fails here, not on the
|
||||
// first request.
|
||||
func New(db *sql.DB, cfg lookup.Config, opts Options) (*lookup.Provider, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("backends: nil database")
|
||||
}
|
||||
res, err := cfg.Resolve()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := cfg.ResolveDialect(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, op := range lookup.AllOps() {
|
||||
if _, err := res.EffectiveMode(op, d.Name()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c := &chooser{cfg: res, dialect: d.Name(), procs: res.Procs}
|
||||
run := procedure.NewDB(db, opts.DBFactory, c.resetProbes)
|
||||
c.db = run
|
||||
|
||||
base, err := direct.NewBase(run, d, res.Schema)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := procedure.NewPasskey(run, res.Procs)
|
||||
return &lookup.Provider{
|
||||
Auth: &authRouter{c: c,
|
||||
proc: procedure.NewAuth(run, res.Procs),
|
||||
direct: direct.NewAuth(base, direct.AuthOptions{UpgradePasswordHash: opts.UpgradePasswordHash})},
|
||||
Keys: &keysRouter{c: c,
|
||||
proc: procedure.NewKeys(run, res.Procs),
|
||||
direct: direct.NewKeys(base)},
|
||||
OAuthClient: &oauthClientRouter{c: c,
|
||||
proc: procedure.NewOAuthClients(run, res.Procs),
|
||||
direct: direct.NewOAuthClients(base)},
|
||||
OAuthUser: &oauthUserRouter{c: c,
|
||||
proc: procedure.NewOAuthUsers(run, res.Procs),
|
||||
direct: direct.NewOAuthUsers(base)},
|
||||
Passkey: &passkeyRouter{c: c, proc: p, direct: direct.NewPasskey(base)},
|
||||
TOTP: &totpRouter{c: c,
|
||||
proc: procedure.NewTOTP(run, res.Procs),
|
||||
direct: direct.NewTOTP(base)},
|
||||
Policy: &policyRouter{c: c,
|
||||
proc: procedure.NewPolicy(run, res.Procs),
|
||||
direct: direct.NewPolicy(base, direct.PolicyOptions{NoGroups: opts.NoGroupTables})},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Failed returns a Provider whose every operation returns err. Constructors that cannot
|
||||
// return an error use it so a bad configuration fails closed on first use.
|
||||
func Failed(err error) *lookup.Provider {
|
||||
c := &chooser{fail: err}
|
||||
return &lookup.Provider{
|
||||
Auth: &authRouter{c: c},
|
||||
Keys: &keysRouter{c: c},
|
||||
OAuthClient: &oauthClientRouter{c: c},
|
||||
OAuthUser: &oauthUserRouter{c: c},
|
||||
Passkey: &passkeyRouter{c: c},
|
||||
TOTP: &totpRouter{c: c},
|
||||
Policy: &policyRouter{c: c},
|
||||
}
|
||||
}
|
||||
|
||||
// chooser decides per operation whether the procedure or the direct store runs.
|
||||
type chooser struct {
|
||||
cfg *lookup.Resolved
|
||||
dialect string
|
||||
procs lookup.ProcNames
|
||||
db *procedure.DB
|
||||
probes sync.Map // proc name -> bool
|
||||
fail error // set by Failed: every operation returns it
|
||||
}
|
||||
|
||||
func (c *chooser) resetProbes() {
|
||||
c.probes.Range(func(k, _ any) bool { c.probes.Delete(k); return true })
|
||||
}
|
||||
|
||||
// useProc reports whether op should call the stored procedure proc. In auto mode on Postgres
|
||||
// the catalog is probed once per procedure (cached until a reconnect).
|
||||
func (c *chooser) useProc(ctx context.Context, op lookup.Op, proc string) (bool, error) {
|
||||
if c.fail != nil {
|
||||
return false, c.fail
|
||||
}
|
||||
m, err := c.cfg.EffectiveMode(op, c.dialect)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch m {
|
||||
case lookup.ModeProcedure:
|
||||
return true, nil
|
||||
case lookup.ModeAuto:
|
||||
if v, ok := c.probes.Load(proc); ok {
|
||||
return v.(bool), nil
|
||||
}
|
||||
exists := probeProcedure(ctx, c.db.Get(), proc)
|
||||
c.probes.Store(proc, exists)
|
||||
return exists, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// probeProcedure asks the Postgres catalog whether a function exists. Any failure counts as
|
||||
// "does not exist" so the probe can never block an operation.
|
||||
func probeProcedure(ctx context.Context, db *sql.DB, proc string) (exists bool) {
|
||||
if db == nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = recover() }()
|
||||
dbtrace.Raw(ctx, "probe.pg_proc")
|
||||
if err := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1)`, proc).Scan(&exists); err != nil {
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// pick returns the store that should serve op.
|
||||
func pick[T any](c *chooser, ctx context.Context, op lookup.Op, proc string, p, d T) (T, error) {
|
||||
use, err := c.useProc(ctx, op, proc)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
if use {
|
||||
return p, nil
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
Reference in New Issue
Block a user