mirror of
https://github.com/zitadel/zitadel.git
synced 2026-07-25 18:28:00 +00:00
Users deploying ZITADEL against a managed PostgreSQL service (RDS, Cloud SQL, Azure Database, etc.) often do not have superuser access and cannot provide `Admin.*` credentials. The documented workaround — provisioning the user and database manually and then running `start-from-setup` — silently skips schema bootstrapping, causing `relation "eventstore.events" does not exist` errors with no clear recovery path. The root cause is that `zitadel init` conflates two steps that require different privileges without exposing them separately: - **Provisioning step** (`CREATE ROLE`, `CREATE DATABASE`, `GRANT`) — requires superuser. - **Schema bootstrapping step** (create `eventstore`/`projections`/`system` schemas and base tables) — requires only DB owner. Users who handle the provisioning step externally have no supported way to run schema bootstrapping alone. ## Changes - **`cmd/initialise/verify_schema.go`** (renamed from `verify_zitadel.go`): Rename `newZitadel()` → `newSchema()` (internal); rename the `init zitadel` sub-command to `zitadel init schema` (backwards-compatible alias kept) with a clear description that it bootstraps the ZITADEL database schema without admin/superuser privileges. Fix stale error log message to reference `init schema`. - **`cmd/initialise/verify_schema_test.go`** (renamed from `verify_zitadel_test.go`): Test file renamed to match source file. - **`cmd/initialise/init.go`**: Update call site to `newSchema()`. Add guidance in the `init` command's Long description about using `zitadel init schema` for users without admin credentials. - **`cmd/initialise/verify_database.go`**: Add a `pg_database` catalog pre-check before attempting `CREATE DATABASE`, so `zitadel init` with `ADMIN=service_user` no longer fails with `permission denied to create database` when the database was already provisioned externally. - **`cmd/initialise/verify_database_test.go`**: Add test cases covering the new catalog-check skip path, the existing error-skip path, and the error-propagation path when the `pg_database` query itself fails. - **`apps/docs/content/self-hosting/manage/database/index.mdx`**: Inline the `_postgres.mdx` partial (now only PostgreSQL is supported), add a top-level callout, and add a **Managed PostgreSQL / No Admin Access** section with explicit 3-step instructions and security guidance (strong passwords, SSL, TLS). Additional improvements: add inline `# Use 'require' or 'verify-full' for production` comments on `Mode: disable` lines in the YAML example; add clarifying comment to the redundant `GRANT` in the SQL snippet; replace admonition syntax with proper Fumadocs `<Callout>` components; add full database connection env vars to the `start-from-setup` example. - **`apps/docs/content/self-hosting/manage/updating_scaling.mdx`**: Rewrite the init phase description to clearly distinguish the provisioning and schema bootstrapping steps, and document `zitadel init schema` as the path for manual provisioning. Replace admonition syntax with proper Fumadocs `<Callout>` components. - **`apps/docs/content/self-hosting/manage/database/_postgres.mdx`**: Deleted (content merged into `index.mdx`). ## Problem This relates to https://github.com/zitadel/zitadel/discussions/9363 I think we can improve our UX in cases where a user wants to use an external DB and/or does not want to share too broad permissions with zitadel ## Related problems * https://github.com/zitadel/zitadel/issues/10432 * https://github.com/zitadel/zitadel/discussions/8583 * https://github.com/zitadel/zitadel/issues/7903 * https://github.com/zitadel/zitadel/issues/9718 * https://github.com/zitadel/zitadel/issues/8012 * https://github.com/zitadel/zitadel/issues/8558 ## Related PRs https://github.com/zitadel/zitadel/pull/11021
110 lines
2.9 KiB
Go
110 lines
2.9 KiB
Go
package initialise
|
|
|
|
import (
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func Test_verifyDB(t *testing.T) {
|
|
err := ReadStmts()
|
|
if err != nil {
|
|
t.Errorf("unable to read stmts: %v", err)
|
|
t.FailNow()
|
|
}
|
|
|
|
type args struct {
|
|
db db
|
|
database string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
targetErr error
|
|
}{
|
|
{
|
|
name: "doesn't exist, create fails",
|
|
args: args{
|
|
db: prepareDB(t,
|
|
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
|
|
{"postgres"},
|
|
}),
|
|
expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", nil, []string{"exists"}, [][]driver.Value{
|
|
{false},
|
|
}, "zitadel"),
|
|
expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", sql.ErrTxDone),
|
|
),
|
|
database: "zitadel",
|
|
},
|
|
targetErr: sql.ErrTxDone,
|
|
},
|
|
{
|
|
name: "doesn't exist, create successful",
|
|
args: args{
|
|
db: prepareDB(t,
|
|
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
|
|
{"postgres"},
|
|
}),
|
|
expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", nil, []string{"exists"}, [][]driver.Value{
|
|
{false},
|
|
}, "zitadel"),
|
|
expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", nil),
|
|
),
|
|
database: "zitadel",
|
|
},
|
|
targetErr: nil,
|
|
},
|
|
{
|
|
name: "already exists in catalog, skip creation",
|
|
args: args{
|
|
db: prepareDB(t,
|
|
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
|
|
{"postgres"},
|
|
}),
|
|
expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", nil, []string{"exists"}, [][]driver.Value{
|
|
{true},
|
|
}, "zitadel"),
|
|
),
|
|
database: "zitadel",
|
|
},
|
|
targetErr: nil,
|
|
},
|
|
{
|
|
name: "catalog check fails",
|
|
args: args{
|
|
db: prepareDB(t,
|
|
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
|
|
{"postgres"},
|
|
}),
|
|
expectQuery("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", sql.ErrConnDone, []string{"exists"}, [][]driver.Value{}, "zitadel"),
|
|
),
|
|
database: "zitadel",
|
|
},
|
|
targetErr: sql.ErrConnDone,
|
|
},
|
|
{
|
|
name: "same database as admin connection, skip creation",
|
|
args: args{
|
|
db: prepareDB(t,
|
|
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
|
|
{"zitadel"},
|
|
}),
|
|
),
|
|
database: "zitadel",
|
|
},
|
|
targetErr: nil,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if err := VerifyDatabase(tt.args.database)(t.Context(), tt.args.db.db); !errors.Is(err, tt.targetErr) {
|
|
t.Errorf("verifyDB() error = %v, want: %v", err, tt.targetErr)
|
|
}
|
|
if err := tt.args.db.mock.ExpectationsWereMet(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
}
|
|
}
|