From c916dd4ac8d53ba01b290fec76391be014374fa9 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Fri, 21 Aug 2026 20:59:42 +0100 Subject: [PATCH] =?UTF-8?q?feat(db):=20per-service=20role=20DSNs=20on=20an?= =?UTF-8?q?=20armed=20shared=20DB=20(M2=20=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/containers/start.go | 76 +++++++++++++++++++++++++++++++++++-- src/lib/db/dbctl.go | 38 +++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go index 1297a47..0fa610c 100644 --- a/src/lib/containers/start.go +++ b/src/lib/containers/start.go @@ -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 diff --git a/src/lib/db/dbctl.go b/src/lib/db/dbctl.go index a83c096..922c89d 100644 --- a/src/lib/db/dbctl.go +++ b/src/lib/db/dbctl.go @@ -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("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) { 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 } +// 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. func quoteIdent(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` @@ -203,6 +229,18 @@ func (c *Client) dbContainerID(ctx context.Context) string { 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 // instance-CA identity (i.e. it enforces mTLS on its TCP edge). func (c *Client) isArmed(ctx context.Context, id string) bool {