diff --git a/docs/administrator.md b/docs/administrator.md index a7dff68ef..1b360cd00 100644 --- a/docs/administrator.md +++ b/docs/administrator.md @@ -208,3 +208,15 @@ generated from the current cluster manifest. There are two types of scans: a `sync scan`, running every `resync_period` seconds for every cluster, and the `repair scan`, coming every `repair_period` only for those clusters that didn't report success as a result of the last operation applied to them. + +## Postgres roles supported by the operator + +The operator is capable of maintaining roles of multiple kinds within a Postgres database cluster: + +1. **System roles** are roles necessary for the proper work of Postgres itself such as a replication role or the initial superuser role. The operator delegates creating such roles to Patroni and only establishes relevant secrets. + +2. **Infrastructure roles** are roles for processes originating from external systems, e.g. monitoring robots. The operator creates such roles in all PG clusters it manages assuming k8s secrets with the relevant credentials exist beforehand. + +3. **Per-cluster robot users** are also roles for processes originating from external systems but defined for an individual Postgres cluster in its manifest. A typical example is a role for connections from an application that uses the database. + +4. **Human users** originate from the Teams API that returns list of the team members given a team id. Operator differentiates between (a) product teams that own a particular Postgres cluster and are granted admin rights to maintain it, and (b) Postgres superuser teams that get the superuser access to all PG databases running in a k8s cluster for the purposes of maintaining and troubleshooting. \ No newline at end of file diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index 76ddb9ff9..7d8e243bb 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -377,6 +377,9 @@ key. List of roles that cannot be overwritten by an application, team or infrastructure role. The default is `admin`. +* **postgres_superuser_teams** + List of teams which members need the superuser role in each PG database cluster to administer Postgres and maintain infrastructure built around it. The default is `postgres_superuser`. + ## Logging and REST API Parameters affecting logging and REST API listener. In the CRD-based configuration they are grouped under the `logging_rest_api` key. diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index cd70d76d9..de7681db4 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -3,9 +3,10 @@ package v1 import ( "github.com/zalando-incubator/postgres-operator/pkg/util/config" + "time" + "github.com/zalando-incubator/postgres-operator/pkg/spec" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "time" ) // +genclient @@ -99,6 +100,7 @@ type TeamsAPIConfiguration struct { PamRoleName string `json:"pam_role_name,omitempty"` PamConfiguration string `json:"pam_configuration,omitempty"` ProtectedRoles []string `json:"protected_role_names,omitempty"` + PostgresSuperuserTeams []string `json:"postgres_superuser_teams,omitempty"` } type LoggingRESTAPIConfiguration struct { diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 01280e548..d58668054 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -631,6 +631,11 @@ func (in *TeamsAPIConfiguration) DeepCopyInto(out *TeamsAPIConfiguration) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.PostgresSuperuserTeams != nil { + in, out := &in.PostgresSuperuserTeams, &out.PostgresSuperuserTeams + *out = make([]string, len(*in)) + copy(*out, *in) + } return } diff --git a/pkg/cluster/cluster.go b/pkg/cluster/cluster.go index f5d514950..7e73bd97c 100644 --- a/pkg/cluster/cluster.go +++ b/pkg/cluster/cluster.go @@ -723,11 +723,13 @@ func (c *Cluster) initRobotUsers() error { return nil } -func (c *Cluster) initHumanUsers() error { - teamMembers, err := c.getTeamMembers() +func (c *Cluster) initTeamMembers(teamID string, isPostgresSuperuserTeam bool) error { + teamMembers, err := c.getTeamMembers(teamID) + if err != nil { - return fmt.Errorf("could not get list of team members: %v", err) + return fmt.Errorf("could not get list of team members for team %q: %v", teamID, err) } + for _, username := range teamMembers { flags := []string{constants.RoleFlagLogin} memberOf := []string{c.OpConfig.PamRoleName} @@ -735,7 +737,7 @@ func (c *Cluster) initHumanUsers() error { if c.shouldAvoidProtectedOrSystemRole(username, "API role") { continue } - if c.OpConfig.EnableTeamSuperuser { + if c.OpConfig.EnableTeamSuperuser || isPostgresSuperuserTeam { flags = append(flags, constants.RoleFlagSuperuser) } else { if c.OpConfig.TeamAdminRole != "" { @@ -761,6 +763,33 @@ func (c *Cluster) initHumanUsers() error { return nil } +func (c *Cluster) initHumanUsers() error { + + var clusterIsOwnedBySuperuserTeam bool + + for _, postgresSuperuserTeam := range c.OpConfig.PostgresSuperuserTeams { + err := c.initTeamMembers(postgresSuperuserTeam, true) + if err != nil { + return fmt.Errorf("Cannot create a team %q of Postgres superusers: %v", postgresSuperuserTeam, err) + } + if postgresSuperuserTeam == c.Spec.TeamID { + clusterIsOwnedBySuperuserTeam = true + } + } + + if clusterIsOwnedBySuperuserTeam { + c.logger.Infof("Team %q owning the cluster is also a team of superusers. Created superuser roles for its members instead of admin roles.", c.Spec.TeamID) + return nil + } + + err := c.initTeamMembers(c.Spec.TeamID, false) + if err != nil { + return fmt.Errorf("Cannot create a team %q of admins owning the PG cluster: %v", c.Spec.TeamID, err) + } + + return nil +} + func (c *Cluster) initInfrastructureRoles() error { // add infrastructure roles from the operator's definition for username, newRole := range c.InfrastructureRoles { diff --git a/pkg/cluster/cluster_test.go b/pkg/cluster/cluster_test.go index 82400344f..c89874e99 100644 --- a/pkg/cluster/cluster_test.go +++ b/pkg/cluster/cluster_test.go @@ -2,6 +2,9 @@ package cluster import ( "fmt" + "reflect" + "testing" + "github.com/Sirupsen/logrus" acidv1 "github.com/zalando-incubator/postgres-operator/pkg/apis/acid.zalan.do/v1" "github.com/zalando-incubator/postgres-operator/pkg/spec" @@ -9,8 +12,6 @@ import ( "github.com/zalando-incubator/postgres-operator/pkg/util/k8sutil" "github.com/zalando-incubator/postgres-operator/pkg/util/teams" "k8s.io/api/core/v1" - "reflect" - "testing" ) const ( @@ -101,6 +102,7 @@ func (m *mockTeamsAPIClient) setMembers(members []string) { m.members = members } +// Test adding a member of a product team owning a particular DB cluster func TestInitHumanUsers(t *testing.T) { var mockTeamsAPI mockTeamsAPIClient @@ -108,7 +110,9 @@ func TestInitHumanUsers(t *testing.T) { cl.teamsAPIClient = &mockTeamsAPI testName := "TestInitHumanUsers" + // members of a product team are granted superuser rights for DBs of their team cl.OpConfig.EnableTeamSuperuser = true + cl.OpConfig.EnableTeamsAPI = true cl.OpConfig.PamRoleName = "zalandos" cl.Spec.TeamID = "test" @@ -146,6 +150,145 @@ func TestInitHumanUsers(t *testing.T) { } } +type mockTeam struct { + teamID string + members []string + isPostgresSuperuserTeam bool +} + +type mockTeamsAPIClientMultipleTeams struct { + teams []mockTeam +} + +func (m *mockTeamsAPIClientMultipleTeams) TeamInfo(teamID, token string) (tm *teams.Team, err error) { + for _, team := range m.teams { + if team.teamID == teamID { + return &teams.Team{Members: team.members}, nil + } + } + + // should not be reached if a slice with teams is populated correctly + return nil, nil +} + +// Test adding members of maintenance teams that get superuser rights for all PG databases +func TestInitHumanUsersWithSuperuserTeams(t *testing.T) { + + var mockTeamsAPI mockTeamsAPIClientMultipleTeams + cl.oauthTokenGetter = &mockOAuthTokenGetter{} + cl.teamsAPIClient = &mockTeamsAPI + cl.OpConfig.EnableTeamSuperuser = false + testName := "TestInitHumanUsersWithSuperuserTeams" + + cl.OpConfig.EnableTeamsAPI = true + cl.OpConfig.PamRoleName = "zalandos" + + teamA := mockTeam{ + teamID: "postgres_superusers", + members: []string{"postgres_superuser"}, + isPostgresSuperuserTeam: true, + } + + userA := spec.PgUser{ + Name: "postgres_superuser", + Origin: spec.RoleOriginTeamsAPI, + MemberOf: []string{cl.OpConfig.PamRoleName}, + Flags: []string{"LOGIN", "SUPERUSER"}, + } + + teamB := mockTeam{ + teamID: "postgres_admins", + members: []string{"postgres_admin"}, + isPostgresSuperuserTeam: true, + } + + userB := spec.PgUser{ + Name: "postgres_admin", + Origin: spec.RoleOriginTeamsAPI, + MemberOf: []string{cl.OpConfig.PamRoleName}, + Flags: []string{"LOGIN", "SUPERUSER"}, + } + + teamTest := mockTeam{ + teamID: "test", + members: []string{"test_user"}, + isPostgresSuperuserTeam: false, + } + + userTest := spec.PgUser{ + Name: "test_user", + Origin: spec.RoleOriginTeamsAPI, + MemberOf: []string{cl.OpConfig.PamRoleName}, + Flags: []string{"LOGIN"}, + } + + tests := []struct { + ownerTeam string + existingRoles map[string]spec.PgUser + superuserTeams []string + teams []mockTeam + result map[string]spec.PgUser + }{ + // case 1: there are two different teams of PG maintainers and one product team + { + ownerTeam: "test", + existingRoles: map[string]spec.PgUser{}, + superuserTeams: []string{"postgres_superusers", "postgres_admins"}, + teams: []mockTeam{teamA, teamB, teamTest}, + result: map[string]spec.PgUser{ + "postgres_superuser": userA, + "postgres_admin": userB, + "test_user": userTest, + }, + }, + // case 2: the team of superusers creates a new PG cluster + { + ownerTeam: "postgres_superusers", + existingRoles: map[string]spec.PgUser{}, + superuserTeams: []string{"postgres_superusers"}, + teams: []mockTeam{teamA}, + result: map[string]spec.PgUser{ + "postgres_superuser": userA, + }, + }, + // case 3: the team owning the cluster is promoted to the maintainers' status + { + ownerTeam: "postgres_superusers", + existingRoles: map[string]spec.PgUser{ + // role with the name exists before w/o superuser privilege + "postgres_superuser": spec.PgUser{ + Origin: spec.RoleOriginTeamsAPI, + Name: "postgres_superuser", + Password: "", + Flags: []string{"LOGIN"}, + MemberOf: []string{cl.OpConfig.PamRoleName}, + Parameters: map[string]string(nil)}}, + superuserTeams: []string{"postgres_superusers"}, + teams: []mockTeam{teamA}, + result: map[string]spec.PgUser{ + "postgres_superuser": userA, + }, + }, + } + + for _, tt := range tests { + + mockTeamsAPI.teams = tt.teams + + cl.Spec.TeamID = tt.ownerTeam + cl.pgUsers = tt.existingRoles + cl.OpConfig.PostgresSuperuserTeams = tt.superuserTeams + + if err := cl.initHumanUsers(); err != nil { + t.Errorf("%s got an unexpected error %v", testName, err) + } + + if !reflect.DeepEqual(cl.pgUsers, tt.result) { + t.Errorf("%s expects %#v, got %#v", testName, tt.result, cl.pgUsers) + } + } +} + func TestShouldDeleteSecret(t *testing.T) { testName := "TestShouldDeleteSecret" diff --git a/pkg/cluster/util.go b/pkg/cluster/util.go index 911bf74f8..9bfcd19c5 100644 --- a/pkg/cluster/util.go +++ b/pkg/cluster/util.go @@ -210,12 +210,14 @@ func (c *Cluster) logVolumeChanges(old, new acidv1.Volume) { c.logger.Debugf("diff\n%s\n", util.PrettyDiff(old, new)) } -func (c *Cluster) getTeamMembers() ([]string, error) { - if c.Spec.TeamID == "" { +func (c *Cluster) getTeamMembers(teamID string) ([]string, error) { + + if teamID == "" { return nil, fmt.Errorf("no teamId specified") } + if !c.OpConfig.EnableTeamsAPI { - c.logger.Debug("team API is disabled, returning empty list of members") + c.logger.Debugf("team API is disabled, returning empty list of members for team %q", teamID) return []string{}, nil } @@ -225,9 +227,9 @@ func (c *Cluster) getTeamMembers() ([]string, error) { return []string{}, nil } - teamInfo, err := c.teamsAPIClient.TeamInfo(c.Spec.TeamID, token) + teamInfo, err := c.teamsAPIClient.TeamInfo(teamID, token) if err != nil { - c.logger.Warnf("could not get team info, returning empty list of team members: %v", err) + c.logger.Warnf("could not get team info for team %q, returning empty list of team members: %v", teamID, err) return []string{}, nil } diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index 251828b32..93ba1a0f4 100644 --- a/pkg/controller/operator_config.go +++ b/pkg/controller/operator_config.go @@ -82,6 +82,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur result.EnableTeamSuperuser = fromCRD.TeamsAPI.EnableTeamSuperuser result.TeamAdminRole = fromCRD.TeamsAPI.TeamAdminRole result.PamRoleName = fromCRD.TeamsAPI.PamRoleName + result.PostgresSuperuserTeams = fromCRD.TeamsAPI.PostgresSuperuserTeams result.APIPort = fromCRD.LoggingRESTAPI.APIPort result.RingLogLines = fromCRD.LoggingRESTAPI.RingLogLines diff --git a/pkg/spec/types.go b/pkg/spec/types.go index c683a1cef..1607dbd9b 100644 --- a/pkg/spec/types.go +++ b/pkg/spec/types.go @@ -23,7 +23,7 @@ const fileWithNamespace = "/var/run/secrets/kubernetes.io/serviceaccount/namespa // RoleOrigin contains the code of the origin of a role type RoleOrigin int -// The rolesOrigin constant values should be sorted by the role priority. +// The rolesOrigin constant values must be sorted by the role priority for resolveNameConflict(...) to work. const ( RoleOriginUnknown RoleOrigin = iota RoleOriginManifest diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index bcfea0647..92fd3fd73 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -103,6 +103,7 @@ type Config struct { TeamAPIRoleConfiguration map[string]string `name:"team_api_role_configuration" default:"log_statement:all"` PodTerminateGracePeriod time.Duration `name:"pod_terminate_grace_period" default:"5m"` ProtectedRoles []string `name:"protected_role_names" default:"admin"` + PostgresSuperuserTeams []string `name:"postgres_superuser_teams" default:""` } // MustMarshal marshals the config or panics