From 3b44f57cf88c3347c7cc385d7b1cb33052dcc727 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 08:46:17 +0100 Subject: [PATCH 1/7] first version of the DefaultPrivilege CRD with missing tests --- generated/build.gradle.kts | 1 + .../core/infrastructure/persistence/Keys.java | 4 + .../infrastructure/persistence/PgCatalog.java | 7 + .../infrastructure/persistence/Tables.java | 6 + .../persistence/tables/PgClass.java | 5 + .../persistence/tables/PgDefaultAcl.java | 254 +++++++++++++ .../tables/records/PgClassRecord.java | 102 +++--- .../tables/records/PgDefaultAclRecord.java | 138 +++++++ .../Privilege.java} | 7 +- .../defaultprivilege/DefaultPrivilege.java | 49 +++ .../DefaultPrivilegeObjectType.java | 92 +++++ .../DefaultPrivilegeReconciler.java | 239 ++++++++++++ .../DefaultPrivilegeService.java | 138 +++++++ .../DefaultPrivilegeSpec.java | 113 ++++++ .../postgresql/crd/grant/GrantObjectType.java | 35 +- .../postgresql/crd/grant/GrantReconciler.java | 8 +- .../postgresql/crd/grant/GrantService.java | 28 +- .../postgresql/crd/grant/GrantSpec.java | 5 +- .../_support/testdata/persisted/Given.java | 9 + .../creator/ClusterConnectionCreate.java | 2 +- .../persisted/creator/DatabaseCreate.java | 2 +- .../creator/DefaultPrivilegeCreate.java | 217 +++++++++++ .../persisted/creator/GrantCreate.java | 10 +- .../persisted/creator/RoleCreate.java | 2 +- .../persisted/creator/SchemaCreate.java | 2 +- .../ClusterConnectionReconcilerTest.java | 4 +- .../DefaultPrivilegeReconcilerTest.java | 346 ++++++++++++++++++ .../crd/grant/GrantReconcilerTest.java | 11 +- 28 files changed, 1736 insertions(+), 100 deletions(-) create mode 100644 generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDefaultAcl.java create mode 100644 generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDefaultAclRecord.java rename operator/src/main/java/it/aboutbits/postgresql/{crd/grant/GrantPrivilege.java => core/Privilege.java} (83%) create mode 100644 operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilege.java create mode 100644 operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java create mode 100644 operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java create mode 100644 operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeService.java create mode 100644 operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeSpec.java create mode 100644 operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java create mode 100644 operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java diff --git a/generated/build.gradle.kts b/generated/build.gradle.kts index 6ab20a7..9642969 100644 --- a/generated/build.gradle.kts +++ b/generated/build.gradle.kts @@ -36,6 +36,7 @@ jooq { | pg_class | pg_database | pg_db_role_setting + | pg_default_acl | pg_get_userbyid | pg_namespace | shobj_description diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java index 183f0f1..eec5760 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java @@ -9,12 +9,14 @@ import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgClass; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDatabase; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDefaultAcl; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgNamespace; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgAuthMembersRecord; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgAuthidRecord; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgClassRecord; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgDatabaseRecord; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgDbRoleSettingRecord; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgDefaultAclRecord; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgNamespaceRecord; import javax.annotation.processing.Generated; @@ -53,6 +55,8 @@ public class Keys { public static final UniqueKey PG_DATABASE_DATNAME_INDEX = Internal.createUniqueKey(PgDatabase.PG_DATABASE, DSL.name("pg_database_datname_index"), new TableField[] { PgDatabase.PG_DATABASE.DATNAME }, true); public static final UniqueKey PG_DATABASE_OID_INDEX = Internal.createUniqueKey(PgDatabase.PG_DATABASE, DSL.name("pg_database_oid_index"), new TableField[] { PgDatabase.PG_DATABASE.OID }, true); public static final UniqueKey PG_DB_ROLE_SETTING_DATABASEID_ROL_INDEX = Internal.createUniqueKey(PgDbRoleSetting.PG_DB_ROLE_SETTING, DSL.name("pg_db_role_setting_databaseid_rol_index"), new TableField[] { PgDbRoleSetting.PG_DB_ROLE_SETTING.SETDATABASE, PgDbRoleSetting.PG_DB_ROLE_SETTING.SETROLE }, true); + public static final UniqueKey PG_DEFAULT_ACL_OID_INDEX = Internal.createUniqueKey(PgDefaultAcl.PG_DEFAULT_ACL, DSL.name("pg_default_acl_oid_index"), new TableField[] { PgDefaultAcl.PG_DEFAULT_ACL.OID }, true); + public static final UniqueKey PG_DEFAULT_ACL_ROLE_NSP_OBJ_INDEX = Internal.createUniqueKey(PgDefaultAcl.PG_DEFAULT_ACL, DSL.name("pg_default_acl_role_nsp_obj_index"), new TableField[] { PgDefaultAcl.PG_DEFAULT_ACL.DEFACLROLE, PgDefaultAcl.PG_DEFAULT_ACL.DEFACLNAMESPACE, PgDefaultAcl.PG_DEFAULT_ACL.DEFACLOBJTYPE }, true); public static final UniqueKey PG_NAMESPACE_NSPNAME_INDEX = Internal.createUniqueKey(PgNamespace.PG_NAMESPACE, DSL.name("pg_namespace_nspname_index"), new TableField[] { PgNamespace.PG_NAMESPACE.NSPNAME }, true); public static final UniqueKey PG_NAMESPACE_OID_INDEX = Internal.createUniqueKey(PgNamespace.PG_NAMESPACE, DSL.name("pg_namespace_oid_index"), new TableField[] { PgNamespace.PG_NAMESPACE.OID }, true); } diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java index b6f28ea..6296fb1 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java @@ -10,6 +10,7 @@ import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgClass; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDatabase; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDefaultAcl; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgNamespace; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.AclexplodeRecord; @@ -111,6 +112,11 @@ public static Aclexplode ACLEXPLODE( */ public final PgDbRoleSetting PG_DB_ROLE_SETTING = PgDbRoleSetting.PG_DB_ROLE_SETTING; + /** + * The table pg_catalog.pg_default_acl. + */ + public final PgDefaultAcl PG_DEFAULT_ACL = PgDefaultAcl.PG_DEFAULT_ACL; + /** * The table pg_catalog.pg_namespace. */ @@ -138,6 +144,7 @@ public final List> getTables() { PgClass.PG_CLASS, PgDatabase.PG_DATABASE, PgDbRoleSetting.PG_DB_ROLE_SETTING, + PgDefaultAcl.PG_DEFAULT_ACL, PgNamespace.PG_NAMESPACE ); } diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java index 3cc5796..4b34b62 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java @@ -10,6 +10,7 @@ import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgClass; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDatabase; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDefaultAcl; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgNamespace; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.AclexplodeRecord; @@ -97,6 +98,11 @@ public static Aclexplode ACLEXPLODE( */ public static final PgDbRoleSetting PG_DB_ROLE_SETTING = PgDbRoleSetting.PG_DB_ROLE_SETTING; + /** + * The table pg_catalog.pg_default_acl. + */ + public static final PgDefaultAcl PG_DEFAULT_ACL = PgDefaultAcl.PG_DEFAULT_ACL; + /** * The table pg_catalog.pg_namespace. */ diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgClass.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgClass.java index 9824976..1386f03 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgClass.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgClass.java @@ -123,6 +123,11 @@ public Class getRecordType() { */ public final TableField RELALLVISIBLE = createField(DSL.name("relallvisible"), SQLDataType.INTEGER.nullable(false), this, ""); + /** + * The column pg_catalog.pg_class.relallfrozen. + */ + public final TableField RELALLFROZEN = createField(DSL.name("relallfrozen"), SQLDataType.INTEGER.nullable(false), this, ""); + /** * The column pg_catalog.pg_class.reltoastrelid. */ diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDefaultAcl.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDefaultAcl.java new file mode 100644 index 0000000..aeffbf4 --- /dev/null +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDefaultAcl.java @@ -0,0 +1,254 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.Keys; +import it.aboutbits.postgresql.core.infrastructure.persistence.PgCatalog; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgDefaultAclRecord; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import javax.annotation.processing.Generated; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgDefaultAcl extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of pg_catalog.pg_default_acl + */ + public static final PgDefaultAcl PG_DEFAULT_ACL = new PgDefaultAcl(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return PgDefaultAclRecord.class; + } + + /** + * The column pg_catalog.pg_default_acl.oid. + */ + public final TableField OID = createField(DSL.name("oid"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_default_acl.defaclrole. + */ + public final TableField DEFACLROLE = createField(DSL.name("defaclrole"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_default_acl.defaclnamespace. + */ + public final TableField DEFACLNAMESPACE = createField(DSL.name("defaclnamespace"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_default_acl.defaclobjtype. + */ + public final TableField DEFACLOBJTYPE = createField(DSL.name("defaclobjtype"), SQLDataType.CHAR.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_default_acl.defaclacl. + */ + public final TableField DEFACLACL = createField(DSL.name("defaclacl"), SQLDataType.VARCHAR.array().nullable(false), this, ""); + + private PgDefaultAcl(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private PgDefaultAcl(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased pg_catalog.pg_default_acl table reference + */ + public PgDefaultAcl(String alias) { + this(DSL.name(alias), PG_DEFAULT_ACL); + } + + /** + * Create an aliased pg_catalog.pg_default_acl table reference + */ + public PgDefaultAcl(Name alias) { + this(alias, PG_DEFAULT_ACL); + } + + /** + * Create a pg_catalog.pg_default_acl table reference + */ + public PgDefaultAcl() { + this(DSL.name("pg_default_acl"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : PgCatalog.PG_CATALOG; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.PG_DEFAULT_ACL_OID_INDEX; + } + + @Override + public List> getUniqueKeys() { + return Arrays.asList(Keys.PG_DEFAULT_ACL_ROLE_NSP_OBJ_INDEX); + } + + @Override + public PgDefaultAcl as(String alias) { + return new PgDefaultAcl(DSL.name(alias), this); + } + + @Override + public PgDefaultAcl as(Name alias) { + return new PgDefaultAcl(alias, this); + } + + @Override + public PgDefaultAcl as(Table alias) { + return new PgDefaultAcl(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public PgDefaultAcl rename(String name) { + return new PgDefaultAcl(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public PgDefaultAcl rename(Name name) { + return new PgDefaultAcl(name, null); + } + + /** + * Rename this table + */ + @Override + public PgDefaultAcl rename(Table name) { + return new PgDefaultAcl(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDefaultAcl where(Condition condition) { + return new PgDefaultAcl(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDefaultAcl where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDefaultAcl where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDefaultAcl where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDefaultAcl where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDefaultAcl where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDefaultAcl where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDefaultAcl where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDefaultAcl whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDefaultAcl whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgClassRecord.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgClassRecord.java index 2cea271..38e5edf 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgClassRecord.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgClassRecord.java @@ -207,11 +207,26 @@ public Integer getRelallvisible() { return (Integer) get(11); } + /** + * Setter for pg_catalog.pg_class.relallfrozen. + */ + public PgClassRecord setRelallfrozen(Integer value) { + set(12, value); + return this; + } + + /** + * Getter for pg_catalog.pg_class.relallfrozen. + */ + public Integer getRelallfrozen() { + return (Integer) get(12); + } + /** * Setter for pg_catalog.pg_class.reltoastrelid. */ public PgClassRecord setReltoastrelid(Long value) { - set(12, value); + set(13, value); return this; } @@ -219,14 +234,14 @@ public PgClassRecord setReltoastrelid(Long value) { * Getter for pg_catalog.pg_class.reltoastrelid. */ public Long getReltoastrelid() { - return (Long) get(12); + return (Long) get(13); } /** * Setter for pg_catalog.pg_class.relhasindex. */ public PgClassRecord setRelhasindex(Boolean value) { - set(13, value); + set(14, value); return this; } @@ -234,14 +249,14 @@ public PgClassRecord setRelhasindex(Boolean value) { * Getter for pg_catalog.pg_class.relhasindex. */ public Boolean getRelhasindex() { - return (Boolean) get(13); + return (Boolean) get(14); } /** * Setter for pg_catalog.pg_class.relisshared. */ public PgClassRecord setRelisshared(Boolean value) { - set(14, value); + set(15, value); return this; } @@ -249,14 +264,14 @@ public PgClassRecord setRelisshared(Boolean value) { * Getter for pg_catalog.pg_class.relisshared. */ public Boolean getRelisshared() { - return (Boolean) get(14); + return (Boolean) get(15); } /** * Setter for pg_catalog.pg_class.relpersistence. */ public PgClassRecord setRelpersistence(String value) { - set(15, value); + set(16, value); return this; } @@ -264,14 +279,14 @@ public PgClassRecord setRelpersistence(String value) { * Getter for pg_catalog.pg_class.relpersistence. */ public String getRelpersistence() { - return (String) get(15); + return (String) get(16); } /** * Setter for pg_catalog.pg_class.relkind. */ public PgClassRecord setRelkind(String value) { - set(16, value); + set(17, value); return this; } @@ -279,14 +294,14 @@ public PgClassRecord setRelkind(String value) { * Getter for pg_catalog.pg_class.relkind. */ public String getRelkind() { - return (String) get(16); + return (String) get(17); } /** * Setter for pg_catalog.pg_class.relnatts. */ public PgClassRecord setRelnatts(Short value) { - set(17, value); + set(18, value); return this; } @@ -294,14 +309,14 @@ public PgClassRecord setRelnatts(Short value) { * Getter for pg_catalog.pg_class.relnatts. */ public Short getRelnatts() { - return (Short) get(17); + return (Short) get(18); } /** * Setter for pg_catalog.pg_class.relchecks. */ public PgClassRecord setRelchecks(Short value) { - set(18, value); + set(19, value); return this; } @@ -309,14 +324,14 @@ public PgClassRecord setRelchecks(Short value) { * Getter for pg_catalog.pg_class.relchecks. */ public Short getRelchecks() { - return (Short) get(18); + return (Short) get(19); } /** * Setter for pg_catalog.pg_class.relhasrules. */ public PgClassRecord setRelhasrules(Boolean value) { - set(19, value); + set(20, value); return this; } @@ -324,14 +339,14 @@ public PgClassRecord setRelhasrules(Boolean value) { * Getter for pg_catalog.pg_class.relhasrules. */ public Boolean getRelhasrules() { - return (Boolean) get(19); + return (Boolean) get(20); } /** * Setter for pg_catalog.pg_class.relhastriggers. */ public PgClassRecord setRelhastriggers(Boolean value) { - set(20, value); + set(21, value); return this; } @@ -339,14 +354,14 @@ public PgClassRecord setRelhastriggers(Boolean value) { * Getter for pg_catalog.pg_class.relhastriggers. */ public Boolean getRelhastriggers() { - return (Boolean) get(20); + return (Boolean) get(21); } /** * Setter for pg_catalog.pg_class.relhassubclass. */ public PgClassRecord setRelhassubclass(Boolean value) { - set(21, value); + set(22, value); return this; } @@ -354,14 +369,14 @@ public PgClassRecord setRelhassubclass(Boolean value) { * Getter for pg_catalog.pg_class.relhassubclass. */ public Boolean getRelhassubclass() { - return (Boolean) get(21); + return (Boolean) get(22); } /** * Setter for pg_catalog.pg_class.relrowsecurity. */ public PgClassRecord setRelrowsecurity(Boolean value) { - set(22, value); + set(23, value); return this; } @@ -369,14 +384,14 @@ public PgClassRecord setRelrowsecurity(Boolean value) { * Getter for pg_catalog.pg_class.relrowsecurity. */ public Boolean getRelrowsecurity() { - return (Boolean) get(22); + return (Boolean) get(23); } /** * Setter for pg_catalog.pg_class.relforcerowsecurity. */ public PgClassRecord setRelforcerowsecurity(Boolean value) { - set(23, value); + set(24, value); return this; } @@ -384,14 +399,14 @@ public PgClassRecord setRelforcerowsecurity(Boolean value) { * Getter for pg_catalog.pg_class.relforcerowsecurity. */ public Boolean getRelforcerowsecurity() { - return (Boolean) get(23); + return (Boolean) get(24); } /** * Setter for pg_catalog.pg_class.relispopulated. */ public PgClassRecord setRelispopulated(Boolean value) { - set(24, value); + set(25, value); return this; } @@ -399,14 +414,14 @@ public PgClassRecord setRelispopulated(Boolean value) { * Getter for pg_catalog.pg_class.relispopulated. */ public Boolean getRelispopulated() { - return (Boolean) get(24); + return (Boolean) get(25); } /** * Setter for pg_catalog.pg_class.relreplident. */ public PgClassRecord setRelreplident(String value) { - set(25, value); + set(26, value); return this; } @@ -414,14 +429,14 @@ public PgClassRecord setRelreplident(String value) { * Getter for pg_catalog.pg_class.relreplident. */ public String getRelreplident() { - return (String) get(25); + return (String) get(26); } /** * Setter for pg_catalog.pg_class.relispartition. */ public PgClassRecord setRelispartition(Boolean value) { - set(26, value); + set(27, value); return this; } @@ -429,14 +444,14 @@ public PgClassRecord setRelispartition(Boolean value) { * Getter for pg_catalog.pg_class.relispartition. */ public Boolean getRelispartition() { - return (Boolean) get(26); + return (Boolean) get(27); } /** * Setter for pg_catalog.pg_class.relrewrite. */ public PgClassRecord setRelrewrite(Long value) { - set(27, value); + set(28, value); return this; } @@ -444,14 +459,14 @@ public PgClassRecord setRelrewrite(Long value) { * Getter for pg_catalog.pg_class.relrewrite. */ public Long getRelrewrite() { - return (Long) get(27); + return (Long) get(28); } /** * Setter for pg_catalog.pg_class.relfrozenxid. */ public PgClassRecord setRelfrozenxid(Long value) { - set(28, value); + set(29, value); return this; } @@ -459,14 +474,14 @@ public PgClassRecord setRelfrozenxid(Long value) { * Getter for pg_catalog.pg_class.relfrozenxid. */ public Long getRelfrozenxid() { - return (Long) get(28); + return (Long) get(29); } /** * Setter for pg_catalog.pg_class.relminmxid. */ public PgClassRecord setRelminmxid(Long value) { - set(29, value); + set(30, value); return this; } @@ -474,14 +489,14 @@ public PgClassRecord setRelminmxid(Long value) { * Getter for pg_catalog.pg_class.relminmxid. */ public Long getRelminmxid() { - return (Long) get(29); + return (Long) get(30); } /** * Setter for pg_catalog.pg_class.relacl. */ public PgClassRecord setRelacl(String[] value) { - set(30, value); + set(31, value); return this; } @@ -489,14 +504,14 @@ public PgClassRecord setRelacl(String[] value) { * Getter for pg_catalog.pg_class.relacl. */ public String[] getRelacl() { - return (String[]) get(30); + return (String[]) get(31); } /** * Setter for pg_catalog.pg_class.reloptions. */ public PgClassRecord setReloptions(String[] value) { - set(31, value); + set(32, value); return this; } @@ -504,7 +519,7 @@ public PgClassRecord setReloptions(String[] value) { * Getter for pg_catalog.pg_class.reloptions. */ public String[] getReloptions() { - return (String[]) get(31); + return (String[]) get(32); } /** @@ -517,7 +532,7 @@ public String[] getReloptions() { */ @Deprecated public PgClassRecord setRelpartbound(Object value) { - set(32, value); + set(33, value); return this; } @@ -531,7 +546,7 @@ public PgClassRecord setRelpartbound(Object value) { */ @Deprecated public Object getRelpartbound() { - return get(32); + return get(33); } // ------------------------------------------------------------------------- @@ -557,7 +572,7 @@ public PgClassRecord() { /** * Create a detached, initialised PgClassRecord */ - public PgClassRecord(Long oid, String relname, Long relnamespace, Long reltype, Long reloftype, Long relowner, Long relam, Long relfilenode, Long reltablespace, Integer relpages, Float reltuples, Integer relallvisible, Long reltoastrelid, Boolean relhasindex, Boolean relisshared, String relpersistence, String relkind, Short relnatts, Short relchecks, Boolean relhasrules, Boolean relhastriggers, Boolean relhassubclass, Boolean relrowsecurity, Boolean relforcerowsecurity, Boolean relispopulated, String relreplident, Boolean relispartition, Long relrewrite, Long relfrozenxid, Long relminmxid, String[] relacl, String[] reloptions, Object relpartbound) { + public PgClassRecord(Long oid, String relname, Long relnamespace, Long reltype, Long reloftype, Long relowner, Long relam, Long relfilenode, Long reltablespace, Integer relpages, Float reltuples, Integer relallvisible, Integer relallfrozen, Long reltoastrelid, Boolean relhasindex, Boolean relisshared, String relpersistence, String relkind, Short relnatts, Short relchecks, Boolean relhasrules, Boolean relhastriggers, Boolean relhassubclass, Boolean relrowsecurity, Boolean relforcerowsecurity, Boolean relispopulated, String relreplident, Boolean relispartition, Long relrewrite, Long relfrozenxid, Long relminmxid, String[] relacl, String[] reloptions, Object relpartbound) { super(PgClass.PG_CLASS); setOid(oid); @@ -572,6 +587,7 @@ public PgClassRecord(Long oid, String relname, Long relnamespace, Long reltype, setRelpages(relpages); setReltuples(reltuples); setRelallvisible(relallvisible); + setRelallfrozen(relallfrozen); setReltoastrelid(reltoastrelid); setRelhasindex(relhasindex); setRelisshared(relisshared); diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDefaultAclRecord.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDefaultAclRecord.java new file mode 100644 index 0000000..da47abe --- /dev/null +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDefaultAclRecord.java @@ -0,0 +1,138 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables.records; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDefaultAcl; + +import javax.annotation.processing.Generated; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgDefaultAclRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for pg_catalog.pg_default_acl.oid. + */ + public PgDefaultAclRecord setOid(Long value) { + set(0, value); + return this; + } + + /** + * Getter for pg_catalog.pg_default_acl.oid. + */ + public Long getOid() { + return (Long) get(0); + } + + /** + * Setter for pg_catalog.pg_default_acl.defaclrole. + */ + public PgDefaultAclRecord setDefaclrole(Long value) { + set(1, value); + return this; + } + + /** + * Getter for pg_catalog.pg_default_acl.defaclrole. + */ + public Long getDefaclrole() { + return (Long) get(1); + } + + /** + * Setter for pg_catalog.pg_default_acl.defaclnamespace. + */ + public PgDefaultAclRecord setDefaclnamespace(Long value) { + set(2, value); + return this; + } + + /** + * Getter for pg_catalog.pg_default_acl.defaclnamespace. + */ + public Long getDefaclnamespace() { + return (Long) get(2); + } + + /** + * Setter for pg_catalog.pg_default_acl.defaclobjtype. + */ + public PgDefaultAclRecord setDefaclobjtype(String value) { + set(3, value); + return this; + } + + /** + * Getter for pg_catalog.pg_default_acl.defaclobjtype. + */ + public String getDefaclobjtype() { + return (String) get(3); + } + + /** + * Setter for pg_catalog.pg_default_acl.defaclacl. + */ + public PgDefaultAclRecord setDefaclacl(String[] value) { + set(4, value); + return this; + } + + /** + * Getter for pg_catalog.pg_default_acl.defaclacl. + */ + public String[] getDefaclacl() { + return (String[]) get(4); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached PgDefaultAclRecord + */ + public PgDefaultAclRecord() { + super(PgDefaultAcl.PG_DEFAULT_ACL); + } + + /** + * Create a detached, initialised PgDefaultAclRecord + */ + public PgDefaultAclRecord(Long oid, Long defaclrole, Long defaclnamespace, String defaclobjtype, String[] defaclacl) { + super(PgDefaultAcl.PG_DEFAULT_ACL); + + setOid(oid); + setDefaclrole(defaclrole); + setDefaclnamespace(defaclnamespace); + setDefaclobjtype(defaclobjtype); + setDefaclacl(defaclacl); + resetTouchedOnNotNull(); + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantPrivilege.java b/operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java similarity index 83% rename from operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantPrivilege.java rename to operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java index dd3854f..edc4ae2 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantPrivilege.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java @@ -1,7 +1,6 @@ -package it.aboutbits.postgresql.crd.grant; +package it.aboutbits.postgresql.core; import com.fasterxml.jackson.annotation.JsonValue; -import org.jooq.Privilege; import org.jooq.impl.DSL; import org.jspecify.annotations.NullMarked; @@ -13,7 +12,7 @@ * */ @NullMarked -public enum GrantPrivilege { +public enum Privilege { SELECT, INSERT, UPDATE, @@ -32,7 +31,7 @@ public String toValue() { return name().toLowerCase(Locale.ROOT); } - public Privilege privilege() { + public org.jooq.Privilege privilege() { return DSL.privilege( name().toLowerCase(Locale.ROOT) ); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilege.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilege.java new file mode 100644 index 0000000..016747f --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilege.java @@ -0,0 +1,49 @@ +package it.aboutbits.postgresql.crd.defaultprivilege; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.fabric8.crd.generator.annotation.AdditionalPrinterColumn; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.Named; +import org.jspecify.annotations.NullMarked; + +@NullMarked +@Version("v1") +@Group("postgresql.aboutbits.it") +@AdditionalPrinterColumn( + name = "Name", + jsonPath = ".status.name", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Phase", + jsonPath = ".status.phase", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Message", + jsonPath = ".status.message", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Since", + jsonPath = ".status.lastPhaseTransitionTime", + type = AdditionalPrinterColumn.Type.DATE +) +@AdditionalPrinterColumn( + name = "Age", + jsonPath = ".metadata.creationTimestamp", + type = AdditionalPrinterColumn.Type.DATE +) +public class DefaultPrivilege + extends CustomResource + implements Namespaced, Named { + @Override + @JsonIgnore + public String getName() { + return ""; + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java new file mode 100644 index 0000000..fc21b12 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java @@ -0,0 +1,92 @@ +package it.aboutbits.postgresql.crd.defaultprivilege; + +import com.fasterxml.jackson.annotation.JsonValue; +import it.aboutbits.postgresql.core.Privilege; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.jooq.Keyword; +import org.jspecify.annotations.NullMarked; + +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import static it.aboutbits.postgresql.core.Privilege.CREATE; +import static it.aboutbits.postgresql.core.Privilege.DELETE; +import static it.aboutbits.postgresql.core.Privilege.INSERT; +import static it.aboutbits.postgresql.core.Privilege.MAINTAIN; +import static it.aboutbits.postgresql.core.Privilege.REFERENCES; +import static it.aboutbits.postgresql.core.Privilege.SELECT; +import static it.aboutbits.postgresql.core.Privilege.TRIGGER; +import static it.aboutbits.postgresql.core.Privilege.TRUNCATE; +import static it.aboutbits.postgresql.core.Privilege.UPDATE; +import static it.aboutbits.postgresql.core.Privilege.USAGE; +import static org.jooq.impl.DSL.keyword; + +/** + * + * https://www.postgresql.org/docs/current/sql-alterdefaultprivileges.html + * + */ +@NullMarked +@Getter +@Accessors(fluent = true) +public enum DefaultPrivilegeObjectType { + SCHEMA( + "n", + List.of( + USAGE, + CREATE + ) + ), + TABLE( + "r", + List.of( + SELECT, + INSERT, + UPDATE, + DELETE, + TRUNCATE, + REFERENCES, + TRIGGER, + MAINTAIN + ) + ), + SEQUENCE( + "S", + List.of( + USAGE, + SELECT, + UPDATE + ) + ); + + @SuppressWarnings("ImmutableEnumChecker") + private final List privileges; + + @SuppressWarnings("ImmutableEnumChecker") + private final Set privilegesSet; + + private final String objectTypeChar; + + DefaultPrivilegeObjectType( + String objectTypeChar, + List privileges + ) { + this.objectTypeChar = objectTypeChar; + + this.privileges = privileges; + this.privilegesSet = Set.copyOf(privileges); + } + + @JsonValue + public String toValue() { + return name().toLowerCase(Locale.ROOT); + } + + public Keyword objectType() { + return keyword( + name().toLowerCase(Locale.ROOT) + ); + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java new file mode 100644 index 0000000..f56d590 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java @@ -0,0 +1,239 @@ +package it.aboutbits.postgresql.crd.defaultprivilege; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import it.aboutbits.postgresql.core.BaseReconciler; +import it.aboutbits.postgresql.core.CRPhase; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; +import org.jspecify.annotations.NullMarked; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@NullMarked +@Slf4j +@RequiredArgsConstructor +public class DefaultPrivilegeReconciler + extends BaseReconciler + implements Reconciler, Cleaner { + private final DefaultPrivilegeService defaultPrivilegeService; + + private final KubernetesClient kubernetesClient; + private final PostgreSQLContextFactory contextFactory; + + @Override + public UpdateControl reconcile( + DefaultPrivilege resource, + Context context + ) { + var spec = resource.getSpec(); + var status = initializeStatus(resource); + + var name = resource.getMetadata().getName(); + var namespace = resource.getMetadata().getNamespace(); + + log.info( + "Reconciling DefaultPrivilege [resource={}/{}, status.phase={}]", + namespace, + name, + status.getPhase() + ); + + var clusterRef = spec.getClusterRef(); + + var clusterConnectionOptional = getReferencedClusterConnection( + kubernetesClient, + resource, + clusterRef + ); + + var objectType = spec.getObjectType(); + var grantPrivileges = Set.copyOf(spec.getPrivileges()); + var allowedPrivilegesForObjectType = objectType.privilegesSet(); + + if (!allowedPrivilegesForObjectType.containsAll(grantPrivileges)) { + var invalid = new HashSet<>(grantPrivileges); + + invalid.removeAll(allowedPrivilegesForObjectType); + + status.setPhase(CRPhase.ERROR) + .setMessage("DefaultPrivilege contains invalid privileges for the specified objectType. [resource=%s/%s, objectType=%s, invalidPrivileges=%s, allowedPrivilegesForObjectType=%s]".formatted( + getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()), + clusterRef.getName(), + objectType, + invalid, + objectType.privileges() + )); + + return UpdateControl.patchStatus(resource); + } + + if (clusterConnectionOptional.isEmpty()) { + status.setPhase(CRPhase.PENDING) + .setMessage("The specified ClusterConnection does not exist or is not ready yet [resource=%s/%s]".formatted( + getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()), + clusterRef.getName() + )); + + return UpdateControl.patchStatus(resource) + .rescheduleAfter(60, TimeUnit.SECONDS); + } + + var database = spec.getDatabase(); + var clusterConnection = clusterConnectionOptional.get(); + + UpdateControl updateControl; + + try (var dsl = contextFactory.getDSLContext(clusterConnection, database)) { + // Run everything in a single transaction + updateControl = dsl.transactionResult( + cfg -> reconcileInTransaction( + cfg.dsl(), + resource, + status + ) + ); + } catch (Exception e) { + return handleError( + resource, + status, + e + ); + } + + return updateControl; + } + + @Override + public DeleteControl cleanup( + DefaultPrivilege resource, + Context context + ) { + var spec = resource.getSpec(); + var status = initializeStatus(resource); + + var name = resource.getMetadata().getName(); + var namespace = resource.getMetadata().getNamespace(); + + log.info( + "Deleting DefaultPrivilege [resource={}/{}, status.phase={}]", + namespace, + name, + status.getPhase() + ); + + if (status.getPhase() != CRPhase.DELETING) { + status.setPhase(CRPhase.DELETING) + .setMessage("DefaultPrivilege deletion in progress"); + + context.getClient().resource(resource).patchStatus(); + } + + var clusterRef = spec.getClusterRef(); + + var clusterConnectionOptional = getReferencedClusterConnection( + kubernetesClient, + resource, + clusterRef + ); + + if (clusterConnectionOptional.isEmpty()) { + status.setMessage("The specified ClusterConnection no longer exists or is not ready yet [resource=%s/%s]".formatted( + getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()), + clusterRef.getName() + )); + + context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(60, TimeUnit.SECONDS); + } + + var clusterConnection = clusterConnectionOptional.get(); + + try (var dsl = contextFactory.getDSLContext(clusterConnection)) { + dsl.transaction(cfg -> { + var tx = cfg.dsl(); + + var currentDefaultPrivileges = defaultPrivilegeService.determineCurrentDefaultPrivileges(tx, spec); + + defaultPrivilegeService.revoke( + tx, + spec, + currentDefaultPrivileges + ); + }); + + return DeleteControl.defaultDelete(); + } catch (Exception e) { + log.error( + "Failed to delete DefaultPrivilege [resource={}/{}, status.phase={}]", + namespace, + name, + status.getPhase() + ); + + status.setMessage("Deletion failed: %s".formatted(e.getMessage())); + + context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(60, TimeUnit.SECONDS); + } + } + + @Override + protected CRStatus newStatus() { + return new CRStatus(); + } + + @SuppressWarnings("java:S3776") + private UpdateControl reconcileInTransaction( + DSLContext tx, + DefaultPrivilege resource, + CRStatus status + ) { + var spec = resource.getSpec(); + + var expectedPrivileges = Set.copyOf(spec.getPrivileges()); + var currentDefaultPrivileges = defaultPrivilegeService.determineCurrentDefaultPrivileges(tx, spec); + + // Calculate Revokes: Current - Expected + var privilegesToRevoke = new HashSet<>(currentDefaultPrivileges); + privilegesToRevoke.removeAll(expectedPrivileges); + + if (!privilegesToRevoke.isEmpty()) { + defaultPrivilegeService.revoke( + tx, + spec, + privilegesToRevoke + ); + } + + // Calculate Grants: Expected - Current + var privilegesToGrant = new HashSet<>(expectedPrivileges); + privilegesToGrant.removeAll(currentDefaultPrivileges); + + if (!privilegesToGrant.isEmpty()) { + defaultPrivilegeService.grant( + tx, + spec, + privilegesToGrant + ); + } + + status.setPhase(CRPhase.READY) + .setMessage(null); + + return UpdateControl.patchStatus(resource); + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeService.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeService.java new file mode 100644 index 0000000..c509a24 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeService.java @@ -0,0 +1,138 @@ +package it.aboutbits.postgresql.crd.defaultprivilege; + +import it.aboutbits.postgresql.core.Privilege; +import it.aboutbits.postgresql.core.SQLUtil; +import it.aboutbits.postgresql.core.infrastructure.persistence.Routines; +import jakarta.inject.Singleton; +import org.jooq.DSLContext; +import org.jooq.DataType; +import org.jooq.impl.SQLDataType; +import org.jspecify.annotations.NullMarked; + +import java.util.Set; + +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.ACLEXPLODE; +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_DEFAULT_ACL; +import static it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType.SCHEMA; +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.noCondition; +import static org.jooq.impl.DSL.query; +import static org.jooq.impl.DSL.quotedName; +import static org.jooq.impl.DSL.role; +import static org.jooq.impl.DSL.sql; +import static org.jooq.impl.DSL.val; + +@NullMarked +@Singleton +public class DefaultPrivilegeService { + private static final DataType OID_DATA_TYPE = SQLDataType.BIGINT; + + // language=SQL + private static final String ROLE_OID_SQL = "{0}::regrole"; + // language=SQL + private static final String NAMESPACE_OID_SQL = "{0}::regnamespace"; + + /// Determines all existing default privileges for the specified `role`, `schema`, and the given `objectType`. + /// + /// @param tx The DSLContext for database operations. + /// @param spec The DefaultPrivilegeSpec containing the specification details. + /// @return A set of Privilege as values. + public Set determineCurrentDefaultPrivileges( + DSLContext tx, + DefaultPrivilegeSpec spec + ) { + var role = spec.getRole(); + var schema = spec.getSchema(); + + var objectType = spec.getObjectType(); + + /* + * select + * --d.defaclnamespace::regnamespace, -- Only for debugging + * a.privilege_type + * from pg_catalog.pg_default_acl d + * cross join aclexplode(d.defaclacl) a + * where + * -- One of the following conditions + * --d.defaclobjtype = 'n' -- For schema + * --d.defaclobjtype = 'r' -- For table/view + * --d.defaclobjtype = 'S' -- For sequence + * --and d.defaclnamespace = ''::regnamespace -- Only if d.defaclobjtype is not 'n' (schema) + * and a.grantee = ''::regrole + */ + var currentObjectPrivileges = tx + .select(ACLEXPLODE.PRIVILEGE_TYPE) + .from(PG_DEFAULT_ACL) + .crossJoin(Routines.aclexplode(PG_DEFAULT_ACL.DEFACLACL)) + .where( + PG_DEFAULT_ACL.DEFACLOBJTYPE.eq(objectType.objectTypeChar()), + objectType == SCHEMA + ? noCondition() + : PG_DEFAULT_ACL.DEFACLNAMESPACE.eq(field( + NAMESPACE_OID_SQL, + OID_DATA_TYPE, + val(schema) + )), + ACLEXPLODE.GRANTEE.eq(field( + ROLE_OID_SQL, + OID_DATA_TYPE, + val(role) + )) + ) + .fetch(r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, Privilege.class)); + + return Set.copyOf(currentObjectPrivileges); + } + + public void grant( + DSLContext tx, + DefaultPrivilegeSpec spec, + Set privilegesToGrant + ) { + var owner = role(spec.getOwner()); + var role = role(spec.getRole()); + var objectType = spec.getObjectType(); + var schema = quotedName(spec.getSchema()); + + var privileges = privilegesToGrant.stream() + .map(Privilege::privilege) + .toList(); + + var statement = query( + "alter default privileges for role {0}{1} grant {2} on {3}s to {4}", + owner, + sql(objectType == SCHEMA ? "" : " in schema {0}", schema), + SQLUtil.concatenateQueryPartsWithComma(privileges), + objectType.objectType(), + role + ); + + tx.execute(statement); + } + + public void revoke( + DSLContext tx, + DefaultPrivilegeSpec spec, + Set privilegesToRevoke + ) { + var owner = role(spec.getOwner()); + var role = role(spec.getRole()); + var objectType = spec.getObjectType(); + var schema = quotedName(spec.getSchema()); + + var privileges = privilegesToRevoke.stream() + .map(Privilege::privilege) + .toList(); + + var statement = query( + "alter default privileges for role {0}{1} revoke {2} on {3}s from {4}", + owner, + sql(objectType == SCHEMA ? "" : " in schema {0}", schema), + SQLUtil.concatenateQueryPartsWithComma(privileges), + objectType.objectType(), + role + ); + + tx.execute(statement); + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeSpec.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeSpec.java new file mode 100644 index 0000000..51e8ef0 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeSpec.java @@ -0,0 +1,113 @@ +package it.aboutbits.postgresql.crd.defaultprivilege; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; +import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.Privilege; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@NullMarked +@Getter +@Setter +@ValidationRule( + value = "self.objectType == 'schema' ? !has(self.schema) : (has(self.schema) && self.schema.trim().size() > 0)", + message = "The DefaultPrivilege schema must be not set if objectType is 'schema', for all other objectType's it is required." +) +public class DefaultPrivilegeSpec { + @Required + private ClusterReference clusterRef = new ClusterReference(); + + /// The database to grant default privileges on for this role. + @Required + @ValidationRule( + value = "self == oldSelf", + message = "The DefaultPrivilege database is immutable. Changing it would require revoking permissions from the old database before granting them in the new one." + ) + @ValidationRule( + value = "self.trim().size() > 0", + message = "The DefaultPrivilege database must not be empty." + ) + private String database = ""; + + /// The name of the role to grant default privileges on. + @Required + @ValidationRule( + value = "self == oldSelf", + message = "The DefaultPrivilege role is immutable. Changing it would require revoking permissions from the old role before granting them to the new one." + ) + @ValidationRule( + value = "self.trim().size() > 0", + message = "The DefaultPrivilege role must not be empty." + ) + private String role = ""; + + /// The name of the owner on which newly created objects default privileges are granted on to the role specified in this spec. + @Required + @ValidationRule( + value = "self == oldSelf", + message = "The DefaultPrivilege owner is immutable. Changing it would require revoking permissions from the old role before granting them to the new one." + ) + @ValidationRule( + value = "self.trim().size() > 0", + message = "The DefaultPrivilege owner must not be empty." + ) + private String owner = ""; + + /// The database schema to grant default privileges on for this role + @Nullable + @io.fabric8.generator.annotation.Nullable + @JsonInclude(JsonInclude.Include.NON_NULL) + @ValidationRule( + value = "self == oldSelf", + message = "The DefaultPrivilege schema is immutable. Changing it would require revoking permissions from the old schema before granting them to objects in the new schema." + ) + @ValidationRule( + value = "self.trim().size() > 0", + message = "The DefaultPrivilege schema must not be empty." + ) + private String schema = null; + + /// The PostgreSQL object type to grant default privileges on. + /// + /// Must be one of: + /// - `schema` + /// - `table` + /// - `sequence` + @Required + @JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_VALUES) + @ValidationRule( + value = "self == oldSelf", + message = "The DefaultPrivilege objectType is immutable. Changing it would require revoking permissions and generating a completely different SQL statement." + ) + private DefaultPrivilegeObjectType objectType = DefaultPrivilegeObjectType.SCHEMA; + + /// The privileges to grant default privileges on the PostgreSQL objects. + /// The Operator also validates if the objectType supports the privileges. + /// + /// There are different kinds of privileges: + /// - `select` + /// - `insert` + /// - `update` + /// - `delete` + /// - `truncate` + /// - `references` + /// - `trigger` + /// - `create` + /// - `usage` + /// - `maintain` + @Required + @JsonFormat(with = JsonFormat.Feature.ACCEPT_CASE_INSENSITIVE_VALUES) + @ValidationRule( + value = "self.size() > 0", + message = "The DefaultPrivilege privileges must not be empty. The Operator currently does not support revoking all privileges from existing roles (e.g. public user) by specifying an empty array." + ) + private List privileges = new ArrayList<>(); +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantObjectType.java b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantObjectType.java index b9261e5..05e1296 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantObjectType.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantObjectType.java @@ -1,6 +1,7 @@ package it.aboutbits.postgresql.crd.grant; import com.fasterxml.jackson.annotation.JsonValue; +import it.aboutbits.postgresql.core.Privilege; import lombok.Getter; import lombok.experimental.Accessors; import org.jooq.Keyword; @@ -10,18 +11,18 @@ import java.util.Locale; import java.util.Set; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.CONNECT; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.CREATE; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.DELETE; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.INSERT; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.MAINTAIN; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.REFERENCES; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.SELECT; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.TEMPORARY; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.TRIGGER; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.TRUNCATE; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.UPDATE; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.USAGE; +import static it.aboutbits.postgresql.core.Privilege.CONNECT; +import static it.aboutbits.postgresql.core.Privilege.CREATE; +import static it.aboutbits.postgresql.core.Privilege.DELETE; +import static it.aboutbits.postgresql.core.Privilege.INSERT; +import static it.aboutbits.postgresql.core.Privilege.MAINTAIN; +import static it.aboutbits.postgresql.core.Privilege.REFERENCES; +import static it.aboutbits.postgresql.core.Privilege.SELECT; +import static it.aboutbits.postgresql.core.Privilege.TEMPORARY; +import static it.aboutbits.postgresql.core.Privilege.TRIGGER; +import static it.aboutbits.postgresql.core.Privilege.TRUNCATE; +import static it.aboutbits.postgresql.core.Privilege.UPDATE; +import static it.aboutbits.postgresql.core.Privilege.USAGE; import static org.jooq.impl.DSL.keyword; /** @@ -42,8 +43,8 @@ public enum GrantObjectType { ), SCHEMA( List.of( - CREATE, - USAGE + USAGE, + CREATE ) ), TABLE( @@ -67,13 +68,13 @@ public enum GrantObjectType { ); @SuppressWarnings("ImmutableEnumChecker") - private final List privileges; + private final List privileges; @SuppressWarnings("ImmutableEnumChecker") - private final Set privilegesSet; + private final Set privilegesSet; GrantObjectType( - List privileges + List privileges ) { this.privileges = privileges; this.privilegesSet = Set.copyOf(privileges); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java index 4168006..98b6dbe 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java @@ -62,7 +62,7 @@ public UpdateControl reconcile( ); var objectType = spec.getObjectType(); - var grantPrivileges = new HashSet<>(spec.getPrivileges()); + var grantPrivileges = Set.copyOf(spec.getPrivileges()); var allowedPrivilegesForObjectType = objectType.privilegesSet(); if (!allowedPrivilegesForObjectType.containsAll(grantPrivileges)) { @@ -221,13 +221,13 @@ private UpdateControl reconcileInTransaction( var schema = spec.getSchema(); var objectType = spec.getObjectType(); - var expectedObjects = new HashSet<>( + var expectedObjects = Set.copyOf( Objects.requireNonNullElse( spec.getObjects(), Collections.emptySet() ) ); - var expectedPrivileges = new HashSet<>(spec.getPrivileges()); + var expectedPrivileges = Set.copyOf(spec.getPrivileges()); var isAllMode = expectedObjects.isEmpty(); @@ -283,7 +283,7 @@ private UpdateControl reconcileInTransaction( } // If we are not in the "ALL" mode, e.g. objects is an empty List, do explicit grants - // We need to exclude objectType's DATABASE and SCHEMA as the CRD doesn't allow to specify objects there + // We need to exclude objectType's DATABASE and SCHEMA as the CRD doesn't allow specifying objects there if (!isAllMode || objectType == GrantObjectType.DATABASE || objectType == GrantObjectType.SCHEMA diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantService.java b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantService.java index 0213415..cb64964 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantService.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantService.java @@ -1,5 +1,6 @@ package it.aboutbits.postgresql.crd.grant; +import it.aboutbits.postgresql.core.Privilege; import it.aboutbits.postgresql.core.SQLUtil; import it.aboutbits.postgresql.core.infrastructure.persistence.Routines; import jakarta.inject.Singleton; @@ -11,7 +12,6 @@ import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -44,9 +44,9 @@ public class GrantService { /// /// @param tx The DSLContext for database operations. /// @param spec The GrantSpec containing the specification details. - /// @return A map with object names as keys and lists of GrantPrivilege as values. + /// @return A map with object names as keys and lists of Privilege as values. @SuppressWarnings("checkstyle:MethodLength") - public Map> determineCurrentObjectPrivileges( + public Map> determineCurrentObjectPrivileges( DSLContext tx, GrantSpec spec ) { @@ -84,7 +84,7 @@ public Map> determineCurrentObjectPrivileges( ) .fetchGroups( PG_DATABASE.DATNAME, - r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, GrantPrivilege.class) + r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, Privilege.class) ); /* * select @@ -113,7 +113,7 @@ public Map> determineCurrentObjectPrivileges( ) .fetchGroups( PG_NAMESPACE.NSPNAME, - r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, GrantPrivilege.class) + r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, Privilege.class) ); /* @@ -156,7 +156,7 @@ public Map> determineCurrentObjectPrivileges( ) .fetchGroups( PG_CLASS.RELNAME, - r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, GrantPrivilege.class) + r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, Privilege.class) ); /* * select @@ -194,7 +194,7 @@ public Map> determineCurrentObjectPrivileges( ) .fetchGroups( PG_CLASS.RELNAME, - r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, GrantPrivilege.class) + r -> r.get(ACLEXPLODE.PRIVILEGE_TYPE, Privilege.class) ); }; @@ -202,7 +202,7 @@ public Map> determineCurrentObjectPrivileges( .stream() .collect(Collectors.toMap( Map.Entry::getKey, - entry -> new HashSet<>(entry.getValue()) + entry -> Set.copyOf(entry.getValue()) )); } @@ -401,7 +401,7 @@ public void grant( DSLContext tx, GrantSpec spec, String object, - Set privilegesToGrant + Set privilegesToGrant ) { var schema = spec.getSchema(); var role = role(spec.getRole()); @@ -413,7 +413,7 @@ public void grant( }; var privileges = privilegesToGrant.stream() - .map(GrantPrivilege::privilege) + .map(Privilege::privilege) .toList(); var statement = query( @@ -430,7 +430,7 @@ public void grant( public void grantOnAll( DSLContext tx, GrantSpec spec, - Set privilegesToGrant + Set privilegesToGrant ) { var schema = quotedName(spec.getSchema()); var role = role(spec.getRole()); @@ -443,7 +443,7 @@ public void grantOnAll( } var privileges = privilegesToGrant.stream() - .map(GrantPrivilege::privilege) + .map(Privilege::privilege) .toList(); var statement = query( @@ -461,7 +461,7 @@ public void revoke( DSLContext tx, GrantSpec spec, String object, - Set privilegesToRevoke + Set privilegesToRevoke ) { var schema = spec.getSchema(); var role = role(spec.getRole()); @@ -473,7 +473,7 @@ public void revoke( }; var privileges = privilegesToRevoke.stream() - .map(GrantPrivilege::privilege) + .map(Privilege::privilege) .toList(); var statement = query( diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantSpec.java b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantSpec.java index 93fa802..62ccafe 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantSpec.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantSpec.java @@ -5,6 +5,7 @@ import io.fabric8.generator.annotation.Required; import io.fabric8.generator.annotation.ValidationRule; import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.Privilege; import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullMarked; @@ -17,7 +18,7 @@ @Getter @Setter @ValidationRule( - value = "self.objectType == 'database' ? !has(self.schema) : (has(self.schema) && self.schema.size() > 0)", + value = "self.objectType == 'database' ? !has(self.schema) : (has(self.schema) && self.schema.trim().size() > 0)", message = "The Grant schema must be not set if objectType is 'database', for all other objectType's it is required." ) @ValidationRule( @@ -111,5 +112,5 @@ public class GrantSpec { value = "self.size() > 0", message = "The Grant privileges must not be empty. The Operator currently does not support revoking all privileges from existing roles (e.g. public user) by specifying an empty array." ) - private List privileges = new ArrayList<>(); + private List privileges = new ArrayList<>(); } diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java index e23543d..422df54 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -3,6 +3,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql._support.testdata.persisted.creator.ClusterConnectionCreate; import it.aboutbits.postgresql._support.testdata.persisted.creator.DatabaseCreate; +import it.aboutbits.postgresql._support.testdata.persisted.creator.DefaultPrivilegeCreate; import it.aboutbits.postgresql._support.testdata.persisted.creator.GrantCreate; import it.aboutbits.postgresql._support.testdata.persisted.creator.RoleCreate; import it.aboutbits.postgresql._support.testdata.persisted.creator.SchemaCreate; @@ -116,6 +117,14 @@ public GrantCreate grant() { kubernetesClient ); } + + public DefaultPrivilegeCreate defaultPrivilege() { + return new DefaultPrivilegeCreate( + numberOfItems, + given, + kubernetesClient + ); + } } public record DBConnectionDetails( diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index a0b90fb..b19063a 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -100,7 +100,7 @@ protected ClusterConnection create(int index) { .withName(name) .waitUntilCondition( clusterConnection -> clusterConnection.getStatus() != null, - 10, + 5, TimeUnit.SECONDS ); } diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DatabaseCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DatabaseCreate.java index b4807fd..7b4260c 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DatabaseCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DatabaseCreate.java @@ -91,7 +91,7 @@ protected Database create(int index) { .withName(name) .waitUntilCondition( db -> db.getStatus() != null, - 10, + 5, TimeUnit.SECONDS ); } diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java new file mode 100644 index 0000000..55ffe23 --- /dev/null +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java @@ -0,0 +1,217 @@ +package it.aboutbits.postgresql._support.testdata.persisted.creator; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.Privilege; +import it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilege; +import it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType; +import it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeSpec; +import lombok.AccessLevel; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +@NullMarked +@Setter +@Accessors(fluent = true, chain = true) +public class DefaultPrivilegeCreate extends TestDataCreator { + private final Given given; + + private final KubernetesClient kubernetesClient; + + @Nullable + private String withNamespace; + @Setter(AccessLevel.NONE) + private boolean withoutNamespace = false; + + @Nullable + private String withName; + + @Nullable + private String withClusterConnectionName; + + @Nullable + private String withClusterConnectionNamespace; + + @Nullable + private String withDatabase; + + @Nullable + private String withRole; + + @Nullable + private String withOwner; + + @Nullable + private String withSchema; + + private DefaultPrivilegeObjectType withObjectType = DefaultPrivilegeObjectType.SCHEMA; + + @Setter(AccessLevel.NONE) + private List withPrivileges = new ArrayList<>(); + + public DefaultPrivilegeCreate( + int numberOfItems, + Given given, + KubernetesClient kubernetesClient + ) { + super(numberOfItems); + this.given = given; + this.kubernetesClient = kubernetesClient; + } + + @SuppressWarnings("unused") + public DefaultPrivilegeCreate withPrivileges(List privileges) { + this.withPrivileges = privileges; + return this; + } + + @SuppressWarnings("unused") + public DefaultPrivilegeCreate withPrivileges(Privilege... privileges) { + this.withPrivileges = List.of(privileges); + return this; + } + + @SuppressWarnings("unused") + public DefaultPrivilegeCreate withoutNamespace() { + withoutNamespace = true; + return this; + } + + @Override + protected DefaultPrivilege create(int index) { + var namespace = getNamespace(); + var name = getName(); + + var item = new DefaultPrivilege(); + + item.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(namespace) + .build() + ); + + var clusterRef = new ClusterReference(); + clusterRef.setName(getClusterConnectionName()); + clusterRef.setNamespace(withClusterConnectionNamespace); + + var spec = new DefaultPrivilegeSpec(); + + spec.setClusterRef(clusterRef); + spec.setDatabase(getDatabase()); + spec.setRole(getRole()); + spec.setOwner(getOwner()); + + spec.setObjectType(withObjectType); + + if (withObjectType != DefaultPrivilegeObjectType.SCHEMA + || withSchema != null + ) { + spec.setSchema(getSchema()); + } + + spec.setPrivileges(withPrivileges); + + item.setSpec(spec); + + kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(namespace) + .resource(item) + .serverSideApply(); + + //noinspection ConstantConditions + return kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(namespace) + .withName(name) + .waitUntilCondition( + defaultPrivilege -> defaultPrivilege.getStatus() != null, + 5, + TimeUnit.SECONDS + ); + } + + @Nullable + private String getNamespace() { + if (withoutNamespace) { + return null; + } + + if (withNamespace != null) { + return withNamespace; + } + + return kubernetesClient.getNamespace(); + } + + private String getName() { + if (withName != null) { + return withName; + } + + return randomKubernetesNameSuffix("test-grant"); + } + + private String getClusterConnectionName() { + return Objects.requireNonNullElse( + withClusterConnectionName, + "test-cluster-connection" + ); + } + + private String getDatabase() { + if (withDatabase != null) { + return withDatabase; + } + + return given.one() + .database() + .returnFirst() + .getSpec() + .getName(); + } + + private String getRole() { + if (withRole != null) { + return withRole; + } + + return given.one() + .role() + .returnFirst() + .getSpec() + .getName(); + } + + private String getOwner() { + if (withOwner != null) { + return withOwner; + } + + return given.one() + .role() + .returnFirst() + .getSpec() + .getName(); + } + + private String getSchema() { + if (withSchema != null) { + return withSchema; + } + + return given.one() + .schema() + .returnFirst() + .getSpec() + .getName(); + } +} diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java index fa463b2..cefd0e8 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java @@ -5,9 +5,9 @@ import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.Privilege; import it.aboutbits.postgresql.crd.grant.Grant; import it.aboutbits.postgresql.crd.grant.GrantObjectType; -import it.aboutbits.postgresql.crd.grant.GrantPrivilege; import it.aboutbits.postgresql.crd.grant.GrantSpec; import lombok.AccessLevel; import lombok.Setter; @@ -58,7 +58,7 @@ public class GrantCreate extends TestDataCreator { private List withObjects = null; @Setter(AccessLevel.NONE) - private List withPrivileges = new ArrayList<>(); + private List withPrivileges = new ArrayList<>(); public GrantCreate( int numberOfItems, @@ -83,13 +83,13 @@ public GrantCreate withObjects(String... objects) { } @SuppressWarnings("unused") - public GrantCreate withPrivileges(List privileges) { + public GrantCreate withPrivileges(List privileges) { this.withPrivileges = privileges; return this; } @SuppressWarnings("unused") - public GrantCreate withPrivileges(GrantPrivilege... privileges) { + public GrantCreate withPrivileges(Privilege... privileges) { this.withPrivileges = List.of(privileges); return this; } @@ -153,7 +153,7 @@ protected Grant create(int index) { .withName(name) .waitUntilCondition( grant -> grant.getStatus() != null, - 10, + 5, TimeUnit.SECONDS ); } diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java index 4479b1c..c1436a6 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java @@ -122,7 +122,7 @@ protected Role create(int index) { .withName(name) .waitUntilCondition( role -> role.getStatus() != null, - 10, + 5, TimeUnit.SECONDS ); } diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SchemaCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SchemaCreate.java index fd5bd18..a869783 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SchemaCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SchemaCreate.java @@ -91,7 +91,7 @@ protected Schema create(int index) { .withName(name) .waitUntilCondition( db -> db.getStatus() != null, - 10, + 5, TimeUnit.SECONDS ); } diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java index 02babfb..1e34a4a 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java @@ -81,11 +81,11 @@ private static void assertThatClusterConnectionHasExpectedStatus( .satisfies(status -> { assertThat(status.getLastProbeTime()).isCloseTo( now, - within(10, ChronoUnit.SECONDS) + within(5, ChronoUnit.SECONDS) ); assertThat(status.getLastPhaseTransitionTime()).isCloseTo( now, - within(10, ChronoUnit.SECONDS) + within(5, ChronoUnit.SECONDS) ); }) .usingRecursiveComparison() diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java new file mode 100644 index 0000000..0eb1714 --- /dev/null +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java @@ -0,0 +1,346 @@ +package it.aboutbits.postgresql.crd.defaultprivilege; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.quarkus.test.junit.QuarkusTest; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql._support.valuesource.BlankSource; +import it.aboutbits.postgresql.core.CRPhase; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; +import it.aboutbits.postgresql.crd.database.Database; +import it.aboutbits.postgresql.crd.role.Role; +import it.aboutbits.postgresql.crd.schema.Schema; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static it.aboutbits.postgresql.core.Privilege.SELECT; +import static it.aboutbits.postgresql.core.Privilege.USAGE; +import static it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType.SCHEMA; +import static it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType.SEQUENCE; +import static it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType.TABLE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@NullMarked +@QuarkusTest +@RequiredArgsConstructor +class DefaultPrivilegeReconcilerTest { + private final Given given; + private final KubernetesClient kubernetesClient; + + @BeforeEach + void resetEnvironment() { + deleteResources(DefaultPrivilege.class); + deleteResources(Schema.class); + deleteResources(Database.class); + deleteResources(Role.class); + deleteResources(ClusterConnection.class); + + // Create the default connection "test-cluster-connection" used by GrantCreate defaults + given.one().clusterConnection() + .withName("test-cluster-connection") + .returnFirst(); + } + + private void deleteResources(Class resourceClass) { + kubernetesClient.resources(resourceClass) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + } + + @Nested + class CRDValidation { + @Nested + class FieldSize { + @ParameterizedTest + @BlankSource + @DisplayName("Should fail when the database is a blank or empty String (CEL rule)") + void failWhenDatabaseIsBlankOrEmptyString( + String blankOrEmptyString + ) { + // then + assertThatThrownBy(() -> + // given / when + given.one() + .defaultPrivilege() + .withDatabase(blankOrEmptyString) + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .apply() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege database must not be empty."); + } + + @ParameterizedTest + @BlankSource + @DisplayName("Should fail when the role is a blank or empty String (CEL rule)") + void failWhenRoleIsBlankOrEmptyString( + String blankOrEmptyString + ) { + // then + assertThatThrownBy(() -> + // given / when + given.one() + .defaultPrivilege() + .withRole(blankOrEmptyString) + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .apply() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege role must not be empty."); + } + + @ParameterizedTest + @BlankSource + @DisplayName("Should fail when the owner is a blank or empty String (CEL rule)") + void failWhenOwnerIsBlankOrEmptyString( + String blankOrEmptyString + ) { + // then + assertThatThrownBy(() -> + // given / when + given.one() + .defaultPrivilege() + .withOwner(blankOrEmptyString) + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .apply() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege owner must not be empty."); + } + + @ParameterizedTest + @BlankSource + @DisplayName("Should fail when the schema is a blank or empty String (CEL rule)") + void failWhenSchemaIsBlankOrEmptyString( + String blankOrEmptyString + ) { + // then + assertThatThrownBy(() -> + // given / when + given.one() + .defaultPrivilege() + .withSchema(blankOrEmptyString) + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .apply() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege schema must not be empty."); + } + + @Test + @DisplayName("Should fail when the privileges are an empty List (CEL rule)") + void failWhenPrivilegesAreAnEmptyList( + ) { + // then + assertThatThrownBy(() -> + // given / when + given.one() + .defaultPrivilege() + .withObjectType(SCHEMA) + .withPrivileges(List.of()) + .apply() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege privileges must not be empty."); + } + } + + @Nested + class ImmutableFields { + @Test + @DisplayName("Should fail when the database changes (CEL rule)") + void failWhenDatabaseChanges() { + // given + var item = given.one() + .defaultPrivilege() + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .returnFirst(); + + // then + assertThatThrownBy(() -> { + // when + item.getSpec().setDatabase("new-database"); + + kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(item.getMetadata().getNamespace()) + .withName(item.getMetadata().getName()) + .patch(item); + }).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege database is immutable."); + } + + @Test + @DisplayName("Should fail when the role changes (CEL rule)") + void failWhenRoleChanges() { + // given + var item = given.one() + .defaultPrivilege() + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .returnFirst(); + + // then + assertThatThrownBy(() -> { + // when + item.getSpec().setRole("new-role"); + + kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(item.getMetadata().getNamespace()) + .withName(item.getMetadata().getName()) + .patch(item); + }).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege role is immutable."); + } + + @Test + @DisplayName("Should fail when the owner changes (CEL rule)") + void failWhenOwnerChanges() { + // given + var item = given.one() + .defaultPrivilege() + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .returnFirst(); + + // then + assertThatThrownBy(() -> { + // when + item.getSpec().setOwner("new-owner"); + + kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(item.getMetadata().getNamespace()) + .withName(item.getMetadata().getName()) + .patch(item); + }).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege owner is immutable."); + } + + @Test + @DisplayName("Should fail when the schema changes (CEL rule)") + void failWhenSchemaChanges() { + // given + var item = given.one() + .defaultPrivilege() + .withObjectType(TABLE) + .withPrivileges(USAGE) + .returnFirst(); + + // then + assertThatThrownBy(() -> { + // when + item.getSpec().setSchema("new-schema"); + + kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(item.getMetadata().getNamespace()) + .withName(item.getMetadata().getName()) + .patch(item); + }).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege schema is immutable."); + } + + @Test + @DisplayName("Should fail when the objectType changes (CEL rule)") + void failWhenObjectTypeChanges() { + // given + var item = given.one() + .defaultPrivilege() + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .returnFirst(); + + // then + assertThatThrownBy(() -> { + // when + item.getSpec().setObjectType(SEQUENCE); + + kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(item.getMetadata().getNamespace()) + .withName(item.getMetadata().getName()) + .patch(item); + }).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege objectType is immutable."); + } + } + + @Nested + class RelatedFields { + @Test + @DisplayName("Should fail when objectType is SCHEMA but schema is set (CEL rule)") + void failWhenDatabaseHasSchema() { + // then + assertThatThrownBy(() -> + // given / when + given.one() + .defaultPrivilege() + .withSchema("some-schema") + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .returnFirst() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("The DefaultPrivilege schema must be not set if objectType is 'schema'"); + } + + @Test + @DisplayName("Should reconcile to ERROR when privileges are invalid for objectType") + void errorWhenInvalidPrivileges() { + // given + var now = OffsetDateTime.now(ZoneOffset.UTC); + + // when + var defaultPrivilege = given.one() + .defaultPrivilege() + .withObjectType(SCHEMA) + // SELECT is not allowed for SCHEMA + .withPrivileges(SELECT) + .returnFirst(); + + var expectedStatus = new CRStatus() + .setName("") + .setPhase(CRPhase.ERROR) + .setMessage("DefaultPrivilege contains invalid privileges for the specified objectType") + .setObservedGeneration(1L); + + // then + assertThatGrantHasStatus( + defaultPrivilege, + expectedStatus, + now + ); + } + } + } + + private void assertThatGrantHasStatus( + DefaultPrivilege defaultPrivilege, + CRStatus expectedStatus, + OffsetDateTime now + ) { + assertThat(defaultPrivilege) + .isNotNull() + .extracting(DefaultPrivilege::getStatus) + .satisfies(status -> { + assertThat(status.getLastProbeTime()).isAfter( + now + ); + assertThat(status.getLastPhaseTransitionTime()).isAfter( + now + ); + }) + .usingRecursiveComparison() + .ignoringFields("message", "lastProbeTime", "lastPhaseTransitionTime") + .isEqualTo(expectedStatus); + } +} diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java index 5f04b15..744a541 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java @@ -9,6 +9,7 @@ import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.core.Privilege; import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import it.aboutbits.postgresql.crd.database.Database; import it.aboutbits.postgresql.crd.role.Role; @@ -30,14 +31,14 @@ import java.util.concurrent.TimeUnit; import java.util.function.Predicate; +import static it.aboutbits.postgresql.core.Privilege.CONNECT; +import static it.aboutbits.postgresql.core.Privilege.CREATE; +import static it.aboutbits.postgresql.core.Privilege.SELECT; +import static it.aboutbits.postgresql.core.Privilege.USAGE; import static it.aboutbits.postgresql.crd.grant.GrantObjectType.DATABASE; import static it.aboutbits.postgresql.crd.grant.GrantObjectType.SCHEMA; import static it.aboutbits.postgresql.crd.grant.GrantObjectType.SEQUENCE; import static it.aboutbits.postgresql.crd.grant.GrantObjectType.TABLE; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.CONNECT; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.CREATE; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.SELECT; -import static it.aboutbits.postgresql.crd.grant.GrantPrivilege.USAGE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.jooq.impl.DSL.quotedName; @@ -1171,7 +1172,7 @@ private void assertThatPrivileges( ClusterConnection clusterConnection, Grant grant, String objectName, - Set expectedPrivileges + Set expectedPrivileges ) { var databaseName = grant.getSpec().getDatabase(); From 5560bbca1a8a4c64605289fab953fd8f200e24be Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 09:00:11 +0100 Subject: [PATCH 2/7] update to Quarkus 3.30.7 --- gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index ac266ce..47b7308 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,9 +5,9 @@ org.gradle.logging.level=INFO # Quarkus quarkusPluginId=io.quarkus -quarkusPluginVersion=3.30.6 +quarkusPluginVersion=3.30.7 # https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom quarkusPlatformGroupId=io.quarkus.platform quarkusPlatformArtifactId=quarkus-bom -quarkusPlatformVersion=3.30.6 +quarkusPlatformVersion=3.30.7 systemProp.quarkus.analytics.disabled=true From 867633f114795d9a5238a06af20f53a4e8d55aed Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 14:35:20 +0100 Subject: [PATCH 3/7] implement more DefaultPrivilegeReconciler tests --- .../DefaultPrivilegeReconcilerTest.java | 388 +++++++++++++++++- 1 file changed, 386 insertions(+), 2 deletions(-) diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java index 0eb1714..113ff50 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java @@ -8,6 +8,8 @@ import it.aboutbits.postgresql._support.valuesource.BlankSource; import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.core.Privilege; import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import it.aboutbits.postgresql.crd.database.Database; import it.aboutbits.postgresql.crd.role.Role; @@ -23,8 +25,11 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import static it.aboutbits.postgresql.core.Privilege.CREATE; import static it.aboutbits.postgresql.core.Privilege.SELECT; import static it.aboutbits.postgresql.core.Privilege.USAGE; import static it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType.SCHEMA; @@ -38,6 +43,8 @@ @RequiredArgsConstructor class DefaultPrivilegeReconcilerTest { private final Given given; + private final DefaultPrivilegeService defaultPrivilegeService; + private final PostgreSQLContextFactory postgreSQLContextFactory; private final KubernetesClient kubernetesClient; @BeforeEach @@ -314,7 +321,7 @@ void errorWhenInvalidPrivileges() { .setObservedGeneration(1L); // then - assertThatGrantHasStatus( + assertThatDefaultPrivilegeHasStatus( defaultPrivilege, expectedStatus, now @@ -323,7 +330,357 @@ void errorWhenInvalidPrivileges() { } } - private void assertThatGrantHasStatus( + @Nested + class SchemaTests { + @Test + @DisplayName("Should grant and revoke default privileges on schema") + void defaultPrivilegeOnSchema() { + // given + var now = OffsetDateTime.now(ZoneOffset.UTC); + + var clusterConnectionMain = given.one() + .clusterConnection() + .returnFirst(); + + var database = given.one() + .database() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + // To create a Schema in the new database, we need a ClusterConnection pointing to it + var clusterConnectionDb = given.one() + .clusterConnection() + .withDatabase(database.getSpec().getName()) + .returnFirst(); + + var role = given.one() + .role() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + // when + var defaultPrivilege = given.one() + .defaultPrivilege() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .withDatabase(database.getSpec().getName()) + .withRole(role.getSpec().getName()) + .withObjectType(SCHEMA) + .withPrivileges(USAGE) + .returnFirst(); + + // then + var expectedStatus = new CRStatus() + .setName("") + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + + assertThatDefaultPrivilegeHasStatus( + defaultPrivilege, + expectedStatus, + now + ); + + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.of(USAGE) + ); + + // given: grant all privileges change + var spec = defaultPrivilege.getSpec(); + var expectedPrivileges = SCHEMA.privileges(); + var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); + + spec.setPrivileges(expectedPrivileges); + + // when + applyDefaultPrivilege( + defaultPrivilege, + g -> g.getStatus().getObservedGeneration() >= initialGeneration + 1 + ); + + // then + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.copyOf(expectedPrivileges) + ); + + // given: grant privileges change + spec.setPrivileges(List.of(CREATE)); + + // when + applyDefaultPrivilege( + defaultPrivilege, + g -> g.getStatus().getObservedGeneration() >= initialGeneration + 2 + ); + + // then + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.of(CREATE) + ); + + // when: defaultPrivilege CR instance delete + kubernetesClient.resources(DefaultPrivilege.class) + .resource(defaultPrivilege) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + + // then + assertThatNoDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege + ); + } + } + + @Nested + class TableTests { + @Test + @DisplayName("Should grant and revoke default privileges on table") + void defaultPrivilegeOnTable() { + // given + var now = OffsetDateTime.now(ZoneOffset.UTC); + + var clusterConnectionMain = given.one() + .clusterConnection() + .returnFirst(); + var database = given.one() + .database() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + var clusterConnectionDb = given.one() + .clusterConnection() + .withDatabase(database.getSpec().getName()) + .returnFirst(); + + var schema = given.one() + .schema() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .returnFirst(); + + var role = given.one() + .role() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + // when + var defaultPrivilege = given.one() + .defaultPrivilege() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .withDatabase(database.getSpec().getName()) + .withSchema(schema.getSpec().getName()) + .withRole(role.getSpec().getName()) + .withObjectType(TABLE) + .withPrivileges(SELECT) + .returnFirst(); + + // then + var expectedStatus = new CRStatus() + .setName("") + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + + assertThatDefaultPrivilegeHasStatus( + defaultPrivilege, + expectedStatus, + now + ); + + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.of(SELECT) + ); + + // given: grant all privileges change + var spec = defaultPrivilege.getSpec(); + var expectedPrivileges = TABLE.privileges(); + var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); + + spec.setPrivileges(expectedPrivileges); + + // when + applyDefaultPrivilege( + defaultPrivilege, + g -> g.getStatus().getObservedGeneration() >= initialGeneration + 1 + ); + + // then + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.copyOf(expectedPrivileges) + ); + + // given: defaultPrivilege privileges change + spec.setPrivileges(List.of(SELECT)); + + // when + applyDefaultPrivilege( + defaultPrivilege, + g -> g.getStatus().getObservedGeneration() >= initialGeneration + 2 + ); + + // then + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.of(SELECT) + ); + + // when: defaultPrivilege CR instance delete + kubernetesClient.resources(DefaultPrivilege.class) + .resource(defaultPrivilege) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + + // then + assertThatNoDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege + ); + } + } + + @Nested + class SequenceTests { + @Test + @DisplayName("Should grant and revoke default privileges on sequence") + void defaultPrivilegeOnSequence() { + // given + var now = OffsetDateTime.now(ZoneOffset.UTC); + + var clusterConnectionMain = given.one() + .clusterConnection() + .returnFirst(); + var database = given.one() + .database() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + var clusterConnectionDb = given.one() + .clusterConnection() + .withDatabase(database.getSpec().getName()) + .returnFirst(); + + var schema = given.one() + .schema() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .returnFirst(); + + var role = given.one() + .role() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + // when + var defaultPrivilege = given.one() + .defaultPrivilege() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .withDatabase(database.getSpec().getName()) + .withSchema(schema.getSpec().getName()) + .withRole(role.getSpec().getName()) + .withObjectType(SEQUENCE) + .withPrivileges(USAGE) + .returnFirst(); + + // then + var expectedStatus = new CRStatus() + .setName("") + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + + assertThatDefaultPrivilegeHasStatus( + defaultPrivilege, + expectedStatus, + now + ); + + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.of(USAGE) + ); + + // given: grant all privileges change + var spec = defaultPrivilege.getSpec(); + var expectedPrivileges = SEQUENCE.privileges(); + var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); + + spec.setPrivileges(expectedPrivileges); + + // when + applyDefaultPrivilege( + defaultPrivilege, + g -> g.getStatus().getObservedGeneration() >= initialGeneration + 1 + ); + + // then + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.copyOf(expectedPrivileges) + ); + + // given: grant privileges change + spec.setPrivileges(List.of(SELECT)); + + // when + applyDefaultPrivilege( + defaultPrivilege, + g -> g.getStatus().getObservedGeneration() >= initialGeneration + 2 + ); + + // then + assertThatDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege, + Set.of(SELECT) + ); + + // when: defaultPrivilege CR instance delete + kubernetesClient.resources(DefaultPrivilege.class) + .resource(defaultPrivilege) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + + // then + assertThatNoDefaultPrivileges( + clusterConnectionDb, + defaultPrivilege + ); + } + } + + private DefaultPrivilege applyDefaultPrivilege( + DefaultPrivilege defaultPrivilege, + Predicate condition + ) { + var namespace = kubernetesClient.getNamespace(); + + defaultPrivilege.getMetadata().setManagedFields(null); + defaultPrivilege.getMetadata().setResourceVersion(null); + + var applied = kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(namespace) + .resource(defaultPrivilege) + .serverSideApply(); + + return kubernetesClient.resources(DefaultPrivilege.class) + .inNamespace(namespace) + .withName(applied.getMetadata().getName()) + .waitUntilCondition( + condition, + 5, + TimeUnit.SECONDS + ); + } + + private void assertThatDefaultPrivilegeHasStatus( DefaultPrivilege defaultPrivilege, CRStatus expectedStatus, OffsetDateTime now @@ -343,4 +700,31 @@ private void assertThatGrantHasStatus( .ignoringFields("message", "lastProbeTime", "lastPhaseTransitionTime") .isEqualTo(expectedStatus); } + + private void assertThatDefaultPrivileges( + ClusterConnection clusterConnection, + DefaultPrivilege defaultPrivilege, + Set expectedPrivileges + ) { + var databaseName = defaultPrivilege.getSpec().getDatabase(); + + try (var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection, databaseName)) { + var privileges = defaultPrivilegeService.determineCurrentDefaultPrivileges(dsl, defaultPrivilege.getSpec()); + + assertThat(privileges).containsAll(expectedPrivileges); + } + } + + private void assertThatNoDefaultPrivileges( + ClusterConnection clusterConnection, + DefaultPrivilege defaultPrivilege + ) { + var databaseName = defaultPrivilege.getSpec().getDatabase(); + + try (var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection, databaseName)) { + var privileges = defaultPrivilegeService.determineCurrentDefaultPrivileges(dsl, defaultPrivilege.getSpec()); + + assertThat(privileges).isEmpty(); + } + } } From b4f4dc817e52854173f9523f3e3182f5c1e2215c Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 14:36:40 +0100 Subject: [PATCH 4/7] fix CRD cleanup method having issues with PATCH status as the resourceVersion is not synchronized in the operator primary cache --- .../crd/database/DatabaseReconciler.java | 3 +++ .../DefaultPrivilegeReconciler.java | 15 ++++++++++----- .../postgresql/crd/grant/GrantReconciler.java | 3 +++ .../postgresql/crd/role/RoleReconciler.java | 3 +++ .../postgresql/crd/schema/SchemaReconciler.java | 3 +++ 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/database/DatabaseReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/database/DatabaseReconciler.java index de149d5..86c5645 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/database/DatabaseReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/database/DatabaseReconciler.java @@ -117,6 +117,9 @@ public DeleteControl cleanup( } context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(1, TimeUnit.SECONDS); } // We do not actually delete the database if the reclaimPolicy is set to RETAIN, we only delete the CR instance diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java index f56d590..83ce268 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java @@ -136,6 +136,9 @@ public DeleteControl cleanup( .setMessage("DefaultPrivilege deletion in progress"); context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(1, TimeUnit.SECONDS); } var clusterRef = spec.getClusterRef(); @@ -166,11 +169,13 @@ public DeleteControl cleanup( var currentDefaultPrivileges = defaultPrivilegeService.determineCurrentDefaultPrivileges(tx, spec); - defaultPrivilegeService.revoke( - tx, - spec, - currentDefaultPrivileges - ); + if (!currentDefaultPrivileges.isEmpty()) { + defaultPrivilegeService.revoke( + tx, + spec, + currentDefaultPrivileges + ); + } }); return DeleteControl.defaultDelete(); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java index 98b6dbe..56efdd7 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java @@ -141,6 +141,9 @@ public DeleteControl cleanup( .setMessage("Grant deletion in progress"); context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(1, TimeUnit.SECONDS); } var clusterRef = spec.getClusterRef(); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index b95f9e7..2926467 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -147,6 +147,9 @@ public DeleteControl cleanup( .setMessage("Role deletion in progress"); context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(1, TimeUnit.SECONDS); } var clusterRef = spec.getClusterRef(); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/schema/SchemaReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/schema/SchemaReconciler.java index 5bfcaa8..a4e6c6d 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/schema/SchemaReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/schema/SchemaReconciler.java @@ -119,6 +119,9 @@ public DeleteControl cleanup( } context.getClient().resource(resource).patchStatus(); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(1, TimeUnit.SECONDS); } // We do not actually delete the schema if the reclaimPolicy is set to RETAIN, we only delete the CR instance From 086f0e437ba5e3e733d2d5195c3364ee040ee3d9 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 14:41:47 +0100 Subject: [PATCH 5/7] fix test naming --- .../creator/DefaultPrivilegeCreate.java | 2 +- .../DefaultPrivilegeReconcilerTest.java | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java index 55ffe23..c4aa865 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/DefaultPrivilegeCreate.java @@ -157,7 +157,7 @@ private String getName() { return withName; } - return randomKubernetesNameSuffix("test-grant"); + return randomKubernetesNameSuffix("test-default-privilege"); } private String getClusterConnectionName() { diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java index 113ff50..1e38cc1 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java @@ -386,7 +386,7 @@ void defaultPrivilegeOnSchema() { Set.of(USAGE) ); - // given: grant all privileges change + // given: grant all default privileges change var spec = defaultPrivilege.getSpec(); var expectedPrivileges = SCHEMA.privileges(); var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); @@ -406,7 +406,7 @@ void defaultPrivilegeOnSchema() { Set.copyOf(expectedPrivileges) ); - // given: grant privileges change + // given: DefaultPrivilege privileges change spec.setPrivileges(List.of(CREATE)); // when @@ -422,7 +422,7 @@ void defaultPrivilegeOnSchema() { Set.of(CREATE) ); - // when: defaultPrivilege CR instance delete + // when: DefaultPrivilege CR instance delete kubernetesClient.resources(DefaultPrivilege.class) .resource(defaultPrivilege) .withTimeout(5, TimeUnit.SECONDS) @@ -496,7 +496,7 @@ void defaultPrivilegeOnTable() { Set.of(SELECT) ); - // given: grant all privileges change + // given: grant all default privileges change var spec = defaultPrivilege.getSpec(); var expectedPrivileges = TABLE.privileges(); var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); @@ -516,7 +516,7 @@ void defaultPrivilegeOnTable() { Set.copyOf(expectedPrivileges) ); - // given: defaultPrivilege privileges change + // given: DefaultPrivilege privileges change spec.setPrivileges(List.of(SELECT)); // when @@ -532,7 +532,7 @@ void defaultPrivilegeOnTable() { Set.of(SELECT) ); - // when: defaultPrivilege CR instance delete + // when: DefaultPrivilege CR instance delete kubernetesClient.resources(DefaultPrivilege.class) .resource(defaultPrivilege) .withTimeout(5, TimeUnit.SECONDS) @@ -606,7 +606,7 @@ void defaultPrivilegeOnSequence() { Set.of(USAGE) ); - // given: grant all privileges change + // given: grant all default privileges change var spec = defaultPrivilege.getSpec(); var expectedPrivileges = SEQUENCE.privileges(); var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); @@ -626,7 +626,7 @@ void defaultPrivilegeOnSequence() { Set.copyOf(expectedPrivileges) ); - // given: grant privileges change + // given: DefaultPrivilege privileges change spec.setPrivileges(List.of(SELECT)); // when @@ -642,7 +642,7 @@ void defaultPrivilegeOnSequence() { Set.of(SELECT) ); - // when: defaultPrivilege CR instance delete + // when: DefaultPrivilege CR instance delete kubernetesClient.resources(DefaultPrivilege.class) .resource(defaultPrivilege) .withTimeout(5, TimeUnit.SECONDS) From 1f4fca9ff0609f047182768029e3c6ba9d2d7cac Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 14:44:50 +0100 Subject: [PATCH 6/7] reorder DefaultPrivilegeObjectType field --- .../crd/defaultprivilege/DefaultPrivilegeObjectType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java index fc21b12..f444d73 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeObjectType.java @@ -61,14 +61,14 @@ public enum DefaultPrivilegeObjectType { ) ); + private final String objectTypeChar; + @SuppressWarnings("ImmutableEnumChecker") private final List privileges; @SuppressWarnings("ImmutableEnumChecker") private final Set privilegesSet; - private final String objectTypeChar; - DefaultPrivilegeObjectType( String objectTypeChar, List privileges From b4f4b1d73e194174de07c62485bd0ec66c6c2369 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 22 Jan 2026 14:48:49 +0100 Subject: [PATCH 7/7] fix comment --- .../crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java index 1e38cc1..d644854 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java @@ -55,7 +55,7 @@ void resetEnvironment() { deleteResources(Role.class); deleteResources(ClusterConnection.class); - // Create the default connection "test-cluster-connection" used by GrantCreate defaults + // Create the default connection "test-cluster-connection" used by DefaultPrivilegeCreate defaults given.one().clusterConnection() .withName("test-cluster-connection") .returnFirst();