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/README.md b/README.md index 1c67f598..2a53e79f 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Check the example: 2. Create managed resources for your SQL server flavor: - **MySQL**: `Database`, `Grant`, `User` (See [the examples](examples/mysql)) - - **PostgreSQL**: `Database`, `Grant`, `Extension`, `Role` (See [the examples](examples/postgresql)) + - **PostgreSQL**: `Database`, `Schema`, `Grant`, `Extension`, `Role` (See [the examples](examples/postgresql)) - **MSSQL**: `Database`, `Grant`, `User` (See [the examples](examples/mssql)) [crossplane]: https://crossplane.io diff --git a/apis/postgresql/v1alpha1/grant_types.go b/apis/postgresql/v1alpha1/grant_types.go index 1bf6fc7c..16c3a7a8 100644 --- a/apis/postgresql/v1alpha1/grant_types.go +++ b/apis/postgresql/v1alpha1/grant_types.go @@ -19,12 +19,18 @@ package v1alpha1 import ( "context" - "github.com/pkg/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" "github.com/crossplane/crossplane-runtime/pkg/reference" + "github.com/pkg/errors" +) + +const ( + errNoPrivileges = "privileges not passed" + errUnknownGrant = "cannot identify grant type based on passed params" + errMemberOfWithPrivileges = "cannot set privileges in the same grant as memberOf" ) // A GrantSpec defines the desired state of a Grant. @@ -43,24 +49,169 @@ type GrantPrivilege string // +kubebuilder:validation:MinItems:=1 type GrantPrivileges []GrantPrivilege +type GrantType string + +// GrantType is the list of the possible grant types represented by a GrantParameters +const ( + RoleMember GrantType = "ROLE_MEMBER" + RoleDatabase GrantType = "ROLE_DATABASE" + RoleSchema GrantType = "ROLE_SCHEMA" + RoleTable GrantType = "ROLE_TABLE" + RoleSequence GrantType = "ROLE_SEQUENCE" + RoleRoutine GrantType = "ROLE_ROUTE" + RoleColumn GrantType = "ROLE_COLUMN" + RoleForeignDataWrapper GrantType = "ROLE_FOREIGN_DATA_WRAPPER" + RoleForeignServer GrantType = "ROLE_FOREIGN_SERVER" +) + +type marker struct{} +type stringSet struct { + elements map[string]marker +} + +func newStringSet() *stringSet { + return &stringSet{ + elements: make(map[string]marker), + } +} + +func (s *stringSet) add(element string) { + s.elements[element] = marker{} +} + +func (s *stringSet) contains(element string) bool { + _, exists := s.elements[element] + return exists +} + +func (s *stringSet) containsExactly(elements ...string) bool { + if len(s.elements) != len(elements) { + return false + } + for _, elem := range elements { + if !s.contains(elem) { + return false + } + } + return true +} + +func (gp *GrantParameters) filledInFields() *stringSet { + fields := map[string]bool{ + "MemberOf": gp.MemberOf != nil, + "Database": gp.Database != nil, + "Schema": gp.Schema != nil, + "Tables": len(gp.Tables) > 0, + "Columns": len(gp.Columns) > 0, + "Sequences": len(gp.Sequences) > 0, + "Routines": len(gp.Routines) > 0, + "ForeignServers": len(gp.ForeignServers) > 0, + "ForeignDataWrappers": len(gp.ForeignDataWrappers) > 0, + } + set := newStringSet() + + for key, hasField := range fields { + if hasField { + set.add(key) + } + } + return set +} + +var grantTypeFields = map[GrantType][]string{ + RoleMember: {"MemberOf"}, + RoleDatabase: {"Database"}, + RoleSchema: {"Database", "Schema"}, + RoleTable: {"Database", "Schema", "Tables"}, + RoleColumn: {"Database", "Schema", "Tables", "Columns"}, + RoleSequence: {"Database", "Schema", "Sequences"}, + RoleRoutine: {"Database", "Schema", "Routines"}, + RoleForeignServer: {"Database", "ForeignServers"}, + RoleForeignDataWrapper: {"Database", "ForeignDataWrappers"}, +} + +// IdentifyGrantType return the deduced GrantType from the filled in fields. +func (gp *GrantParameters) IdentifyGrantType() (GrantType, error) { + ff := gp.filledInFields() + pc := len(gp.Privileges) + + var gt *GrantType + + for k, v := range grantTypeFields { + if ff.containsExactly(v...) { + gt = &k + break + } + } + if gt == nil { + return "", errors.New(errUnknownGrant) + } + if *gt == RoleMember && pc > 0 { + return "", errors.New(errMemberOfWithPrivileges) + } + if *gt != RoleMember && pc < 1 { + return "", errors.New(errNoPrivileges) + } + return *gt, nil +} + // Some privileges are shorthands for multiple privileges. These translations // 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 -var grantReplacements = map[GrantPrivilege]GrantPrivileges{ - "ALL": {"CREATE", "TEMPORARY", "CONNECT"}, - "ALL PRIVILEGES": {"CREATE", "TEMPORARY", "CONNECT"}, - "TEMP": {"TEMPORARY"}, +var grantReplacements = map[GrantType]map[GrantPrivilege]GrantPrivileges{ + RoleDatabase: { + "ALL": {"CREATE", "TEMPORARY", "CONNECT"}, + "ALL PRIVILEGES": {"CREATE", "TEMPORARY", "CONNECT"}, + "TEMP": {"TEMPORARY"}, + }, + RoleSchema: { + "ALL": {"CREATE", "USAGE"}, + "ALL PRIVILEGES": {"CREATE", "USAGE"}, + }, + RoleTable: { + "ALL": {"SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "MAINTAIN"}, + "ALL PRIVILEGES": {"SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "MAINTAIN"}, + }, + RoleColumn: { + "ALL": {"SELECT", "INSERT", "UPDATE", "REFERENCES"}, + "ALL PRIVILEGES": {"SELECT", "INSERT", "UPDATE", "REFERENCES"}, + }, + RoleSequence: { + "ALL": {"USAGE", "SELECT", "UPDATE"}, + "ALL PRIVILEGES": {"USAGE", "SELECT", "UPDATE"}, + }, + RoleRoutine: { + "ALL": {"EXECUTE"}, + "ALL PRIVILEGES": {"EXECUTE"}, + }, + RoleForeignDataWrapper: { + "ALL": {"USAGE"}, + "ALL PRIVILEGES": {"USAGE"}, + }, + RoleForeignServer: { + "ALL": {"USAGE"}, + "ALL PRIVILEGES": {"USAGE"}, + }, } // ExpandPrivileges expands any shorthand privileges to their full equivalents. -func (gp *GrantPrivileges) ExpandPrivileges() GrantPrivileges { +func (gp *GrantParameters) ExpandPrivileges() GrantPrivileges { + gt, err := gp.IdentifyGrantType() + if err != nil { + return gp.Privileges + } + gr, ex := grantReplacements[gt] + if !ex { + return gp.Privileges + } + privilegeSet := make(map[GrantPrivilege]struct{}) // Replace any shorthand privileges with their full equivalents - for _, p := range *gp { - if _, ok := grantReplacements[p]; ok { - for _, rp := range grantReplacements[p] { + for _, p := range gp.Privileges { + if _, ok := gr[p]; ok { + for _, rp := range gr[p] { privilegeSet[rp] = struct{}{} } } else { @@ -99,6 +250,15 @@ const ( GrantOptionGrant GrantOption = "GRANT" ) +type Routine struct { + // The name of the routine. + Name string `json:"name,omitempty"` + + // The arguments of the routine. + // +optional + Arguments []string `json:"args,omitempty"` +} + // GrantParameters define the desired state of a PostgreSQL grant instance. type GrantParameters struct { // Privileges to be granted. @@ -141,6 +301,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 +332,30 @@ 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"` + + // The columns upon which to grant the privileges. + // +optional + Columns []string `json:"columns,omitempty"` + + // The tables upon which to grant the privileges. + // +optional + Tables []string `json:"tables,omitempty"` + + // The sequences upon which to grant the privileges. + // +optional + Sequences []string `json:"sequences,omitempty"` + + // The routines upon which to grant the privileges. + // +optional + Routines []Routine `json:"routines,omitempty"` + + // The foreign data wrappers upon which to grant the privileges. + // +optional + ForeignDataWrappers []string `json:"foreignDataWrappers,omitempty"` + + // The foreign servers upon which to grant the privileges. + // +optional + ForeignServers []string `json:"foreignServers,omitempty"` } // A GrantStatus represents the observed state of a Grant. @@ -212,6 +410,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..0761c4e6 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,38 @@ func (in *GrantParameters) DeepCopyInto(out *GrantParameters) { *out = new(bool) **out = **in } + if in.Columns != nil { + in, out := &in.Columns, &out.Columns + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Tables != nil { + in, out := &in.Tables, &out.Tables + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Sequences != nil { + in, out := &in.Sequences, &out.Sequences + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Routines != nil { + in, out := &in.Routines, &out.Routines + *out = make([]Routine, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ForeignDataWrappers != nil { + in, out := &in.ForeignDataWrappers, &out.ForeignDataWrappers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ForeignServers != nil { + in, out := &in.ForeignServers, &out.ForeignServers + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GrantParameters. @@ -891,6 +938,26 @@ func (in *RoleStatus) DeepCopy() *RoleStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Routine) DeepCopyInto(out *Routine) { + *out = *in + if in.Arguments != nil { + in, out := &in.Arguments, &out.Arguments + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Routine. +func (in *Routine) DeepCopy() *Routine { + if in == nil { + return nil + } + out := new(Routine) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Schema) DeepCopyInto(out *Schema) { *out = *in diff --git a/cluster/local/postgresdb_functions.sh b/cluster/local/postgresdb_functions.sh index 4a64c7e2..0217c2c9 100644 --- a/cluster/local/postgresdb_functions.sh +++ b/cluster/local/postgresdb_functions.sh @@ -17,7 +17,7 @@ setup_postgresdb_no_tls() { --from-literal endpoint="postgresdb-postgresql.default.svc.cluster.local" \ --from-literal port="5432" - "${KUBECTL}" port-forward --namespace default svc/postgresdb-postgresql 5432:5432 & + "${KUBECTL}" port-forward --namespace default svc/postgresdb-postgresql 5432:5432 | grep -v "Handling connection for" & PORT_FORWARD_PID=$! } @@ -40,6 +40,46 @@ EOF echo "${yaml}" | "${KUBECTL}" apply -f - } +create_grantable_objects() { + TARGET_DB='db1' + TARGE_SCHEMA='public' + request=" + CREATE TABLE \"$TARGE_SCHEMA\".test_table(col1 INT NULL); + CREATE SEQUENCE \"$TARGE_SCHEMA\".test_sequence_1 START WITH 1000 INCREMENT BY 1; + CREATE SEQUENCE \"$TARGE_SCHEMA\".test_sequence_2 START WITH 1000 INCREMENT BY 1; + CREATE PROCEDURE \"$TARGE_SCHEMA\".test_procedure(arg TEXT) LANGUAGE plpgsql AS \$\$ BEGIN END; \$\$; + CREATE TABLE \"$TARGE_SCHEMA\".test_table_column(test_column INT NULL); + CREATE FOREIGN DATA WRAPPER test_foreign_data_wrapper; + CREATE SERVER test_foreign_server FOREIGN DATA WRAPPER test_foreign_data_wrapper; + " + create_objects=$(PGPASSWORD="${postgres_root_pw}" psql -h localhost -p 5432 -U postgres -d "$TARGET_DB" -wtAc "$request") + if [ $? -eq 0 ]; then + echo_info "PostgresDB objects created in schema public" + else + echo_error "ERROR: could not create grantable objects: $create_objects" + fi +} + +delete_grantable_objects() { + TARGET_DB='db1' + TARGE_SCHEMA='public' + request=" + DROP SERVER test_foreign_server; + DROP FOREIGN DATA WRAPPER test_foreign_data_wrapper; + DROP TABLE \"$TARGE_SCHEMA\".test_table_column; + DROP PROCEDURE \"$TARGE_SCHEMA\".test_procedure(TEXT); + DROP SEQUENCE \"$TARGE_SCHEMA\".test_sequence_1; + DROP SEQUENCE \"$TARGE_SCHEMA\".test_sequence_2; + DROP TABLE \"$TARGE_SCHEMA\".test_table; + " + drop_objects=$(PGPASSWORD="${postgres_root_pw}" psql -h localhost -p 5432 -U postgres -d "$TARGET_DB" -wtAc "$request") + if [ $? -eq 0 ]; then + echo_info "PostgresDB objects dropped from schema public" + else + echo_error "ERROR: could not delete grantable objects: $drop_objects" + fi +} + setup_postgresdb_tests(){ # install provider resources echo_step "creating PostgresDB Database resource" @@ -50,10 +90,6 @@ echo_step "creating PostgresDB Role resource" # create grant "${KUBECTL}" apply -f ${projectdir}/examples/postgresql/role.yaml -echo_step "creating PostgresDB Grant resource" -# create grant -"${KUBECTL}" apply -f ${projectdir}/examples/postgresql/grant.yaml - echo_step "creating PostgresDB Schema resources" # create grant "${KUBECTL}" apply -f ${projectdir}/examples/postgresql/schema.yaml @@ -66,13 +102,21 @@ echo_step "check if database is ready" "${KUBECTL}" wait --timeout 2m --for condition=Ready -f ${projectdir}/examples/postgresql/database.yaml echo_step_completed -echo_step "check if grant is ready" -"${KUBECTL}" wait --timeout 2m --for condition=Ready -f ${projectdir}/examples/postgresql/grant.yaml -echo_step_completed - echo_step "check if schema is ready" "${KUBECTL}" wait --timeout 2m --for condition=Ready -f ${projectdir}/examples/postgresql/schema.yaml echo_step_completed + +echo_step "create grantable objects" +create_grantable_objects +echo_step_completed + +echo_step "creating PostgresDB Grant resource" +# create grant +"${KUBECTL}" apply -f ${projectdir}/examples/postgresql/grant.yaml + +echo_step "check if grant is ready" +"${KUBECTL}" wait --timeout 2m --for condition=Ready -f ${projectdir}/examples/postgresql/grant.yaml +echo_step_completed } check_all_roles_privileges() { @@ -103,37 +147,148 @@ check_role_privileges() { local expected_privileges=$2 local target_db=$4 - echo_info "Checking privileges for role: $role (expected: $expected_privileges)" - echo "" + echo -n "Privileges for role: $role (expected: $expected_privileges)" + result=$(PGPASSWORD="$3" psql -h localhost -p 5432 -U postgres -d postgres -wtAc" SELECT CASE WHEN has_database_privilege('$role', '$target_db', 'CONNECT') THEN 'CONNECT' ELSE NULL END, CASE WHEN has_database_privilege('$role', '$target_db', 'CREATE') THEN 'CREATE' ELSE NULL END, CASE WHEN has_database_privilege('$role', '$target_db', 'TEMP') THEN 'TEMP' ELSE NULL END " | tr '\n' ',' | sed 's/,$//') if [ "$result" = "$expected_privileges" ]; then - echo_info "Privileges for $role are as expected: $result" - echo "" + echo " condition met" else + echo "" echo_error "ERROR: Privileges for $role do not match expected. Found: $result, Expected: $expected_privileges" echo "" fi } -check_schema_privileges(){ +check_all_schema_privileges() { # check if schema privileges are set properly echo_step "check if schema privileges are set properly" - TARGET_DB='db1' + OWNER_ROLE='ownerrole' + USER_ROLE='no-grants-role' - nspacl=$(PGPASSWORD="${postgres_root_pw}" psql -h localhost -p 5432 -U postgres -d "$TARGET_DB" -wtAc "SELECT nspacl FROM pg_namespace WHERE nspname = 'public';") - nspacl=$(echo "$nspacl" | xargs) + # Define roles and their expected privileges + roles="$OWNER_ROLE $USER_ROLE" + dbs="db1 example" + schemas="public my-schema" + privileges="USAGE|f,CREATE|f USAGE|t,CREATE|t" - if [[ "$nspacl" == "{ownerrole=UC/ownerrole}" ]]; then - echo "Privileges on schema public are as expected: $nspacl" - echo_info "OK" + # Iterate over roles and expected privileges + role_index=1 + for role in $roles; do + expected_privileges=$(echo "$privileges" | cut -d ' ' -f $role_index) + target_db=$(echo "$dbs" | cut -d ' ' -f $role_index) + target_schema=$(echo "$schemas" | cut -d ' ' -f $role_index) + check_schema_privileges "$role" "$expected_privileges" "${postgres_root_pw}" "$target_db" "$target_schema" + role_index=$((role_index + 1)) + done + + echo_step_completed +} + +check_privileges(){ + local target_db=$1 + local object=$2 + local role=$3 + local expected=$4 + local request=$5 + echo -n "Privileges on $object for role: $role (expected: $expected)" + + response=$(PGPASSWORD="${postgres_root_pw}" psql -h localhost -p 5432 -U postgres -d "$target_db" -wtAc "$request") + response=$(echo "$response" | xargs | tr ' ' ',') + + if [[ "$response" == "$expected" ]]; then + echo " condition met" else - echo "Privileges on schema public are NOT as expected: $nspacl" - echo_error "Not OK" + echo "" + echo_error "Found unexpected privileges: $response" + echo "" fi +} - echo_step_completed +check_schema_privileges(){ + local role=$1 + local expected_privileges=$2 + local target_db=$4 + local target_schema=$5 + + request="select acl.privilege_type, acl.is_grantable from pg_namespace n, aclexplode(n.nspacl) acl INNER JOIN pg_roles s ON acl.grantee = s.oid where n.nspname = '$target_schema' and s.rolname='$role'" + + check_privileges $target_db "schema $target_db.$target_schema" $role $expected_privileges "$request" +} + +check_table_privileges(){ + target_db="db1" + schema="public" + table="test_table" + role='no-grants-role' + expected_privileges='INSERT|NO,SELECT|NO' + + request="select privilege_type, is_grantable from information_schema.role_table_grants where grantee = '$role' and table_schema = '$schema' and table_name='$table' order by privilege_type asc" + + check_privileges $target_db "table $schema.$table" $role $expected_privileges "$request" +} + +check_sequence_privileges(){ + target_db="db1" + schema="public" + role='no-grants-role' + expected_privileges='SELECT|f,UPDATE|f,USAGE|f' + + sequence="test_sequence_1" + request="select acl.privilege_type, acl.is_grantable from pg_class c inner join pg_namespace n on c.relnamespace = n.oid, aclexplode(c.relacl) as acl inner join pg_roles s on acl.grantee = s.oid where c.relkind = 'S' and n.nspname = '$schema' and s.rolname='$role' and c.relname = '$sequence'" + check_privileges $target_db "sequence $schema.$sequence" $role $expected_privileges "$request" + + sequence="test_sequence_2" + request="select acl.privilege_type, acl.is_grantable from pg_class c inner join pg_namespace n on c.relnamespace = n.oid, aclexplode(c.relacl) as acl inner join pg_roles s on acl.grantee = s.oid where c.relkind = 'S' and n.nspname = '$schema' and s.rolname='$role' and c.relname = '$sequence'" + check_privileges $target_db "sequence $schema.$sequence" $role $expected_privileges "$request" +} + +check_routine_privileges(){ + target_db="db1" + schema="public" + routine="test_procedure" + role='no-grants-role' + expected_privileges='EXECUTE|NO' + + request="select privilege_type, is_grantable from information_schema.role_routine_grants where grantee = '$role' and routine_schema = '$schema' and routine_name='$routine' order by privilege_type asc" + + check_privileges $target_db "routine $schema.$routine" $role $expected_privileges "$request" +} + +check_column_privileges(){ + target_db="db1" + schema="public" + table="test_table_column" + column="test_column" + role='no-grants-role' + expected_privileges='UPDATE|NO' + + request="select privilege_type, is_grantable from information_schema.role_column_grants where grantee = '$role' and table_schema = '$schema' and table_name='$table' and column_name='$column' order by privilege_type asc" + + check_privileges $target_db "column $column on table $schema.$table" $role $expected_privileges "$request" +} + +check_foreign_data_wrapper_privileges(){ + target_db="db1" + foreign_data_wrapper="test_foreign_data_wrapper" + role='no-grants-role' + expected_privileges='USAGE|NO' + + request="select privilege_type, is_grantable from information_schema.role_usage_grants where grantee = '$role' and object_type = 'FOREIGN DATA WRAPPER' and object_name='$foreign_data_wrapper' order by privilege_type asc" + + check_privileges $target_db "foreign data wrapper $foreign_data_wrapper" $role $expected_privileges "$request" +} + +check_foreign_server_privileges(){ + target_db="db1" + foreign_server="test_foreign_server" + role='no-grants-role' + expected_privileges='USAGE|NO' + + request="select privilege_type, is_grantable from information_schema.role_usage_grants where grantee = '$role' and object_type = 'FOREIGN SERVER' and object_name='$foreign_server' order by privilege_type asc" + + check_privileges $target_db "foreign server $foreign_server" $role $expected_privileges "$request" } setup_observe_only_database(){ @@ -168,7 +323,23 @@ check_observe_only_database(){ echo_step_completed } +check_custom_object_privileges(){ + echo_step "check if custom_object_privileges privileges are set properly" + + check_table_privileges + check_sequence_privileges + check_routine_privileges + check_column_privileges + check_foreign_data_wrapper_privileges + check_foreign_server_privileges + + echo_step_completed +} + delete_postgresdb_resources(){ + echo_step "deleting grantable resources" + delete_grantable_objects + # uninstall echo_step "uninstalling ${PROJECT_NAME}" "${KUBECTL}" delete -f "${projectdir}/examples/postgresql/grant.yaml" @@ -196,6 +367,7 @@ integration_tests_postgres() { setup_postgresdb_tests check_observe_only_database check_all_roles_privileges - check_schema_privileges + check_all_schema_privileges + check_custom_object_privileges delete_postgresdb_resources } \ No newline at end of file diff --git a/examples/postgresql/grant.yaml b/examples/postgresql/grant.yaml index b37be69c..cbe7e82a 100644 --- a/examples/postgresql/grant.yaml +++ b/examples/postgresql/grant.yaml @@ -64,3 +64,123 @@ spec: databaseRef: name: "db1" revokePublicOnDb: true +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-schema +spec: + forProvider: + privileges: + - ALL + withOption: GRANT + roleRef: + name: no-grants-role + databaseRef: + name: example + schemaRef: + name: my-schema +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-table +spec: + forProvider: + privileges: + - SELECT + - INSERT + roleRef: + name: no-grants-role + databaseRef: + name: db1 + schemaRef: + name: public + tables: + - test_table +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-sequence +spec: + forProvider: + privileges: + - ALL + roleRef: + name: no-grants-role + databaseRef: + name: db1 + schemaRef: + name: public + sequences: + - test_sequence_1 + - test_sequence_2 +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-routine +spec: + forProvider: + privileges: + - ALL + roleRef: + name: no-grants-role + databaseRef: + name: db1 + schemaRef: + name: public + routines: + - name: test_procedure + args: + - TEXT +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-column +spec: + forProvider: + privileges: + - UPDATE + roleRef: + name: no-grants-role + databaseRef: + name: db1 + schemaRef: + name: public + tables: + - test_table_column + columns: + - test_column +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-foreign-data-wrapper +spec: + forProvider: + privileges: + - USAGE + roleRef: + name: no-grants-role + databaseRef: + name: db1 + foreignDataWrappers: + - test_foreign_data_wrapper +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Grant +metadata: + name: example-grant-role-1-on-foreign-server +spec: + forProvider: + privileges: + - USAGE + roleRef: + name: no-grants-role + databaseRef: + name: db1 + foreignServers: + - test_foreign_server \ No newline at end of file diff --git a/examples/postgresql/role.yaml b/examples/postgresql/role.yaml index 5f07914c..860de429 100644 --- a/examples/postgresql/role.yaml +++ b/examples/postgresql/role.yaml @@ -42,4 +42,16 @@ spec: createDb: true login: true createRole: true - inherit: true \ No newline at end of file + inherit: true +--- +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Role +metadata: + name: no-grants-role +spec: + writeConnectionSecretToRef: + name: no-grants-role-secret + namespace: default + forProvider: + privileges: + login: true \ No newline at end of file diff --git a/package/crds/postgresql.sql.crossplane.io_grants.yaml b/package/crds/postgresql.sql.crossplane.io_grants.yaml index f9849f71..e16a70a0 100644 --- a/package/crds/postgresql.sql.crossplane.io_grants.yaml +++ b/package/crds/postgresql.sql.crossplane.io_grants.yaml @@ -83,6 +83,11 @@ spec: description: GrantParameters define the desired state of a PostgreSQL grant instance. properties: + columns: + description: The columns upon which to grant the privileges. + items: + type: string + type: array database: description: Database this grant is for. type: string @@ -162,6 +167,17 @@ spec: type: string type: object type: object + foreignDataWrappers: + description: The foreign data wrappers upon which to grant the + privileges. + items: + type: string + type: array + foreignServers: + description: The foreign servers upon which to grant the privileges. + items: + type: string + type: array memberOf: description: MemberOf is the Role that this grant makes Role a member of. @@ -336,6 +352,109 @@ spec: type: string type: object type: object + routines: + description: The routines upon which to grant the privileges. + items: + properties: + args: + description: The arguments of the routine. + items: + type: string + type: array + name: + description: The name of the routine. + type: string + type: object + type: array + 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 + sequences: + description: The sequences upon which to grant the privileges. + items: + type: string + type: array + tables: + description: The tables upon which to grant the privileges. + items: + type: string + type: array withOption: description: |- WithOption allows an option to be set on the grant. diff --git a/pkg/controller/postgresql/grant/reconciler.go b/pkg/controller/postgresql/grant/reconciler.go index 7a236c09..3b0519e9 100644 --- a/pkg/controller/postgresql/grant/reconciler.go +++ b/pkg/controller/postgresql/grant/reconciler.go @@ -34,6 +34,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/event" "github.com/crossplane/crossplane-runtime/pkg/feature" "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" + "github.com/crossplane/crossplane-runtime/pkg/reference" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/crossplane-contrib/provider-sql/apis/postgresql/v1alpha1" @@ -48,18 +49,15 @@ const ( errNoSecretRef = "ProviderConfig does not reference a credentials Secret" errGetSecret = "cannot get credentials Secret" - errNotGrant = "managed resource is not a Grant custom resource" - errSelectGrant = "cannot select grant" - errCreateGrant = "cannot create grant" - errRevokeGrant = "cannot revoke grant" - errNoRole = "role not passed or could not be resolved" - errNoDatabase = "database not passed or could not be resolved" - errNoPrivileges = "privileges not passed" - errUnknownGrant = "cannot identify grant type based on passed params" + errNotGrant = "managed resource is not a Grant custom resource" + errSelectGrant = "cannot select grant" + errCreateGrant = "cannot create grant" + errRevokeGrant = "cannot revoke grant" + errNoRole = "role not passed or could not be resolved" - errInvalidParams = "invalid parameters for grant type %s" - - errMemberOfWithDatabaseOrPrivileges = "cannot set privileges or database in the same grant as memberOf" + errUnknownGrant = "cannot identify grant type based on passed params" + errUnsupportedGrant = "grant type not supported: %s" + errInvalidParams = "invalid parameters for grant type %s" maxConcurrency = 5 ) @@ -126,8 +124,12 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E if err := c.kube.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, s); err != nil { return nil, errors.Wrap(err, errGetSecret) } + db := reference.FromPtrValue(cr.Spec.ForProvider.Database) + if db == "" { + db = pc.Spec.DefaultDatabase + } return &external{ - db: c.newDB(s.Data, pc.Spec.DefaultDatabase, clients.ToString(pc.Spec.SSLMode)), + db: c.newDB(s.Data, db, clients.ToString(pc.Spec.SSLMode)), kube: c.kube, }, nil } @@ -137,96 +139,326 @@ type external struct { kube client.Client } -type grantType string +func yesOrNo(b bool) string { + if b { + return "YES" + } else { + return "NO" + } +} -const ( - roleMember grantType = "ROLE_MEMBER" - roleDatabase grantType = "ROLE_DATABASE" -) +func selectForeignServerGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + + // Join grantee. Filter by schema name, table name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT COUNT(*) >= $1 AS ct " + + "FROM (SELECT 1 " + + "FROM information_schema.role_usage_grants " + + // Filter by column, table, schema, role and grantable setting + "WHERE grantee=$2 " + + "AND object_type = 'FOREIGN SERVER' " + + "AND object_name = ANY($3) " + + "AND is_grantable=$4 " + + "GROUP BY object_name " + + // Check privileges match. Convoluted right-hand-side is necessary to + // ensure identical sort order of the input permissions. + "HAVING array_agg(TEXT(privilege_type) ORDER BY privilege_type ASC) " + + "= (SELECT array(SELECT unnest($5::text[]) as perms ORDER BY perms ASC))" + + ") sub" + q.Parameters = []interface{}{ + len(gp.ForeignServers), + gp.Role, + pq.Array(gp.ForeignServers), + yesOrNo(gro), + pq.Array(sp), + } + + return nil +} + +func selectForeignDataWrapperGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + + // Join grantee. Filter by schema name, table name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT COUNT(*) >= $1 AS ct " + + "FROM (SELECT 1 " + + "FROM information_schema.role_usage_grants " + + // Filter by column, table, schema, role and grantable setting + "WHERE grantee=$2 " + + "AND object_type = 'FOREIGN DATA WRAPPER' " + + "AND object_name = ANY($3) " + + "AND is_grantable=$4 " + + "GROUP BY object_name " + + // Check privileges match. Convoluted right-hand-side is necessary to + // ensure identical sort order of the input permissions. + "HAVING array_agg(TEXT(privilege_type) ORDER BY privilege_type ASC) " + + "= (SELECT array(SELECT unnest($5::text[]) as perms ORDER BY perms ASC))" + + ") sub" + q.Parameters = []interface{}{ + len(gp.ForeignDataWrappers), + gp.Role, + pq.Array(gp.ForeignDataWrappers), + yesOrNo(gro), + pq.Array(sp), + } -func identifyGrantType(gp v1alpha1.GrantParameters) (grantType, error) { - pc := len(gp.Privileges) + return nil +} - // If memberOf is specified, this is ROLE_MEMBER - // NOTE: If any of these are set, even if the lookup by ref or selector fails, - // then this is still a roleMember grant type. - if gp.MemberOfRef != nil || gp.MemberOfSelector != nil || gp.MemberOf != nil { - if gp.Database != nil || pc > 0 { - return "", errors.New(errMemberOfWithDatabaseOrPrivileges) - } - return roleMember, nil +func selectColumnGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + + // Join grantee. Filter by schema name, table name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT COUNT(*) = $1 AS ct " + + "FROM (SELECT 1 FROM pg_class c " + + "INNER JOIN pg_namespace n ON c.relnamespace = n.oid " + + "INNER JOIN pg_attribute attr on c.oid = attr.attrelid, " + + "aclexplode(attr.attacl) as acl " + + "INNER JOIN pg_roles s ON acl.grantee = s.oid " + + "WHERE c.relkind = 'r' " + + // Filter by table, schema, role and grantable setting + "AND n.nspname=$2 " + + "AND s.rolname=$3 " + + "AND c.relname = ANY($4) " + + "AND attr.attname = ANY($5) " + + "AND acl.is_grantable=$6 " + + "GROUP BY c.relname, n.nspname, s.rolname, attr.attname, 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($7::text[]) as perms ORDER BY perms ASC))" + + ") sub" + q.Parameters = []interface{}{ + len(gp.Tables) * len(gp.Columns), + gp.Schema, + gp.Role, + pq.Array(gp.Tables), + pq.Array(gp.Columns), + gro, + pq.Array(sp), } - if gp.Database == nil { - return "", errors.New(errNoDatabase) + return nil +} + +func selectRoutineGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + + routinesSignatures := make([]string, len(gp.Routines)) + for i, routine := range gp.Routines { + routinesSignatures[i] = signature(routine) } - if pc < 1 { - return "", errors.New(errNoPrivileges) + // Join grantee. Filter by routine name and signature, schema name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT COUNT(*) = $1 AS ct " + + "FROM (SELECT " + + // format routine args + "p.proname || '(' || coalesce(array_to_string(array_agg(pg_catalog.format_type(t, NULL) ORDER BY args.ord), ',')) || ')' " + + "AS signature " + + "FROM pg_proc p " + + "LEFT JOIN unnest(p.proargtypes) WITH ORDINALITY AS args(t, ord) on true " + + "INNER JOIN pg_namespace n ON p.pronamespace = n.oid, " + + "aclexplode(p.proacl) as acl " + + "INNER JOIN pg_roles s ON acl.grantee = s.oid " + + // Filter by sequence, schema, role and grantable setting + "WHERE n.nspname=$2 " + + "AND s.rolname=$3 " + + "AND acl.is_grantable=$4 " + + "GROUP BY n.nspname, s.rolname, acl.is_grantable, p.oid " + + // 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($5::text[]) as perms ORDER BY perms ASC))" + + ") sub " + + "WHERE sub.signature = ANY($6)" + q.Parameters = []interface{}{ + len(gp.Routines), + gp.Schema, + gp.Role, + gro, + pq.Array(sp), + pq.Array(routinesSignatures), } - // This is ROLE_DATABASE - return roleDatabase, nil + return nil } -func selectGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { - gt, err := identifyGrantType(gp) - if err != nil { - return err +func selectSequenceGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + // Join grantee. Filter by sequence name, schema name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT COUNT(*) = $1 AS ct " + + "FROM (SELECT 1 FROM pg_class c " + + "INNER JOIN pg_namespace n ON c.relnamespace = n.oid, " + + "aclexplode(c.relacl) as acl " + + "INNER JOIN pg_roles s ON acl.grantee = s.oid " + + "WHERE c.relkind = 'S' " + + // Filter by sequence, schema, role and grantable setting + "AND n.nspname=$2 " + + "AND s.rolname=$3 " + + "AND c.relname = ANY($4) " + + "AND acl.is_grantable=$5 " + + "GROUP BY c.relname, n.nspname, 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($6::text[]) as perms ORDER BY perms ASC))" + + ") sub" + q.Parameters = []interface{}{ + len(gp.Sequences), + gp.Schema, + gp.Role, + pq.Array(gp.Sequences), + gro, + pq.Array(sp), } - switch gt { - case roleMember: - ao := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionAdmin - - // Always returns a row with a true or false value - // A simpler query would use ::regrol to cast the - // roleid and member oids to their role names, but - // if this is used with a nonexistent role name it will - // throw an error rather than return false. - q.String = "SELECT EXISTS(SELECT 1 FROM pg_auth_members m " + - "INNER JOIN pg_roles mo ON m.roleid = mo.oid " + - "INNER JOIN pg_roles r ON m.member = r.oid " + - "WHERE r.rolname=$1 AND mo.rolname=$2 AND " + - "m.admin_option = $3)" - - q.Parameters = []interface{}{ - gp.Role, - gp.MemberOf, - ao, - } - return nil - case roleDatabase: - gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant - - 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)))" - - q.Parameters = []interface{}{ - gp.Database, - gp.Role, - gro, - pq.Array(sp), - } - return nil + return nil +} + +func selectTableGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + + // Join grantee. Filter by schema name, table name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT COUNT(*) = $1 AS ct " + + "FROM (SELECT 1 FROM pg_class c " + + "INNER JOIN pg_namespace n ON c.relnamespace = n.oid, " + + "aclexplode(c.relacl) as acl " + + "INNER JOIN pg_roles s ON acl.grantee = s.oid " + + "WHERE c.relkind = 'r' " + + // Filter by table, schema, role and grantable setting + "AND n.nspname=$2 " + + "AND s.rolname=$3 " + + "AND c.relname = ANY($4) " + + "AND acl.is_grantable=$5 " + + "GROUP BY c.relname, n.nspname, 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($6::text[]) as perms ORDER BY perms ASC))" + + ") sub" + q.Parameters = []interface{}{ + len(gp.Tables), + gp.Schema, + gp.Role, + pq.Array(gp.Tables), + gro, + pq.Array(sp), + } + + return nil +} + +func selectSchemaGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.ExpandPrivileges() + sp := ep.ToStringSlice() + // Join grantee. Filter by schema name and grantee name. + // Finally, perform a permission comparison against expected + // permissions. + q.String = "SELECT EXISTS(SELECT 1 " + + "FROM pg_namespace n, " + + "aclexplode(nspacl) as acl " + + "INNER JOIN pg_roles s ON acl.grantee = s.oid " + + // Filter by schema, role and grantable setting + "WHERE n.nspname=$1 " + + "AND s.rolname=$2 " + + "AND acl.is_grantable=$3 " + + "GROUP BY n.nspname, 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)))" + q.Parameters = []interface{}{ + gp.Schema, + gp.Role, + gro, + pq.Array(sp), + } + return nil +} + +func selectDatabaseGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + gro := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionGrant + + ep := gp.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)))" + + q.Parameters = []interface{}{ + gp.Database, + gp.Role, + gro, + pq.Array(sp), + } + return nil +} + +func selectMemberGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { + ao := gp.WithOption != nil && *gp.WithOption == v1alpha1.GrantOptionAdmin + + // Always returns a row with a true or false value + // A simpler query would use ::regrol to cast the + // roleid and member oids to their role names, but + // if this is used with a nonexistent role name it will + // throw an error rather than return false. + q.String = "SELECT EXISTS(SELECT 1 FROM pg_auth_members m " + + "INNER JOIN pg_roles mo ON m.roleid = mo.oid " + + "INNER JOIN pg_roles r ON m.member = r.oid " + + "WHERE r.rolname=$1 AND mo.rolname=$2 AND " + + "m.admin_option = $3)" + + q.Parameters = []interface{}{ + gp.Role, + gp.MemberOf, + ao, } - return errors.New(errUnknownGrant) + return nil } func withOption(option *v1alpha1.GrantOption) string { @@ -236,8 +468,37 @@ func withOption(option *v1alpha1.GrantOption) string { return "" } +func selectGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { // nolint: gocyclo + gt, err := gp.IdentifyGrantType() + if err != nil { + return err + } + + switch gt { + case v1alpha1.RoleMember: + return selectMemberGrantQuery(gp, q) + case v1alpha1.RoleDatabase: + return selectDatabaseGrantQuery(gp, q) + case v1alpha1.RoleSchema: + return selectSchemaGrantQuery(gp, q) + case v1alpha1.RoleTable: + return selectTableGrantQuery(gp, q) + case v1alpha1.RoleSequence: + return selectSequenceGrantQuery(gp, q) + case v1alpha1.RoleRoutine: + return selectRoutineGrantQuery(gp, q) + case v1alpha1.RoleColumn: + return selectColumnGrantQuery(gp, q) + case v1alpha1.RoleForeignDataWrapper: + return selectForeignDataWrapperGrantQuery(gp, q) + case v1alpha1.RoleForeignServer: + return selectForeignServerGrantQuery(gp, q) + } + return errors.Errorf(errUnsupportedGrant, gt) +} + func createGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query) error { // nolint: gocyclo - gt, err := identifyGrantType(gp) + gt, err := gp.IdentifyGrantType() if err != nil { return err } @@ -245,59 +506,269 @@ func createGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query) error { / ro := pq.QuoteIdentifier(*gp.Role) switch gt { - case roleMember: - if gp.MemberOf == nil || gp.Role == nil { - return errors.Errorf(errInvalidParams, roleMember) - } + case v1alpha1.RoleMember: + return createMemberGrantQueries(gp, ql, ro) + case v1alpha1.RoleDatabase: + return createDatabaseGrantQueries(gp, ql, ro) + case v1alpha1.RoleSchema: + return createSchemaGrantQueries(gp, ql, ro) + case v1alpha1.RoleTable: + return createTableGrantQueries(gp, ql, ro) + case v1alpha1.RoleSequence: + return createSequenceGrantQueries(gp, ql, ro) + case v1alpha1.RoleRoutine: + return createRoutineGrantQueries(gp, ql, ro) + case v1alpha1.RoleColumn: + return createColumnGrantQueries(gp, ql, ro) + case v1alpha1.RoleForeignDataWrapper: + return createForeignDataWrapperGrantQueries(gp, ql, ro) + case v1alpha1.RoleForeignServer: + return createForeignServerGrantQueries(gp, ql, ro) + } + return errors.Errorf(errUnsupportedGrant, gt) +} - mo := pq.QuoteIdentifier(*gp.MemberOf) +func createMemberGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.MemberOf == nil || gp.Role == nil { + return errors.Errorf(errInvalidParams, v1alpha1.RoleMember) + } - *ql = append(*ql, - xsql.Query{String: fmt.Sprintf("REVOKE %s FROM %s", mo, ro)}, - xsql.Query{String: fmt.Sprintf("GRANT %s TO %s %s", mo, ro, - withOption(gp.WithOption), - )}, - ) - return nil - case roleDatabase: - if gp.Database == nil || gp.Role == nil || len(gp.Privileges) < 1 { - return errors.Errorf(errInvalidParams, roleDatabase) - } + mo := pq.QuoteIdentifier(*gp.MemberOf) - db := pq.QuoteIdentifier(*gp.Database) - sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + *ql = append(*ql, + xsql.Query{String: fmt.Sprintf("REVOKE %s FROM %s", mo, ro)}, + xsql.Query{String: fmt.Sprintf("GRANT %s TO %s %s", mo, ro, + withOption(gp.WithOption), + )}, + ) + return nil +} - *ql = append(*ql, - // REVOKE ANY MATCHING EXISTING PERMISSIONS - xsql.Query{String: fmt.Sprintf("REVOKE %s ON DATABASE %s FROM %s", - sp, - db, - ro, - )}, +func createDatabaseGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleDatabase) + } - // GRANT REQUESTED PERMISSIONS - xsql.Query{String: fmt.Sprintf("GRANT %s ON DATABASE %s TO %s %s", - sp, + db := pq.QuoteIdentifier(*gp.Database) + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON DATABASE %s FROM %s", + sp, + db, + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON DATABASE %s TO %s %s", + sp, + db, + ro, + withOption(gp.WithOption), + )}, + ) + if gp.RevokePublicOnDb != nil && *gp.RevokePublicOnDb { + *ql = append(*ql, + // REVOKE FROM PUBLIC + xsql.Query{String: fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM PUBLIC", db, - ro, - withOption(gp.WithOption), )}, ) - if gp.RevokePublicOnDb != nil && *gp.RevokePublicOnDb { - *ql = append(*ql, - // REVOKE FROM PUBLIC - xsql.Query{String: fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM PUBLIC", - db, - )}, - ) - } - return nil } - return errors.New(errUnknownGrant) + return nil +} + +func createSchemaGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || gp.Schema == nil || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleSchema) + } + + sh := pq.QuoteIdentifier(*gp.Schema) + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON SCHEMA %s FROM %s", + sp, + sh, + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON SCHEMA %s TO %s %s", + sp, + sh, + ro, + withOption(gp.WithOption), + )}, + ) + return nil +} + +func createTableGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || gp.Schema == nil || len(gp.Tables) < 1 || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleTable) + } + + tb := strings.Join(prefixAndQuote(*gp.Schema, gp.Tables), ",") + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON TABLE %s FROM %s", + sp, + tb, + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON TABLE %s TO %s %s", + sp, + tb, + ro, + withOption(gp.WithOption), + )}, + ) + return nil +} + +func createColumnGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || gp.Schema == nil || len(gp.Tables) < 1 || len(gp.Columns) < 1 || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleColumn) + } + + co := strings.Join(gp.Columns, ",") + cp := columnsPrivileges(gp.Privileges.ToStringSlice(), co) + tb := strings.Join(prefixAndQuote(*gp.Schema, gp.Tables), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON TABLE %s FROM %s", + cp, + tb, + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON TABLE %s TO %s %s", + cp, + tb, + ro, + withOption(gp.WithOption), + )}, + ) + return nil +} + +func createSequenceGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || gp.Schema == nil || len(gp.Sequences) < 1 || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleSequence) + } + + sq := strings.Join(prefixAndQuote(*gp.Schema, gp.Sequences), ",") + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON SEQUENCE %s FROM %s", + sp, + sq, + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON SEQUENCE %s TO %s %s", + sp, + sq, + ro, + withOption(gp.WithOption), + )}, + ) + return nil +} + +func createRoutineGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || gp.Schema == nil || len(gp.Routines) < 1 || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleRoutine) + } + + rt := strings.Join(quotedSignatures(*gp.Schema, gp.Routines), ",") + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON ROUTINE %s FROM %s", + sp, + rt, + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON ROUTINE %s TO %s %s", + sp, + rt, + ro, + withOption(gp.WithOption), + )}, + ) + return nil } -func deleteGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { - gt, err := identifyGrantType(gp) +func createForeignDataWrapperGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || len(gp.ForeignDataWrappers) < 1 || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleForeignDataWrapper) + } + + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON FOREIGN DATA WRAPPER %s FROM %s", + sp, + strings.Join(gp.ForeignDataWrappers, ","), + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON FOREIGN DATA WRAPPER %s TO %s %s", + sp, + strings.Join(gp.ForeignDataWrappers, ","), + ro, + withOption(gp.WithOption), + )}, + ) + return nil +} + +func createForeignServerGrantQueries(gp v1alpha1.GrantParameters, ql *[]xsql.Query, ro string) error { + if gp.Database == nil || len(gp.ForeignServers) < 1 || gp.Role == nil || len(gp.Privileges) < 1 { + return errors.Errorf(errInvalidParams, v1alpha1.RoleForeignServer) + } + + sp := strings.Join(gp.Privileges.ToStringSlice(), ",") + + *ql = append(*ql, + // REVOKE ANY MATCHING EXISTING PERMISSIONS + xsql.Query{String: fmt.Sprintf("REVOKE %s ON FOREIGN SERVER %s FROM %s", + sp, + strings.Join(gp.ForeignServers, ","), + ro, + )}, + + // GRANT REQUESTED PERMISSIONS + xsql.Query{String: fmt.Sprintf("GRANT %s ON FOREIGN SERVER %s TO %s %s", + sp, + strings.Join(gp.ForeignServers, ","), + ro, + withOption(gp.WithOption), + )}, + ) + return nil +} + +func deleteGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { // nolint: gocyclo + gt, err := gp.IdentifyGrantType() if err != nil { return err } @@ -305,21 +776,111 @@ func deleteGrantQuery(gp v1alpha1.GrantParameters, q *xsql.Query) error { ro := pq.QuoteIdentifier(*gp.Role) switch gt { - case roleMember: + case v1alpha1.RoleMember: q.String = fmt.Sprintf("REVOKE %s FROM %s", pq.QuoteIdentifier(*gp.MemberOf), ro, ) return nil - case roleDatabase: + case v1alpha1.RoleDatabase: q.String = fmt.Sprintf("REVOKE %s ON DATABASE %s FROM %s", strings.Join(gp.Privileges.ToStringSlice(), ","), pq.QuoteIdentifier(*gp.Database), ro, ) return nil + case v1alpha1.RoleSchema: + q.String = fmt.Sprintf("REVOKE %s ON SCHEMA %s FROM %s", + strings.Join(gp.Privileges.ToStringSlice(), ","), + pq.QuoteIdentifier(*gp.Schema), + ro, + ) + return nil + case v1alpha1.RoleTable: + q.String = fmt.Sprintf("REVOKE %s ON TABLE %s FROM %s", + strings.Join(gp.Privileges.ToStringSlice(), ","), + strings.Join(prefixAndQuote(*gp.Schema, gp.Tables), ","), + ro, + ) + return nil + case v1alpha1.RoleSequence: + q.String = fmt.Sprintf("REVOKE %s ON SEQUENCE %s FROM %s", + strings.Join(gp.Privileges.ToStringSlice(), ","), + strings.Join(prefixAndQuote(*gp.Schema, gp.Sequences), ","), + ro, + ) + return nil + case v1alpha1.RoleRoutine: + q.String = fmt.Sprintf("REVOKE %s ON ROUTINE %s FROM %s", + strings.Join(gp.Privileges.ToStringSlice(), ","), + strings.Join(quotedSignatures(*gp.Schema, gp.Routines), ","), + ro, + ) + return nil + case v1alpha1.RoleColumn: + co := strings.Join(gp.Columns, ",") + cp := columnsPrivileges(gp.Privileges.ToStringSlice(), co) + q.String = fmt.Sprintf("REVOKE %s ON TABLE %s FROM %s", + cp, + strings.Join(prefixAndQuote(*gp.Schema, gp.Tables), ","), + ro, + ) + return nil + case v1alpha1.RoleForeignDataWrapper: + q.String = fmt.Sprintf("REVOKE %s ON FOREIGN DATA WRAPPER %s FROM %s", + strings.Join(gp.Privileges.ToStringSlice(), ","), + strings.Join(gp.ForeignDataWrappers, ","), + ro, + ) + return nil + case v1alpha1.RoleForeignServer: + q.String = fmt.Sprintf("REVOKE %s ON FOREIGN SERVER %s FROM %s", + strings.Join(gp.Privileges.ToStringSlice(), ","), + strings.Join(gp.ForeignServers, ","), + ro, + ) + return nil + } + return errors.Errorf(errUnsupportedGrant, gt) +} + +// signature returns routines with the same format as the select query +func signature(r v1alpha1.Routine) string { + args := make([]string, len(r.Arguments)) + for i, v := range r.Arguments { + args[i] = strings.ToLower(v) } - return errors.New(errUnknownGrant) + return r.Name + "(" + strings.Join(args, ",") + ")" +} + +// quotedSignatures returns routines in a quoted grantable format, prefixed with the schema +func quotedSignatures(sc string, rs []v1alpha1.Routine) []string { + qsc := pq.QuoteIdentifier(sc) + sigs := make([]string, len(rs)) + + for i, r := range rs { + sigs[i] = qsc + "." + pq.QuoteIdentifier(r.Name) + "(" + strings.Join(r.Arguments, ",") + ")" + } + return sigs +} + +// prefixAndQuote returns objects in a quoted grantable format, prefixed with the schema +func prefixAndQuote(sc string, obj []string) []string { + qsc := pq.QuoteIdentifier(sc) + ret := make([]string, len(obj)) + for i, v := range obj { + ret[i] = qsc + "." + pq.QuoteIdentifier(v) + } + return ret +} + +// columnsPrivileges returns the privileges for columns in grant format +func columnsPrivileges(priv []string, cols string) string { + ret := make([]string, len(priv)) + for i, v := range priv { + ret[i] = v + "(" + cols + ")" + } + return strings.Join(ret, ",") } func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { @@ -334,7 +895,10 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex gp := cr.Spec.ForProvider var query xsql.Query - if err := selectGrantQuery(gp, &query); err != nil { + + err := selectGrantQuery(gp, &query) + + if err != nil { return managed.ExternalObservation{}, err } @@ -371,9 +935,7 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext if err := createGrantQueries(cr.Spec.ForProvider, &queries); err != nil { return managed.ExternalCreation{}, errors.Wrap(err, errCreateGrant) } - - err := c.db.ExecTx(ctx, queries) - return managed.ExternalCreation{}, errors.Wrap(err, errCreateGrant) + return managed.ExternalCreation{}, errors.Wrap(c.db.ExecTx(ctx, queries), errCreateGrant) } func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.ExternalUpdate, error) { @@ -387,12 +949,12 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) error { if !ok { return errors.New(errNotGrant) } + var query xsql.Query cr.SetConditions(xpv1.Deleting()) - err := deleteGrantQuery(cr.Spec.ForProvider, &query) - if err != nil { + if err := deleteGrantQuery(cr.Spec.ForProvider, &query); err != nil { return errors.Wrap(err, errRevokeGrant) } diff --git a/pkg/controller/postgresql/grant/reconciler_test.go b/pkg/controller/postgresql/grant/reconciler_test.go index 8ab046db..e2634488 100644 --- a/pkg/controller/postgresql/grant/reconciler_test.go +++ b/pkg/controller/postgresql/grant/reconciler_test.go @@ -220,6 +220,24 @@ func TestObserve(t *testing.T) { err: errors.New(errNotGrant), }, }, + "ErrBadGrant": { + reason: "An error should be returned if the managed resource has no identifiable grant type", + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Tables: []string{"test-example"}, + Role: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: errors.New(errUnknownGrant), + }, + }, "SuccessNoGrant": { reason: "We should return ResourceExists: false when no grant is found", fields: fields{ @@ -382,6 +400,235 @@ func TestObserve(t *testing.T) { err: nil, }, }, + "SuccessRoleSchema": { + reason: "We should return no error if we can find our role-schema grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + Schema: ptr.To("testschema"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, + "SuccessRoleTable": { + reason: "We should return no error if we can find our role-table grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + Schema: ptr.To("testschema"), + Tables: []string{"testtable"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, + "SuccessRoleColumn": { + reason: "We should return no error if we can find our role-column grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + Schema: ptr.To("testschema"), + Tables: []string{"testtable"}, + Columns: []string{"testcolumn"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, + "SuccessRoleSequence": { + reason: "We should return no error if we can find our role-sequence grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + Schema: ptr.To("testschema"), + Sequences: []string{"testsequence"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, + "SuccessRoleRoutine": { + reason: "We should return no error if we can find our role-routine grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + Schema: ptr.To("testschema"), + Routines: []v1alpha1.Routine{{Name: "testroutine", Arguments: []string{"text"}}}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, + "SuccessRoleForeingDataWrapper": { + reason: "We should return no error if we can find our role-foreign-data-wrapper grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + ForeignDataWrappers: []string{"testforeigndatawrapper"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, + "SuccessRoleForeignServer": { + reason: "We should return no error if we can find our role-foreign-server grant", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + bv := dest[0].(*bool) + *bv = true + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("testdb"), + Role: ptr.To("testrole"), + ForeignServers: []string{"testforeignserver"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + WithOption: &gog, + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, } for name, tc := range cases { @@ -400,6 +647,7 @@ func TestObserve(t *testing.T) { func TestCreate(t *testing.T) { errBoom := errors.New("boom") + goa := v1alpha1.GrantOptionAdmin type fields struct { db xsql.DB @@ -430,6 +678,24 @@ func TestCreate(t *testing.T) { err: errors.New(errNotGrant), }, }, + "ErrBadGrant": { + reason: "An error should be returned if the managed resource has no identifiable grant type", + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Tables: []string{"test-example"}, + Role: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errors.New(errUnknownGrant), errCreateGrant), + }, + }, "ErrExec": { reason: "Any errors encountered while creating the grant should be returned", fields: fields{ @@ -452,8 +718,148 @@ func TestCreate(t *testing.T) { err: errors.Wrap(errBoom, errCreateGrant), }, }, - "Success": { - reason: "No error should be returned when we successfully create a grant", + "RoleMembershipSuccess": { + reason: "No error should be returned when we successfully create a role-membership grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Role: ptr.To("testrole"), + MemberOf: ptr.To("parentrole"), + WithOption: &goa, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleDatabaseSuccess": { + reason: "No error should be returned when we successfully create a role-database grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleSchemaSuccess": { + reason: "No error should be returned when we successfully create a role-schema grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleTableSuccess": { + reason: "No error should be returned when we successfully create a role-table grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Tables: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleColumnSuccess": { + reason: "No error should be returned when we successfully create a role-column grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Tables: []string{"test-example"}, + Columns: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleSequenceSuccess": { + reason: "No error should be returned when we successfully create a role-sequence grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Sequences: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleRoutineSuccess": { + reason: "No error should be returned when we successfully create a role-routine grant", fields: fields{ db: &mockDB{ MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, @@ -465,6 +871,8 @@ func TestCreate(t *testing.T) { ForProvider: v1alpha1.GrantParameters{ Database: ptr.To("test-example"), Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Routines: []v1alpha1.Routine{{Name: "test-example", Arguments: []string{"test-example"}}}, Privileges: v1alpha1.GrantPrivileges{"ALL"}, }, }, @@ -474,6 +882,52 @@ func TestCreate(t *testing.T) { err: nil, }, }, + "RoleForeignDataWrapperSuccess": { + reason: "No error should be returned when we successfully create a role-foreign-data-wrapper grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + ForeignDataWrappers: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, + "RoleForeignServerSuccess": { + reason: "No error should be returned when we successfully create a role-foreign-server grant", + fields: fields{ + db: &mockDB{ + MockExecTx: func(ctx context.Context, ql []xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + ForeignServers: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: want{ + err: nil, + }, + }, } for name, tc := range cases { @@ -571,6 +1025,22 @@ func TestDelete(t *testing.T) { }, want: errors.New(errNotGrant), }, + "ErrBadGrant": { + reason: "An error should be returned if the managed resource has no identifiable grant type", + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Tables: []string{"test-example"}, + Role: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: errors.Wrap(errors.New(errUnknownGrant), errRevokeGrant), + }, "ErrDropGrant": { reason: "Errors dropping a grant should be returned", fields: fields{ @@ -593,24 +1063,176 @@ func TestDelete(t *testing.T) { }, want: errors.Wrap(errBoom, errRevokeGrant), }, - "Success": { - reason: "No error should be returned if the grant was revoked", + "RoleDatabaseSuccess": { + reason: "No error should be returned if the role-database grant was revoked", + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + want: nil, + }, + "RoleSchemaSuccess": { + reason: "No error should be returned if the role-schema grant was revoked", + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + want: nil, + }, + "RoleTableSuccess": { + reason: "No error should be returned if the role-table grant was revoked", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, args: args{ mg: &v1alpha1.Grant{ Spec: v1alpha1.GrantSpec{ ForProvider: v1alpha1.GrantParameters{ Database: ptr.To("test-example"), Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Tables: []string{"test-example"}, Privileges: v1alpha1.GrantPrivileges{"ALL"}, }, }, }, }, + want: nil, + }, + "RoleColumnSuccess": { + reason: "No error should be returned if the role-column grant was revoked", fields: fields{ db: &mockDB{ MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, }, }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Tables: []string{"test-example"}, + Columns: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: nil, + }, + "RoleSequenceSuccess": { + reason: "No error should be returned if the role-sequence grant was revoked", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Sequences: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: nil, + }, + "RoleRoutineSuccess": { + reason: "No error should be returned if the role-routine grant was revoked", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + Schema: ptr.To("test-example"), + Routines: []v1alpha1.Routine{{Name: "test-example", Arguments: []string{"test-example"}}}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: nil, + }, + "RoleForeignDataWrapperSuccess": { + reason: "No error should be returned if the role-foreign-data-wrapper grant was revoked", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + ForeignDataWrappers: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, + want: nil, + }, + "RoleForeignServerSuccess": { + reason: "No error should be returned if the role-foreign-server grant was revoked", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.Grant{ + Spec: v1alpha1.GrantSpec{ + ForProvider: v1alpha1.GrantParameters{ + Database: ptr.To("test-example"), + Role: ptr.To("test-example"), + ForeignServers: []string{"test-example"}, + Privileges: v1alpha1.GrantPrivileges{"ALL"}, + }, + }, + }, + }, want: nil, }, }