S4: it is not DDL *rights* that the migration needs, it is *ownership*
- Upgrade
- 26.0.0 → 26.7.1
- Scale
- 1,002 users, 2 realms
- Database
- PostgreSQL 16.15, single node, container
- Topology
- single container
- Host
- Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe
- Condition
- DB user without DDL rights
Summary
S4 asked whether a DML-only database account fails cleanly. It does. But the run
answered a more useful question than the one asked: granting that account
full DDL rights does not fix it. The first changeset in this upgrade issues
DROP INDEX, and in Postgres DROP INDEX requires ownership of the index —
a thing no GRANT confers.
The correct pre-flight check is therefore not "does the Keycloak database user have DDL permissions", which is what everyone asks. It is "does the Keycloak database user own the Keycloak objects".
Environment
| Field | Value |
|---|---|
| Keycloak from → to | 26.0.0 → 26.7.1 |
| Distribution / start mode | quay.io/keycloak/keycloak official image, start w/ external DB |
| Database | PostgreSQL 16.15, single node, container |
| Postgres tuning | shared_buffers 128MB, maintenance_work_mem 64MB, statement_timeout 0 — stock |
| Adverse scenario | S4 — the database role Keycloak connects as is the variable |
| Dataset scale | 1,002 users, 2 realms |
| Seeding method | partialImport (bin/seed-realm.sh --profile typical) |
| Topology | single container |
| Host | Hetzner CCX33 (fsn1) — 8 vCPU dedicated, 30 GB RAM, local NVMe |
| JVM heap | -Xms1g -Xmx4g |
| Lab change | docker-compose.dbidentity.yml added so KC_DB_USERNAME / KC_DB_PASSWORD / KC_DB_SCHEMA are env-driven without editing the base compose file |
Procedure
./bin/restore.sh baseline-26.0.0-typical-1k
# a locked-down enterprise account: DML on everything, DDL on nothing
create role kc_dml login password 'kc_dml';
grant connect on database keycloak to kc_dml;
grant usage on schema public to kc_dml;
grant select,insert,update,delete on all tables in schema public to kc_dml;
grant usage,select on all sequences in schema public to kc_dml;
revoke create on schema public from kc_dml, public;
# .env
COMPOSE_FILE=docker-compose.yml:docker-compose.dbidentity.yml
KC_DB_USERNAME=kc_dml
KC_DB_PASSWORD=kc_dml
./bin/upgrade.sh 26.7.1
Then two further runs, each changing exactly one thing: grant create on schema public to kc_dml (run B), and alter table … owner to kc_dml for every table
and sequence (run C).
Results
| Run | Role's rights | Outcome | Elapsed | Schema after | Users |
|---|---|---|---|---|---|
| A | DML only, no CREATE | exit 1, refused to start | died at 182s | model 26.0.0, changelog 144 (untouched) | 1,002 |
| B | DML + CREATE on schema (verified: can CREATE TABLE) | exit 1, identical failure | died at 151s | model 26.0.0, changelog 144 (untouched) | 1,002 |
| C | owner of every table and sequence | READY, clean | 17s | model 26.7.1, changelog 211 | 1,002 |
The failure, identical in runs A and B
ERROR [liquibase.changelog.ChangeSet] ChangeSet
META-INF/jpa-changelog-20.0.0.xml::20.0.0-12964-supported-dbs-edb-migration::keycloak
encountered an exception.: liquibase.exception.DatabaseException:
ERROR: must be owner of index idx_group_att_by_name_value
[Failed SQL: (0) DROP INDEX public.IDX_GROUP_ATT_BY_NAME_VALUE]
ERROR: Failed to update database
ERROR: Failed to start server in (production) mode
Run B is the load-bearing one. kc_dml demonstrably had DDL rights — the probe
create table ddl_probe(x int); drop table ddl_probe; succeeded as that role
immediately before the run — and the migration failed at exactly the same
changeset with exactly the same message. must be owner of is not a
privilege error and cannot be granted away. Postgres reserves DROP INDEX,
and ALTER TABLE, to the object's owner (or a superuser, or a member of the
owning role).
Why it fails on the first changeset
The 26.0.0 baseline carries 144 changelog rows. Reading them back after the successful run C:
| id | exectype | orderexecuted |
|---|---|---|
20.0.0-12964-supported-dbs | EXECUTED | 109 |
20.0.0-12964-unsupported-dbs | MARK_RAN | 110 |
20.0.0-12964-supported-dbs-edb-migration | EXECUTED | 145 |
Order 145 is the first row past the 144-row baseline. The very first thing a
26.0.0 → 26.7.1 migration does is drop and rebuild IDX_GROUP_ATT_BY_NAME_VALUE,
via a changeset that lives in the 20.0.0 changelog file and exists to fix up
EDB deployments. So the ownership requirement bites immediately — which is the
good news, because nothing else has been applied when it does.
Outcome
-
Failed cleanly, before touching data —
databasechangelogstill 144, all 1,002 users present, bothdatabasechangeloglockrowslocked=f - Container exited 1 and stayed exited (one attempt, no retry loop)
-
503fromBootstrapFilterwhile the failed bootstrap was in flight - Recoverable by fixing ownership alone — no restore needed
Verification
| Claim | Primary source | Checked |
|---|---|---|
| DML-only account fails the migration | run A, exit 1, changelog 144 | ☑ |
Granting CREATE does not fix it | run B, same changeset, same message, after a successful CREATE TABLE probe as that role | ☑ |
| Ownership does fix it | run C, ready in 17s, changelog 211 | ☑ |
| Failure applies nothing | changelog 144 and 1,002 users after both failed runs | ☑ |
| The blocking changeset is the first pending one | orderexecuted = 145 against a 144-row baseline | ☑ |
| Holds on other version pairs | not tested — a pair with no DROP INDEX/ALTER TABLE in its delta may not hit this | ☐ |
| Holds on MySQL / MariaDB / Oracle | not tested — Postgres only; ownership semantics differ per engine | ☐ |
Publishable extract
Everyone asks whether the Keycloak database user has DDL permissions. That is the wrong question.
We gave Keycloak a locked-down Postgres account —
select,insert,update,deleteon every table, nocreate— and upgraded a 1,002-user realm from 26.0.0 to 26.7.1 on Postgres 16.15. It failed, cleanly, having applied nothing:ERROR: must be owner of index idx_group_att_by_name_value [Failed SQL: (0) DROP INDEX public.IDX_GROUP_ATT_BY_NAME_VALUE]Then we granted that same account
CREATEon the schema and proved it could create and drop a table. The upgrade failed again, at the same changeset, with the same message. Only making the account the owner of the tables let the migration run — after which it completed in 17 seconds.The reason is plain Postgres, not Keycloak:
DROP INDEXandALTER TABLErequire ownership, and ownership is not a privilege you canGRANT. Any shop where a DBA creates the schema and the application connects as a different role — which is most shops with a change-control process — has a Keycloak upgrade that will stop dead on its first changeset.The check to run before your maintenance window is one query:
select tablename, tableowner from pg_tables where schemaname = 'public' and tableowner <> '<your keycloak role>';If that returns rows, your upgrade has not been tested. Ours hadn't either.