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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user