Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions docs/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,15 @@ creating one instance per host for redundancy:

## Database Credentials

Each service instance is automatically provisioned with two dedicated
database users. The Control Plane manages these credentials; you do not
need to create or rotate them manually. The credentials are:

- `svc_{service_id}_ro` is a read-only user with read access to the
database; this user is the default for most service types.
- `svc_{service_id}_rw` is a read-write user with read and write access
to the database; this user is provisioned when the service needs
read/write access.
Each service connects to the database as a user you specify with the
`connect_as` field. The `connect_as` value must reference a username
in your `database_users` array. The Control Plane uses those
credentials to generate the service's connection string and to configure
any required role grants (for example, granting the anonymous role to
a PostgREST authenticator).

You own and manage the `connect_as` user. Removing a service does not
drop the underlying database user.

## Next Steps

Expand Down
17 changes: 14 additions & 3 deletions docs/services/managing.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ The following table describes the fields in a service spec:

| Field | Type | Required | Description |
|-------|------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `service_id` | string | Yes | A unique identifier for this service within the database. Used in credential names (`svc_{service_id}_ro` / `svc_{service_id}_rw`). |
| `service_id` | string | Yes | A unique identifier for this service within the database. |
| `service_type` | string | Yes | The type of service to run. One of: `mcp`, `rag`, `postgrest`. |
| `version` | string | Yes | The service version in semver format (e.g., `1.0.0`) or the literal `latest`. |
| `host_ids` | array | Yes | The IDs of the hosts to run this service on. One instance is created per host. |
| `config` | object | No | Service-type-specific configuration. See the page for your service type for valid fields. When omitted, the service uses sensible defaults. |
| `port` | integer | No | Host port to publish the service on. Set to `0` to let Docker assign a random port. When omitted, the service is not reachable from outside the Docker network. |
| `cpus` | string | No | CPU limit for the service container. Accepts a decimal (e.g., `"0.5"`) or millicpu suffix (e.g., `"500m"`). Defaults to container defaults if unspecified. |
| `memory` | string | No | Memory limit for the service container in SI or IEC notation (e.g., `"512M"`, `"1GiB"`). Defaults to container defaults if unspecified. |
| `connect_as` | string | Yes | Username of the `database_users` entry the service connects to Postgres as. Must exist in `database_users`. |
| `database_connection` | object | No | Optional routing configuration for how the service connects to the database. See [Database Connection Routing](#database-connection-routing). |

## Adding a Service
Expand Down Expand Up @@ -82,13 +83,22 @@ database with a PostgREST service instance. The service exposes the
"nodes": [
{ "name": "n1", "host_ids": ["host-1"] }
],
"database_users": [
{
"username": "app",
"password": "changeme",
"db_owner": true,
"attributes": ["LOGIN"]
}
],
"services": [
{
"service_id": "api",
"service_type": "postgrest",
"version": "latest",
"host_ids": ["host-1"],
"port": 3100,
"connect_as": "app",
"config": {
"jwt_secret": "a-secret-key-of-at-least-32-characters"
}
Expand Down Expand Up @@ -157,8 +167,9 @@ service instances for that service and revokes its database credentials.

Removing a service is irreversible. The Control Plane deletes all
service instances, their configuration, and their data directories.
Database credentials for the service are revoked. Any clients
connected to the service lose access immediately.
Any clients connected to the service lose access immediately.
The `connect_as` database user is **not** dropped — it is
customer-managed and may be shared with other services or applications.

## Checking Service Status

Expand Down
50 changes: 39 additions & 11 deletions docs/services/postgrest.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ on every deploy.
## Overview

The Control Plane provisions a PostgREST container on each host you
specify. The container connects to the database using
automatically-managed credentials and serves HTTP at your configured
port. Anonymous requests run as the configured `db_anon_role`.
JWT-authenticated requests switch to any role granted to the
authenticator.
specify. The container connects to the database as the user specified
in `connect_as` (a `database_users` entry you control) and serves HTTP
at your configured port. Anonymous requests run as the configured
`db_anon_role`. JWT-authenticated requests switch to any role granted
to the `connect_as` user.

See [Managing Services](managing.md) for instructions on adding,
updating, and removing services. The sections below cover
Expand Down Expand Up @@ -54,7 +54,8 @@ using a signed token. Omit these fields to run in anonymous-only mode.
### Read-Only API (No JWT)

This example provisions a PostgREST service with default settings. All
requests run as the anonymous role.
requests run as the anonymous role. The `connect_as` field names the
`database_users` entry PostgREST connects to Postgres as.

=== "curl"

Expand All @@ -68,13 +69,22 @@ requests run as the anonymous role.
"nodes": [
{ "name": "n1", "host_ids": ["host-1"] }
],
"database_users": [
{
"username": "app",
"password": "changeme",
"db_owner": true,
"attributes": ["LOGIN"]
}
],
"services": [
{
"service_id": "api",
"service_type": "postgrest",
"version": "latest",
"host_ids": ["host-1"],
"port": 3100,
"connect_as": "app",
"config": {}
}
]
Expand All @@ -85,7 +95,8 @@ requests run as the anonymous role.
### JWT-Authenticated API

This example enables JWT authentication. Clients send a signed token to
switch to a specific PostgreSQL role.
switch to a specific PostgreSQL role. Every role a JWT can claim must be
granted to the `connect_as` user.

=== "curl"

Expand All @@ -99,13 +110,22 @@ switch to a specific PostgreSQL role.
"nodes": [
{ "name": "n1", "host_ids": ["host-1"] }
],
"database_users": [
{
"username": "app",
"password": "changeme",
"db_owner": true,
"attributes": ["LOGIN"]
}
],
"services": [
{
"service_id": "api",
"service_type": "postgrest",
"version": "latest",
"host_ids": ["host-1"],
"port": 3100,
"connect_as": "app",
"config": {
"jwt_secret": "a-secret-key-of-at-least-32-characters"
}
Expand All @@ -132,13 +152,22 @@ before deploying.
"nodes": [
{ "name": "n1", "host_ids": ["host-1"] }
],
"database_users": [
{
"username": "app",
"password": "changeme",
"db_owner": true,
"attributes": ["LOGIN"]
}
],
"services": [
{
"service_id": "api",
"service_type": "postgrest",
"version": "latest",
"host_ids": ["host-1"],
"port": 3100,
"connect_as": "app",
"config": {
"db_schemas": "public,api",
"jwt_secret": "a-secret-key-of-at-least-32-characters"
Expand Down Expand Up @@ -198,10 +227,9 @@ the PostgreSQL role PostgREST uses for the request.
--data '{"name": "Widget", "price": 9.99}'
```

The `role` claim must name a PostgreSQL role granted to the PostgREST
authenticator user. The authenticator username is visible in the
`service_instances` field of your database status response. Grant your
application roles to that user before sending authenticated requests.
The `role` claim must name a PostgreSQL role granted to the `connect_as`
user. Grant your application roles to the `connect_as` user in
PostgreSQL before sending authenticated requests.

## Preflight Checks

Expand Down
109 changes: 50 additions & 59 deletions e2e/postgrest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
)

// postgrestSpec returns a ServiceSpec for a PostgREST service on the given host.
// PostgREST connects as the "admin" database user (connect_as).
func postgrestSpec(hostID string, port int, config map[string]any) *controlplane.ServiceSpec {
if config == nil {
config = map[string]any{}
Expand All @@ -27,11 +28,13 @@ func postgrestSpec(hostID string, port int, config map[string]any) *controlplane
HostIds: []controlplane.Identifier{controlplane.Identifier(hostID)},
Port: pointerTo(port),
Config: config,
ConnectAs: "admin",
}
}

// postgrestBaseSpec returns the common database spec used across PostgREST tests.
// services is appended directly so callers control what services are included.
// The "admin" user serves as the PostgREST connect_as user.
func postgrestBaseSpec(dbName string, nodeHosts []string, services []*controlplane.ServiceSpec) *controlplane.DatabaseSpec {
nodes := make([]*controlplane.DatabaseNodeSpec, len(nodeHosts))
for i, h := range nodeHosts {
Expand Down Expand Up @@ -262,9 +265,11 @@ func TestPostgRESTHealthCheck(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode, "PostgREST root endpoint should return 200")
}

// TestPostgRESTServiceUserRoles verifies the CP created the correct Postgres
// roles for the PostgREST authenticator.
func TestPostgRESTServiceUserRoles(t *testing.T) {
// TestPostgRESTAuthenticatorRole verifies the connect_as user was configured
// correctly as a PostgREST authenticator by PostgRESTAuthenticatorResource:
// NOINHERIT must be set and the db_anon_role must be granted to it.
// No svc_* auto-created roles should exist for PostgREST.
func TestPostgRESTAuthenticatorRole(t *testing.T) {
t.Parallel()

fixture.SkipIfServicesUnsupported(t)
Expand Down Expand Up @@ -294,42 +299,36 @@ func TestPostgRESTServiceUserRoles(t *testing.T) {
require.NoError(t, err)
defer conn.Close(ctx)

// The RW service user must have NOINHERIT (rolinherit = false).
rows, err := conn.Query(ctx, `
SELECT rolname, rolinherit
FROM pg_roles
WHERE rolname LIKE 'svc_%'
AND rolname LIKE '%_rw'
ORDER BY rolname
`)
require.NoError(t, err)
defer rows.Close()

found := false
for rows.Next() {
var rolname string
var rolinherit bool
require.NoError(t, rows.Scan(&rolname, &rolinherit))
assert.False(t, rolinherit, "RW service role %s must have NOINHERIT (rolinherit = false)", rolname)
found = true
t.Logf("role %s: rolinherit=%v", rolname, rolinherit)
}
assert.True(t, found, "expected at least one _rw service role")
// The connect_as user ("admin") must have NOINHERIT set by the authenticator resource.
var rolinherit bool
err = conn.QueryRow(ctx,
`SELECT rolinherit FROM pg_roles WHERE rolname = 'admin'`,
).Scan(&rolinherit)
require.NoError(t, err, "connect_as user 'admin' must exist in pg_roles")
assert.False(t, rolinherit, "connect_as user must have NOINHERIT (rolinherit = false)")

// The RW role must be granted the anon role (pgedge_application_read_only).
// The db_anon_role (pgedge_application_read_only by default) must be granted to the connect_as user.
var anonGranted bool
err = conn.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM pg_auth_members m
JOIN pg_roles r ON m.member = r.oid
JOIN pg_roles g ON m.roleid = g.oid
WHERE r.rolname LIKE 'svc_%_rw'
WHERE r.rolname = 'admin'
AND g.rolname = 'pgedge_application_read_only'
)
`).Scan(&anonGranted)
require.NoError(t, err)
assert.True(t, anonGranted, "RW service role must be granted pgedge_application_read_only")
assert.True(t, anonGranted, "connect_as user must be granted the db_anon_role")

// No auto-created svc_* roles should exist for PostgREST.
var svcRoleCount int
err = conn.QueryRow(ctx,
`SELECT COUNT(*) FROM pg_roles WHERE rolname LIKE 'svc_%'`,
).Scan(&svcRoleCount)
require.NoError(t, err)
assert.Equal(t, 0, svcRoleCount, "no svc_* roles should be created for PostgREST with connect_as")
}

// TestPostgRESTAddToExistingDatabase verifies PostgREST provisions correctly
Expand Down Expand Up @@ -401,7 +400,8 @@ func TestPostgRESTRemove(t *testing.T) {

assert.Empty(t, db.ServiceInstances, "service instances should be empty after removal")

// Verify the service user roles are dropped from Postgres.
// Verify the connect_as user still exists after removal — it is a
// customer-managed database_users entry, not an auto-created service role.
conn, err := db.ConnectToInstance(ctx, ConnectionOptions{
Matcher: And(WithNode("n1"), WithRole("primary")),
Username: "admin",
Expand All @@ -410,12 +410,12 @@ func TestPostgRESTRemove(t *testing.T) {
require.NoError(t, err)
defer conn.Close(ctx)

var count int
err = conn.QueryRow(ctx, `
SELECT COUNT(*) FROM pg_roles WHERE rolname LIKE 'svc_%'
`).Scan(&count)
var exists bool
err = conn.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'admin')`,
).Scan(&exists)
require.NoError(t, err)
assert.Equal(t, 0, count, "all service roles should be dropped after PostgREST removal")
assert.True(t, exists, "connect_as user must not be dropped when PostgREST is removed")
}

// TestPostgRESTConfigUpdate verifies updating PostgREST config updates the
Expand Down Expand Up @@ -505,33 +505,24 @@ func TestPostgRESTMultiHostDBURI(t *testing.T) {

waitForPostgRESTRunning(ctx, t, db, "postgrest-api", host1, 5*time.Minute)

// Connect to Postgres and confirm service roles exist on all nodes.
for _, nodeName := range []string{"n1"} {
conn, err := db.ConnectToInstance(ctx, ConnectionOptions{
Matcher: And(WithNode(nodeName), WithRole("primary")),
Username: "admin",
Password: "testpassword",
})
if err != nil {
// The primary moved — find it on whichever node is primary.
conn, err = db.ConnectToInstance(ctx, ConnectionOptions{
Matcher: WithRole("primary"),
Username: "admin",
Password: "testpassword",
})
}
require.NoError(t, err, "failed to connect to primary on node %s", nodeName)
defer conn.Close(ctx)

var count int
err = conn.QueryRow(ctx, `
SELECT COUNT(*) FROM pg_roles WHERE rolname LIKE 'svc_%_rw'
`).Scan(&count)
require.NoError(t, err)
assert.GreaterOrEqual(t, count, 1, "RW service role must exist on node %s", nodeName)
}
// Connect to Postgres and confirm the connect_as user was configured
// as an authenticator (NOINHERIT + anon role granted).
conn, err := db.ConnectToInstance(ctx, ConnectionOptions{
Matcher: WithRole("primary"),
Username: "admin",
Password: "testpassword",
})
require.NoError(t, err, "failed to connect to primary")
defer conn.Close(ctx)

var rolinherit bool
err = conn.QueryRow(ctx,
`SELECT rolinherit FROM pg_roles WHERE rolname = 'admin'`,
).Scan(&rolinherit)
require.NoError(t, err)
assert.False(t, rolinherit, "connect_as user must have NOINHERIT on multi-host deployment")

t.Log("multi-host PostgREST provisioned, service roles present on all nodes")
t.Log("multi-host PostgREST provisioned, connect_as user configured as authenticator")
}

// TestPostgRESTFailover provisions PostgREST on a 3-node database, triggers a
Expand Down
4 changes: 2 additions & 2 deletions server/internal/api/apiv1/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ func validateServiceSpec(svc *api.ServiceSpec, path []string, isUpdate bool, dbU
}

// Validate connect_as references a valid database_users entry.
// MCP and RAG both require connect_as; PostgREST will adopt it in a future change.
if svc.ServiceType == "mcp" || svc.ServiceType == "rag" {
// Required for MCP, PostgREST, and RAG — all three use connect_as credentials.
if svc.ServiceType == "mcp" || svc.ServiceType == "postgrest" || svc.ServiceType == "rag" {
errs = append(errs, validateConnectAs(svc, dbUsers, path)...)
}

Expand Down
Loading