Skip to content

Commit 4005f53

Browse files
committed
fix: address PostgREST PR review feedback (PLAT-499/500/501/502/503)
Move credential handling out of container env vars into postgrest.conf via db-uri. Environment variables are visible to all users on the host via docker inspect and /proc; the config file is restricted to the service user (mode 0600). Move config file generation to PostgRESTServiceConfig.GenerateConf in the database package, where it belongs alongside the type it serializes. PostgRESTConnParams carries the runtime connection details (host, port, credentials) separately from the user-supplied PostgRESTServiceConfig. Fix merge conflict resolution in service_user_role.go: remove the duplicate MCP code block that was left in and drop DBOwner: false to align with the upstream change in main. Implement Update() for PostgREST ServiceUserRole to reconcile DBAnonRole changes at runtime. Queries pg_auth_members for stale role memberships, revokes them, and re-applies the desired grants idempotently. Without this, a DBAnonRole change would leave the authenticator role unable to SET ROLE to the new anon role. Add REVOKE CONNECT ON DATABASE before DROP ROLE in Delete() for PostgREST service users. PostgreSQL refuses to drop a role that holds database privileges, causing the DROP to fail silently. Revoking first ensures clean deletion.
1 parent 1666a31 commit 4005f53

8 files changed

Lines changed: 309 additions & 275 deletions

server/internal/database/postgrest_service_config.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package database
22

33
import (
4+
"bytes"
45
"fmt"
6+
"net/url"
57
"sort"
68
"strings"
79
)
@@ -145,3 +147,66 @@ func validatePostgRESTUnknownKeys(config map[string]any) []error {
145147
}
146148
return []error{fmt.Errorf("unknown config keys: %s", strings.Join(quoted, ", "))}
147149
}
150+
151+
// PostgRESTConnParams holds the connection and credential details needed to
152+
// generate a complete postgrest.conf. These are kept separate from
153+
// PostgRESTServiceConfig because they are runtime-provisioned values (not
154+
// user-supplied configuration).
155+
type PostgRESTConnParams struct {
156+
Username string
157+
Password string
158+
DatabaseName string
159+
DatabaseHosts []ServiceHostEntry
160+
TargetSessionAttrs string
161+
}
162+
163+
// GenerateConf renders a postgrest.conf file from the service config and the
164+
// runtime connection parameters. The db-uri (including credentials) is written
165+
// into the file; no credentials are exposed as environment variables.
166+
func (c *PostgRESTServiceConfig) GenerateConf(conn PostgRESTConnParams) ([]byte, error) {
167+
var buf bytes.Buffer
168+
169+
fmt.Fprintf(&buf, "db-uri = %q\n", buildPostgRESTDBURI(conn))
170+
fmt.Fprintf(&buf, "db-schemas = %q\n", c.DBSchemas)
171+
fmt.Fprintf(&buf, "db-anon-role = %q\n", c.DBAnonRole)
172+
fmt.Fprintf(&buf, "db-pool = %d\n", c.DBPool)
173+
fmt.Fprintf(&buf, "db-max-rows = %d\n", c.MaxRows)
174+
175+
if c.JWTSecret != nil {
176+
fmt.Fprintf(&buf, "jwt-secret = %q\n", *c.JWTSecret)
177+
}
178+
if c.JWTAud != nil {
179+
fmt.Fprintf(&buf, "jwt-aud = %q\n", *c.JWTAud)
180+
}
181+
if c.JWTRoleClaimKey != nil {
182+
fmt.Fprintf(&buf, "jwt-role-claim-key = %q\n", *c.JWTRoleClaimKey)
183+
}
184+
if c.ServerCORSAllowedOrigins != nil {
185+
fmt.Fprintf(&buf, "server-cors-allowed-origins = %q\n", *c.ServerCORSAllowedOrigins)
186+
}
187+
188+
return buf.Bytes(), nil
189+
}
190+
191+
// buildPostgRESTDBURI constructs a libpq URI with multi-host support.
192+
// Format: postgresql://user:pass@host1:port1,host2:port2/dbname[?target_session_attrs=...]
193+
func buildPostgRESTDBURI(conn PostgRESTConnParams) string {
194+
userInfo := url.UserPassword(conn.Username, conn.Password)
195+
196+
hostParts := make([]string, len(conn.DatabaseHosts))
197+
for i, h := range conn.DatabaseHosts {
198+
hostParts[i] = fmt.Sprintf("%s:%d", h.Host, h.Port)
199+
}
200+
201+
uri := fmt.Sprintf("postgresql://%s@%s/%s",
202+
userInfo.String(),
203+
strings.Join(hostParts, ","),
204+
url.PathEscape(conn.DatabaseName),
205+
)
206+
207+
if conn.TargetSessionAttrs != "" {
208+
uri += "?target_session_attrs=" + url.QueryEscape(conn.TargetSessionAttrs)
209+
}
210+
211+
return uri
212+
}

