feat(db): per-service role DSNs on an armed shared DB (M2 §4)

Database-capable services now get a per-service DATABASE_URL injected at
start (role = cert CN, sslmode=verify-full against the instance CA) when
the shared database runs mTLS-armed; one-off migrate/seed helpers get the
identity mount plus libpq SSL env under the same role. SyncAuthorization
grants each peer CREATE on public and USAGE + ALL on tables/sequences in
every user schema so per-service roles can run migrations and CRUD.
This commit is contained in:
2026-08-21 20:59:42 +01:00
parent 545338016e
commit c916dd4ac8
2 changed files with 111 additions and 3 deletions
+73 -3
View File
@@ -22,6 +22,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
@@ -65,8 +66,11 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
// CA and mount it plus the CA bundle into the container. The compose file // CA and mount it plus the CA bundle into the container. The compose file
// downloaded from git never declares these mounts — Maestro injects them // downloaded from git never declares these mounts — Maestro injects them
// (security theme M1 §2). Failing to provision identity is fatal: a peer // (security theme M1 §2). Failing to provision identity is fatal: a peer
// without a cert cannot authenticate on the mTLS edges. // without a cert cannot authenticate on the mTLS edges. Database-capable
if err := injectServiceIdentity(ctx, project, serviceName); err != nil { // services additionally get their per-service database role DSN here (M2
// §4): on an armed (mTLS) shared database the shared-superuser URL from
// their compose file cannot connect at all.
if err := injectServiceIdentity(ctx, dc, project, serviceName); err != nil {
return err return err
} }
@@ -115,7 +119,14 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
// fingerprint recorded at install. A service that already has the pin skips the // fingerprint recorded at install. A service that already has the pin skips the
// fetch; one installed before this feature (or whose install could not reach // fetch; one installed before this feature (or whose install could not reach
// the auth service) backfills it here, best-effort. // the auth service) backfills it here, best-effort.
func injectServiceIdentity(ctx context.Context, project *composetypes.Project, serviceName string) error { //
// For services declaring the database capability (M2 §4), when the shared
// database runs mTLS-armed, every app service additionally receives a
// per-service DATABASE_URL (role = the service's cert CN, verify-full against
// the instance CA), overriding the shared-superuser URL their compose file
// ships. One-off helpers (migrate/seed) get the identity mount plus libpq SSL
// env so they can reach the armed database under the same role.
func injectServiceIdentity(ctx context.Context, dc *docker.DockerClient, project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot() dataRoot, err := getDataRoot()
if err != nil { if err != nil {
return fmt.Errorf("resolve data root: %w", err) return fmt.Errorf("resolve data root: %w", err)
@@ -142,6 +153,26 @@ func injectServiceIdentity(ctx context.Context, project *composetypes.Project, s
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key", "AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
} }
// Per-service database role DSN (M2 §4). Only on an armed database — a
// plain (compose-managed dev) Postgres cannot satisfy verify-full, and its
// stock trust rule needs no per-service roles anyway. Best-effort: without
// the capability in the install record (pre-manifest installs) nothing is
// injected and the compose defaults apply.
dbEnv := map[string]string{}
if serviceName != DatabaseServiceName && hasDatabaseCapability(serviceName) {
if dbctl.New(dc).Armed(ctx) {
env["DATABASE_URL"] = mtlsDatabaseURL(serviceName)
dbEnv = map[string]string{
"PGUSER": serviceName,
"PGSSLMODE": "verify-full",
"PGSSLCERT": identityMountDir + "/tls.crt",
"PGSSLKEY": identityMountDir + "/tls.key",
"PGSSLROOTCERT": identityMountDir + "/ca.crt",
}
logger.Info("Injected per-service database DSN for %s (armed shared database)", serviceName)
}
}
// The auth service is the attester itself — nothing to pin there. For every // The auth service is the attester itself — nothing to pin there. For every
// other peer, pin its signing key (fetch + persist once). // other peer, pin its signing key (fetch + persist once).
if serviceName != AuthServiceName { if serviceName != AuthServiceName {
@@ -152,6 +183,15 @@ func injectServiceIdentity(ctx context.Context, project *composetypes.Project, s
for name, s := range project.Services { for name, s := range project.Services {
if isOneoff(s) { if isOneoff(s) {
// Migrate/seed helpers talk libpq/psycopg straight to the shared
// database; on an armed database they need the identity mount plus
// the same per-service role (their compose file's PGUSER is the
// plain-dev default).
if len(dbEnv) > 0 {
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, dbEnv)
project.Services[name] = s
}
continue continue
} }
s.Volumes = append(s.Volumes, composeVolumes(mounts)...) s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
@@ -162,6 +202,36 @@ func injectServiceIdentity(ctx context.Context, project *composetypes.Project, s
return nil return nil
} }
// mtlsDatabaseURL builds the per-service connection URL for the shared
// database: role = the service's cert CN, no password (clientcert=verify-full
// makes the certificate the credential), TLS verified against the instance CA.
func mtlsDatabaseURL(serviceName string) string {
return fmt.Sprintf(
"postgres://%s@%s:5432/%s?sslmode=verify-full&sslrootcert=%s/ca.crt&sslcert=%s/tls.crt&sslkey=%s/tls.key",
serviceName, DatabaseServiceName, dbctl.DBName,
identityMountDir, identityMountDir, identityMountDir,
)
}
// hasDatabaseCapability reports whether the installed service declared the
// database capability in its manifest (persisted at install/reconcile).
func hasDatabaseCapability(serviceName string) bool {
dir, err := getContainerDataDir(serviceName)
if err != nil {
return false
}
metadata, err := readInstalledMetadata(dir)
if err != nil {
return false
}
for _, c := range metadata.Capabilities {
if c == "database" {
return true
}
}
return false
}
// hostBindSource maps a dataRoot-relative path to a bind-mount source the // hostBindSource maps a dataRoot-relative path to a bind-mount source the
// Docker daemon can resolve. Bind sources are resolved on the daemon's host // Docker daemon can resolve. Bind sources are resolved on the daemon's host
// namespace, but inside the dev container the data root lives at a // namespace, but inside the dev container the data root lives at a
+38
View File
@@ -95,6 +95,12 @@ func (c *Client) SyncAuthorization(ctx context.Context) error {
script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role)) script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role))
} }
script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role)) script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role))
// The peer connects and migrates under its own role (its cert CN),
// not the shared superuser: it may create tables in public (PG15+
// revoked that from PUBLIC) and must reach the tables it already
// owns or shares across every user schema (e.g. auth's own schema).
script += fmt.Sprintf("GRANT CREATE ON SCHEMA public TO %s;\n", quoteIdent(role))
script += grantExistingScript(role)
} }
if c.isArmed(ctx, id) { if c.isArmed(ctx, id) {
script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName)) script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName))
@@ -176,6 +182,26 @@ func dataRoot() (string, error) {
return filepath.Join("/data", ".dcntrlized"), nil return filepath.Join("/data", ".dcntrlized"), nil
} }
// grantExistingScript returns the DO block granting a role USAGE plus ALL
// PRIVILEGES on tables and sequences in every user schema. Existing volumes
// hold tables created by the postgres superuser (and by other services'
// earlier migrations); without these grants a per-service role could connect
// but not read or migrate anything.
func grantExistingScript(role string) string {
return fmt.Sprintf(`DO $dcntrlized_grants$ DECLARE s text; BEGIN
FOR s IN SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg_%%' AND nspname <> 'information_schema' LOOP
EXECUTE format('GRANT USAGE ON SCHEMA %%I TO %%I', s, %s);
EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %%I TO %%I', s, %s);
EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %%I TO %%I', s, %s);
END LOOP; END $dcntrlized_grants$;
`, quoteLiteral(role), quoteLiteral(role), quoteLiteral(role))
}
// quoteLiteral renders a PostgreSQL string literal safe for interpolation.
func quoteLiteral(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// quoteIdent renders a PostgreSQL identifier safe for interpolation. // quoteIdent renders a PostgreSQL identifier safe for interpolation.
func quoteIdent(s string) string { func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
@@ -203,6 +229,18 @@ func (c *Client) dbContainerID(ctx context.Context) string {
return "" return ""
} }
// Armed reports whether the running shared database received the injected
// instance-CA identity (i.e. it enforces mTLS on its TCP edge). Callers use
// this to decide which connection URL peers get: verify-full mTLS DSNs on an
// armed database, plaintext defaults otherwise.
func (c *Client) Armed(ctx context.Context) bool {
id := c.dbContainerID(ctx)
if id == "" {
return false
}
return c.isArmed(ctx, id)
}
// isArmed reports whether the database container received the injected // isArmed reports whether the database container received the injected
// instance-CA identity (i.e. it enforces mTLS on its TCP edge). // instance-CA identity (i.e. it enforces mTLS on its TCP edge).
func (c *Client) isArmed(ctx context.Context, id string) bool { func (c *Client) isArmed(ctx context.Context, id string) bool {