mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-09 06:46:28 +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,165 @@
|
||||
package lookup
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
ddlpkg "github.com/bitechdev/ResolveSpec/pkg/security/lookup/ddl"
|
||||
)
|
||||
|
||||
// TestDefaultSchemaMatchesSQLiteDDL keeps the default schema in step with the reference DDL:
|
||||
// every table and column in the DDL must be a known logical column and vice versa.
|
||||
func TestDefaultSchemaMatchesSQLiteDDL(t *testing.T) {
|
||||
ddl, err := ddlpkg.SQL("sqlite")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tableRe := regexp.MustCompile(`(?s)CREATE TABLE IF NOT EXISTS (\w+) \((.*?)\n\);`)
|
||||
colRe := regexp.MustCompile(`^\s*(\w+)\s+[A-Z]+`)
|
||||
def := DefaultSchema()
|
||||
seen := map[Entity]bool{}
|
||||
for _, m := range tableRe.FindAllStringSubmatch(string(ddl), -1) {
|
||||
e := Entity(m[1])
|
||||
tbl, ok := def[e]
|
||||
if !ok {
|
||||
t.Errorf("DDL table %s has no entity", e)
|
||||
continue
|
||||
}
|
||||
seen[e] = true
|
||||
cols := map[string]bool{}
|
||||
for _, line := range strings.Split(m[2], "\n") {
|
||||
if cm := colRe.FindStringSubmatch(line); cm != nil && cm[1] != "PRIMARY" && cm[1] != "CHECK" && cm[1] != "FOREIGN" {
|
||||
cols[cm[1]] = true
|
||||
if _, ok := tbl.Columns[cm[1]]; !ok {
|
||||
t.Errorf("DDL column %s.%s is not a logical column", e, cm[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := range tbl.Columns {
|
||||
if !cols[c] {
|
||||
t.Errorf("logical column %s.%s is not in the DDL", e, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
for e := range def {
|
||||
if !seen[e] {
|
||||
t.Errorf("entity %s is not in the DDL", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSchemaValid(t *testing.T) {
|
||||
if err := DefaultSchema().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DefaultProcNames().Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaMergeAndLookup(t *testing.T) {
|
||||
cfg := Config{Schema: Schema{
|
||||
EntityUsers: {Name: "app_users", Schema: "auth", Columns: map[string]string{"username": "login_name"}},
|
||||
}}
|
||||
r, err := cfg.Resolve()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.Schema.TableName(EntityUsers); got != "app_users" {
|
||||
t.Errorf("table = %q", got)
|
||||
}
|
||||
if got := r.Schema.SchemaName(EntityUsers); got != "auth" {
|
||||
t.Errorf("schema = %q", got)
|
||||
}
|
||||
if got := r.Schema.Col(UsersUsername); got != "login_name" {
|
||||
t.Errorf("username col = %q", got)
|
||||
}
|
||||
if got := r.Schema.Col(UsersEmail); got != "email" {
|
||||
t.Errorf("email col = %q, want default", got)
|
||||
}
|
||||
// Merge must not mutate the defaults.
|
||||
if DefaultSchema().Col(UsersUsername) != "username" {
|
||||
t.Error("default schema was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaZeroValueUsesDefaults(t *testing.T) {
|
||||
var s Schema
|
||||
if s.TableName(EntityUserKeys) != "user_keys" || s.Col(KeysKeyHash) != "key_hash" {
|
||||
t.Error("zero schema should fall back to defaults")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsBadConfig(t *testing.T) {
|
||||
bad := map[string]Config{
|
||||
"table injection": {Schema: Schema{EntityUsers: {Name: "users; DROP TABLE users"}}},
|
||||
"column injection": {Schema: Schema{EntityUsers: {Columns: map[string]string{"id": "id) --"}}}},
|
||||
"schema injection": {Schema: Schema{EntityUsers: {Schema: "a.b"}}},
|
||||
"unknown entity": {Schema: Schema{"nope": {Name: "x"}}},
|
||||
"unknown column": {Schema: Schema{EntityUsers: {Columns: map[string]string{"nope": "x"}}}},
|
||||
"proc injection": {Procs: ProcNames{Login: "f(); --"}},
|
||||
"bad mode": {Mode: "sometimes"},
|
||||
"bad override": {Overrides: map[Op]Mode{OpLogin: "x"}},
|
||||
"bad dialect": {Dialect: "oracle"},
|
||||
}
|
||||
for name, cfg := range bad {
|
||||
if _, err := cfg.Resolve(); err == nil {
|
||||
t.Errorf("%s: expected error", name)
|
||||
}
|
||||
}
|
||||
// A single schema qualifier is allowed on table and procedure names.
|
||||
ok := Config{
|
||||
Schema: Schema{EntityUsers: {Name: "auth.users"}},
|
||||
Procs: ProcNames{Login: "auth.resolvespec_login"},
|
||||
}
|
||||
if _, err := ok.Resolve(); err != nil {
|
||||
t.Errorf("qualified names should be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcNamesMerge(t *testing.T) {
|
||||
m := DefaultProcNames().Merge(ProcNames{Login: "custom_login"})
|
||||
if m.Login != "custom_login" {
|
||||
t.Errorf("Login = %q", m.Login)
|
||||
}
|
||||
if m.Register != "resolvespec_register" {
|
||||
t.Errorf("Register = %q, want default", m.Register)
|
||||
}
|
||||
if DefaultProcNames().LoginAPIKey != "resolvespec_login_api_key" {
|
||||
t.Error("LoginAPIKey default missing")
|
||||
}
|
||||
if DefaultProcNames().KeystoreValidateKey != "resolvespec_keystore_validate_key" {
|
||||
t.Error("keystore defaults missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
dialect string
|
||||
want Mode
|
||||
wantErr bool
|
||||
}{
|
||||
{"pg default", Config{}, DialectPostgres, ModeProcedure, false},
|
||||
{"sqlite default", Config{}, DialectSQLite, ModeDirect, false},
|
||||
{"mysql default", Config{}, DialectMySQL, ModeDirect, false},
|
||||
{"pg direct", Config{Mode: ModeDirect}, DialectPostgres, ModeDirect, false},
|
||||
{"pg auto probes", Config{Mode: ModeAuto}, DialectPostgres, ModeAuto, false},
|
||||
{"sqlite auto is direct", Config{Mode: ModeAuto}, DialectSQLite, ModeDirect, false},
|
||||
{"sqlite procedure rejected", Config{Mode: ModeProcedure}, DialectSQLite, "", true},
|
||||
{"override wins", Config{Overrides: map[Op]Mode{OpSession: ModeDirect}}, DialectPostgres, ModeDirect, false},
|
||||
{"override only for its op", Config{Overrides: map[Op]Mode{OpSession: ModeDirect}}, DialectPostgres, ModeProcedure, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
op := OpLogin
|
||||
if strings.HasPrefix(c.name, "override wins") {
|
||||
op = OpSession
|
||||
}
|
||||
got, err := c.cfg.EffectiveMode(op, c.dialect)
|
||||
if (err != nil) != c.wantErr || got != c.want {
|
||||
t.Errorf("%s: got (%q, %v), want (%q, err=%v)", c.name, got, err, c.want, c.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user