server/internal/database/postgrest_service_config_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,168 @@
11
package database_test
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/pgEdge/control-plane/server/internal/database"
78
"github.com/stretchr/testify/assert"
89
"github.com/stretchr/testify/require"
910
)
1011

12+
// parseConf parses key=value lines from a postgrest.conf into a map.
13+
// Surrounding quotes are stripped from string values.
14+
func parseConf(t *testing.T, data []byte) map[string]string {
15+
t.Helper()
16+
m := make(map[string]string)
17+
for _, line := range strings.Split(string(data), "\n") {
18+
line = strings.TrimSpace(line)
19+
if line == "" {
20+
continue
21+
}
22+
parts := strings.SplitN(line, " = ", 2)
23+
if len(parts) != 2 {
24+
t.Fatalf("unexpected line in postgrest.conf: %q", line)
25+
}
26+
key := strings.TrimSpace(parts[0])
27+
val := strings.TrimSpace(parts[1])
28+
if strings.HasPrefix(val, `"`) && strings.HasSuffix(val, `"`) {
29+
val = val[1 : len(val)-1]
30+
}
31+
m[key] = val
32+
}
33+
return m
34+
}
35+
36+
func makeTestConn() database.PostgRESTConnParams {
37+
return database.PostgRESTConnParams{
38+
Username: "svc_pgrest",
39+
Password: "testpass",
40+
DatabaseName: "mydb",
41+
DatabaseHosts: []database.ServiceHostEntry{{Host: "pg-host1", Port: 5432}},
42+
}
43+
}
44+
45+
func TestGenerateConf_CoreFields(t *testing.T) {
46+
cfg := &database.PostgRESTServiceConfig{
47+
DBSchemas: "public",
48+
DBAnonRole: "pgedge_application_read_only",
49+
DBPool: 10,
50+
MaxRows: 1000,
51+
}
52+
data, err := cfg.GenerateConf(makeTestConn())
53+
require.NoError(t, err)
54+
m := parseConf(t, data)
55+
assert.Equal(t, "public", m["db-schemas"])
56+
assert.Equal(t, "pgedge_application_read_only", m["db-anon-role"])
57+
assert.Equal(t, "10", m["db-pool"])
58+
assert.Equal(t, "1000", m["db-max-rows"])
59+
}
60+
61+
func TestGenerateConf_CustomCoreFields(t *testing.T) {
62+
cfg := &database.PostgRESTServiceConfig{
63+
DBSchemas: "api,private",
64+
DBAnonRole: "web_anon",
65+
DBPool: 5,
66+
MaxRows: 500,
67+
}
68+
data, err := cfg.GenerateConf(makeTestConn())
69+
require.NoError(t, err)
70+
m := parseConf(t, data)
71+
assert.Equal(t, "api,private", m["db-schemas"])
72+
assert.Equal(t, "web_anon", m["db-anon-role"])
73+
assert.Equal(t, "5", m["db-pool"])
74+
assert.Equal(t, "500", m["db-max-rows"])
75+
}
76+
77+
func TestGenerateConf_JWTFieldsAbsent(t *testing.T) {
78+
cfg := &database.PostgRESTServiceConfig{
79+
DBSchemas: "public",
80+
DBAnonRole: "web_anon",
81+
DBPool: 10,
82+
MaxRows: 1000,
83+
}
84+
data, err := cfg.GenerateConf(makeTestConn())
85+
require.NoError(t, err)
86+
m := parseConf(t, data)
87+
for _, key := range []string{"jwt-secret", "jwt-aud", "jwt-role-claim-key", "server-cors-allowed-origins"} {
88+
assert.NotContains(t, m, key, "%s should be absent when not configured", key)
89+
}
90+
}
91+
92+
func TestGenerateConf_AllJWTFields(t *testing.T) {
93+
secret := "a-very-long-jwt-secret-that-is-at-least-32-chars"
94+
aud := "my-api-audience"
95+
roleClaimKey := ".role"
96+
corsOrigins := "https://example.com"
97+
cfg := &database.PostgRESTServiceConfig{
98+
DBSchemas: "public",
99+
DBAnonRole: "web_anon",
100+
DBPool: 10,
101+
MaxRows: 1000,
102+
JWTSecret: &secret,
103+
JWTAud: &aud,
104+
JWTRoleClaimKey: &roleClaimKey,
105+
ServerCORSAllowedOrigins: &corsOrigins,
106+
}
107+
data, err := cfg.GenerateConf(makeTestConn())
108+
require.NoError(t, err)
109+
m := parseConf(t, data)
110+
assert.Equal(t, secret, m["jwt-secret"])
111+
assert.Equal(t, aud, m["jwt-aud"])
112+
assert.Equal(t, roleClaimKey, m["jwt-role-claim-key"])
113+
assert.Equal(t, corsOrigins, m["server-cors-allowed-origins"])
114+
}
115+
116+
func TestGenerateConf_DBURIContainsCredentials(t *testing.T) {
117+
cfg := &database.PostgRESTServiceConfig{
118+
DBSchemas: "public",
119+
DBAnonRole: "web_anon",
120+
DBPool: 10,
121+
MaxRows: 1000,
122+
}
123+
conn := database.PostgRESTConnParams{
124+
Username: "svc_pgrest",
125+
Password: "s3cr3t",
126+
DatabaseName: "mydb",
127+
DatabaseHosts: []database.ServiceHostEntry{{Host: "pg-host1", Port: 5432}},
128+
}
129+
data, err := cfg.GenerateConf(conn)
130+
require.NoError(t, err)
131+
m := parseConf(t, data)
132+
uri, ok := m["db-uri"]
133+
require.True(t, ok, "db-uri must be present in postgrest.conf")
134+
assert.Contains(t, uri, "svc_pgrest")
135+
assert.Contains(t, uri, "s3cr3t")
136+
assert.Contains(t, uri, "pg-host1")
137+
assert.Contains(t, uri, "mydb")
138+
}
139+
140+
func TestGenerateConf_DBURIMultiHost(t *testing.T) {
141+
cfg := &database.PostgRESTServiceConfig{
142+
DBSchemas: "public",
143+
DBAnonRole: "web_anon",
144+
DBPool: 10,
145+
MaxRows: 1000,
146+
}
147+
conn := database.PostgRESTConnParams{
148+
Username: "svc_pgrest",
149+
Password: "pass",
150+
DatabaseName: "mydb",
151+
DatabaseHosts: []database.ServiceHostEntry{
152+
{Host: "pg-host1", Port: 5432},
153+
{Host: "pg-host2", Port: 5432},
154+
},
155+
TargetSessionAttrs: "read-write",
156+
}
157+
data, err := cfg.GenerateConf(conn)
158+
require.NoError(t, err)
159+
m := parseConf(t, data)
160+
uri := m["db-uri"]
161+
assert.Contains(t, uri, "pg-host1")
162+
assert.Contains(t, uri, "pg-host2")
163+
assert.Contains(t, uri, "target_session_attrs")
164+
}
165+
11166
func TestParsePostgRESTServiceConfig(t *testing.T) {
12167
t.Run("defaults applied for empty config", func(t *testing.T) {
13168
cfg, errs := database.ParsePostgRESTServiceConfig(map[string]any{})

server/internal/orchestrator/swarm/postgrest_config.go

Lines changed: 0 additions & 47 deletions
This file was deleted.

server/internal/orchestrator/swarm/postgrest_config_resource.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,19 @@ func PostgRESTConfigResourceIdentifier(serviceInstanceID string) resource.Identi
2525
}
2626

2727
// PostgRESTConfigResource manages the postgrest.conf file on the host filesystem.
28-
// The file is bind-mounted read-only into the container; credentials are not included.
28+
// The file is bind-mounted read-only into the container and includes the db-uri
29+
// with embedded credentials.
2930
type PostgRESTConfigResource struct {
30-
ServiceInstanceID string `json:"service_instance_id"`
31-
ServiceID string `json:"service_id"`
32-
HostID string `json:"host_id"`
33-
DirResourceID string `json:"dir_resource_id"`
34-
Config *database.PostgRESTServiceConfig `json:"config"`
31+
ServiceInstanceID string `json:"service_instance_id"`
32+
ServiceID string `json:"service_id"`
33+
HostID string `json:"host_id"`
34+
DirResourceID string `json:"dir_resource_id"`
35+
Config *database.PostgRESTServiceConfig `json:"config"`
36+
Username string `json:"username"`
37+
Password string `json:"password"`
38+
DatabaseName string `json:"database_name"`
39+
DatabaseHosts []database.ServiceHostEntry `json:"database_hosts"`
40+
TargetSessionAttrs string `json:"target_session_attrs,omitempty"`
3541
}
3642

3743
func (r *PostgRESTConfigResource) ResourceVersion() string {
@@ -113,8 +119,12 @@ func (r *PostgRESTConfigResource) Delete(ctx context.Context, rc *resource.Conte
113119
}
114120

115121
func (r *PostgRESTConfigResource) writeConfigFile(fs afero.Fs, dirPath string) error {
116-
content, err := GeneratePostgRESTConfig(&PostgRESTConfigParams{
117-
Config: r.Config,
122+
content, err := r.Config.GenerateConf(database.PostgRESTConnParams{
123+
Username: r.Username,
124+
Password: r.Password,
125+
DatabaseName: r.DatabaseName,
126+
DatabaseHosts: r.DatabaseHosts,
127+
TargetSessionAttrs: r.TargetSessionAttrs,
118128
})
119129
if err != nil {
120130
return fmt.Errorf("failed to generate PostgREST config: %w", err)

0 commit comments

Comments
 (0)