Skip to content

Commit 5981e8e

Browse files
jirwinlaurenleach
andauthored
Check and remove grants and role memberships before removing a role (#39)
* Check and remove grants and role memberships before removing a role * Remove type grants explicitly since postgres doesn't support revoking all types at once. * add period to comment * Better error handling * Attempt at tests for creating and deleting user * Don't hardcode entitlement in test * Pull resource id when fetching the new user id * Fix fetching user id * Fix flag in delete user command. * Return correct error --------- Co-authored-by: Lauren Leach <[email protected]>
1 parent 548a3a8 commit 5981e8e

File tree

2 files changed

+287
-11
lines changed

2 files changed

+287
-11
lines changed

.github/workflows/ci.yaml

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ jobs:
4747
POSTGRES_PASSWORD: secretpassword
4848
env:
4949
BATON_LOG_LEVEL: debug
50-
BATON_DSN: 'postgres://postgres:secretpassword@localhost:5432/postgres'
51-
CONNECTOR_GRANT: 'grant:entitlement:role:3375:member:role:10'
52-
CONNECTOR_ENTITLEMENT: 'entitlement:role:3375:member'
53-
CONNECTOR_PRINCIPAL: 'role:10'
54-
CONNECTOR_PRINCIPAL_TYPE: 'role'
50+
BATON_DSN: "postgres://postgres:secretpassword@localhost:5432/postgres"
51+
CONNECTOR_GRANT: "grant:entitlement:role:3375:member:role:10"
52+
CONNECTOR_ENTITLEMENT: "entitlement:role:3375:member"
53+
CONNECTOR_PRINCIPAL: "role:10"
54+
CONNECTOR_PRINCIPAL_TYPE: "role"
55+
CONNECTOR_NEW_USER: "testuser"
5556
steps:
5657
- name: Install Go
5758
uses: actions/setup-go@v5
@@ -63,7 +64,7 @@ jobs:
6364
run: sudo apt install postgresql-client
6465
# - name: Import sql into postgres
6566
# env:
66-
# PGPASSWORD: secretpassword
67+
# PGPASSWORD: secretpassword
6768
# run: psql -h localhost --user postgres -f test/ci.sql
6869
- name: Install baton
6970
run: ./scripts/get-baton.sh && mv baton /usr/local/bin
@@ -91,7 +92,30 @@ jobs:
9192
run: ./baton-postgresql && baton grants --entitlement "${{ env.CONNECTOR_ENTITLEMENT }}" --output-format=json | jq --exit-status ".grants[].principal.id.resource == \"${{ env.CONNECTOR_PRINCIPAL }}\""
9293

9394
- name: Create user
94-
run: ./baton-postgresql --create-account-login 'testuser'
95+
run: ./baton-postgresql --create-account-login "${{ env.CONNECTOR_NEW_USER }}"
96+
97+
- name: Check user was created
98+
run: ./baton-postgresql && baton resources -o json | jq -e --arg login "${{ env.CONNECTOR_NEW_USER }}" 'any(.resources[].resource.annotations[]?;.["@type"]=="type.googleapis.com/c1.connector.v2.UserTrait" and .login==$login)'
99+
100+
- name: Fetch user id
101+
shell: bash
102+
run: |
103+
set -eub pipefail
104+
NEW_USER_ID=$(baton resources -t role -o json | jq -r --arg login "${{ env.CONNECTOR_NEW_USER }}" '.resources[].resource | select(any(.annotations[]?; .["@type"]=="type.googleapis.com/c1.connector.v2.UserTrait" and .login==$login)) | .id.resource')
105+
echo "NEW_USER_ID=$NEW_USER_ID" >> "$GITHUB_ENV"
106+
107+
- name: Grant role to user
108+
run: ./baton-postgresql --grant-entitlement "${{ env.CONNECTOR_ENTITLEMENT }}" --grant-principal "${{ env.NEW_USER_ID }}" --grant-principal-type "${{ env.CONNECTOR_PRINCIPAL_TYPE }}"
109+
110+
- name: Check role was granted
111+
run: ./baton-postgresql && baton grants --entitlement "${{ env.CONNECTOR_ENTITLEMENT }}" -o json | jq -e --arg login "${{ env.CONNECTOR_NEW_USER }}" 'any(.grants[]?; any(.principal.annotations[]?; .["@type"]=="type.googleapis.com/c1.connector.v2.UserTrait" and .login==$login) or any(.grant.principal.annotations[]?; .["@type"]=="type.googleapis.com/c1.connector.v2.UserTrait" and .login==$login))'
112+
113+
- name: Delete user
114+
run: ./baton-postgresql --delete-resource "${{ env.NEW_USER_ID }}" --delete-resource-type "${{ env.CONNECTOR_PRINCIPAL_TYPE }}"
115+
116+
- name: Check user was deleted
117+
run: ./baton-postgresql && baton resources -o json | jq -e --arg login "${{ env.CONNECTOR_NEW_USER }}" 'any(.resources[].resource.annotations[]?;.["@type"]=="type.googleapis.com/c1.connector.v2.UserTrait" and .login==$login) | not'
118+
95119
# TODO: get correct role id using baton CLI
96120
# - name: Rotate credentials for user
97121
# run: ./baton-postgresql --rotate-credentials 'role:16384' --rotate-credentials-type 'role'

pkg/postgres/roles.go

Lines changed: 256 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import (
1414
"go.uber.org/zap"
1515
)
1616

17+
var errRevokeGrantsFromRole = errors.New("error revoking grants from role")
18+
var errRevokeParentRolesFromRole = errors.New("error revoking parent roles from role")
19+
1720
type RoleModel struct {
1821
ID int64 `db:"oid"`
1922
Name string `db:"rolname"`
@@ -157,19 +160,268 @@ func (c *Client) CreateRole(ctx context.Context, roleName string) error {
157160
return err
158161
}
159162

160-
func (c *Client) DeleteRole(ctx context.Context, roleName string) error {
163+
// RoleOwnsObjects checks if a role owns any database objects.
164+
func (c *Client) RoleOwnsObjects(ctx context.Context, roleName string) (bool, error) {
165+
l := ctxzap.Extract(ctx)
166+
167+
query := `
168+
SELECT EXISTS(
169+
SELECT 1 FROM (
170+
-- Check for owned schemas
171+
SELECT 1 FROM pg_namespace WHERE nspowner = (SELECT oid FROM pg_roles WHERE rolname = $1)
172+
UNION ALL
173+
-- Check for owned tables
174+
SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1)
175+
UNION ALL
176+
-- Check for owned functions
177+
SELECT 1 FROM pg_proc WHERE proowner = (SELECT oid FROM pg_roles WHERE rolname = $1)
178+
UNION ALL
179+
-- Check for owned sequences
180+
SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) AND relkind = 'S'
181+
UNION ALL
182+
-- Check for owned views
183+
SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) AND relkind = 'v'
184+
UNION ALL
185+
-- Check for owned types
186+
SELECT 1 FROM pg_type WHERE typowner = (SELECT oid FROM pg_roles WHERE rolname = $1)
187+
UNION ALL
188+
-- Check for owned databases
189+
SELECT 1 FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1)
190+
) owned_objects
191+
)`
192+
193+
var ownsObjects bool
194+
err := c.db.QueryRow(ctx, query, roleName).Scan(&ownsObjects)
195+
if err != nil {
196+
l.Error("error checking if role owns objects", zap.Error(err))
197+
return false, err
198+
}
199+
200+
return ownsObjects, nil
201+
}
202+
203+
// RevokeAllGrantsFromRole revokes all grants from a role across all schemas.
204+
func (c *Client) RevokeAllGrantsFromRole(ctx context.Context, roleName string) error {
205+
l := ctxzap.Extract(ctx)
206+
207+
sanitizedRoleName := pgx.Identifier{roleName}.Sanitize()
208+
209+
schemasQuery := `
210+
SELECT nspname
211+
FROM pg_namespace
212+
WHERE nspname NOT LIKE 'pg_%'
213+
AND nspname != 'information_schema'
214+
ORDER BY nspname`
215+
216+
rows, err := c.db.Query(ctx, schemasQuery)
217+
if err != nil {
218+
l.Error("error querying schemas", zap.Error(err))
219+
return err
220+
}
221+
defer rows.Close()
222+
223+
var schemas []string
224+
for rows.Next() {
225+
var schemaName string
226+
if err := rows.Scan(&schemaName); err != nil {
227+
l.Error("error scanning schema name", zap.Error(err))
228+
return err
229+
}
230+
schemas = append(schemas, schemaName)
231+
}
232+
233+
if err := rows.Err(); err != nil {
234+
l.Error("error iterating schemas", zap.Error(err))
235+
return err
236+
}
237+
238+
var revokeError error
239+
for _, schema := range schemas {
240+
sanitizedSchema := pgx.Identifier{schema}.Sanitize()
241+
242+
revokeTablesQuery := fmt.Sprintf("REVOKE ALL ON ALL TABLES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
243+
l.Debug("revoking table grants", zap.String("query", revokeTablesQuery))
244+
if _, err := c.db.Exec(ctx, revokeTablesQuery); err != nil {
245+
l.Warn("error revoking table grants", zap.String("schema", schema), zap.Error(err))
246+
revokeError = errors.Join(revokeError, err)
247+
}
248+
249+
revokeSequencesQuery := fmt.Sprintf("REVOKE ALL ON ALL SEQUENCES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
250+
l.Debug("revoking sequence grants", zap.String("query", revokeSequencesQuery))
251+
if _, err := c.db.Exec(ctx, revokeSequencesQuery); err != nil {
252+
l.Warn("error revoking sequence grants", zap.String("schema", schema), zap.Error(err))
253+
revokeError = errors.Join(revokeError, err)
254+
}
255+
256+
revokeFunctionsQuery := fmt.Sprintf("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
257+
l.Debug("revoking function grants", zap.String("query", revokeFunctionsQuery))
258+
if _, err := c.db.Exec(ctx, revokeFunctionsQuery); err != nil {
259+
l.Warn("error revoking function grants", zap.String("schema", schema), zap.Error(err))
260+
revokeError = errors.Join(revokeError, err)
261+
}
262+
263+
typesQuery := `
264+
SELECT typname
265+
FROM pg_type t
266+
JOIN pg_namespace n ON t.typnamespace = n.oid
267+
WHERE n.nspname = $1
268+
AND t.typtype = 'c'`
269+
270+
typeRows, err := c.db.Query(ctx, typesQuery, schema)
271+
if err != nil {
272+
l.Warn("error querying types", zap.String("schema", schema), zap.Error(err))
273+
revokeError = errors.Join(revokeError, err)
274+
} else {
275+
defer typeRows.Close()
276+
277+
for typeRows.Next() {
278+
var typeName string
279+
if err := typeRows.Scan(&typeName); err != nil {
280+
l.Warn("error scanning type name", zap.String("schema", schema), zap.Error(err))
281+
revokeError = errors.Join(revokeError, err)
282+
continue
283+
}
284+
285+
sanitizedTypeName := pgx.Identifier{schema, typeName}.Sanitize()
286+
revokeTypeQuery := fmt.Sprintf("REVOKE ALL ON TYPE %s FROM %s", sanitizedTypeName, sanitizedRoleName)
287+
l.Debug("revoking type grants", zap.String("query", revokeTypeQuery))
288+
if _, err := c.db.Exec(ctx, revokeTypeQuery); err != nil {
289+
l.Warn("error revoking type grants", zap.String("schema", schema), zap.String("type", typeName), zap.Error(err))
290+
revokeError = errors.Join(revokeError, err)
291+
}
292+
}
293+
}
294+
295+
revokeSchemaQuery := fmt.Sprintf("REVOKE ALL ON SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
296+
l.Debug("revoking schema grants", zap.String("query", revokeSchemaQuery))
297+
if _, err := c.db.Exec(ctx, revokeSchemaQuery); err != nil {
298+
l.Warn("error revoking schema grants", zap.String("schema", schema), zap.Error(err))
299+
revokeError = errors.Join(revokeError, err)
300+
}
301+
}
302+
303+
revokeDbQuery := fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM %s", pgx.Identifier{c.DatabaseName()}.Sanitize(), sanitizedRoleName)
304+
l.Debug("revoking database grants", zap.String("query", revokeDbQuery))
305+
if _, err := c.db.Exec(ctx, revokeDbQuery); err != nil {
306+
l.Warn("error revoking database grants", zap.Error(err))
307+
revokeError = errors.Join(revokeError, err)
308+
}
309+
310+
if revokeError != nil {
311+
return errors.Join(errRevokeGrantsFromRole, revokeError)
312+
}
313+
314+
return nil
315+
}
316+
317+
// RemoveRoleFromAllRoles removes a role from all other roles.
318+
func (c *Client) RemoveRoleFromAllRoles(ctx context.Context, roleName string) error {
319+
l := ctxzap.Extract(ctx)
320+
321+
sanitizedRoleName := pgx.Identifier{roleName}.Sanitize()
322+
323+
// Get all roles that have this role as a member
324+
query := `
325+
SELECT r.rolname
326+
FROM pg_roles r
327+
JOIN pg_auth_members am ON r.oid = am.roleid
328+
JOIN pg_roles member ON am.member = member.oid
329+
WHERE member.rolname = $1`
330+
331+
rows, err := c.db.Query(ctx, query, roleName)
332+
if err != nil {
333+
l.Error("error querying role memberships", zap.Error(err))
334+
return err
335+
}
336+
defer rows.Close()
337+
338+
var parentRoles []string
339+
for rows.Next() {
340+
var parentRole string
341+
if err := rows.Scan(&parentRole); err != nil {
342+
l.Error("error scanning parent role", zap.Error(err))
343+
return err
344+
}
345+
parentRoles = append(parentRoles, parentRole)
346+
}
347+
348+
if err := rows.Err(); err != nil {
349+
l.Error("error iterating parent roles", zap.Error(err))
350+
return err
351+
}
352+
353+
var revokeError error
354+
// Remove the role from each parent role
355+
for _, parentRole := range parentRoles {
356+
sanitizedParentRole := pgx.Identifier{parentRole}.Sanitize()
357+
revokeQuery := fmt.Sprintf("REVOKE %s FROM %s", sanitizedParentRole, sanitizedRoleName)
358+
359+
l.Debug("removing role from parent role", zap.String("query", revokeQuery))
360+
if _, err := c.db.Exec(ctx, revokeQuery); err != nil {
361+
l.Error("error removing role from parent role", zap.String("parent_role", parentRole), zap.Error(err))
362+
revokeError = errors.Join(revokeError, fmt.Errorf("error removing role from %s role: %w", parentRole, err))
363+
}
364+
}
365+
366+
if revokeError != nil {
367+
return errors.Join(errRevokeParentRolesFromRole, revokeError)
368+
}
369+
370+
return nil
371+
}
372+
373+
// SafeDeleteRole safely deletes a role by first revoking grants and removing memberships.
374+
func (c *Client) SafeDeleteRole(ctx context.Context, roleName string) error {
161375
l := ctxzap.Extract(ctx)
162376

163377
if roleName == "" {
164378
return errors.New("role name cannot be empty")
165379
}
166380

381+
ownsObjects, err := c.RoleOwnsObjects(ctx, roleName)
382+
if err != nil {
383+
l.Error("error checking if role owns objects", zap.Error(err))
384+
return err
385+
}
386+
387+
if ownsObjects {
388+
return fmt.Errorf("cannot delete role '%s': role owns database objects (tables, schemas, functions, etc.). Please transfer ownership or drop objects first", roleName)
389+
}
390+
391+
l.Debug("revoking all grants from role", zap.String("role", roleName))
392+
grantsRevokeError := c.RevokeAllGrantsFromRole(ctx, roleName)
393+
if grantsRevokeError != nil {
394+
l.Error("error revoking grants from role", zap.Error(grantsRevokeError))
395+
if !errors.Is(grantsRevokeError, errRevokeGrantsFromRole) {
396+
return fmt.Errorf("error revoking existing grants from role: %w", grantsRevokeError)
397+
}
398+
}
399+
400+
l.Debug("removing role from all parent roles", zap.String("role", roleName))
401+
roleRevokeError := c.RemoveRoleFromAllRoles(ctx, roleName)
402+
if roleRevokeError != nil {
403+
l.Error("error removing role from parent roles", zap.Error(roleRevokeError))
404+
if !errors.Is(roleRevokeError, errRevokeParentRolesFromRole) {
405+
return fmt.Errorf("error removing role from parent roles: %w", roleRevokeError)
406+
}
407+
}
408+
167409
sanitizedRoleName := pgx.Identifier{roleName}.Sanitize()
168410
query := "DROP ROLE " + sanitizedRoleName
411+
l.Debug("dropping role", zap.String("query", query))
412+
_, err = c.db.Exec(ctx, query)
413+
if err != nil {
414+
l.Error("error dropping role", zap.Error(err))
415+
finalError := errors.Join(err, roleRevokeError, grantsRevokeError)
416+
return fmt.Errorf("error dropping role(%s): %w", roleName, finalError)
417+
}
169418

170-
l.Debug("deleting role", zap.String("query", query))
171-
_, err := c.db.Exec(ctx, query)
172-
return err
419+
l.Info("successfully deleted role", zap.String("role", roleName))
420+
return nil
421+
}
422+
423+
func (c *Client) DeleteRole(ctx context.Context, roleName string) error {
424+
return c.SafeDeleteRole(ctx, roleName)
173425
}
174426

175427
func (c *Client) CreateUser(ctx context.Context, login string, password string) (*RoleModel, error) {

0 commit comments

Comments
 (0)