Skip to content

Commit a77d5df

Browse files
authored
reverse membership for additional owner roles (zalando#1862)
* reverse membership for additional owner roles * remove type RoleOriginSpilo * use e2e images with cron_admin inside * let operator resolve reversed membership * make additional owner roles part of the sync user strategy * add more context in the docs about additional_owner_roles
1 parent 9eb7517 commit a77d5df

File tree

7 files changed

+97
-72
lines changed

7 files changed

+97
-72
lines changed

docs/reference/operator_parameters.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,18 @@ under the `users` key.
178178
`standby`.
179179

180180
* **additional_owner_roles**
181-
Specifies database roles that will become members of all database owners.
182-
Then owners can use `SET ROLE` to obtain privileges of these roles to e.g.
183-
create/update functionality from extensions as part of a migration script.
184-
Note, that roles listed here should be preconfigured in the docker image
185-
and already exist in the database cluster on startup. One such role can be
186-
`cron_admin` which is provided by the Spilo docker image to set up cron
187-
jobs inside the `postgres` database. Default is `empty`.
181+
Specifies database roles that will be granted to all database owners. Owners
182+
can then use `SET ROLE` to obtain privileges of these roles to e.g. create
183+
or update functionality from extensions as part of a migration script. One
184+
such role can be `cron_admin` which is provided by the Spilo docker image to
185+
set up cron jobs inside the `postgres` database. In general, roles listed
186+
here should be preconfigured in the docker image and already exist in the
187+
database cluster on startup. Otherwise, syncing roles will return an error
188+
on each cluster sync process. Alternatively, you have to create the role and
189+
do the GRANT manually. Note, the operator will not allow additional owner
190+
roles to be members of database owners because it should be vice versa. If
191+
the operator cannot set up the correct membership it tries to revoke all
192+
additional owner roles from database owners. Default is `empty`.
188193

189194
* **enable_password_rotation**
190195
For all `LOGIN` roles that are not database owners the operator can rotate

e2e/tests/test_e2e.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from tests.k8s_api import K8s
1313
from kubernetes.client.rest import ApiException
1414

15-
SPILO_CURRENT = "registry.opensource.zalan.do/acid/spilo-14-e2e:0.1"
16-
SPILO_LAZY = "registry.opensource.zalan.do/acid/spilo-14-e2e:0.2"
15+
SPILO_CURRENT = "registry.opensource.zalan.do/acid/spilo-14-e2e:0.3"
16+
SPILO_LAZY = "registry.opensource.zalan.do/acid/spilo-14-e2e:0.4"
1717

1818

1919
def to_selector(labels):
@@ -161,10 +161,21 @@ def setUpClass(cls):
161161
@timeout_decorator.timeout(TEST_TIMEOUT_SEC)
162162
def test_additional_owner_roles(self):
163163
'''
164-
Test adding additional member roles to existing database owner roles
164+
Test granting additional roles to existing database owners
165165
'''
166166
k8s = self.k8s
167167

168+
# first test - wait for the operator to get in sync and set everything up
169+
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"},
170+
"Operator does not get in sync")
171+
leader = k8s.get_cluster_leader_pod()
172+
173+
# produce wrong membership for cron_admin
174+
grant_dbowner = """
175+
GRANT bar_owner TO cron_admin;
176+
"""
177+
self.query_database(leader.metadata.name, "postgres", grant_dbowner)
178+
168179
# enable PostgresTeam CRD and lower resync
169180
owner_roles = {
170181
"data": {
@@ -175,16 +186,15 @@ def test_additional_owner_roles(self):
175186
self.eventuallyEqual(lambda: k8s.get_operator_state(), {"0": "idle"},
176187
"Operator does not get in sync")
177188

178-
leader = k8s.get_cluster_leader_pod()
179189
owner_query = """
180190
SELECT a2.rolname
181191
FROM pg_catalog.pg_authid a
182192
JOIN pg_catalog.pg_auth_members am
183193
ON a.oid = am.member
184-
AND a.rolname = 'cron_admin'
194+
AND a.rolname IN ('zalando', 'bar_owner', 'bar_data_owner')
185195
JOIN pg_catalog.pg_authid a2
186196
ON a2.oid = am.roleid
187-
WHERE a2.rolname IN ('zalando', 'bar_owner', 'bar_data_owner');
197+
WHERE a2.rolname = 'cron_admin';
188198
"""
189199
self.eventuallyEqual(lambda: len(self.query_database(leader.metadata.name, "postgres", owner_query)), 3,
190200
"Not all additional users found in database", 10, 5)

pkg/cluster/cluster.go

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,10 @@ func New(cfg Config, kubeClient k8sutil.KubernetesClient, pgSpec acidv1.Postgres
133133
Services: make(map[PostgresRole]*v1.Service),
134134
Endpoints: make(map[PostgresRole]*v1.Endpoints)},
135135
userSyncStrategy: users.DefaultUserSyncStrategy{
136-
PasswordEncryption: passwordEncryption,
137-
RoleDeletionSuffix: cfg.OpConfig.RoleDeletionSuffix},
136+
PasswordEncryption: passwordEncryption,
137+
RoleDeletionSuffix: cfg.OpConfig.RoleDeletionSuffix,
138+
AdditionalOwnerRoles: cfg.OpConfig.AdditionalOwnerRoles,
139+
},
138140
deleteOptions: metav1.DeleteOptions{PropagationPolicy: &deletePropagationPolicy},
139141
podEventsQueue: podEventsQueue,
140142
KubeClient: kubeClient,
@@ -1308,28 +1310,15 @@ func (c *Cluster) initRobotUsers() error {
13081310
}
13091311

13101312
func (c *Cluster) initAdditionalOwnerRoles() {
1311-
for _, additionalOwner := range c.OpConfig.AdditionalOwnerRoles {
1312-
// fetch all database owners the additional should become a member of
1313-
memberOf := make([]string, 0)
1314-
for username, pgUser := range c.pgUsers {
1315-
if pgUser.IsDbOwner {
1316-
memberOf = append(memberOf, username)
1317-
}
1318-
}
1313+
if len(c.OpConfig.AdditionalOwnerRoles) == 0 {
1314+
return
1315+
}
13191316

1320-
if len(memberOf) > 0 {
1321-
namespace := c.Namespace
1322-
additionalOwnerPgUser := spec.PgUser{
1323-
Origin: spec.RoleOriginSpilo,
1324-
MemberOf: memberOf,
1325-
Name: additionalOwner,
1326-
Namespace: namespace,
1327-
}
1328-
if currentRole, present := c.pgUsers[additionalOwner]; present {
1329-
c.pgUsers[additionalOwner] = c.resolveNameConflict(&currentRole, &additionalOwnerPgUser)
1330-
} else {
1331-
c.pgUsers[additionalOwner] = additionalOwnerPgUser
1332-
}
1317+
// fetch database owners and assign additional owner roles
1318+
for username, pgUser := range c.pgUsers {
1319+
if pgUser.IsDbOwner {
1320+
pgUser.MemberOf = append(pgUser.MemberOf, c.OpConfig.AdditionalOwnerRoles...)
1321+
c.pgUsers[username] = pgUser
13331322
}
13341323
}
13351324
}

pkg/cluster/cluster_test.go

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,9 @@ func TestInitAdditionalOwnerRoles(t *testing.T) {
148148

149149
manifestUsers := map[string]acidv1.UserFlags{"foo_owner": {}, "bar_owner": {}, "app_user": {}}
150150
expectedUsers := map[string]spec.PgUser{
151-
"foo_owner": {Origin: spec.RoleOriginManifest, Name: "foo_owner", Namespace: cl.Namespace, Password: "f123", Flags: []string{"LOGIN"}, IsDbOwner: true},
152-
"bar_owner": {Origin: spec.RoleOriginManifest, Name: "bar_owner", Namespace: cl.Namespace, Password: "b123", Flags: []string{"LOGIN"}, IsDbOwner: true},
153-
"app_user": {Origin: spec.RoleOriginManifest, Name: "app_user", Namespace: cl.Namespace, Password: "a123", Flags: []string{"LOGIN"}, IsDbOwner: false},
154-
"cron_admin": {Origin: spec.RoleOriginSpilo, Name: "cron_admin", Namespace: cl.Namespace, MemberOf: []string{"foo_owner", "bar_owner"}},
155-
"part_man": {Origin: spec.RoleOriginSpilo, Name: "part_man", Namespace: cl.Namespace, MemberOf: []string{"foo_owner", "bar_owner"}},
151+
"foo_owner": {Origin: spec.RoleOriginManifest, Name: "foo_owner", Namespace: cl.Namespace, Password: "f123", Flags: []string{"LOGIN"}, IsDbOwner: true, MemberOf: []string{"cron_admin", "part_man"}},
152+
"bar_owner": {Origin: spec.RoleOriginManifest, Name: "bar_owner", Namespace: cl.Namespace, Password: "b123", Flags: []string{"LOGIN"}, IsDbOwner: true, MemberOf: []string{"cron_admin", "part_man"}},
153+
"app_user": {Origin: spec.RoleOriginManifest, Name: "app_user", Namespace: cl.Namespace, Password: "a123", Flags: []string{"LOGIN"}, IsDbOwner: false},
156154
}
157155

158156
cl.Spec.Databases = map[string]string{"foo_db": "foo_owner", "bar_db": "bar_owner"}
@@ -163,24 +161,15 @@ func TestInitAdditionalOwnerRoles(t *testing.T) {
163161
t.Errorf("%s could not init manifest users", testName)
164162
}
165163

166-
// update passwords to compare with result
167-
for manifestUser := range manifestUsers {
168-
pgUser := cl.pgUsers[manifestUser]
169-
pgUser.Password = manifestUser[0:1] + "123"
170-
cl.pgUsers[manifestUser] = pgUser
171-
}
172-
164+
// now assign additional roles to owners
173165
cl.initAdditionalOwnerRoles()
174166

175-
for _, additionalOwnerRole := range cl.Config.OpConfig.AdditionalOwnerRoles {
176-
expectedPgUser := expectedUsers[additionalOwnerRole]
177-
existingPgUser, exists := cl.pgUsers[additionalOwnerRole]
178-
if !exists {
179-
t.Errorf("%s additional owner role %q not initilaized", testName, additionalOwnerRole)
180-
}
167+
// update passwords to compare with result
168+
for username, existingPgUser := range cl.pgUsers {
169+
expectedPgUser := expectedUsers[username]
181170
if !util.IsEqualIgnoreOrder(expectedPgUser.MemberOf, existingPgUser.MemberOf) {
182-
t.Errorf("%s unexpected membership of additional owner role %q: expected member of %#v, got member of %#v",
183-
testName, additionalOwnerRole, expectedPgUser.MemberOf, existingPgUser.MemberOf)
171+
t.Errorf("%s unexpected membership of user %q: expected member of %#v, got member of %#v",
172+
testName, username, expectedPgUser.MemberOf, existingPgUser.MemberOf)
184173
}
185174
}
186175
}

pkg/cluster/k8sres.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1650,7 +1650,7 @@ func (c *Cluster) generateUserSecrets() map[string]*v1.Secret {
16501650
func (c *Cluster) generateSingleUserSecret(namespace string, pgUser spec.PgUser) *v1.Secret {
16511651
//Skip users with no password i.e. human users (they'll be authenticated using pam)
16521652
if pgUser.Password == "" {
1653-
if pgUser.Origin != spec.RoleOriginTeamsAPI && pgUser.Origin != spec.RoleOriginSpilo {
1653+
if pgUser.Origin != spec.RoleOriginTeamsAPI {
16541654
c.logger.Warningf("could not generate secret for a non-teamsAPI role %q: role has no password",
16551655
pgUser.Name)
16561656
}

pkg/spec/types.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ const (
3030
RoleOriginManifest
3131
RoleOriginInfrastructure
3232
RoleOriginTeamsAPI
33-
RoleOriginSpilo
3433
RoleOriginSystem
3534
RoleOriginBootstrap
3635
RoleConnectionPooler

pkg/util/users/users.go

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const (
2020
alterRoleSetSQL = `ALTER ROLE "%s" SET %s TO %s`
2121
dropUserSQL = `SET LOCAL synchronous_commit = 'local'; DROP ROLE "%s";`
2222
grantToUserSQL = `GRANT %s TO "%s"`
23+
revokeFromUserSQL = `REVOKE %s FROM %s`
2324
doBlockStmt = `SET LOCAL synchronous_commit = 'local'; DO $$ BEGIN %s; END;$$;`
2425
passwordTemplate = "ENCRYPTED PASSWORD '%s'"
2526
inRoleTemplate = `IN ROLE %s`
@@ -31,8 +32,9 @@ const (
3132
// an existing roles of another role membership, nor it removes the already assigned flag
3233
// (except for the NOLOGIN). TODO: process other NOflags, i.e. NOSUPERUSER correctly.
3334
type DefaultUserSyncStrategy struct {
34-
PasswordEncryption string
35-
RoleDeletionSuffix string
35+
PasswordEncryption string
36+
RoleDeletionSuffix string
37+
AdditionalOwnerRoles []string
3638
}
3739

3840
// ProduceSyncRequests figures out the types of changes that need to happen with the given users.
@@ -53,30 +55,27 @@ func (strategy DefaultUserSyncStrategy) ProduceSyncRequests(dbUsers spec.PgUserM
5355
}
5456
} else {
5557
r := spec.PgSyncUserRequest{}
56-
r.User = dbUser
5758
newMD5Password := util.NewEncryptor(strategy.PasswordEncryption).PGUserPassword(newUser)
5859

5960
// do not compare for roles coming from docker image
60-
if newUser.Origin != spec.RoleOriginSpilo {
61-
if dbUser.Password != newMD5Password {
62-
r.User.Password = newMD5Password
63-
r.Kind = spec.PGsyncUserAlter
64-
}
65-
if addNewFlags, equal := util.SubstractStringSlices(newUser.Flags, dbUser.Flags); !equal {
66-
r.User.Flags = addNewFlags
67-
r.Kind = spec.PGsyncUserAlter
68-
}
61+
if dbUser.Password != newMD5Password {
62+
r.User.Password = newMD5Password
63+
r.Kind = spec.PGsyncUserAlter
6964
}
7065
if addNewRoles, equal := util.SubstractStringSlices(newUser.MemberOf, dbUser.MemberOf); !equal {
7166
r.User.MemberOf = addNewRoles
67+
r.User.IsDbOwner = newUser.IsDbOwner
68+
r.Kind = spec.PGsyncUserAlter
69+
}
70+
if addNewFlags, equal := util.SubstractStringSlices(newUser.Flags, dbUser.Flags); !equal {
71+
r.User.Flags = addNewFlags
7272
r.Kind = spec.PGsyncUserAlter
7373
}
7474
if r.Kind == spec.PGsyncUserAlter {
7575
r.User.Name = newUser.Name
7676
reqs = append(reqs, r)
7777
}
78-
if newUser.Origin != spec.RoleOriginSpilo &&
79-
len(newUser.Parameters) > 0 &&
78+
if len(newUser.Parameters) > 0 &&
8079
!reflect.DeepEqual(dbUser.Parameters, newUser.Parameters) {
8180
reqs = append(reqs, spec.PgSyncUserRequest{Kind: spec.PGSyncAlterSet, User: newUser})
8281
}
@@ -120,6 +119,15 @@ func (strategy DefaultUserSyncStrategy) ExecuteSyncRequests(requests []spec.PgSy
120119
if err := strategy.alterPgUser(request.User, db); err != nil {
121120
reqretries = append(reqretries, request)
122121
errors = append(errors, fmt.Sprintf("could not alter user %q: %v", request.User.Name, err))
122+
// XXX: we do not allow additional owner roles to be members of database owners
123+
// if ALTER fails it could be because of the wrong memberhip (check #1862 for details)
124+
// so in any case try to revoke the database owner from the additional owner roles
125+
// the initial ALTER statement will be retried once and should work then
126+
if request.User.IsDbOwner && len(strategy.AdditionalOwnerRoles) > 0 {
127+
if err := resolveOwnerMembership(request.User, strategy.AdditionalOwnerRoles, db); err != nil {
128+
errors = append(errors, fmt.Sprintf("could not resolve owner membership for %q: %v", request.User.Name, err))
129+
}
130+
}
123131
}
124132
case spec.PGSyncAlterSet:
125133
if err := strategy.alterPgUserSet(request.User, db); err != nil {
@@ -152,6 +160,21 @@ func (strategy DefaultUserSyncStrategy) ExecuteSyncRequests(requests []spec.PgSy
152160
return nil
153161
}
154162

163+
func resolveOwnerMembership(dbOwner spec.PgUser, additionalOwners []string, db *sql.DB) error {
164+
errors := make([]string, 0)
165+
for _, additionalOwner := range additionalOwners {
166+
if err := revokeRole(dbOwner.Name, additionalOwner, db); err != nil {
167+
errors = append(errors, fmt.Sprintf("could not revoke %q from %q: %v", dbOwner.Name, additionalOwner, err))
168+
}
169+
}
170+
171+
if len(errors) > 0 {
172+
return fmt.Errorf("could not resolve membership between %q and additional owner roles: %v", dbOwner.Name, strings.Join(errors, `', '`))
173+
}
174+
175+
return nil
176+
}
177+
155178
func (strategy DefaultUserSyncStrategy) alterPgUserSet(user spec.PgUser, db *sql.DB) error {
156179
queries := produceAlterRoleSetStmts(user)
157180
query := fmt.Sprintf(doBlockStmt, strings.Join(queries, ";"))
@@ -272,6 +295,16 @@ func quoteMemberList(user spec.PgUser) string {
272295
return strings.Join(memberof, ",")
273296
}
274297

298+
func revokeRole(groupRole, role string, db *sql.DB) error {
299+
revokeStmt := fmt.Sprintf(revokeFromUserSQL, groupRole, role)
300+
301+
if _, err := db.Exec(fmt.Sprintf(doBlockStmt, revokeStmt)); err != nil {
302+
return err
303+
}
304+
305+
return nil
306+
}
307+
275308
// quoteVal quotes values to be used at ALTER ROLE SET param = value if necessary
276309
func quoteParameterValue(name, val string) string {
277310
start := val[0]

0 commit comments

Comments
 (0)