feat(cmd): skip privileged database commands during initialization if objects already present (#11021)

This pull request addresses an issue where ZITADEL's initialization
process could fail in environments with restricted database permissions,
such as managed database services (e.g., Google Cloud SQL, Amazon RDS,
Azure PostgreSQL).

## Which problem is solved

Previously, `zitadel init` (and its sub commands) and `zitadel
start-from-init` commands would attempt to execute `CREATE DATABASE`,
`CREATE USER`, and `GRANT` statements. These commands often fail if the
provided database user lacks superuser privileges, which is a common
security practice in hosted database environments.

## How the problem is solved

With this change, Zitadel is now smarter during initialization. It
checks if the database and user specified in your configuration already
exist and are accessible. If they are, ZITADEL will skip the creation
and grant commands, allowing for a seamless setup even with a
less-privileged database user.

## Additional information

closes #10730
closes https://github.com/zitadel/zitadel/issues/8198

### How to leverage this feature (skip admin commands)

To have ZITADEL skip the database administration commands, you need to
configure it so that the initialization process can use the
already-existing database and user. This is achieved by pointing the
admin access in your ZITADEL configuration to the user and database that
ZITADEL will use for its operations.

In your ZITADEL configuration file (e.g., zitadel.yaml), configure the
Database section as follows:

```yaml
Database:
  Postgres:
    Database: existing_database
    # Admin credentials should be the same as the application user credentials
    Admin:
      User: zitadel-user
      ExistingDatabase: existing_database # this is used to connect the admin to the previously created database
    # Application user credentials
    User:
      User: zitadel-user
```

---------

Co-authored-by: Marco A. <[email protected]>
This commit is contained in:
Silvan
2025-11-11 10:28:06 +00:00
committed by GitHub
co-authored by Marco A.
parent fbdbd2d418
commit e074668c6b
8 changed files with 137 additions and 3 deletions
+12
View File
@@ -2,6 +2,7 @@ package initialise
import (
"context"
"database/sql"
_ "embed"
"fmt"
@@ -37,6 +38,17 @@ The user provided by flags needs privileges to
func VerifyDatabase(databaseName string) func(context.Context, *database.DB) error {
return func(ctx context.Context, db *database.DB) error {
var currentDatabase string
err := db.QueryRowContext(ctx, func(r *sql.Row) error {
return r.Scan(&currentDatabase)
}, "SELECT current_database()")
if err != nil {
return fmt.Errorf("unable to get current database: %w", err)
}
if currentDatabase == databaseName {
logging.WithFields("database", databaseName).Info("database is same as config.database.postgres.admin.ExistingDatabase, skipping creation")
return nil
}
logging.WithFields("database", databaseName).Info("verify database")
return exec(ctx, db, fmt.Sprintf(databaseStmt, databaseName), []string{dbAlreadyExistsCode})
+23 -2
View File
@@ -1,8 +1,8 @@
package initialise
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"testing"
)
@@ -27,6 +27,9 @@ func Test_verifyDB(t *testing.T) {
name: "doesn't exists, create fails",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", sql.ErrTxDone),
),
database: "zitadel",
@@ -37,6 +40,9 @@ func Test_verifyDB(t *testing.T) {
name: "doesn't exists, create successful",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", nil),
),
database: "zitadel",
@@ -47,16 +53,31 @@ func Test_verifyDB(t *testing.T) {
name: "already exists",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_database()", nil, []string{"current_database"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel with the name of the database\nCREATE DATABASE \"zitadel\"", nil),
),
database: "zitadel",
},
targetErr: nil,
},
{
name: "same database, 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)(context.Background(), tt.args.db.db); !errors.Is(err, tt.targetErr) {
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 {
+12
View File
@@ -2,6 +2,7 @@ package initialise
import (
"context"
"database/sql"
_ "embed"
"fmt"
@@ -32,6 +33,17 @@ Prerequisites:
func VerifyGrant(databaseName, username string) func(context.Context, *database.DB) error {
return func(ctx context.Context, db *database.DB) error {
var currentUser string
err := db.QueryRowContext(ctx, func(r *sql.Row) error {
return r.Scan(&currentUser)
}, "SELECT current_user")
if err != nil {
return fmt.Errorf("unable to get current user: %w", err)
}
if currentUser == username {
logging.WithFields("username", username).Info("config.database.postgres.user.username is same as config.database.postgres.admin.username, skipping grant")
return nil
}
logging.WithFields("user", username, "database", databaseName).Info("verify grant")
return exec(ctx, db, fmt.Sprintf(grantStmt, databaseName, username), nil)
+29
View File
@@ -3,11 +3,18 @@ package initialise
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"testing"
)
func Test_verifyGrant(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
@@ -22,6 +29,9 @@ func Test_verifyGrant(t *testing.T) {
name: "doesn't exists, create fails",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"admin"},
}),
expectExec("GRANT ALL ON DATABASE \"zitadel\" TO \"zitadel-user\"", sql.ErrTxDone),
),
database: "zitadel",
@@ -33,6 +43,9 @@ func Test_verifyGrant(t *testing.T) {
name: "correct",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"admin"},
}),
expectExec("GRANT ALL ON DATABASE \"zitadel\" TO \"zitadel-user\"", nil),
),
database: "zitadel",
@@ -44,6 +57,9 @@ func Test_verifyGrant(t *testing.T) {
name: "already exists",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"admin"},
}),
expectExec("GRANT ALL ON DATABASE \"zitadel\" TO \"zitadel-user\"", nil),
),
database: "zitadel",
@@ -51,6 +67,19 @@ func Test_verifyGrant(t *testing.T) {
},
targetErr: nil,
},
{
name: "same user, skip grant",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"zitadel-user"},
}),
),
database: "zitadel",
username: "zitadel-user",
},
targetErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+12
View File
@@ -2,6 +2,7 @@ package initialise
import (
"context"
"database/sql"
_ "embed"
"fmt"
@@ -37,6 +38,17 @@ The user provided by flags needs privileges to
func VerifyUser(username, password string) func(context.Context, *database.DB) error {
return func(ctx context.Context, db *database.DB) error {
var currentUser string
err := db.QueryRowContext(ctx, func(r *sql.Row) error {
return r.Scan(&currentUser)
}, "SELECT current_user")
if err != nil {
return fmt.Errorf("unable to get current user: %w", err)
}
if currentUser == username {
logging.WithFields("username", username).Info("config.database.postgres.user.username is same as config.database.postgres.admin.username, skipping create user")
return nil
}
logging.WithFields("username", username).Info("verify user")
if password != "" {
+26 -1
View File
@@ -3,6 +3,7 @@ package initialise
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"testing"
)
@@ -28,6 +29,9 @@ func Test_verifyUser(t *testing.T) {
name: "doesn't exists, create fails",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel-user with the name of the user\nCREATE USER \"zitadel-user\"", sql.ErrTxDone),
),
username: "zitadel-user",
@@ -39,6 +43,9 @@ func Test_verifyUser(t *testing.T) {
name: "correct without password",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel-user with the name of the user\nCREATE USER \"zitadel-user\"", nil),
),
username: "zitadel-user",
@@ -50,6 +57,9 @@ func Test_verifyUser(t *testing.T) {
name: "correct with password",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel-user with the name of the user\nCREATE USER \"zitadel-user\" WITH PASSWORD 'password'", nil),
),
username: "zitadel-user",
@@ -61,6 +71,9 @@ func Test_verifyUser(t *testing.T) {
name: "already exists",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"postgres"},
}),
expectExec("-- replace zitadel-user with the name of the user\nCREATE USER \"zitadel-user\" WITH PASSWORD 'password'", nil),
),
username: "zitadel-user",
@@ -68,11 +81,23 @@ func Test_verifyUser(t *testing.T) {
},
targetErr: nil,
},
{
name: "same user, skip create",
args: args{
db: prepareDB(t,
expectQuery("SELECT current_user", nil, []string{"current_user"}, [][]driver.Value{
{"zitadel-user"},
}),
),
username: "zitadel-user",
},
targetErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := VerifyUser(tt.args.username, tt.args.password)(context.Background(), tt.args.db.db); !errors.Is(err, tt.targetErr) {
t.Errorf("VerifyGrant() error = %v, want: %v", err, tt.targetErr)
t.Errorf("VerifyUser() error = %v, want: %v", err, tt.targetErr)
}
if err := tt.args.db.mock.ExpectationsWereMet(); err != nil {
t.Error(err)
@@ -12,6 +12,7 @@ import Login from "./_login.md";
import CodeBlock from "@theme/CodeBlock";
import DefaultsYamlSource from "!!raw-loader!./defaults.yaml";
import StepsYamlSource from "!!raw-loader!./steps.yaml";
import SkipSuperUserDatabaseQueries from "!!raw-loader!./example-skip-superuser-queries.yaml";
This guide assumes you are familiar with [running ZITADEL using the least amount of configuration possible](/docs/self-hosting/deploy/overview).
@@ -47,6 +48,18 @@ By using the FirstInstance section, you can overwrite the [DefaultInstance confi
<CodeBlock language="yaml">{StepsYamlSource}</CodeBlock>
</details>
### Pre-existing Database and User
By default, when you run `zitadel init` or `zitadel start-from-init`, ZITADEL checks if the specified database and user exist. If they don't, ZITADEL creates them and grants the necessary permissions.
If you manage the database and user manually, you can instruct ZITADEL to skip these checks. This is useful if the ZITADEL user has limited database permissions, for example, if it is not a `SUPERUSER`. This scenario is most common on cloud providers.
To skip the checks, you must ensure the database objects (database, user, grant) exist before running the commands and then set the `Database.postgres.Admin.ExistingDatabase` field in your configuration as shown below. This tells ZITADEL to connect to the existing database as an admin user, bypassing the creation and permission steps.
<details>
<summary>skip-superuser-queries-config.yaml</summary>
<CodeBlock language="yaml">{SkipSuperUserDatabaseQueries}</CodeBlock>
</details>
### Multiple configuration files
ZITADEL merges configuration files when multiple `--config` and `--steps` flags are provided.
@@ -0,0 +1,10 @@
Database:
postgres:
Database: existing_database
# Admin credentials should be the same as the application user credentials
Admin:
Username: zitadel-user
ExistingDatabase: existing_database # this is used to connect the admin to the previously created database
# Application user credentials
User:
Username: zitadel-user