Skip to content

Commit 5dde08a

Browse files
authored
Create schemas for users in granted databases (#3940)
* Create schemas for users in granted databases To help developers set up and connect quickly, the operator can now create schemas for `spec.users` without using an init SQL script. This is a gated feature: to turn on set the FeatureGate `AutoCreateUserSchema=true`. If turned on, a cluster can be annotated with `postgres-operator.crunchydata.com/autoCreateUserSchema=true`. If the feature is turned on and the cluster is annotated, PGO will create a schema named after the user in every database where that user has permissions. (PG note: creating a schema with the same name as the user means that the PG `search_path` should not need to be updated, since `search_path` defaults to `"$user", public`.) As with our usual pattern, the operator does not remove/delete PG objects (users, databases) that are removed from the spec. NOTE: There are several schema names that would be dangerous to the cluster's operation; for instance, if you had pgbouncer enabled (which would create a `pgbouncer` schema) it would be dangerous to create a user named `pgbouncer` and use this feature to create a schema for that user. We have a blacklist for such reserved names, which result in the skipping being logged for now. Issues: [PGO-1333]
1 parent ecc6d42 commit 5dde08a

File tree

5 files changed

+163
-14
lines changed

5 files changed

+163
-14
lines changed

internal/controller/postgrescluster/postgres.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ func (r *Reconciler) reconcilePostgresUsersInPostgreSQL(
534534
}
535535

536536
write := func(ctx context.Context, exec postgres.Executor) error {
537-
return postgres.WriteUsersInPostgreSQL(ctx, exec, specUsers, verifiers)
537+
return postgres.WriteUsersInPostgreSQL(ctx, cluster, exec, specUsers, verifiers)
538538
}
539539

540540
revision, err := safeHash32(func(hasher io.Writer) error {

internal/naming/annotations.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,9 @@ const (
7070
// bridge cluster, the user must add this annotation to the CR to allow the CR to take control of
7171
// the Bridge Cluster. The Value assigned to the annotation must be the ID of existing cluster.
7272
CrunchyBridgeClusterAdoptionAnnotation = annotationPrefix + "adopt-bridge-cluster"
73+
74+
// AutoCreateUserSchemaAnnotation is an annotation used to allow users to control whether the cluster
75+
// has schemas automatically created for the users defined in `spec.users` for all of the databases
76+
// listed for that user.
77+
AutoCreateUserSchemaAnnotation = annotationPrefix + "autoCreateUserSchema"
7378
)

internal/postgres/users.go

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,17 @@ import (
2424
pg_query "github.com/pganalyze/pg_query_go/v5"
2525

2626
"github.com/crunchydata/postgres-operator/internal/logging"
27+
"github.com/crunchydata/postgres-operator/internal/naming"
28+
"github.com/crunchydata/postgres-operator/internal/util"
2729
"github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1"
2830
)
2931

32+
var RESERVED_SCHEMA_NAMES = map[string]bool{
33+
"public": true, // This is here for documentation; Postgres will reject a role named `public` as reserved
34+
"pgbouncer": true,
35+
"monitor": true,
36+
}
37+
3038
func sanitizeAlterRoleOptions(options string) string {
3139
const AlterRolePrefix = `ALTER ROLE "any" WITH `
3240

@@ -61,7 +69,7 @@ func sanitizeAlterRoleOptions(options string) string {
6169
// grants them access to their specified databases. The databases must already
6270
// exist.
6371
func WriteUsersInPostgreSQL(
64-
ctx context.Context, exec Executor,
72+
ctx context.Context, cluster *v1beta1.PostgresCluster, exec Executor,
6573
users []v1beta1.PostgresUserSpec, verifiers map[string]string,
6674
) error {
6775
log := logging.FromContext(ctx)
@@ -162,5 +170,83 @@ SELECT pg_catalog.format('GRANT ALL PRIVILEGES ON DATABASE %I TO %I',
162170

163171
log.V(1).Info("wrote PostgreSQL users", "stdout", stdout, "stderr", stderr)
164172

173+
// The operator will attemtp to write schemas for the users in the spec if
174+
// * the feature gate is enabled and
175+
// * the cluster is annotated.
176+
if util.DefaultMutableFeatureGate.Enabled(util.AutoCreateUserSchema) {
177+
autoCreateUserSchemaAnnotationValue, annotationExists := cluster.Annotations[naming.AutoCreateUserSchemaAnnotation]
178+
if annotationExists && strings.EqualFold(autoCreateUserSchemaAnnotationValue, "true") {
179+
log.V(1).Info("Writing schemas for users.")
180+
err = WriteUsersSchemasInPostgreSQL(ctx, exec, users)
181+
}
182+
}
183+
184+
return err
185+
}
186+
187+
// WriteUsersSchemasInPostgreSQL will create a schema for each user in each database that user has access to
188+
func WriteUsersSchemasInPostgreSQL(ctx context.Context, exec Executor,
189+
users []v1beta1.PostgresUserSpec) error {
190+
191+
log := logging.FromContext(ctx)
192+
193+
var err error
194+
var stdout string
195+
var stderr string
196+
197+
for i := range users {
198+
spec := users[i]
199+
200+
// We skip if the user has the name of a reserved schema
201+
if RESERVED_SCHEMA_NAMES[string(spec.Name)] {
202+
log.V(1).Info("Skipping schema creation for user with reserved name",
203+
"name", string(spec.Name))
204+
continue
205+
}
206+
207+
// We skip if the user has no databases
208+
if len(spec.Databases) == 0 {
209+
continue
210+
}
211+
212+
var sql bytes.Buffer
213+
214+
// Prevent unexpected dereferences by emptying "search_path". The "pg_catalog"
215+
// schema is still searched, and only temporary objects can be created.
216+
// - https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SEARCH-PATH
217+
_, _ = sql.WriteString(`SET search_path TO '';`)
218+
219+
_, _ = sql.WriteString(`SELECT * FROM json_array_elements_text(:'databases');`)
220+
221+
databases, _ := json.Marshal(spec.Databases)
222+
223+
stdout, stderr, err = exec.ExecInDatabasesFromQuery(ctx,
224+
sql.String(),
225+
strings.Join([]string{
226+
// Quiet NOTICE messages from IF EXISTS statements.
227+
// - https://www.postgresql.org/docs/current/runtime-config-client.html
228+
`SET client_min_messages = WARNING;`,
229+
230+
// Creates a schema named after and owned by the user
231+
// - https://www.postgresql.org/docs/current/ddl-schemas.html
232+
// - https://www.postgresql.org/docs/current/sql-createschema.html
233+
234+
// We create a schema named after the user because
235+
// the PG search_path does not need to be updated,
236+
// since search_path defaults to "$user", public.
237+
// - https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH
238+
`CREATE SCHEMA IF NOT EXISTS :"username" AUTHORIZATION :"username";`,
239+
}, "\n"),
240+
map[string]string{
241+
"databases": string(databases),
242+
"username": string(spec.Name),
243+
244+
"ON_ERROR_STOP": "on", // Abort when any one statement fails.
245+
"QUIET": "on", // Do not print successful commands to stdout.
246+
},
247+
)
248+
249+
log.V(1).Info("wrote PostgreSQL schemas", "stdout", stdout, "stderr", stderr)
250+
}
165251
return err
166252
}

internal/postgres/users_test.go

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"context"
2020
"errors"
2121
"io"
22+
"regexp"
2223
"strings"
2324
"testing"
2425

@@ -59,7 +60,8 @@ func TestWriteUsersInPostgreSQL(t *testing.T) {
5960
return expected
6061
}
6162

62-
assert.Equal(t, expected, WriteUsersInPostgreSQL(ctx, exec, nil, nil))
63+
cluster := new(v1beta1.PostgresCluster)
64+
assert.Equal(t, expected, WriteUsersInPostgreSQL(ctx, cluster, exec, nil, nil))
6365
})
6466

6567
t.Run("Empty", func(t *testing.T) {
@@ -104,17 +106,19 @@ COMMIT;`))
104106
return nil
105107
}
106108

107-
assert.NilError(t, WriteUsersInPostgreSQL(ctx, exec, nil, nil))
109+
cluster := new(v1beta1.PostgresCluster)
110+
assert.NilError(t, WriteUsersInPostgreSQL(ctx, cluster, exec, nil, nil))
108111
assert.Equal(t, calls, 1)
109112

110-
assert.NilError(t, WriteUsersInPostgreSQL(ctx, exec, []v1beta1.PostgresUserSpec{}, nil))
113+
assert.NilError(t, WriteUsersInPostgreSQL(ctx, cluster, exec, []v1beta1.PostgresUserSpec{}, nil))
111114
assert.Equal(t, calls, 2)
112115

113-
assert.NilError(t, WriteUsersInPostgreSQL(ctx, exec, nil, map[string]string{}))
116+
assert.NilError(t, WriteUsersInPostgreSQL(ctx, cluster, exec, nil, map[string]string{}))
114117
assert.Equal(t, calls, 3)
115118
})
116119

117120
t.Run("OptionalFields", func(t *testing.T) {
121+
cluster := new(v1beta1.PostgresCluster)
118122
calls := 0
119123
exec := func(
120124
_ context.Context, stdin io.Reader, _, _ io.Writer, command ...string,
@@ -134,7 +138,7 @@ COMMIT;`))
134138
return nil
135139
}
136140

137-
assert.NilError(t, WriteUsersInPostgreSQL(ctx, exec,
141+
assert.NilError(t, WriteUsersInPostgreSQL(ctx, cluster, exec,
138142
[]v1beta1.PostgresUserSpec{
139143
{
140144
Name: "user-no-options",
@@ -162,6 +166,7 @@ COMMIT;`))
162166

163167
t.Run("PostgresSuperuser", func(t *testing.T) {
164168
calls := 0
169+
cluster := new(v1beta1.PostgresCluster)
165170
exec := func(
166171
_ context.Context, stdin io.Reader, _, _ io.Writer, command ...string,
167172
) error {
@@ -177,7 +182,7 @@ COMMIT;`))
177182
return nil
178183
}
179184

180-
assert.NilError(t, WriteUsersInPostgreSQL(ctx, exec,
185+
assert.NilError(t, WriteUsersInPostgreSQL(ctx, cluster, exec,
181186
[]v1beta1.PostgresUserSpec{
182187
{
183188
Name: "postgres",
@@ -192,3 +197,52 @@ COMMIT;`))
192197
assert.Equal(t, calls, 1)
193198
})
194199
}
200+
201+
func TestWriteUsersSchemasInPostgreSQL(t *testing.T) {
202+
ctx := context.Background()
203+
204+
t.Run("Mixed users", func(t *testing.T) {
205+
calls := 0
206+
exec := func(
207+
_ context.Context, stdin io.Reader, _, _ io.Writer, command ...string,
208+
) error {
209+
calls++
210+
211+
b, err := io.ReadAll(stdin)
212+
assert.NilError(t, err)
213+
214+
// The command strings will contain either of two possibilities, depending on the user called.
215+
commands := strings.Join(command, ",")
216+
re := regexp.MustCompile("--set=databases=\\[\"db1\"\\],--set=username=user-single-db|--set=databases=\\[\"db1\",\"db2\"\\],--set=username=user-multi-db")
217+
assert.Assert(t, cmp.Regexp(re, commands))
218+
219+
assert.Assert(t, cmp.Contains(string(b), `CREATE SCHEMA IF NOT EXISTS :"username" AUTHORIZATION :"username";`))
220+
return nil
221+
}
222+
223+
assert.NilError(t, WriteUsersSchemasInPostgreSQL(ctx, exec,
224+
[]v1beta1.PostgresUserSpec{
225+
{
226+
Name: "user-single-db",
227+
Databases: []v1beta1.PostgresIdentifier{"db1"},
228+
},
229+
{
230+
Name: "user-no-databases",
231+
},
232+
{
233+
Name: "user-multi-dbs",
234+
Databases: []v1beta1.PostgresIdentifier{"db1", "db2"},
235+
},
236+
{
237+
Name: "public",
238+
Databases: []v1beta1.PostgresIdentifier{"db3"},
239+
},
240+
},
241+
))
242+
// The spec.users has four elements, but two will be skipped:
243+
// * the user with the reserved name `public`
244+
// * the user with 0 databases
245+
assert.Equal(t, calls, 2)
246+
})
247+
248+
}

internal/util/features.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const (
3535
// Enables support of appending custom queries to default PGMonitor queries
3636
AppendCustomQueries featuregate.Feature = "AppendCustomQueries"
3737
//
38+
// Enables automatic creation of user schema
39+
AutoCreateUserSchema featuregate.Feature = "AutoCreateUserSchema"
40+
//
3841
// Enables support of auto-grow volumes
3942
AutoGrowVolumes featuregate.Feature = "AutoGrowVolumes"
4043
//
@@ -58,12 +61,13 @@ const (
5861
//
5962
// - https://releases.k8s.io/v1.20.0/pkg/features/kube_features.go#L729-732
6063
var pgoFeatures = map[featuregate.Feature]featuregate.FeatureSpec{
61-
AppendCustomQueries: {Default: false, PreRelease: featuregate.Alpha},
62-
AutoGrowVolumes: {Default: false, PreRelease: featuregate.Alpha},
63-
BridgeIdentifiers: {Default: false, PreRelease: featuregate.Alpha},
64-
InstanceSidecars: {Default: false, PreRelease: featuregate.Alpha},
65-
PGBouncerSidecars: {Default: false, PreRelease: featuregate.Alpha},
66-
TablespaceVolumes: {Default: false, PreRelease: featuregate.Alpha},
64+
AppendCustomQueries: {Default: false, PreRelease: featuregate.Alpha},
65+
AutoCreateUserSchema: {Default: false, PreRelease: featuregate.Alpha},
66+
AutoGrowVolumes: {Default: false, PreRelease: featuregate.Alpha},
67+
BridgeIdentifiers: {Default: false, PreRelease: featuregate.Alpha},
68+
InstanceSidecars: {Default: false, PreRelease: featuregate.Alpha},
69+
PGBouncerSidecars: {Default: false, PreRelease: featuregate.Alpha},
70+
TablespaceVolumes: {Default: false, PreRelease: featuregate.Alpha},
6771
}
6872

6973
// DefaultMutableFeatureGate is a mutable, shared global FeatureGate.

0 commit comments

Comments
 (0)