diff --git a/AGENTS.md b/AGENTS.md
index cc63e569b..b4023a72f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -71,23 +71,30 @@ Run `make fmt` before committing; CI runs `make lint`.
```bash
make test # Full module test run; TEST_DBS=sqlite (integration tests always SQLite)
make test-sqlite # Same as test — explicit SQLite-only, no Docker
-make test-all-db # All 8 DBs via Docker (postgres, sqlite, mongodb, arangodb, scylladb, cassandradb, dynamodb, couchbase)
+make test-all-db # All 12 DBs via Docker (postgres, cockroachdb, mysql, mariadb, sqlite, libsql, mongodb, arangodb, scylladb, cassandradb, dynamodb, couchbase)
make smoke # Release e2e smoke tests (build tag `smoke`, 5m timeout)
# Single-DB targets (each spins up Docker, runs tests, tears down)
make test-postgres
+make test-cockroachdb
+make test-mysql
+make test-mariadb
make test-mongodb
make test-scylladb
make test-cassandradb
make test-arangodb
make test-dynamodb
make test-couchbase
+make test-sqlserver # Not in test-all-db: 2 upsert tests are known-red
+ # (driver builds MERGE from the PK, ignores OnConflict.Columns)
# Docker helpers for test-all-db
make test-docker-up # Start all test DB containers + Redis
make test-cleanup # Remove all test containers
-make test-cleanup-postgres | test-cleanup-mongodb | test-cleanup-scylladb | test-cleanup-cassandradb
+make test-cleanup-postgres | test-cleanup-cockroachdb | test-cleanup-mysql | test-cleanup-mariadb
+make test-cleanup-mongodb | test-cleanup-scylladb | test-cleanup-cassandradb
make test-cleanup-arangodb | test-cleanup-dynamodb | test-cleanup-couchbase
+make test-cleanup-sqlserver
```
**Test env vars**:
diff --git a/Makefile b/Makefile
index 621b878fa..c16b0270a 100644
--- a/Makefile
+++ b/Makefile
@@ -7,6 +7,12 @@ DOCKER_IMAGE ?= quay.io/authorizer/authorizer:$(VERSION)
# Integration tests and memory_store/db tests always use SQLite.
# Redis memory_store tests run only when TEST_ENABLE_REDIS=1.
GO_TEST_ALL := go test -p 1 -v ./...
+# Pinned to a release line so the suite does not move under us.
+COCKROACH_IMAGE := cockroachdb/cockroach:latest-v24.3
+MYSQL_IMAGE := mysql:8.0.40
+MARIADB_IMAGE := mariadb:11
+SQLSERVER_IMAGE := mcr.microsoft.com/mssql/server:2022-latest
+SQLSERVER_PASSWORD := Str0ng!Passw0rd
.PHONY: all bootstrap build build-app build-dashboard build-local-image build-push-image trivy-scan
@@ -123,6 +129,33 @@ test-postgres: test-cleanup-postgres
docker rm -vf authorizer_postgres; \
exit $$status
+# CockroachDB is Postgres-wire compatible and runs through the same GORM SQL
+# provider, so this target exists to prove that compatibility actually holds
+# (AutoMigrate, transactions, LIKE anchoring) rather than being assumed.
+test-cockroachdb: test-cleanup-cockroachdb
+ docker run -d --name authorizer_cockroachdb -p 26257:26257 $(COCKROACH_IMAGE) start-single-node --insecure
+ sh scripts/wait-for-sql.sh authorizer_cockroachdb cockroachdb && \
+ { go clean --testcache; TEST_DBS="cockroachdb" $(GO_TEST_ALL); }; \
+ status=$$?; \
+ docker rm -vf authorizer_cockroachdb; \
+ exit $$status
+
+test-mysql: test-cleanup-mysql
+ docker run -d --name authorizer_mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=authorizer_test $(MYSQL_IMAGE)
+ sh scripts/wait-for-sql.sh authorizer_mysql mysql && \
+ { go clean --testcache; TEST_DBS="mysql" $(GO_TEST_ALL); }; \
+ status=$$?; \
+ docker rm -vf authorizer_mysql; \
+ exit $$status
+
+test-mariadb: test-cleanup-mariadb
+ docker run -d --name authorizer_mariadb -p 3307:3306 -e MARIADB_ROOT_PASSWORD=password -e MARIADB_DATABASE=authorizer_test $(MARIADB_IMAGE)
+ sh scripts/wait-for-sql.sh authorizer_mariadb mariadb && \
+ { go clean --testcache; TEST_DBS="mariadb" $(GO_TEST_ALL); }; \
+ status=$$?; \
+ docker rm -vf authorizer_mariadb; \
+ exit $$status
+
test-sqlite:
go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL)
@@ -224,7 +257,10 @@ test-all-db: test-cleanup test-docker-up
@# the && chain a probe failure is captured by status and still cleans up.
sh scripts/wait-for-cql.sh authorizer_scylla_db && \
sh scripts/wait-for-cql.sh authorizer_cassandra_db && \
- { go clean --testcache; TEST_DBS="couchbase,postgres,sqlite,mongodb,arangodb,scylladb,cassandradb,dynamodb" $(GO_TEST_ALL); }; \
+ sh scripts/wait-for-sql.sh authorizer_mysql mysql && \
+ sh scripts/wait-for-sql.sh authorizer_mariadb mariadb && \
+ sh scripts/wait-for-sql.sh authorizer_cockroachdb cockroachdb && \
+ { go clean --testcache; TEST_DBS="couchbase,postgres,cockroachdb,mysql,mariadb,sqlite,libsql,mongodb,arangodb,scylladb,cassandradb,dynamodb" $(GO_TEST_ALL); }; \
status=$$?; \
$(MAKE) test-cleanup; \
exit $$status
@@ -233,6 +269,11 @@ 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
+ @# Only 26257 (SQL) is published. Cockroach's admin UI is on 8080, which is
+ @# the port `make dev` binds.
+ docker run -d --name authorizer_cockroachdb -p 26257:26257 $(COCKROACH_IMAGE) start-single-node --insecure
+ docker run -d --name authorizer_mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -e MYSQL_DATABASE=authorizer_test $(MYSQL_IMAGE)
+ docker run -d --name authorizer_mariadb -p 3307:3306 -e MARIADB_ROOT_PASSWORD=password -e MARIADB_DATABASE=authorizer_test $(MARIADB_IMAGE)
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_cassandra_db -p 9142:9042 -e MAX_HEAP_SIZE=512M -e HEAP_NEWSIZE=128M cassandra:4.1
@@ -252,10 +293,16 @@ test-docker-up:
@# there costs nothing: the probe returns in 0s once CQL answers.
-sh scripts/wait-for-cql.sh authorizer_scylla_db
-sh scripts/wait-for-cql.sh authorizer_cassandra_db
+ -sh scripts/wait-for-sql.sh authorizer_mysql mysql
+ -sh scripts/wait-for-sql.sh authorizer_mariadb mariadb
+ -sh scripts/wait-for-sql.sh authorizer_cockroachdb cockroachdb
# Remove all test database containers
test-cleanup:
-docker rm -vf authorizer_postgres
+ -docker rm -vf authorizer_cockroachdb
+ -docker rm -vf authorizer_mysql
+ -docker rm -vf authorizer_mariadb
-docker rm -vf authorizer_scylla_db
-docker rm -vf authorizer_cassandra_db
-docker rm -vf authorizer_mongodb_db
@@ -266,6 +313,28 @@ test-cleanup:
test-cleanup-postgres:
-docker rm -vf authorizer_postgres
+test-cleanup-cockroachdb:
+ -docker rm -vf authorizer_cockroachdb
+test-sqlserver: test-cleanup-sqlserver
+ @# Not part of test-all-db: two upsert tests are known-red on SQL Server
+ @# because gorm.io/driver/sqlserver builds its MERGE predicate from the
+ @# primary key and ignores clause.OnConflict.Columns, so an upsert keyed on
+ @# a composite unique index inserts a second row instead of replacing one.
+ @# Tracked separately from the AutoMigrate fixes; everything else passes.
+ docker run -d --name authorizer_sqlserver -p 1433:1433 \
+ -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD='$(SQLSERVER_PASSWORD)' $(SQLSERVER_IMAGE)
+ sh scripts/wait-for-sql.sh authorizer_sqlserver sqlserver && \
+ docker exec authorizer_sqlserver /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa \
+ -P '$(SQLSERVER_PASSWORD)' -Q "CREATE DATABASE authorizer_test" && \
+ { go clean --testcache; TEST_DBS="sqlserver" $(GO_TEST_ALL); }; \
+ status=$$?; \
+ docker rm -vf authorizer_sqlserver; \
+ exit $$status
+
+test-cleanup-mysql:
+ -docker rm -vf authorizer_mysql
+test-cleanup-mariadb:
+ -docker rm -vf authorizer_mariadb
test-cleanup-mongodb:
-docker rm -vf authorizer_mongodb_db
test-cleanup-scylladb:
@@ -278,6 +347,8 @@ test-cleanup-dynamodb:
-docker rm -vf authorizer_dynamodb
test-cleanup-couchbase:
-docker rm -vf authorizer_couchbase
+test-cleanup-sqlserver:
+ -docker rm -vf authorizer_sqlserver
generate-graphql:
go run github.com/99designs/gqlgen --verbose generate && go mod tidy
generate-db-template:
diff --git a/internal/storage/db/sql/cockroachdb.go b/internal/storage/db/sql/cockroachdb.go
new file mode 100644
index 000000000..21f60813c
--- /dev/null
+++ b/internal/storage/db/sql/cockroachdb.go
@@ -0,0 +1,63 @@
+package sql
+
+import (
+ "fmt"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/gorm"
+ "gorm.io/gorm/schema"
+)
+
+// cockroachDialector is the Postgres dialector with one migration behaviour
+// changed. It embeds postgres.Dialector rather than the gorm.Dialector
+// interface so the driver's optional interfaces — SavePoint/RollbackTo, which
+// gorm type-asserts for nested transactions — stay promoted.
+type cockroachDialector struct {
+ postgres.Dialector
+}
+
+// newCockroachDialector returns the Postgres dialector for dbURL with the
+// CockroachDB migrator behaviour layered on.
+func newCockroachDialector(dbURL string) gorm.Dialector {
+ return cockroachDialector{postgres.Dialector{Config: &postgres.Config{DSN: dbURL}}}
+}
+
+// Migrator keeps the driver's own migrator — and with it every method gorm
+// expects to find, including the BuildIndexOptions it type-asserts with no
+// ok-check — and replaces only the unique reconciliation.
+func (d cockroachDialector) Migrator(db *gorm.DB) gorm.Migrator {
+ return cockroachMigrator{d.Dialector.Migrator(db).(postgres.Migrator)}
+}
+
+type cockroachMigrator struct {
+ postgres.Migrator
+}
+
+// MigrateColumnUnique leaves the unique indexes the models declare alone; see
+// skipUniqueIndexDrop for why CockroachDB needs this and PostgreSQL does not.
+func (m cockroachMigrator) MigrateColumnUnique(value any, field *schema.Field, columnType gorm.ColumnType) error {
+ if skipUniqueIndexDrop(field, columnType) {
+ return nil
+ }
+ return m.Migrator.MigrateColumnUnique(value, field, columnType)
+}
+
+// dropUniqueConstraint removes a single-column UNIQUE constraint on
+// CockroachDB, where `ALTER TABLE ... DROP CONSTRAINT` — what
+// Migrator.DropConstraint emits — is rejected outright:
+//
+// unimplemented: cannot drop UNIQUE constraint "authorizer_users_email_key"
+// using ALTER TABLE DROP CONSTRAINT, use DROP INDEX CASCADE instead
+// (SQLSTATE 0A000)
+//
+// A UNIQUE constraint there is an index, so it has to go in those terms.
+// Without this the legacy-uniqueness cleanup silently does nothing on an
+// upgraded v1 database, which leaves UNIQUE on authorizer_otps.phone_number —
+// where v2 writes the empty string for every email OTP, so the second one
+// would be rejected.
+//
+// The identifiers are internal: table comes from a fixed list and constraint
+// from this database's own information_schema, never from a request.
+func dropUniqueConstraint(db *gorm.DB, table, constraint string) error {
+ return db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS %q@%q CASCADE`, table, constraint)).Error
+}
diff --git a/internal/storage/db/sql/migrator.go b/internal/storage/db/sql/migrator.go
new file mode 100644
index 000000000..0820cbd6d
--- /dev/null
+++ b/internal/storage/db/sql/migrator.go
@@ -0,0 +1,39 @@
+package sql
+
+import (
+ "gorm.io/gorm"
+ "gorm.io/gorm/schema"
+)
+
+// skipUniqueIndexDrop reports whether AutoMigrate is about to drop a unique
+// index the models declare, and should be left alone.
+//
+// GORM reconciles single-column uniqueness by comparing what the catalog
+// reports against the field's `unique` tag. On a disagreement it issues
+// DROP CONSTRAINT "uni_
_" — a NamingStrategy-derived name that
+// only ever exists for a constraint GORM itself created from a `unique` tag.
+//
+// PostgreSQL lists only real UNIQUE constraints in
+// information_schema.table_constraints, so the two agree and nothing happens.
+// CockroachDB lists UNIQUE *indexes* there too, and MySQL reports
+// COLUMN_KEY='UNI' for them, so on both every `gorm:"uniqueIndex"` field looks
+// like a constraint the model no longer wants — and startup dies on a name that
+// was never created:
+//
+// cockroachdb: constraint "uni_authorizer_oauth_states_state_key" of relation
+// "authorizer_oauth_states" does not exist (SQLSTATE 42704)
+// mysql: Error 1091 (42000): Can't DROP
+// 'uni_authorizer_oauth_states_state_key'
+//
+// Those indexes are declared by the models and are meant to be there, so the
+// drop is skipped. The create direction — a `gorm:"unique"` field whose
+// constraint is genuinely missing — is always delegated untouched.
+//
+// Each affected driver wraps its own concrete migrator (see cockroachdb.go and
+// mysql.go) rather than the gorm.Migrator interface: gorm type-asserts
+// migrator.BuildIndexOptionsInterface with no ok-check, so a wrapper that
+// embeds the interface panics the moment a table with an index is created.
+func skipUniqueIndexDrop(field *schema.Field, columnType gorm.ColumnType) bool {
+ unique, ok := columnType.Unique()
+ return ok && unique && !field.Unique && !field.PrimaryKey
+}
diff --git a/internal/storage/db/sql/mysql.go b/internal/storage/db/sql/mysql.go
new file mode 100644
index 000000000..554597b7d
--- /dev/null
+++ b/internal/storage/db/sql/mysql.go
@@ -0,0 +1,81 @@
+package sql
+
+import (
+ "fmt"
+
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+ "gorm.io/gorm/schema"
+)
+
+// indexedStringSize is the column width GORM's mysql driver already gives an
+// unsized string that carries an `index` or `unique` tag: 191 characters, the
+// widest utf8mb4 column InnoDB can index under the 767-byte prefix limit.
+const indexedStringSize = 191
+
+// mysqlDialector is the MySQL dialector with two migration behaviours changed.
+// It embeds mysql.Dialector (which itself holds a *Config, so Initialize's
+// writes are visible here) rather than the gorm.Dialector interface, so the
+// driver's methods all stay promoted.
+type mysqlDialector struct {
+ mysql.Dialector
+}
+
+// newMySQLDialector returns the MySQL dialector for dbURL with the MySQL
+// migration fixes layered on. It goes through mysql.New so the driver fills in
+// DSNConfig from the DSN, as Initialize expects.
+func newMySQLDialector(dbURL string) gorm.Dialector {
+ return mysqlDialector{*mysql.New(mysql.Config{DSN: dbURL}).(*mysql.Dialector)}
+}
+
+// Migrator keeps the driver's own migrator — and with it every method gorm
+// expects to find, including the BuildIndexOptions it type-asserts with no
+// ok-check — and replaces only the unique reconciliation.
+//
+// Config.Dialector is repointed at this wrapper because
+// migrator.Migrator.DataTypeOf resolves column types through it: left as the
+// driver built it, every DDL type decision would route back to the unwrapped
+// dialector and skip DataTypeOf below.
+func (d mysqlDialector) Migrator(db *gorm.DB) gorm.Migrator {
+ m := d.Dialector.Migrator(db).(mysql.Migrator)
+ // Spelled out: mysql.Migrator embeds both migrator.Migrator and the
+ // driver's Dialector, and each carries a Config, so a bare m.Config is
+ // ambiguous.
+ m.Migrator.Config.Dialector = d
+ return mysqlMigrator{m}
+}
+
+type mysqlMigrator struct {
+ mysql.Migrator
+}
+
+// MigrateColumnUnique leaves the unique indexes the models declare alone; see
+// skipUniqueIndexDrop.
+func (m mysqlMigrator) MigrateColumnUnique(value any, field *schema.Field, columnType gorm.ColumnType) error {
+ if skipUniqueIndexDrop(field, columnType) {
+ return nil
+ }
+ return m.Migrator.MigrateColumnUnique(value, field, columnType)
+}
+
+// DataTypeOf gives an unsized string column under a `uniqueIndex` a width, so
+// MySQL can actually index it.
+//
+// The driver sizes an unsized string to varchar(191) when the field carries
+// `index` or `unique`, but it checks only those two tags — a field tagged
+// `uniqueIndex` (named or composite) is missed and becomes `longtext`. MySQL
+// then rejects the table outright, because a TEXT column needs an explicit key
+// length to be indexed, and the server cannot create its schema at all:
+//
+// Error 1170 (42000): BLOB/TEXT column 'client_id' used in key specification
+// without a key length
+//
+// Same width the driver picks for the tags it does recognise, so an indexed
+// column is sized consistently however its index was declared.
+func (d mysqlDialector) DataTypeOf(field *schema.Field) string {
+ if field.DataType == schema.String && field.Size == 0 &&
+ field.TagSettings["UNIQUEINDEX"] != "" && d.DefaultStringSize == 0 {
+ return fmt.Sprintf("varchar(%d)", indexedStringSize)
+ }
+ return d.Dialector.DataTypeOf(field)
+}
diff --git a/internal/storage/db/sql/provider.go b/internal/storage/db/sql/provider.go
index 612fd3cff..903cc2b6d 100644
--- a/internal/storage/db/sql/provider.go
+++ b/internal/storage/db/sql/provider.go
@@ -3,9 +3,7 @@ package sql
import (
libsql "github.com/ekristen/gorm-libsql"
"github.com/rs/zerolog"
- "gorm.io/driver/mysql"
"gorm.io/driver/postgres"
- "gorm.io/driver/sqlserver"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
@@ -63,16 +61,24 @@ func NewProvider(
dbURL := config.DatabaseURL
switch dbType {
- case constants.DbTypePostgres, constants.DbTypeYugabyte, constants.DbTypeCockroachDB:
+ case constants.DbTypePostgres, constants.DbTypeYugabyte:
sqlDB, err = gorm.Open(postgres.Open(dbURL), ormConfig)
+ case constants.DbTypeCockroachDB:
+ // Postgres wire protocol, but AutoMigrate needs one behaviour changed —
+ // see cockroachdb.go.
+ sqlDB, err = gorm.Open(newCockroachDialector(dbURL), ormConfig)
case constants.DbTypeSqlite:
sqlDB, err = gorm.Open(sqlite.Open(dbURL+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"), ormConfig)
case constants.DbTypeLibSQL:
sqlDB, err = gorm.Open(libsql.Open(dbURL), ormConfig)
case constants.DbTypeMysql, constants.DbTypeMariaDB, constants.DbTypePlanetScaleDB:
- sqlDB, err = gorm.Open(mysql.Open(dbURL), ormConfig)
+ // MySQL wire protocol, but AutoMigrate needs two behaviours changed —
+ // see mysql.go.
+ sqlDB, err = gorm.Open(newMySQLDialector(dbURL), ormConfig)
case constants.DbTypeSqlserver:
- sqlDB, err = gorm.Open(sqlserver.Open(dbURL), ormConfig)
+ // MS SQL wire protocol, but AutoMigrate needs one behaviour changed —
+ // see sqlserver.go.
+ sqlDB, err = gorm.Open(newSQLServerDialector(dbURL), ormConfig)
}
if err != nil {
@@ -93,7 +99,7 @@ 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)
+ clearLegacyColumnUniqueness(sqlDB, dbType, deps.Log)
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 {
@@ -151,7 +157,7 @@ func (p *provider) Close() error {
// why this must run before AutoMigrate. Best-effort and non-fatal: fresh
// installs, and catalogs without information_schema (sqlite), simply find
// nothing to drop.
-func clearLegacyColumnUniqueness(db *gorm.DB, log *zerolog.Logger) {
+func clearLegacyColumnUniqueness(db *gorm.DB, dbType string, log *zerolog.Logger) {
// Run through a discarding GORM logger. The information_schema probe below
// intentionally errors on databases without that catalog (sqlite); GORM's
// default logger writes such errors to os.Stdout, which corrupts the MCP
@@ -197,7 +203,11 @@ func clearLegacyColumnUniqueness(db *gorm.DB, log *zerolog.Logger) {
}
for name, cols := range columnsByConstraint {
if len(cols) == 1 && targets[cols[0]] {
- if err := db.Migrator().DropConstraint(t.model, name); err != nil {
+ err := db.Migrator().DropConstraint(t.model, name)
+ if err != nil && dbType == constants.DbTypeCockroachDB {
+ err = dropUniqueConstraint(db, t.name, name)
+ }
+ if err != nil {
log.Debug().Err(err).Str("constraint", name).Msg("failed to drop legacy unique constraint")
}
}
diff --git a/internal/storage/db/sql/provider_migration_test.go b/internal/storage/db/sql/provider_migration_test.go
index bfc621d67..5868792b7 100644
--- a/internal/storage/db/sql/provider_migration_test.go
+++ b/internal/storage/db/sql/provider_migration_test.go
@@ -2,6 +2,7 @@ package sql
import (
"context"
+ "fmt"
"net"
"os"
"path/filepath"
@@ -31,10 +32,12 @@ func sqlTestDeps(t *testing.T) *Dependencies {
// TEST_DBS=postgres to cover the engine where the original failure was reported.
func sqlMigrationTestDBTypes() []string {
supported := map[string]bool{
- constants.DbTypePostgres: true,
- constants.DbTypeSqlite: true,
- constants.DbTypeMysql: true,
- constants.DbTypeMariaDB: true,
+ constants.DbTypePostgres: true,
+ constants.DbTypeCockroachDB: true,
+ constants.DbTypeSqlite: true,
+ constants.DbTypeMysql: true,
+ constants.DbTypeMariaDB: true,
+ constants.DbTypeSqlserver: true,
}
env := os.Getenv("TEST_DBS")
if env == "" {
@@ -65,9 +68,20 @@ func sqlMigrationTestConfig(t *testing.T, dbType string) *config.Config {
case constants.DbTypePostgres:
cfg.DatabaseURL = "postgres://postgres:postgres@localhost:5434/postgres"
skipIfTCPClosed(t, "localhost:5434")
- case constants.DbTypeMysql, constants.DbTypeMariaDB:
+ case constants.DbTypeCockroachDB:
+ cfg.DatabaseURL = "postgres://root@localhost:26257/defaultdb?sslmode=disable"
+ skipIfTCPClosed(t, "localhost:26257")
+ case constants.DbTypeMysql:
cfg.DatabaseURL = "root:password@tcp(localhost:3306)/authorizer_test"
skipIfTCPClosed(t, "localhost:3306")
+ case constants.DbTypeMariaDB:
+ cfg.DatabaseURL = "root:password@tcp(localhost:3307)/authorizer_test"
+ skipIfTCPClosed(t, "localhost:3307")
+ case constants.DbTypeSqlserver:
+ // Fixed password: SQL Server rejects weak ones outright. Matches the
+ // container `make test-sqlserver` starts.
+ cfg.DatabaseURL = "sqlserver://sa:Str0ng!Passw0rd@localhost:1433?database=authorizer_test"
+ skipIfTCPClosed(t, "localhost:1433")
default:
t.Skipf("unsupported SQL db type for migration test: %s", dbType)
}
@@ -175,7 +189,7 @@ func TestProviderEmailPhoneUpdatesAndUniqueness(t *testing.T) {
// DDL; sqlite rebuilds tables and has no comparable failure mode.
func TestStaleUniqueConstraintMigration(t *testing.T) {
for _, dbType := range sqlMigrationTestDBTypes() {
- if dbType != constants.DbTypePostgres {
+ if dbType != constants.DbTypePostgres && dbType != constants.DbTypeCockroachDB {
continue
}
t.Run(dbType, func(t *testing.T) {
@@ -199,15 +213,31 @@ func TestStaleUniqueConstraintMigration(t *testing.T) {
// be dropped with DROP INDEX — field-reported case)
// - otps.email: standalone UNIQUE INDEX "idx_authorizer_otps_email"
// Idempotent so reruns against the shared test DB don't clash.
+ // CockroachDB rejects ALTER TABLE ... DROP CONSTRAINT for a UNIQUE
+ // constraint (SQLSTATE 0A000) because there it *is* an index, so the
+ // teardown half of each pair is written in the dialect's own terms —
+ // the same split the provider's cleanup has to make.
+ drop := func(table, name string) string {
+ if dbType == constants.DbTypeCockroachDB {
+ return fmt.Sprintf("DROP INDEX IF EXISTS %s@%s CASCADE", table, name)
+ }
+ return fmt.Sprintf("ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s", table, name)
+ }
+ dropIndex := func(table, name string) string {
+ if dbType == constants.DbTypeCockroachDB {
+ return fmt.Sprintf("DROP INDEX IF EXISTS %s@%s CASCADE", table, name)
+ }
+ return fmt.Sprintf("DROP INDEX IF EXISTS %s", name)
+ }
for _, stmt := range []string{
- `ALTER TABLE authorizer_users DROP CONSTRAINT IF EXISTS authorizer_users_email_key`,
+ drop("authorizer_users", "authorizer_users_email_key"),
`ALTER TABLE authorizer_users ADD CONSTRAINT authorizer_users_email_key UNIQUE (email)`,
- `ALTER TABLE authorizer_users DROP CONSTRAINT IF EXISTS my_legacy_phone_uq`,
+ drop("authorizer_users", "my_legacy_phone_uq"),
`ALTER TABLE authorizer_users ADD CONSTRAINT my_legacy_phone_uq UNIQUE (phone_number)`,
- `ALTER TABLE authorizer_otps DROP CONSTRAINT IF EXISTS idx_authorizer_otps_phone_number`,
- `DROP INDEX IF EXISTS idx_authorizer_otps_phone_number`,
+ drop("authorizer_otps", "idx_authorizer_otps_phone_number"),
+ dropIndex("authorizer_otps", "idx_authorizer_otps_phone_number"),
`ALTER TABLE authorizer_otps ADD CONSTRAINT idx_authorizer_otps_phone_number UNIQUE (phone_number)`,
- `DROP INDEX IF EXISTS idx_authorizer_otps_email`,
+ dropIndex("authorizer_otps", "idx_authorizer_otps_email"),
`CREATE UNIQUE INDEX idx_authorizer_otps_email ON authorizer_otps (email)`,
} {
require.NoError(t, p1.db.Exec(stmt).Error, stmt)
diff --git a/internal/storage/db/sql/session_token.go b/internal/storage/db/sql/session_token.go
index 93d9f5121..4070251e7 100644
--- a/internal/storage/db/sql/session_token.go
+++ b/internal/storage/db/sql/session_token.go
@@ -10,9 +10,23 @@ import (
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)
+// likeEscapeChar is the ESCAPE character for every LIKE pattern built here.
+//
+// It is deliberately not a backslash. MySQL treats a backslash as an escape
+// character inside string literals, so the literal `ESCAPE '\'` is unterminated
+// there and the statement is rejected outright — `Error 1064 (42000): You have
+// an error in your SQL syntax ... near ”\”` — while PostgreSQL and SQLite
+// accept it. Session-token revocation ran through these two statements, so on
+// MySQL it failed instead of revoking.
+//
+// Naming the escape character explicitly also makes a backslash in a value
+// literal on every engine, which is why it is not escaped below: MySQL's LIKE
+// otherwise treats backslash as an escape even with no ESCAPE clause.
+const likeEscapeChar = "!"
+
// likeEscaper escapes LIKE metacharacters so a value is matched literally when
-// used with an explicit ESCAPE '\' clause.
-var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
+// used with an explicit ESCAPE clause.
+var likeEscaper = strings.NewReplacer(`!`, `!!`, `%`, `!%`, `_`, `!_`)
func escapeLike(s string) string {
return likeEscaper.Replace(s)
@@ -68,7 +82,7 @@ func (p *provider) DeleteSessionTokenByUserIDAndKey(ctx context.Context, userId,
// in an id are treated literally.
func (p *provider) DeleteAllSessionTokensByUserID(ctx context.Context, userId string) error {
escaped := escapeLike(userId)
- return p.db.WithContext(ctx).Where(`user_id = ? OR user_id LIKE ? ESCAPE '\'`, userId, "%:"+escaped).Delete(&schemas.SessionToken{}).Error
+ return p.db.WithContext(ctx).Where(`user_id = ? OR user_id LIKE ? ESCAPE '`+likeEscapeChar+`'`, userId, "%:"+escaped).Delete(&schemas.SessionToken{}).Error
}
// DeleteSessionTokensByNamespace deletes all session tokens for a namespace (e.g., "auth_provider").
@@ -76,7 +90,7 @@ func (p *provider) DeleteAllSessionTokensByUserID(ctx context.Context, userId st
// The namespace is escaped so its metacharacters (auth-recipe names contain "_")
// are matched literally rather than as LIKE wildcards.
func (p *provider) DeleteSessionTokensByNamespace(ctx context.Context, namespace string) error {
- return p.db.WithContext(ctx).Where(`user_id LIKE ? ESCAPE '\'`, escapeLike(namespace)+":%").Delete(&schemas.SessionToken{}).Error
+ return p.db.WithContext(ctx).Where(`user_id LIKE ? ESCAPE '`+likeEscapeChar+`'`, escapeLike(namespace)+":%").Delete(&schemas.SessionToken{}).Error
}
// CleanExpiredSessionTokens removes expired session tokens from the database
diff --git a/internal/storage/db/sql/sqlserver.go b/internal/storage/db/sql/sqlserver.go
new file mode 100644
index 000000000..6fa10ba57
--- /dev/null
+++ b/internal/storage/db/sql/sqlserver.go
@@ -0,0 +1,67 @@
+package sql
+
+import (
+ "fmt"
+
+ "gorm.io/driver/sqlserver"
+ "gorm.io/gorm"
+ "gorm.io/gorm/schema"
+)
+
+// sqlServerIndexedStringSize is the column width GORM's sqlserver driver
+// already gives an unsized string that carries an `index` or `unique` tag.
+const sqlServerIndexedStringSize = 256
+
+// sqlServerDialector is the SQL Server dialector with its unsized-string
+// sizing corrected. It embeds sqlserver.Dialector (which holds a *Config, so
+// Initialize's writes are visible here) rather than the gorm.Dialector
+// interface, so the driver's own methods all stay promoted.
+//
+// Unlike CockroachDB and MySQL, SQL Server needs no MigrateColumnUnique
+// override: its ColumnTypes reads uniqueness from INFORMATION_SCHEMA.
+// TABLE_CONSTRAINTS, which lists real UNIQUE constraints only — a
+// CREATE UNIQUE INDEX is not a constraint there, so the catalog and the models
+// agree, exactly as on PostgreSQL.
+type sqlServerDialector struct {
+ sqlserver.Dialector
+}
+
+// newSQLServerDialector returns the SQL Server dialector for dbURL with the
+// sizing fix layered on.
+func newSQLServerDialector(dbURL string) gorm.Dialector {
+ return sqlServerDialector{*sqlserver.Open(dbURL).(*sqlserver.Dialector)}
+}
+
+// Migrator keeps the driver's own migrator and only repoints the dialector the
+// migrator resolves column types through: left as the driver built it, every
+// DDL type decision would route back to the unwrapped dialector and skip
+// DataTypeOf below.
+func (d sqlServerDialector) Migrator(db *gorm.DB) gorm.Migrator {
+ m := d.Dialector.Migrator(db).(sqlserver.Migrator)
+ // Spelled out: sqlserver.Migrator embeds migrator.Migrator, which carries
+ // the Config whose Dialector drives DataTypeOf.
+ m.Migrator.Config.Dialector = d
+ return m
+}
+
+// DataTypeOf gives an unsized string column under a `uniqueIndex` a width, so
+// SQL Server can actually index it.
+//
+// The driver sizes an unsized string to nvarchar(256) when the field carries
+// `index` or `unique`, but it checks only those two tags — a field tagged
+// `uniqueIndex` (named or composite) is missed and becomes nvarchar(MAX),
+// which SQL Server refuses as an index key, so the server cannot create its
+// schema at all:
+//
+// mssql: Column 'client_id' in table 'authorizer_clients' is of a type that
+// is invalid for use as a key column in an index.
+//
+// Same width the driver picks for the tags it does recognise, so an indexed
+// column is sized consistently however its index was declared.
+func (d sqlServerDialector) DataTypeOf(field *schema.Field) string {
+ if field.DataType == schema.String && field.Size == 0 &&
+ field.TagSettings["UNIQUEINDEX"] != "" && d.DefaultStringSize == 0 {
+ return fmt.Sprintf("nvarchar(%d)", sqlServerIndexedStringSize)
+ }
+ return d.Dialector.DataTypeOf(field)
+}
diff --git a/internal/storage/db/sql/user_cascade_test.go b/internal/storage/db/sql/user_cascade_test.go
index 2e0118615..c98f20f3a 100644
--- a/internal/storage/db/sql/user_cascade_test.go
+++ b/internal/storage/db/sql/user_cascade_test.go
@@ -4,6 +4,7 @@ import (
"context"
"testing"
+ "github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
@@ -45,15 +46,21 @@ func TestDeleteUserCascadesAllUserOwnedRows(t *testing.T) {
defer func() { _ = p.Close() }()
ctx := context.Background()
+
+ // Everything the test writes is suffixed so a rerun against a
+ // persistent database (make test-postgres reuses its container only
+ // within one run, but a developer's local DB does not) starts clean
+ // instead of colliding with the previous run's fixtures.
+ uniq := uuid.New().String()
user, err := p.AddUser(ctx, &schemas.User{
- Email: refs.NewStringRef("cascade-" + dbType + "@example.com"),
+ Email: refs.NewStringRef("cascade-" + dbType + "-" + uniq + "@example.com"),
SignupMethods: "basic_auth",
})
require.NoError(t, err)
- const orgID = "org-cascade"
- const issuer = "https://idp.example.com"
- const subject = "upstream-subject-1"
+ orgID := "org-cascade-" + uniq
+ issuer := "https://idp.example.com"
+ subject := "upstream-subject-" + uniq
require.NoError(t, p.AddSession(ctx, &schemas.Session{UserID: user.ID}))
_, err = p.AddFederatedIdentity(ctx, &schemas.FederatedIdentity{
@@ -65,7 +72,7 @@ func TestDeleteUserCascadesAllUserOwnedRows(t *testing.T) {
_, err = p.AddAuthenticator(ctx, &schemas.Authenticator{UserID: user.ID, Method: "totp", Secret: "s3cret"})
require.NoError(t, err)
_, err = p.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{
- UserID: user.ID, CredentialID: "cred-" + dbType, PublicKey: "pk", Name: "laptop",
+ UserID: user.ID, CredentialID: "cred-" + dbType + "-" + uniq, PublicKey: "pk", Name: "laptop",
})
require.NoError(t, err)
require.NoError(t, p.AddSessionToken(ctx, &schemas.SessionToken{UserID: user.ID, KeyName: "access", Token: "t"}))
@@ -87,7 +94,7 @@ func TestDeleteUserCascadesAllUserOwnedRows(t *testing.T) {
_, err = p.GetFederatedIdentity(ctx, orgID, issuer, subject)
require.Error(t, err, "federated identity must be gone after the user is deleted")
fresh, err := p.AddUser(ctx, &schemas.User{
- Email: refs.NewStringRef("cascade-reprovision-" + dbType + "@example.com"),
+ Email: refs.NewStringRef("cascade-reprovision-" + dbType + "-" + uniq + "@example.com"),
SignupMethods: "basic_auth",
})
require.NoError(t, err)
@@ -95,6 +102,10 @@ func TestDeleteUserCascadesAllUserOwnedRows(t *testing.T) {
OrgID: orgID, Issuer: issuer, Subject: subject, UserID: fresh.ID,
})
require.NoError(t, err, "re-provisioning the same (org, issuer, subject) must succeed")
+
+ // Leave nothing behind: the cascade just proven above is what clears
+ // the re-provisioned identity too.
+ require.NoError(t, p.DeleteUser(ctx, fresh))
})
}
}
diff --git a/internal/storage/provider_test.go b/internal/storage/provider_test.go
index 83f098d08..ddf52b6f4 100644
--- a/internal/storage/provider_test.go
+++ b/internal/storage/provider_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net"
"os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -31,8 +32,15 @@ var allDBTypes = []string{
constants.DbTypeCassandraDB,
constants.DbTypeCouchbaseDB,
constants.DbTypeDynamoDB,
+ constants.DbTypeCockroachDB,
+ constants.DbTypeMysql,
+ constants.DbTypeMariaDB,
}
+// sqlServerTestDSN points at the container `make test-sqlserver` starts. The
+// password is fixed because SQL Server rejects weak ones outright.
+const sqlServerTestDSN = "sqlserver://sa:Str0ng!Passw0rd@localhost:1433?database=authorizer_test"
+
// getTestDBTypes returns the list of database types to test against.
// Reads from TEST_DBS env var (comma-separated). Defaults to allDBTypes if not set.
func getTestDBTypes() []string {
@@ -63,6 +71,23 @@ func getTestDBConfig(dbType string) *config.Config {
switch dbType {
case constants.DbTypePostgres:
cfg.DatabaseURL = "postgres://postgres:postgres@localhost:5434/postgres"
+ case constants.DbTypeSqlserver:
+ cfg.DatabaseURL = sqlServerTestDSN
+ case constants.DbTypeLibSQL:
+ // Local file, same as the sqlite case: libsql's embedded mode is a
+ // sqlite fork, so this exercises the dialect without a sqld server.
+ cfg.DatabaseURL = "file:" + filepath.Join(os.TempDir(), "authorizer_libsql_test.db")
+ case constants.DbTypeMysql:
+ cfg.DatabaseURL = "root:password@tcp(localhost:3306)/authorizer_test"
+ case constants.DbTypeMariaDB:
+ // A separate port from MySQL on purpose: one provider backs both DB
+ // types, so sharing a port would silently test one engine twice.
+ cfg.DatabaseURL = "root:password@tcp(localhost:3307)/authorizer_test"
+ case constants.DbTypeCockroachDB:
+ // CockroachDB speaks the Postgres wire protocol and runs through the same
+ // GORM provider; a separate port keeps it from being mistaken for (or
+ // colliding with) the Postgres container.
+ cfg.DatabaseURL = "postgres://root@localhost:26257/defaultdb?sslmode=disable"
case constants.DbTypeSqlite:
cfg.DatabaseURL = "test.db"
case constants.DbTypeMongoDB:
@@ -386,8 +411,30 @@ func testAnchoredSessionTokenDelete(t *testing.T, ctx context.Context, provider
_, err = provider.GetSessionTokenByUserIDAndKey(ctx, targetStored+"\n", "session_token_key")
assert.NoError(t, err, "a trailing newline makes the user id a different value; it must not be deleted")
+ // LIKE metacharacters in the id. The SQL provider matches the ":"
+ // suffix with LIKE, so "%" and "_" have to be escaped — and so does the
+ // escape character itself, or "!" is read as an escape sequence and
+ // the row survives. Every other backend compares literally (Go string
+ // compare, AQL ==, QuoteMeta'd regex), so this case is what keeps the six
+ // equivalent rather than each engine's own idea of "literal".
+ metaUser := "m%_!" + suffix
+ metaStored := "auth_provider:" + metaUser
+ // Reachable only if "%" and "_" are treated as wildcards: "%" would swallow
+ // "ZZZ" and "_" would match "q".
+ metaDecoy := "auth_provider:mZZZq!" + suffix
+ addToken(metaStored)
+ addToken(metaDecoy)
+
+ require.NoError(t, provider.DeleteAllSessionTokensByUserID(ctx, metaUser))
+
+ _, err = provider.GetSessionTokenByUserIDAndKey(ctx, metaStored, "session_token_key")
+ assert.Error(t, err, "a user id holding LIKE metacharacters must still have its token deleted")
+ _, err = provider.GetSessionTokenByUserIDAndKey(ctx, metaDecoy, "session_token_key")
+ assert.NoError(t, err, "a user id that matches only when %% and _ act as wildcards must survive")
+
_ = provider.DeleteAllSessionTokensByUserID(ctx, otherUser)
_ = provider.DeleteAllSessionTokensByUserID(ctx, targetUser+"\n")
+ _ = provider.DeleteAllSessionTokensByUserID(ctx, "mZZZq!"+suffix)
}
// testUserSearchOperations verifies ListUsers' optional case-insensitive
diff --git a/internal/storage/schemas/audit_log.go b/internal/storage/schemas/audit_log.go
index bd004c10f..e4d4c9c82 100644
--- a/internal/storage/schemas/audit_log.go
+++ b/internal/storage/schemas/audit_log.go
@@ -13,12 +13,12 @@ import (
type AuditLog struct {
Key string `json:"_key,omitempty" bson:"_key,omitempty" cql:"_key,omitempty" dynamo:"key,omitempty"` // for arangodb
ID string `gorm:"primaryKey;type:char(36)" json:"_id" bson:"_id" cql:"id" dynamo:"id,hash"`
- ActorID string `gorm:"type:char(36)" json:"actor_id" bson:"actor_id" cql:"actor_id" dynamo:"actor_id,omitempty" index:"actor_id,hash"`
+ ActorID string `gorm:"type:varchar(36)" json:"actor_id" bson:"actor_id" cql:"actor_id" dynamo:"actor_id,omitempty" index:"actor_id,hash"`
ActorType string `gorm:"type:varchar(30)" json:"actor_type" bson:"actor_type" cql:"actor_type" dynamo:"actor_type"`
ActorEmail string `gorm:"type:varchar(256)" json:"actor_email" bson:"actor_email" cql:"actor_email" dynamo:"actor_email"`
Action string `gorm:"type:varchar(100)" json:"action" bson:"action" cql:"action" dynamo:"action,omitempty" index:"action,hash"`
ResourceType string `gorm:"type:varchar(50)" json:"resource_type" bson:"resource_type" cql:"resource_type" dynamo:"resource_type"`
- ResourceID string `gorm:"type:char(36)" json:"resource_id" bson:"resource_id" cql:"resource_id" dynamo:"resource_id"`
+ ResourceID string `gorm:"type:varchar(36)" json:"resource_id" bson:"resource_id" cql:"resource_id" dynamo:"resource_id"`
IPAddress string `gorm:"type:varchar(45)" json:"ip_address" bson:"ip_address" cql:"ip_address" dynamo:"ip_address"`
UserAgent string `gorm:"type:text" json:"user_agent" bson:"user_agent" cql:"user_agent" dynamo:"user_agent"`
Metadata string `gorm:"type:text" json:"metadata" bson:"metadata" cql:"metadata" dynamo:"metadata"`
diff --git a/internal/storage/schemas/authenticators.go b/internal/storage/schemas/authenticators.go
index d60314be2..f3e25788d 100644
--- a/internal/storage/schemas/authenticators.go
+++ b/internal/storage/schemas/authenticators.go
@@ -6,7 +6,7 @@ package schemas
type Authenticator struct {
Key string `json:"_key,omitempty" bson:"_key,omitempty" cql:"_key,omitempty" dynamo:"key,omitempty"` // for arangodb
ID string `gorm:"primaryKey;type:char(36)" json:"_id" bson:"_id" cql:"id" dynamo:"id,hash"`
- UserID string `gorm:"type:char(36);uniqueIndex:idx_authenticator_user_id_method" json:"user_id" bson:"user_id" cql:"user_id" dynamo:"user_id" index:"user_id,hash"`
+ UserID string `gorm:"type:varchar(36);uniqueIndex:idx_authenticator_user_id_method" json:"user_id" bson:"user_id" cql:"user_id" dynamo:"user_id" index:"user_id,hash"`
Method string `gorm:"type:varchar(64);uniqueIndex:idx_authenticator_user_id_method" json:"method" bson:"method" cql:"method" dynamo:"method"`
Secret string `json:"secret" bson:"secret" cql:"secret" dynamo:"secret"`
RecoveryCodes *string `json:"recovery_codes" bson:"recovery_codes" cql:"recovery_codes" dynamo:"recovery_codes"`
diff --git a/internal/storage/schemas/session.go b/internal/storage/schemas/session.go
index 3400917f8..b0a49d891 100644
--- a/internal/storage/schemas/session.go
+++ b/internal/storage/schemas/session.go
@@ -6,7 +6,7 @@ package schemas
type Session struct {
Key string `json:"_key,omitempty" bson:"_key,omitempty" cql:"_key,omitempty" dynamo:"key,omitempty"` // for arangodb
ID string `gorm:"primaryKey;type:char(36)" json:"_id" bson:"_id" cql:"id" dynamo:"id,hash"`
- UserID string `gorm:"type:char(36)" json:"user_id" bson:"user_id" cql:"user_id" dynamo:"user_id" index:"user_id,hash"`
+ UserID string `gorm:"type:varchar(36)" json:"user_id" bson:"user_id" cql:"user_id" dynamo:"user_id" index:"user_id,hash"`
UserAgent string `json:"user_agent" bson:"user_agent" cql:"user_agent" dynamo:"user_agent"`
IP string `json:"ip" bson:"ip" cql:"ip" dynamo:"ip"`
CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"`
diff --git a/internal/storage/schemas/webhook_log.go b/internal/storage/schemas/webhook_log.go
index 97e90eb04..a35cee736 100644
--- a/internal/storage/schemas/webhook_log.go
+++ b/internal/storage/schemas/webhook_log.go
@@ -16,7 +16,7 @@ type WebhookLog struct {
HttpStatus int64 `json:"http_status" bson:"http_status" cql:"http_status" dynamo:"http_status"`
Response string `json:"response" bson:"response" cql:"response" dynamo:"response"`
Request string `json:"request" bson:"request" cql:"request" dynamo:"request"`
- WebhookID string `gorm:"type:char(36)" json:"webhook_id" bson:"webhook_id" cql:"webhook_id" dynamo:"webhook_id" index:"webhook_id,hash"`
+ WebhookID string `gorm:"type:varchar(36)" json:"webhook_id" bson:"webhook_id" cql:"webhook_id" dynamo:"webhook_id" index:"webhook_id,hash"`
CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"`
UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"`
}
diff --git a/scripts/wait-for-sql.sh b/scripts/wait-for-sql.sh
new file mode 100755
index 000000000..5262df981
--- /dev/null
+++ b/scripts/wait-for-sql.sh
@@ -0,0 +1,88 @@
+#!/bin/sh
+# Wait until a SQL test container actually serves queries.
+#
+# Same reason wait-for-cql.sh exists: wait-for-test-dbs.sh probes with `nc -z`,
+# which on Docker Desktop connects to the published-port proxy and succeeds the
+# instant `docker run -p` returns. For these engines that proves nothing —
+# MySQL binds 3306, runs its init scripts and then *restarts*, and CockroachDB
+# accepts TCP before it is accepting SQL clients. Both windows are long enough
+# for the storage tests to start against a database that is not there yet. This
+# asks the server itself, from inside the container, which a proxy cannot answer.
+set -e
+
+CONTAINER="$1"
+ENGINE="$2"
+TIMEOUT_SECONDS="${TEST_SQL_WAIT_TIMEOUT:-180}"
+
+if [ -z "$CONTAINER" ] || [ -z "$ENGINE" ]; then
+ echo "usage: wait-for-sql.sh " >&2
+ exit 2
+fi
+
+probe() {
+ case "$ENGINE" in
+ mysql)
+ # A query against the test database, not `mysqladmin ping`: the entrypoint
+ # starts a temporary server to run its init (which is what creates
+ # authorizer_test), *then* restarts. Ping answers during that window, so
+ # it reports ready while the database does not exist yet and the next
+ # connection is refused mid-restart.
+ docker exec "$CONTAINER" mysql -uroot -ppassword -e 'SELECT 1' authorizer_test >/dev/null 2>&1
+ ;;
+ mariadb)
+ # MariaDB 11 renamed the client binaries; the mysql* names are only
+ # compatibility symlinks and are being dropped, so try the real one first.
+ docker exec "$CONTAINER" mariadb -uroot -ppassword -e 'SELECT 1' authorizer_test >/dev/null 2>&1 ||
+ docker exec "$CONTAINER" mysql -uroot -ppassword -e 'SELECT 1' authorizer_test >/dev/null 2>&1
+ ;;
+ cockroachdb)
+ docker exec "$CONTAINER" ./cockroach sql --insecure -e 'SELECT 1' >/dev/null 2>&1
+ ;;
+ sqlserver)
+ # Against the server, not a database: the mssql image has no
+ # MSSQL_DATABASE equivalent, so authorizer_test is created by the caller
+ # once the server answers.
+ docker exec "$CONTAINER" /opt/mssql-tools18/bin/sqlcmd -C -S localhost \
+ -U sa -P "${SQLSERVER_PASSWORD:-Str0ng!Passw0rd}" -Q 'SELECT 1' >/dev/null 2>&1
+ ;;
+ *)
+ echo "unknown engine '$ENGINE' (known: mysql, mariadb, cockroachdb, sqlserver)" >&2
+ exit 2
+ ;;
+ esac
+}
+
+started=$(date +%s)
+deadline=$(( started + TIMEOUT_SECONDS ))
+
+echo "waiting for $ENGINE in $CONTAINER (up to ${TIMEOUT_SECONDS}s)..."
+while :; do
+ # Fail fast when the container died mid-wait: an exited container otherwise
+ # looks exactly like a slow boot until the whole timeout burns down.
+ state=$(docker inspect -f '{{.State.Status}}' "$CONTAINER" 2>/dev/null | tr -d '[:space:]')
+ [ -n "$state" ] || state=missing
+ case "$state" in
+ running) ;;
+ missing)
+ echo " no such container: $CONTAINER" >&2
+ exit 1
+ ;;
+ *)
+ echo " $CONTAINER is '$state', not running — it died during startup" >&2
+ docker logs --tail 20 "$CONTAINER" 2>&1 | sed 's/^/ /' >&2
+ exit 1
+ ;;
+ esac
+
+ if probe; then
+ echo " $CONTAINER serving $ENGINE (after $(( $(date +%s) - started ))s)"
+ exit 0
+ fi
+
+ if [ "$(date +%s)" -ge "$deadline" ]; then
+ echo " $CONTAINER never served $ENGINE within ${TIMEOUT_SECONDS}s" >&2
+ docker logs --tail 20 "$CONTAINER" 2>&1 | sed 's/^/ /' >&2
+ exit 1
+ fi
+ sleep 2
+done
diff --git a/scripts/wait-for-test-dbs.sh b/scripts/wait-for-test-dbs.sh
index 7e7f99fbc..0eb8d192b 100755
--- a/scripts/wait-for-test-dbs.sh
+++ b/scripts/wait-for-test-dbs.sh
@@ -14,8 +14,10 @@ set -e
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 cassandradb:9142 arangodb:8529 dynamodb:8000"
+# already provisions and waits for it. For mysql, mariadb and cockroachdb this
+# probe is only a first gate: their ports open before they serve queries, so
+# scripts/wait-for-sql.sh is what actually has to pass before tests run.
+ALL_SERVICES="redis:6380 postgres:5434 cockroachdb:26257 mysql:3306 mariadb:3307 mongodb:27017 scylladb:9042 cassandradb:9142 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