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