diff --git a/Makefile b/Makefile index 206156b8..9e8cfe7c 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,14 @@ test-postgres: test-cleanup-postgres docker rm -vf authorizer_postgres; \ exit $$status +test-serenedb: test-cleanup-serenedb + docker run -d --name authorizer_serenedb -p 7890:7890 -e POSTGRES_PASSWORD=postgres serenedb/serenedb:26.08.2 + sh scripts/wait-for-test-dbs.sh serenedb && \ + { go clean --testcache; TEST_DBS="serenedb" $(GO_TEST_ALL); }; \ + status=$$?; \ + docker rm -vf authorizer_serenedb; \ + exit $$status + test-sqlite: go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL) @@ -203,7 +211,7 @@ test-all-db: test-cleanup test-docker-up @# failed to start them and produced a second, misleading failure. Same @# capture-status-then-clean shape the e2e-playground target already uses. go clean --testcache; \ - TEST_DBS="couchbase,postgres,sqlite,mongodb,arangodb,scylladb,dynamodb" $(GO_TEST_ALL); \ + TEST_DBS="couchbase,postgres,serenedb,sqlite,mongodb,arangodb,scylladb,dynamodb" $(GO_TEST_ALL); \ status=$$?; \ $(MAKE) test-cleanup; \ exit $$status @@ -212,6 +220,7 @@ test-all-db: test-cleanup test-docker-up test-docker-up: docker run -d --name authorizer_redis -p 6380:6379 redis docker run -d --name authorizer_postgres -p 5434:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres postgres + docker run -d --name authorizer_serenedb -p 7890:7890 -e POSTGRES_PASSWORD=postgres serenedb/serenedb:26.08.2 docker run -d --name authorizer_mongodb_db -p 27017:27017 mongo:4.4.15 docker run -d --name authorizer_scylla_db -p 9042:9042 scylladb/scylla docker run -d --name authorizer_arangodb -p 8529:8529 -e ARANGO_NO_AUTH=1 arangodb/arangodb:3.10.3 @@ -223,6 +232,7 @@ test-docker-up: # Remove all test database containers test-cleanup: -docker rm -vf authorizer_postgres + -docker rm -vf authorizer_serenedb -docker rm -vf authorizer_scylla_db -docker rm -vf authorizer_mongodb_db -docker rm -vf authorizer_arangodb @@ -232,6 +242,8 @@ test-cleanup: test-cleanup-postgres: -docker rm -vf authorizer_postgres +test-cleanup-serenedb: + -docker rm -vf authorizer_serenedb test-cleanup-mongodb: -docker rm -vf authorizer_mongodb_db test-cleanup-scylladb: diff --git a/internal/config/fga.go b/internal/config/fga.go index b7899b23..4f2b35d4 100644 --- a/internal/config/fga.go +++ b/internal/config/fga.go @@ -29,8 +29,8 @@ func (c *Config) FGAStoreConfig() (store string, url string, enabled bool) { // Derive from the main database when OpenFGA supports it. Postgres- and // mysql-compatible variants beyond these (cockroachdb, yugabyte, libsql, - // planetscale) are intentionally NOT auto-mapped — they require an explicit - // --fga-store to avoid silent incompatibilities. + // planetscale, serenedb) are intentionally NOT auto-mapped — they require an + // explicit --fga-store to avoid silent incompatibilities. switch c.DatabaseType { case constants.DbTypePostgres: return "postgres", c.DatabaseURL, true diff --git a/internal/config/fga_test.go b/internal/config/fga_test.go index 0df3c696..05c2065a 100644 --- a/internal/config/fga_test.go +++ b/internal/config/fga_test.go @@ -127,6 +127,14 @@ func TestFGAStoreConfig(t *testing.T) { cfg: Config{DatabaseType: "cockroachdb", DatabaseURL: "postgres://h/db"}, wantEnabled: false, }, + { + // SereneDB speaks the Postgres wire protocol but is a search-OLAP + // engine, not an OpenFGA datastore. Auto-mapping it would point the + // authorization store at a backend OpenFGA never migrated. + name: "serenedb is NOT auto-mapped (needs explicit store)", + cfg: Config{DatabaseType: "serenedb", DatabaseURL: "postgres://h/db"}, + wantEnabled: false, + }, } for _, tc := range cases { diff --git a/internal/constants/db_types.go b/internal/constants/db_types.go index d6d0777f..31bbfedc 100644 --- a/internal/constants/db_types.go +++ b/internal/constants/db_types.go @@ -19,6 +19,8 @@ const ( DbTypePlanetScaleDB = "planetscale" // DbTypeCockroachDB is the cockroach database type DbTypeCockroachDB = "cockroachdb" + // DbTypeSereneDB is the serenedb database type (PostgreSQL wire protocol) + DbTypeSereneDB = "serenedb" // DbTypeArangoDB is the arangodb database type DbTypeArangoDB = "arangodb" diff --git a/internal/storage/db/serenedb/connpool.go b/internal/storage/db/serenedb/connpool.go new file mode 100644 index 00000000..1ddd888c --- /dev/null +++ b/internal/storage/db/serenedb/connpool.go @@ -0,0 +1,103 @@ +package serenedb + +import ( + "context" + stdsql "database/sql" + "errors" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" +) + +// maxWriteConflictRetries bounds the retry loop below. SereneDB resolves a +// conflict by aborting one writer immediately rather than making it wait, so +// the retries are cheap and a contended row converges in a few rounds. +const maxWriteConflictRetries = 8 + +// sqlState reports whether err carries the given SQLSTATE. +func sqlState(err error, code string) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == code +} + +// uniqueViolation reports whether err is a duplicate-key rejection from a +// unique index. SereneDB returns the standard SQLSTATE 23505 for these. +func uniqueViolation(err error) bool { return sqlState(err, "23505") } + +// retryPool retries statements SereneDB aborts with a write conflict. +// +// Its MVCC is optimistic: two transactions touching the same row abort one of +// them (SQLSTATE 40001, "Conflict on tuple deletion") rather than blocking it, +// so retrying — not failing — is the resolution. Without this, concurrent +// writes to one row fail most of the time and leak the driver's message to API +// callers: 20 simultaneous profile updates for one user produced 17 errors. +// +// The retry sits at the pool so every write path gets it, not just the ones +// this package overrides. It applies only to autocommit statements: BeginTx +// hands back the raw *sql.Tx, because replaying one statement inside an already +// aborted transaction is wrong — the whole transaction has to be replayed, and +// GORM's Transaction() offers no hook for that. This is also why the provider +// sets SkipDefaultTransaction: GORM otherwise wraps every single Create/Update +// in a transaction, which would route all writes through BeginTx and past this +// retry. The four explicit Transaction() call sites in the SQL provider +// (user/client/organization/webhook cascade deletes) still surface 40001 under +// contention. +type retryPool struct { + db *stdsql.DB +} + +// newRetryPool adapts a database/sql handle for gorm. +func newRetryPool(db *stdsql.DB) gorm.ConnPool { return &retryPool{db: db} } + +func (p *retryPool) retry(fn func() error) error { + var err error + for attempt := 0; attempt < maxWriteConflictRetries; attempt++ { + if err = fn(); err == nil || !sqlState(err, "40001") { + return err + } + time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond) + } + return err +} + +func (p *retryPool) PrepareContext(ctx context.Context, query string) (*stdsql.Stmt, error) { + return p.db.PrepareContext(ctx, query) +} + +func (p *retryPool) ExecContext(ctx context.Context, query string, args ...any) (stdsql.Result, error) { + var res stdsql.Result + err := p.retry(func() error { + var err error + res, err = p.db.ExecContext(ctx, query, args...) + return err + }) + return res, err +} + +func (p *retryPool) QueryContext(ctx context.Context, query string, args ...any) (*stdsql.Rows, error) { + var rows *stdsql.Rows + err := p.retry(func() error { + var err error + rows, err = p.db.QueryContext(ctx, query, args...) + return err + }) + return rows, err +} + +// QueryRowContext cannot retry: *sql.Row defers its error to Scan, so the +// conflict is not visible here. Single-row reads do not raise 40001 — it is a +// write conflict — so this is a read-path no-op rather than a gap. +func (p *retryPool) QueryRowContext(ctx context.Context, query string, args ...any) *stdsql.Row { + return p.db.QueryRowContext(ctx, query, args...) +} + +// BeginTx implements gorm.ConnPoolBeginner. The transaction is deliberately +// unwrapped — see the type comment. +func (p *retryPool) BeginTx(ctx context.Context, opts *stdsql.TxOptions) (gorm.ConnPool, error) { + return p.db.BeginTx(ctx, opts) +} + +// GetDBConn implements gorm.GetDBConnector so gorm.DB.DB() keeps working — +// HealthCheck and Close both go through it. +func (p *retryPool) GetDBConn() (*stdsql.DB, error) { return p.db, nil } diff --git a/internal/storage/db/serenedb/provider.go b/internal/storage/db/serenedb/provider.go new file mode 100644 index 00000000..2274be86 --- /dev/null +++ b/internal/storage/db/serenedb/provider.go @@ -0,0 +1,86 @@ +// Package serenedb adapts the SQL storage provider to SereneDB, a search-OLAP +// engine that speaks the PostgreSQL wire protocol. It reuses the SQL provider +// wholesale and overrides only the two places where SereneDB's SQL differs from +// PostgreSQL's: +// +// 1. Migration. GORM AutoMigrate reconciles column types on every boot, but +// SereneDB reports varchar(n) as text and refuses ALTER COLUMN ... TYPE on +// an indexed column, so the second startup fails. migrate below creates what +// is missing and never alters what exists. +// 2. Upserts with an explicit conflict target (see upsert.go). +// +// SereneDB's MVCC is optimistic, so contended same-row writes abort with +// SQLSTATE 40001 instead of blocking. That is handled at the connection pool +// (see connpool.go) so every write path gets it, not just the two overridden +// here — but only for autocommit statements. The SQL provider's four explicit +// Transaction() call sites still surface 40001 under sustained same-row +// contention. +package serenedb + +import ( + "github.com/rs/zerolog" + "gorm.io/gorm" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/storage/db/sql" +) + +// Dependencies struct for the serenedb data store provider +type Dependencies struct { + Log *zerolog.Logger +} + +type provider struct { + *sql.Provider +} + +// NewProvider returns a new SereneDB provider +func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { + base, err := sql.NewProviderWithOptions(cfg, &sql.Dependencies{Log: deps.Log}, sql.Options{ + Migrate: migrate, + WrapPool: newRetryPool, + SkipDefaultTransaction: true, + }) + if err != nil { + return nil, err + } + return &provider{Provider: base}, nil +} + +// migrate creates missing tables, columns and indexes, and nothing else. +// SereneDB supports CREATE TABLE, ALTER TABLE ADD COLUMN and CREATE INDEX, but +// not the ALTER COLUMN ... TYPE / SET NOT NULL / SET DEFAULT that GORM's +// AutoMigrate issues when reconciling an existing table. +func migrate(db *gorm.DB) error { + m := db.Migrator() + for _, model := range sql.Models() { + if !m.HasTable(model) { + if err := m.CreateTable(model); err != nil { + return err + } + continue + } + stmt := &gorm.Statement{DB: db} + if err := stmt.Parse(model); err != nil { + return err + } + for _, field := range stmt.Schema.Fields { + if field.DBName == "" || field.IgnoreMigration { + continue + } + if !m.HasColumn(model, field.DBName) { + if err := m.AddColumn(model, field.DBName); err != nil { + return err + } + } + } + for name := range stmt.Schema.ParseIndexes() { + if !m.HasIndex(model, name) { + if err := m.CreateIndex(model, name); err != nil { + return err + } + } + } + } + return nil +} diff --git a/internal/storage/db/serenedb/provider_test.go b/internal/storage/db/serenedb/provider_test.go new file mode 100644 index 00000000..0c6473cb --- /dev/null +++ b/internal/storage/db/serenedb/provider_test.go @@ -0,0 +1,230 @@ +package serenedb + +import ( + "context" + "net" + "os" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +const testDBURL = "postgres://postgres:postgres@localhost:7890/postgres" + +// newTestProvider connects to the SereneDB container started by +// `make test-serenedb`. Gated on TEST_DBS first, the way the SQL migration +// tests are, so `make test` (TEST_DBS=sqlite) stays Docker-free even on a +// machine that happens to have something bound to :7890. +func newTestProvider(t *testing.T) *provider { + t.Helper() + if !slices.Contains(strings.Split(os.Getenv("TEST_DBS"), ","), constants.DbTypeSereneDB) { + t.Skip("set TEST_DBS=serenedb (make test-serenedb) to run SereneDB tests") + } + conn, err := net.DialTimeout("tcp", "localhost:7890", 2*time.Second) + if err != nil { + t.Skipf("skipping SereneDB tests: not reachable on localhost:7890: %v", err) + } + _ = conn.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)).With().Timestamp().Logger() + p, err := NewProvider(&config.Config{ + DatabaseType: constants.DbTypeSereneDB, + DatabaseURL: testDBURL, + DatabaseName: "authorizer_test", + }, &Dependencies{Log: &logger}) + require.NoError(t, err) + require.NotNil(t, p) + return p +} + +// TestMigrateIsRepeatable is the restart path. GORM AutoMigrate fails here on +// the second boot — SereneDB reports varchar(n) as text, so AutoMigrate tries +// ALTER COLUMN ... TYPE and SereneDB refuses it on an indexed column. The +// create-only migrator must be a no-op once the schema exists. +func TestMigrateIsRepeatable(t *testing.T) { + p := newTestProvider(t) + require.NoError(t, p.Close()) + + for i := 0; i < 2; i++ { + p := newTestProvider(t) + require.NoError(t, p.Close()) + } +} + +// TestAddVerificationRequestUpsert covers the ON CONFLICT (email, identifier) +// fallback: SereneDB rejects a unique index as a conflict target, so the second +// request must be turned into an UPDATE off the 23505, not surface as an error +// or leave a duplicate row. +func TestAddVerificationRequestUpsert(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + email := uuid.New().String() + "@authorizer.dev" + identifier := "basic_auth_signup" + // Tokens must be unique per run: the table is not truncated between runs + // and GetVerificationRequestByToken looks them up globally. + tokenOne := "token-1-" + uuid.New().String() + tokenTwo := "token-2-" + uuid.New().String() + + first, err := p.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Email: email, + Identifier: identifier, + Token: tokenOne, + ExpiresAt: time.Now().Add(time.Hour).Unix(), + Nonce: "nonce-1", + }) + require.NoError(t, err) + require.NotNil(t, first) + + second, err := p.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Email: email, + Identifier: identifier, + Token: tokenTwo, + ExpiresAt: time.Now().Add(2 * time.Hour).Unix(), + Nonce: "nonce-2", + }) + require.NoError(t, err, "re-requesting verification must upsert, not fail on the unique index") + require.NotNil(t, second) + + // The live token is the new one, and the old one is gone — one row, updated. + got, err := p.GetVerificationRequestByToken(ctx, tokenTwo) + require.NoError(t, err) + assert.Equal(t, email, got.Email) + assert.Equal(t, "nonce-2", got.Nonce) + + _, err = p.GetVerificationRequestByToken(ctx, tokenOne) + assert.Error(t, err, "the superseded token must no longer resolve") + + var count int64 + require.NoError(t, p.DB().WithContext(ctx).Model(&schemas.VerificationRequest{}). + Where("email = ? AND identifier = ?", email, identifier).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +// TestAddAuthenticatorConcurrentEnrollment covers the (user_id, method) +// fallback. The check-then-insert in AddAuthenticator has a race; on PostgreSQL +// ON CONFLICT closes it. Concurrent enrollment must still leave exactly one +// row, or GetAuthenticatorDetailsByUserId's First() returns an arbitrary one +// and MFA fails intermittently. +func TestAddAuthenticatorConcurrentEnrollment(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + user, err := p.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(uuid.New().String() + "@authorizer.dev"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + const goroutines = 8 + var wg sync.WaitGroup + errs := make([]error, goroutines) + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = p.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "secret", + }) + }(i) + } + wg.Wait() + for i, err := range errs { + assert.NoError(t, err, "concurrent enrollment %d must not surface the unique violation", i) + } + + var count int64 + require.NoError(t, p.DB().WithContext(ctx).Model(&schemas.Authenticator{}). + Where("user_id = ? AND method = ?", user.ID, constants.EnvKeyTOTPAuthenticator). + Count(&count).Error) + assert.Equal(t, int64(1), count, "concurrent enrollment must not duplicate the authenticator") +} + +// TestAuthenticatorUniqueIndexEnforced is the backstop for the create-only +// migrator. AddAuthenticator's conflict fallback only works because the +// (user_id, method) unique index exists and raises 23505 — if the migrator ever +// stopped creating it, the concurrency test above would go quietly flaky +// instead of failing. This writes the duplicate row directly, bypassing the +// pre-check, and asserts the index rejects it. +func TestAuthenticatorUniqueIndexEnforced(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + userID := uuid.New().String() + first := &schemas.Authenticator{ + ID: uuid.New().String(), Key: uuid.New().String(), + UserID: userID, Method: constants.EnvKeyTOTPAuthenticator, Secret: "one", + } + require.NoError(t, p.DB().WithContext(ctx).Create(first).Error) + + second := &schemas.Authenticator{ + ID: uuid.New().String(), Key: uuid.New().String(), + UserID: userID, Method: constants.EnvKeyTOTPAuthenticator, Secret: "two", + } + err := p.DB().WithContext(ctx).Create(second).Error + require.Error(t, err, "a second enrollment for the same (user_id, method) must be rejected") + assert.True(t, uniqueViolation(err), "expected SQLSTATE 23505, got %v", err) +} + +// TestDeleteUserCascadeStaysAtomic guards the one place this provider's GORM +// config differs from the SQL one. SkipDefaultTransaction removes the implicit +// transaction GORM wraps around each single write — required, because that +// transaction routes every write through BeginTx and past the pool's conflict +// retry. Explicit Transaction() blocks are supposed to be unaffected, and +// DeleteUser's cascade is one of them. This asserts that rather than inferring +// it: every user-keyed table must be empty afterwards. +func TestDeleteUserCascadeStaysAtomic(t *testing.T) { + p := newTestProvider(t) + defer p.Close() //nolint:errcheck + ctx := context.Background() + + user, err := p.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(uuid.New().String() + "@authorizer.dev"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + orgID := uuid.New().String() + require.NoError(t, p.AddSession(ctx, &schemas.Session{UserID: user.ID})) + _, err = p.AddFederatedIdentity(ctx, &schemas.FederatedIdentity{ + OrgID: orgID, Issuer: "https://idp.example.com", Subject: uuid.New().String(), UserID: user.ID, + }) + require.NoError(t, err) + _, err = p.AddOrgMembership(ctx, &schemas.OrgMembership{OrgID: orgID, UserID: user.ID, Roles: "member"}) + require.NoError(t, err) + _, err = p.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, Method: constants.EnvKeyTOTPAuthenticator, Secret: "s3cret", + }) + require.NoError(t, err) + _, err = p.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: user.ID, CredentialID: uuid.New().String(), PublicKey: "pk", Name: "laptop", + }) + require.NoError(t, err) + require.NoError(t, p.AddSessionToken(ctx, &schemas.SessionToken{UserID: user.ID, KeyName: "access", Token: "t"})) + require.NoError(t, p.AddMFASession(ctx, &schemas.MFASession{UserID: user.ID, KeyName: "mfa"})) + + require.NoError(t, p.DeleteUser(ctx, user)) + + for _, table := range schemas.UserOwnedCollections { + var count int64 + require.NoError(t, p.DB().WithContext(ctx).Table(table).Where("user_id = ?", user.ID).Count(&count).Error) + assert.Zero(t, count, "%s still holds rows for the deleted user", table) + } +} diff --git a/internal/storage/db/serenedb/upsert.go b/internal/storage/db/serenedb/upsert.go new file mode 100644 index 00000000..6c2c39cb --- /dev/null +++ b/internal/storage/db/serenedb/upsert.go @@ -0,0 +1,91 @@ +package serenedb + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// AddAuthenticator mirrors the SQL provider's upsert on (user_id, method). +// +// SereneDB resolves an ON CONFLICT target only against an inline UNIQUE table +// constraint, never against a CREATE UNIQUE INDEX — which is what GORM emits +// for the `uniqueIndex` tag, and what ALTER TABLE cannot retrofit here. So the +// insert runs bare and the conflict is handled from the 23505 the index still +// raises: the loser of a concurrent enrollment updates the winner's row instead +// of leaving a duplicate behind. Same end state as ON CONFLICT DO UPDATE, and +// the same protection against the check-then-insert race above it. +func (p *provider) AddAuthenticator(ctx context.Context, authenticators *schemas.Authenticator) (*schemas.Authenticator, error) { + exists, _ := p.GetAuthenticatorDetailsByUserId(ctx, authenticators.UserID, authenticators.Method) + if exists != nil { + return authenticators, nil + } + + if authenticators.ID == "" { + authenticators.ID = uuid.New().String() + } + authenticators.Key = authenticators.ID + authenticators.CreatedAt = time.Now().Unix() + authenticators.UpdatedAt = time.Now().Unix() + + err := func() error { + err := p.DB().WithContext(ctx).Create(&authenticators).Error + if err == nil || !uniqueViolation(err) { + return err + } + // Lost the race. Update every column ON CONFLICT ... UPDATE ALL would + // have written — GORM excludes the primary key from UpdateAll, so id is + // left on the winning row. + return p.DB().WithContext(ctx).Model(&schemas.Authenticator{}). + Where("user_id = ? AND method = ?", authenticators.UserID, authenticators.Method). + Updates(map[string]any{ + "key": authenticators.Key, + "user_id": authenticators.UserID, + "method": authenticators.Method, + "secret": authenticators.Secret, + "recovery_codes": authenticators.RecoveryCodes, + "verified_at": authenticators.VerifiedAt, + "updated_at": authenticators.UpdatedAt, + }).Error + }() + if err != nil { + return nil, err + } + return authenticators, nil +} + +// AddVerificationRequest mirrors the SQL provider's upsert on +// (email, identifier). See AddAuthenticator for why ON CONFLICT is unavailable. +func (p *provider) AddVerificationRequest(ctx context.Context, verificationRequest *schemas.VerificationRequest) (*schemas.VerificationRequest, error) { + if verificationRequest.ID == "" { + verificationRequest.ID = uuid.New().String() + } + verificationRequest.Key = verificationRequest.ID + verificationRequest.CreatedAt = time.Now().Unix() + verificationRequest.UpdatedAt = time.Now().Unix() + + err := func() error { + err := p.DB().WithContext(ctx).Create(&verificationRequest).Error + if err == nil || !uniqueViolation(err) { + return err + } + // Re-requesting verification for the same (email, identifier) replaces + // the live token, exactly as the DoUpdates column list does on + // PostgreSQL. + return p.DB().WithContext(ctx).Model(&schemas.VerificationRequest{}). + Where("email = ? AND identifier = ?", verificationRequest.Email, verificationRequest.Identifier). + Updates(map[string]any{ + "token": verificationRequest.Token, + "expires_at": verificationRequest.ExpiresAt, + "nonce": verificationRequest.Nonce, + "redirect_uri": verificationRequest.RedirectURI, + }).Error + }() + if err != nil { + return verificationRequest, err + } + return verificationRequest, nil +} diff --git a/internal/storage/db/sql/provider.go b/internal/storage/db/sql/provider.go index 612fd3cf..07f852b9 100644 --- a/internal/storage/db/sql/provider.go +++ b/internal/storage/db/sql/provider.go @@ -1,7 +1,10 @@ package sql import ( + stdsql "database/sql" + libsql "github.com/ekristen/gorm-libsql" + _ "github.com/jackc/pgx/v5/stdlib" // database/sql driver for the pool seam below "github.com/rs/zerolog" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -40,15 +43,64 @@ type indexInfo struct { } **/ +// Provider is the SQL storage provider. It is exported so wire-compatible +// engines that need to override a handful of methods (see +// internal/storage/db/serenedb) can embed it instead of duplicating the whole +// backend. +type Provider = provider + +// DB exposes the underlying GORM handle to embedders. +func (p *provider) DB() *gorm.DB { return p.db } + +// Models is the full set of tables this provider owns, in migration order. +// Kept in one place so alternative migrators cannot drift from AutoMigrate. +func Models() []any { + return []any{&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.AuditLog{}, &schemas.Client{}, &schemas.TrustedIssuer{}, &schemas.Organization{}, &schemas.OrgMembership{}, &schemas.FederatedIdentity{}, &schemas.ScimEndpoint{}, &schemas.ScimGroup{}, &schemas.WebauthnCredential{}, &schemas.OrgDomain{}, &schemas.SAMLServiceProvider{}, &schemas.SAMLIDPKey{}} +} + +// Options customise how the provider is built. Both hooks exist for engines +// that speak a supported wire protocol but differ underneath; both are nil for +// every database type this package handles directly, so the default path is +// unchanged. +type Options struct { + // Migrate replaces GORM AutoMigrate. SereneDB rejects the + // ALTER COLUMN ... TYPE that AutoMigrate re-issues on every boot, so it + // supplies a create-only migrator instead. + Migrate func(*gorm.DB) error + // WrapPool wraps the connection pool before GORM sees it. SereneDB uses it + // to retry write conflicts its optimistic MVCC raises. PostgreSQL family + // only. + WrapPool func(*stdsql.DB) gorm.ConnPool + // SkipDefaultTransaction turns off the transaction GORM wraps around every + // single Create/Update/Delete. Only SereneDB sets it: that implicit + // transaction routes every write through BeginTx, where a conflict aborts + // the whole transaction and cannot be retried statement-by-statement. The + // schemas here carry no GORM associations, so one write is one statement + // and the wrapper buys nothing to begin with. Explicit Transaction() blocks + // are unaffected. + SkipDefaultTransaction bool +} + // NewProvider returns a new SQL provider func NewProvider( config *config.Config, deps *Dependencies, -) (*provider, error) { +) (*Provider, error) { + return NewProviderWithOptions(config, deps, Options{}) +} + +// NewProviderWithOptions returns a new SQL provider with the given hooks +// applied. See Options. +func NewProviderWithOptions( + config *config.Config, + deps *Dependencies, + opts Options, +) (*Provider, error) { var sqlDB *gorm.DB var err error ormConfig := &gorm.Config{ + SkipDefaultTransaction: opts.SkipDefaultTransaction, NamingStrategy: schema.NamingStrategy{ TablePrefix: schemas.Prefix, }, @@ -63,8 +115,16 @@ func NewProvider( dbURL := config.DatabaseURL switch dbType { - case constants.DbTypePostgres, constants.DbTypeYugabyte, constants.DbTypeCockroachDB: - sqlDB, err = gorm.Open(postgres.Open(dbURL), ormConfig) + case constants.DbTypePostgres, constants.DbTypeYugabyte, constants.DbTypeCockroachDB, constants.DbTypeSereneDB: + if opts.WrapPool != nil { + var rawDB *stdsql.DB + if rawDB, err = stdsql.Open("pgx", dbURL); err != nil { + return nil, err + } + sqlDB, err = gorm.Open(postgres.New(postgres.Config{Conn: opts.WrapPool(rawDB)}), ormConfig) + } else { + sqlDB, err = gorm.Open(postgres.Open(dbURL), ormConfig) + } case constants.DbTypeSqlite: sqlDB, err = gorm.Open(sqlite.Open(dbURL+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"), ormConfig) case constants.DbTypeLibSQL: @@ -93,10 +153,15 @@ func NewProvider( // or any custom name) — failing with "constraint does not exist" (Postgres // SQLSTATE 42704) and aborting startup. Clear the legacy uniqueness up front, // name-agnostically, before AutoMigrate runs. - clearLegacyColumnUniqueness(sqlDB, deps.Log) + migrate := opts.Migrate + if migrate == nil { + migrate = func(db *gorm.DB) error { + clearLegacyColumnUniqueness(db, deps.Log) + return db.AutoMigrate(Models()...) + } + } - err = sqlDB.AutoMigrate(&schemas.User{}, &schemas.VerificationRequest{}, &schemas.Session{}, &schemas.Env{}, &schemas.Webhook{}, &schemas.WebhookLog{}, &schemas.EmailTemplate{}, &schemas.OTP{}, &schemas.Authenticator{}, &schemas.SessionToken{}, &schemas.MFASession{}, &schemas.OAuthState{}, &schemas.AuditLog{}, &schemas.Client{}, &schemas.TrustedIssuer{}, &schemas.Organization{}, &schemas.OrgMembership{}, &schemas.FederatedIdentity{}, &schemas.ScimEndpoint{}, &schemas.ScimGroup{}, &schemas.WebauthnCredential{}, &schemas.OrgDomain{}, &schemas.SAMLServiceProvider{}, &schemas.SAMLIDPKey{}) - if err != nil { + if err = migrate(sqlDB); err != nil { return nil, err } diff --git a/internal/storage/provider.go b/internal/storage/provider.go index 872c8bf4..f1f8fc16 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -14,6 +14,7 @@ import ( "github.com/authorizerdev/authorizer/internal/storage/db/couchbase" "github.com/authorizerdev/authorizer/internal/storage/db/dynamodb" "github.com/authorizerdev/authorizer/internal/storage/db/mongodb" + "github.com/authorizerdev/authorizer/internal/storage/db/serenedb" "github.com/authorizerdev/authorizer/internal/storage/db/sql" "github.com/authorizerdev/authorizer/internal/storage/schemas" ) @@ -544,6 +545,10 @@ func New(config *config.Config, deps *Dependencies) (Provider, error) { provider, err = sql.NewProvider(config, &sql.Dependencies{ Log: deps.Log, }) + case constants.DbTypeSereneDB: + provider, err = serenedb.NewProvider(config, &serenedb.Dependencies{ + Log: deps.Log, + }) case constants.DbTypeMongoDB: provider, err = mongodb.NewProvider(config, &mongodb.Dependencies{ Log: deps.Log, diff --git a/internal/storage/provider_test.go b/internal/storage/provider_test.go index 92bc5a84..ab2f3233 100644 --- a/internal/storage/provider_test.go +++ b/internal/storage/provider_test.go @@ -24,6 +24,7 @@ import ( // allDBTypes is the full list of database types supported for storage tests. var allDBTypes = []string{ constants.DbTypePostgres, + constants.DbTypeSereneDB, constants.DbTypeSqlite, constants.DbTypeMongoDB, constants.DbTypeArangoDB, @@ -62,6 +63,8 @@ func getTestDBConfig(dbType string) *config.Config { switch dbType { case constants.DbTypePostgres: cfg.DatabaseURL = "postgres://postgres:postgres@localhost:5434/postgres" + case constants.DbTypeSereneDB: + cfg.DatabaseURL = "postgres://postgres:postgres@localhost:7890/postgres" case constants.DbTypeSqlite: cfg.DatabaseURL = "test.db" case constants.DbTypeMongoDB: diff --git a/scripts/wait-for-test-dbs.sh b/scripts/wait-for-test-dbs.sh index 1dc8d7bf..929c7a8b 100755 --- a/scripts/wait-for-test-dbs.sh +++ b/scripts/wait-for-test-dbs.sh @@ -15,7 +15,7 @@ TIMEOUT_SECONDS="${TEST_DB_WAIT_TIMEOUT:-120}" # name:port pairs. Couchbase is absent on purpose — scripts/couchbase-test.sh # already provisions and waits for it. -ALL_SERVICES="redis:6380 postgres:5434 mongodb:27017 scylladb:9042 arangodb:8529 dynamodb:8000" +ALL_SERVICES="redis:6380 postgres:5434 serenedb:7890 mongodb:27017 scylladb:9042 arangodb:8529 dynamodb:8000" # With no arguments, wait for everything (make test-all-db). With arguments, # wait only for the named services, so a single-backend target does not block on