From e19f985c99825832da848a61e3bdb6191cf9df5a Mon Sep 17 00:00:00 2001 From: Bastien CERIANI Date: Thu, 16 May 2024 17:31:26 +0200 Subject: [PATCH] feat: add grant specs schema, objects, objectType Signed-off-by: Bastien CERIANI --- .gitignore | 1 + apis/postgresql/v1alpha1/grant_types.go | 45 +++++++ .../v1alpha1/zz_generated.deepcopy.go | 25 ++++ build | 2 +- .../postgresql.sql.crossplane.io_grants.yaml | 112 ++++++++++++++++++ pkg/controller/postgresql/grant/reconciler.go | 85 ++++++++++--- 6 files changed, 253 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index d3788258..960b3c64 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .work _output __debug_bin +.tool-versions \ No newline at end of file diff --git a/apis/postgresql/v1alpha1/grant_types.go b/apis/postgresql/v1alpha1/grant_types.go index 1bf6fc7c..560ec2e2 100644 --- a/apis/postgresql/v1alpha1/grant_types.go +++ b/apis/postgresql/v1alpha1/grant_types.go @@ -47,6 +47,7 @@ type GrantPrivileges []GrantPrivilege // happen internally inside postgresql when making grants. When we query the // privileges back, we need to look for the expanded set. // https://www.postgresql.org/docs/15/ddl-priv.html +// TODO: Grand ALL ON SCHEMA should be expanded to GRANT USAGE, CREATE ON SCHEMA var grantReplacements = map[GrantPrivilege]GrantPrivileges{ "ALL": {"CREATE", "TEMPORARY", "CONNECT"}, "ALL PRIVILEGES": {"CREATE", "TEMPORARY", "CONNECT"}, @@ -141,6 +142,20 @@ type GrantParameters struct { // +optional DatabaseSelector *xpv1.Selector `json:"databaseSelector,omitempty"` + // Schema this grant is for. + // +optional + Schema *string `json:"schema,omitempty"` + + // SchemaRef references the schema object this grant it for. + // +immutable + // +optional + SchemaRef *xpv1.Reference `json:"schemaRef,omitempty"` + + // SchemaSelector selects a reference to a Schema this grant is for. + // +immutable + // +optional + SchemaSelector *xpv1.Selector `json:"schemaSelector,omitempty"` + // MemberOf is the Role that this grant makes Role a member of. // +optional MemberOf *string `json:"memberOf,omitempty"` @@ -158,6 +173,22 @@ type GrantParameters struct { // RevokePublicOnDb apply the statement "REVOKE ALL ON DATABASE %s FROM PUBLIC" to make database unreachable from public // +optional RevokePublicOnDb *bool `json:"revokePublicOnDb,omitempty" default:"false"` + + // ObjectType is the PostgreSQL object type to grant the privileges on. + // +kubebuilder:validation:Enum=database;schema;table;sequence;function;procedure;routine;foreign_data_wrapper;foreign_server;column + ObjectType string `json:"objectType" default:"database"` + + // Objects are the objects upon which to grant the privileges. + // An empty list (the default) means to grant permissions on all objects of the specified type. + // You cannot specify this option if the objectType is database or schema. + // When objectType is column, only one value is allowed. + // +optional + Objects []string `json:"objects,omitempty"` + + // The columns upon which to grant the privileges. + // Required when object_type is column. You cannot specify this option if the object_type is not column + // +optional + Columns []string `json:"columns,omitempty"` } // A GrantStatus represents the observed state of a Grant. @@ -212,6 +243,20 @@ func (mg *Grant) ResolveReferences(ctx context.Context, c client.Reader) error { mg.Spec.ForProvider.Database = reference.ToPtrValue(rsp.ResolvedValue) mg.Spec.ForProvider.DatabaseRef = rsp.ResolvedReference + // Resolve spec.forProvider.schema + rsp, err = r.Resolve(ctx, reference.ResolutionRequest{ + CurrentValue: reference.FromPtrValue(mg.Spec.ForProvider.Schema), + Reference: mg.Spec.ForProvider.SchemaRef, + Selector: mg.Spec.ForProvider.SchemaSelector, + To: reference.To{Managed: &Schema{}, List: &SchemaList{}}, + Extract: reference.ExternalName(), + }) + if err != nil { + return errors.Wrap(err, "spec.forProvider.schema") + } + mg.Spec.ForProvider.Schema = reference.ToPtrValue(rsp.ResolvedValue) + mg.Spec.ForProvider.SchemaRef = rsp.ResolvedReference + // Resolve spec.forProvider.role rsp, err = r.Resolve(ctx, reference.ResolutionRequest{ CurrentValue: reference.FromPtrValue(mg.Spec.ForProvider.Role), diff --git a/apis/postgresql/v1alpha1/zz_generated.deepcopy.go b/apis/postgresql/v1alpha1/zz_generated.deepcopy.go index 70d15187..05838181 100644 --- a/apis/postgresql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/postgresql/v1alpha1/zz_generated.deepcopy.go @@ -411,6 +411,21 @@ func (in *GrantParameters) DeepCopyInto(out *GrantParameters) { *out = new(v1.Selector) (*in).DeepCopyInto(*out) } + if in.Schema != nil { + in, out := &in.Schema, &out.Schema + *out = new(string) + **out = **in + } + if in.SchemaRef != nil { + in, out := &in.SchemaRef, &out.SchemaRef + *out = new(v1.Reference) + (*in).DeepCopyInto(*out) + } + if in.SchemaSelector != nil { + in, out := &in.SchemaSelector, &out.SchemaSelector + *out = new(v1.Selector) + (*in).DeepCopyInto(*out) + } if in.MemberOf != nil { in, out := &in.MemberOf, &out.MemberOf *out = new(string) @@ -431,6 +446,16 @@ func (in *GrantParameters) DeepCopyInto(out *GrantParameters) { *out = new(bool) **out = **in } + if in.Objects != nil { + in, out := &in.Objects, &out.Objects + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Columns != nil { + in, out := &in.Columns, &out.Columns + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrantParameters. diff --git a/build b/build index 231258db..1ed19332 160000 --- a/build +++ b/build @@ -1 +1 @@ -Subproject commit 231258db281237379d8ec0c6e4af9d7c1ae5cc4a +Subproject commit 1ed19332b947c449795fd016f3c21ee0a64930fd diff --git a/package/crds/postgresql.sql.crossplane.io_grants.yaml b/package/crds/postgresql.sql.crossplane.io_grants.yaml index f9849f71..a95a3a8e 100644 --- a/package/crds/postgresql.sql.crossplane.io_grants.yaml +++ b/package/crds/postgresql.sql.crossplane.io_grants.yaml @@ -83,6 +83,13 @@ spec: description: GrantParameters define the desired state of a PostgreSQL grant instance. properties: + columns: + description: |- + The columns upon which to grant the privileges. + Required when object_type is column. You cannot specify this option if the object_type is not column + items: + type: string + type: array database: description: Database this grant is for. type: string @@ -242,6 +249,30 @@ spec: type: string type: object type: object + objectType: + description: ObjectType is the PostgreSQL object type to grant + the privileges on. + enum: + - database + - schema + - table + - sequence + - function + - procedure + - routine + - foreign_data_wrapper + - foreign_server + - column + type: string + objects: + description: |- + Objects are the objects upon which to grant the privileges. + An empty list (the default) means to grant permissions on all objects of the specified type. + You cannot specify this option if the objectType is database or schema. + When objectType is column, only one value is allowed. + items: + type: string + type: array privileges: description: |- Privileges to be granted. @@ -336,6 +367,85 @@ spec: type: string type: object type: object + schema: + description: Schema this grant is for. + type: string + schemaRef: + description: SchemaRef references the schema object this grant + it for. + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + schemaSelector: + description: SchemaSelector selects a reference to a Schema this + grant is for. + properties: + matchControllerRef: + description: |- + MatchControllerRef ensures an object with the same controller reference + as the selecting object is selected. + type: boolean + matchLabels: + additionalProperties: + type: string + description: MatchLabels ensures an object with matching labels + is selected. + type: object + policy: + description: Policies for selection. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + type: object withOption: description: |- WithOption allows an option to be set on the grant. @@ -345,6 +455,8 @@ spec: - ADMIN - GRANT type: string + required: + - objectType type: object managementPolicies: default: diff --git a/pkg/controller/postgresql/grant/reconciler.go b/pkg/controller/postgresql/grant/reconciler.go index d73c3943..cfd75fd0 100644 --- a/pkg/controller/postgresql/grant/reconciler.go +++ b/pkg/controller/postgresql/grant/reconciler.go @@ -51,6 +51,7 @@ const ( errSelectGrant = "cannot select grant" errCreateGrant = "cannot create grant" errRevokeGrant = "cannot revoke grant" + errNoSchema = "schema not passed or could not be resolved" errNoRole = "role not passed or could not be resolved" errNoDatabase = "database not passed or could not be resolved" errNoPrivileges = "privileges not passed" @@ -137,6 +138,41 @@ const ( roleDatabase grantType = "ROLE_DATABASE" ) +// sliceContainsStr checks if a slice contains a specific string. +func sliceContainsStr(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +func validateGrantParams(gp v1alpha1.GrantParameters) error { + if gp.Schema == nil && !sliceContainsStr([]string{"database", "foreign_data_wrapper", "foreign_server"}, gp.ObjectType) { + return fmt.Errorf("parameter 'schema' is mandatory for grant resource") + } + if len(gp.Objects) > 0 && (gp.ObjectType == "database" || gp.ObjectType == "schema") { + return fmt.Errorf("cannot specify `objects` when `object_type` is `database` or `schema`") + } + if len(gp.Columns) > 0 && gp.ObjectType != "column" { + return fmt.Errorf("cannot specify `columns` when `object_type` is not `column`") + } + if len(gp.Columns) == 0 && gp.ObjectType == "column" { + return fmt.Errorf("must specify `columns` when `object_type` is `column`") + } + if len(gp.Privileges) != 1 && gp.ObjectType == "column" { + return fmt.Errorf("must specify exactly 1 `privileges` when `object_type` is `column`") + } + if len(gp.Objects) != 1 && gp.ObjectType == "column" { + return fmt.Errorf("must specify exactly 1 table in the `objects` field when `object_type` is `column`") + } + if len(gp.Objects) != 1 && (gp.ObjectType == "foreign_data_wrapper" || gp.ObjectType == "foreign_server") { + return fmt.Errorf("one element must be specified in `objects` when `object_type` is `foreign_data_wrapper` or `foreign_server`") + } + return nil +} + func identifyGrantType(gp v1alpha1.GrantParameters) (grantType, error) { pc := len(gp.Privileges) @@ -194,34 +230,43 @@ func selectGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { ep := gp.Privileges.ExpandPrivileges() sp := ep.ToStringSlice() + // Join grantee. Filter by database name and grantee name. // Finally, perform a permission comparison against expected // permissions. q.String = "SELECT EXISTS(SELECT 1 " + - "FROM pg_database db, " + - "aclexplode(datacl) as acl " + - "INNER JOIN pg_roles s ON acl.grantee = s.oid " + - // Filter by database, role and grantable setting - "WHERE db.datname=$1 " + - "AND s.rolname=$2 " + - "AND acl.is_grantable=$3 " + - "GROUP BY db.datname, s.rolname, acl.is_grantable " + - // Check privileges match. Convoluted right-hand-side is necessary to - // ensure identical sort order of the input permissions. - "HAVING array_agg(acl.privilege_type ORDER BY privilege_type ASC) " + - "= (SELECT array(SELECT unnest($4::text[]) as perms ORDER BY perms ASC)))" + "FROM pg_database db " + + "JOIN pg_namespace nsp ON db.datname = $1 " + + "JOIN LATERAL aclexplode(nsp.nspacl) acl ON true " + + "JOIN pg_roles s ON acl.grantee = s.oid " + + // Filter by role, schema and grantable setting + "WHERE nsp.nspname = $2 " + + "AND s.rolname = $3 " + + "AND acl.is_grantable = $6 " + + "GROUP BY db.datname, nsp.nspname, s.rolname, acl.is_grantable " + + "HAVING array_agg(acl.privilege_type ORDER BY privilege_type ASC) = " + + "(SELECT array(SELECT unnest($7::text[]) ORDER BY 1)))" q.Parameters = []interface{}{ gp.Database, + gp.Schema, gp.Role, + gp.ObjectType, + gp.Objects, gro, pq.Array(sp), } - return nil } return errors.New(errUnknownGrant) } +func withSchema(schema *string) string { + if schema != nil { + return fmt.Sprintf("IN SCHEMA %s", *schema) + } + return "" +} + func withOption(option *v1alpha1.GrantOption) string { if option != nil { return fmt.Sprintf("WITH %s OPTION", string(*option)) @@ -262,17 +307,19 @@ func createGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query) error { / *ql = append(*ql, // REVOKE ANY MATCHING EXISTING PERMISSIONS - xsql.Query{String: fmt.Sprintf("REVOKE %s ON DATABASE %s FROM %s", + xsql.Query{String: fmt.Sprintf("REVOKE %s ON DATABASE %s %s FROM %s", sp, db, + withSchema(gp.Schema), ro, )}, // GRANT REQUESTED PERMISSIONS - xsql.Query{String: fmt.Sprintf("GRANT %s ON DATABASE %s TO %s %s", + xsql.Query{String: fmt.Sprintf("GRANT %s ON DATABASE %s TO %s %s %s", sp, db, ro, + withSchema(gp.Schema), withOption(gp.WithOption), )}, ) @@ -305,9 +352,10 @@ func deleteGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { ) return nil case roleDatabase: - q.String = fmt.Sprintf("REVOKE %s ON DATABASE %s FROM %s", + q.String = fmt.Sprintf("REVOKE %s ON DATABASE %s %s FROM %s", strings.Join(gp.Privileges.ToStringSlice(), ","), pq.QuoteIdentifier(*gp.Database), + withSchema(gp.Schema), ro, ) return nil @@ -357,6 +405,11 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext return managed.ExternalCreation{}, errors.New(errNotGrant) } + // Validate grant specs + if err := validateGrantParams(cr.Spec.ForProvider); err != nil { + return managed.ExternalCreation{}, errors.Wrap(err, errInvalidParams) + } + var queries []xsql.Query cr.SetConditions(xpv1.Creating())