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:
Hein
2026-10-01 13:19:44 +02:00
parent 60bd0a6dd3
commit c9fa8c60f2
118 changed files with 11218 additions and 5565 deletions
+28 -36
View File
@@ -5,37 +5,41 @@ import (
"database/sql"
"encoding/base64"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
_ "github.com/glebarez/go-sqlite"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup/ddl"
)
// directConfig forces the direct (table) backend for every operation.
var directConfig = lookup.Config{Mode: lookup.ModeDirect}
func futureTime() time.Time {
return time.Now().Add(1 * time.Hour)
}
// newDirectTestDB opens a fresh in-memory SQLite database and applies the
// portable Direct-mode schema (database_schema_sqlite.sql), giving every
// portable Direct-mode schema (lookup/ddl/sqlite.sql), giving every
// Direct-mode test a real, isolated database to exercise end-to-end.
func newDirectTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", "file::memory:?cache=shared")
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("failed to open sqlite db: %v", err)
}
db.SetMaxOpenConns(1) // keep the shared in-memory db single-connection so state isn't lost
t.Cleanup(func() { _ = db.Close() })
schemaPath := filepath.Join("database_schema_sqlite.sql")
schema, err := os.ReadFile(schemaPath)
schema, err := ddl.SQL("sqlite")
if err != nil {
t.Fatalf("failed to read schema: %v", err)
}
if _, err := db.Exec(string(schema)); err != nil {
if _, err := db.Exec(schema); err != nil {
t.Fatalf("failed to apply schema: %v", err)
}
return db
@@ -49,7 +53,7 @@ func authenticatedRequest(token string) *http.Request {
func TestDirectMode_RegisterThenLogin(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{
@@ -105,7 +109,7 @@ func TestDirectMode_RegisterThenLogin(t *testing.T) {
func TestDirectMode_SessionLifecycle(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
loginResp, err := auth.Register(ctx, RegisterRequest{Username: "bob", Password: "p", Email: "bob@example.com"})
@@ -140,7 +144,7 @@ func TestDirectMode_SessionLifecycle(t *testing.T) {
func TestDirectMode_PasswordReset(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
if _, err := auth.Register(ctx, RegisterRequest{Username: "carol", Password: "old", Email: "carol@example.com"}); err != nil {
@@ -167,8 +171,8 @@ func TestDirectMode_PasswordReset(t *testing.T) {
func TestDirectMode_JWTLoginAndLogout(t *testing.T) {
db := newDirectTestDB(t)
jwtAuth := NewJWTAuthenticator("secret", db).WithQueryMode(ModeDirect)
directAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
jwtAuth := NewJWTAuthenticator("secret", db).WithLookup(directConfig)
directAuth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
if _, err := directAuth.Register(ctx, RegisterRequest{Username: "dave", Password: "p", Email: "dave@example.com"}); err != nil {
@@ -190,7 +194,7 @@ func TestDirectMode_JWTLoginAndLogout(t *testing.T) {
func TestDirectMode_TOTPEnableAndValidateBackupCode(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{Username: "erin", Password: "p", Email: "erin@example.com"})
@@ -199,7 +203,7 @@ func TestDirectMode_TOTPEnableAndValidateBackupCode(t *testing.T) {
}
userID := regResp.User.UserID
totp := NewDatabaseTwoFactorProvider(db, nil).WithQueryMode(ModeDirect)
totp := NewDatabaseTwoFactorProvider(db, nil).WithLookup(directConfig)
if err := totp.Enable2FA(userID, "SECRET123", []string{"code1", "code2"}); err != nil {
t.Fatalf("Enable2FA() error = %v", err)
@@ -248,7 +252,7 @@ func TestDirectMode_TOTPEnableAndValidateBackupCode(t *testing.T) {
func TestDirectMode_PasskeyStoreAndFetch(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{Username: "frank", Password: "p", Email: "frank@example.com"})
@@ -258,7 +262,7 @@ func TestDirectMode_PasskeyStoreAndFetch(t *testing.T) {
userID := regResp.User.UserID
passkeys := NewDatabasePasskeyProvider(db, DatabasePasskeyProviderOptions{
RPID: "example.com", RPName: "Example", RPOrigin: "https://example.com", QueryMode: ModeDirect,
RPID: "example.com", RPName: "Example", RPOrigin: "https://example.com", Lookup: directConfig,
})
cred, err := passkeys.CompleteRegistration(ctx, userID, PasskeyRegistrationResponse{
@@ -317,7 +321,7 @@ func TestDirectMode_PasskeyStoreAndFetch(t *testing.T) {
func TestDirectMode_OAuthGetOrCreateUserAndSession(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
userCtx := &UserContext{UserName: "gina", Email: "gina@example.com", Roles: []string{"user"}}
@@ -341,7 +345,7 @@ func TestDirectMode_OAuthGetOrCreateUserAndSession(t *testing.T) {
func TestDirectMode_KeyStoreCreateAndValidate(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
regResp, err := auth.Register(ctx, RegisterRequest{Username: "henry", Password: "p", Email: "henry@example.com"})
@@ -349,7 +353,7 @@ func TestDirectMode_KeyStoreCreateAndValidate(t *testing.T) {
t.Fatalf("Register() error = %v", err)
}
ks := NewDatabaseKeyStore(db, DatabaseKeyStoreOptions{QueryMode: ModeDirect})
ks := NewDatabaseKeyStore(db, DatabaseKeyStoreOptions{Lookup: directConfig})
createResp, err := ks.CreateKey(ctx, CreateKeyRequest{
UserID: regResp.User.UserID,
@@ -399,7 +403,7 @@ func TestDirectMode_KeyStoreCreateAndValidate(t *testing.T) {
func TestDirectMode_OAuthServerClientAndCode(t *testing.T) {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig})
ctx := context.Background()
client := &OAuthServerClient{
@@ -489,7 +493,7 @@ func TestDirectMode_OAuthServerClientAndCode(t *testing.T) {
func TestDirectMode_LegacyPlaintextUpgradeIsOptIn(t *testing.T) {
for _, enabled := range []bool{false, true} {
db := newDirectTestDB(t)
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{QueryMode: ModeDirect, UpgradePasswordHash: enabled})
auth := NewDatabaseAuthenticatorWithOptions(db, DatabaseAuthenticatorOptions{Lookup: directConfig, UpgradePasswordHash: enabled})
ctx := context.Background()
if _, err := db.Exec(`DELETE FROM users`); err != nil {
@@ -518,18 +522,6 @@ func TestDirectMode_LegacyPlaintextUpgradeIsOptIn(t *testing.T) {
}
}
func TestVerifyPasswordEdgeCases(t *testing.T) {
h, _ := hashPassword("pw")
if ok, _ := verifyPassword(h, "pw"); !ok {
t.Error("bcrypt match failed")
}
if ok, _ := verifyPassword("", "pw"); ok {
t.Error("empty stored must not match")
}
if ok, _ := verifyPassword("pw", ""); ok {
t.Error("empty supplied must not match")
}
if _, err := hashPassword(string(make([]byte, 73))); err == nil {
t.Error("73-byte password must be rejected")
}
func isBcryptHash(s string) bool {
return strings.HasPrefix(s, "$2a$") || strings.HasPrefix(s, "$2b$") || strings.HasPrefix(s, "$2y$")
}