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"
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"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
// downloaded from git never declares these mounts — Maestro injects them
// (security theme M1 §2). Failing to provision identity is fatal: a peer
// without a cert cannot authenticate on the mTLS edges.
if err := injectServiceIdentity(ctx, project, serviceName); err != nil {
// without a cert cannot authenticate on the mTLS edges. Database-capable
// 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
}
@@ -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
// fetch; one installed before this feature (or whose install could not reach
// 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()
if err != nil {
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",
}
// 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
// other peer, pin its signing key (fetch + persist once).
if serviceName != AuthServiceName {
@@ -152,6 +183,15 @@ func injectServiceIdentity(ctx context.Context, project *composetypes.Project, s
for name, s := range project.Services {
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
}
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
@@ -162,6 +202,36 @@ func injectServiceIdentity(ctx context.Context, project *composetypes.Project, s
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
// 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