mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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(¤tDatabase)
|
||||
}, "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})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤tUser)
|
||||
}, "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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(¤tUser)
|
||||
}, "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 != "" {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user