From 3217dca39f5dbf0858d2e3e22fbb337ad2f79bb2 Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Wed, 19 Aug 2026 00:22:23 +0100
Subject: [PATCH] =?UTF-8?q?feat(db):=20dbctl=20=E2=80=94=20reconcile=20per?=
=?UTF-8?q?-peer=20CONNECT=20authorization=20on=20the=20shared=20DB=20(M2?=
=?UTF-8?q?=20=C2=A74)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New lib/db/dbctl: SyncAuthorization runs after the DB starts (EnsureDatabaseRunning),
ensuring a LOGIN role + CONNECT grant per installed peer and revoking the stock
PUBLIC CONNECT grant on mTLS-armed databases; RevokeForService drops a peer's
CONNECT at uninstall. Admin executes over the in-container unix socket as
postgres (trust). Wired into ensureInfraRunning and UninstallContainer.
Includes unit tests + env-gated live integration test.
---
src/lib/containers/infra.go | 11 +
src/lib/containers/uninstall.go | 4 +
src/lib/db/dbctl.go | 289 +++++++++++++++++++++++++++
src/lib/db/dbctl_integration_test.go | 84 ++++++++
src/lib/db/dbctl_test.go | 78 ++++++++
5 files changed, 466 insertions(+)
create mode 100644 src/lib/db/dbctl.go
create mode 100644 src/lib/db/dbctl_integration_test.go
create mode 100644 src/lib/db/dbctl_test.go
diff --git a/src/lib/containers/infra.go b/src/lib/containers/infra.go
index efa0b51..e8e7de5 100644
--- a/src/lib/containers/infra.go
+++ b/src/lib/containers/infra.go
@@ -20,6 +20,7 @@ import (
"fmt"
"strings"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/db"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
@@ -104,6 +105,16 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error {
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
}
+
+ // For the shared database, reconcile the CONNECT authorization against the
+ // peers Maestro manages (M2 §4): per-role CONNECT grants and, in mTLS mode,
+ // PUBLIC revoked. Best-effort — the database advertises infra-relevant
+ // problems as warnings, never as boot failures.
+ if serviceName == DatabaseServiceName {
+ if err := dbctl.New(dc).SyncAuthorization(ctx); err != nil {
+ logger.Warn("Shared database authorization sync failed: %v", err)
+ }
+ }
return nil
}
diff --git a/src/lib/containers/uninstall.go b/src/lib/containers/uninstall.go
index 55e7409..f17f257 100644
--- a/src/lib/containers/uninstall.go
+++ b/src/lib/containers/uninstall.go
@@ -22,6 +22,7 @@ import (
"strings"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
+ "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"
@@ -59,6 +60,9 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
if fingerprint != "" {
authmgmt.RevokeServiceBestEffort(ctx, serviceName, fingerprint)
}
+ // Drop the database CONNECT grant so the revoked peer cannot open a session
+ // against the shared database either (M2 §4).
+ dbctl.New(dc).RevokeForService(ctx, serviceName)
if _, err := os.Stat(targetDir); err == nil {
// Stop and remove the project containers/networks via compose down when
diff --git a/src/lib/db/dbctl.go b/src/lib/db/dbctl.go
new file mode 100644
index 0000000..a83c096
--- /dev/null
+++ b/src/lib/db/dbctl.go
@@ -0,0 +1,289 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package dbctl administers the shared database's authorization (M2 §4).
+//
+// The database image enables instance-CA mTLS when Maestro injects an identity
+// (server cert + client-CA + hostssl cert / hostnossl reject rules), but the
+// auth rules alone only prove *who* authenticates. Authorization lives here:
+// the stock PUBLIC CONNECT grant is revoked and each installed peer gets a
+// LOGIN role with CONNECT, so a peer that holds no role (or a revoked one)
+// cannot even open a session. The admin path uses the postgres superuser over
+// the in-container unix socket (trust), which deliberately bypasses the mTLS
+// enforcement — Maestro owns the database and drives its lifecycle.
+package dbctl
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
+ "github.com/docker/docker/api/types/container"
+ "github.com/docker/docker/api/types/filters"
+ "github.com/docker/docker/pkg/stdcopy"
+)
+
+const (
+ // DBContainerName is the canonical shared database container (image, DNS).
+ DBContainerName = "dcntrlized-db"
+ // DBName is the shared database name inside the instance.
+ DBName = "dcntrlized"
+ // AdminRole is Maestro's own database role (CN=maestro bootstrap in the
+ // image). It is granted like any peer so its existence is guaranteed.
+ AdminRole = "maestro"
+ // identityMountTarget is the container path of the injected leaf cert. Its
+ // presence in the mount list marks the database as mTLS-armed.
+ identityMountTarget = "/dcntrlized/pki/tls.crt"
+)
+
+// Client reconciles the shared database's CONNECT authorization state.
+type Client struct {
+ dc *docker.DockerClient
+}
+
+// New wraps a Docker client.
+func New(dc *docker.DockerClient) *Client {
+ return &Client{dc: dc}
+}
+
+// SyncAuthorization ensures every installed peer holds a LOGIN role with
+// CONNECT on the shared database, and — when the database runs in mTLS mode —
+// revokes the stock PUBLIC grant. It is safe to call on every start: role
+// creation and grants are idempotent, and the PUBLIC revoke is a no-op on the
+// second run. When the database is not running it returns an error; callers
+// treat it as best-effort.
+func (c *Client) SyncAuthorization(ctx context.Context) error {
+ id := c.dbContainerID(ctx)
+ if id == "" {
+ return fmt.Errorf("shared database %s is not running", DBContainerName)
+ }
+
+ if err := c.waitReady(ctx, id); err != nil {
+ return err
+ }
+
+ roles, err := peerRoles()
+ if err != nil {
+ return err
+ }
+ if len(roles) == 0 {
+ return nil
+ }
+
+ script := "SET client_min_messages TO warning;\n"
+ have := c.existingRoles(ctx, id)
+ for _, role := range roles {
+ if !have[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))
+ }
+ if c.isArmed(ctx, id) {
+ script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName))
+ }
+
+ out, code, err := c.execPSQL(ctx, id, script)
+ if err != nil {
+ return fmt.Errorf("apply database authorization: %w", err)
+ }
+ if code != 0 {
+ return fmt.Errorf("apply database authorization failed (exit %d): %s", code, out)
+ }
+ logger.Info("Reconciled shared database authorization for %d peer(s): %s", len(roles), strings.Join(roles, ", "))
+ return nil
+}
+
+// RevokeForService removes a peer's CONNECT grant when it is uninstalled.
+// Best-effort: the database may already be gone.
+func (c *Client) RevokeForService(ctx context.Context, serviceName string) {
+ if serviceName == "" {
+ return
+ }
+ id := c.dbContainerID(ctx)
+ if id == "" {
+ return
+ }
+ script := fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM %s;", quoteIdent(DBName), quoteIdent(serviceName))
+ out, code, err := c.execPSQL(ctx, id, script)
+ if err != nil || code != 0 {
+ logger.Warn("Failed to revoke database CONNECT for %s: %v (exit %d): %s", serviceName, err, code, out)
+ return
+ }
+ logger.Info("Revoked database CONNECT for %s", serviceName)
+}
+
+// peerRoles returns the guaranteed connectors: Maestro plus every installed
+// service (identified by its install dir name, which is also its cert CN).
+func peerRoles() ([]string, error) {
+ roles := []string{AdminRole}
+ root, err := dataRoot()
+ if err != nil {
+ return nil, fmt.Errorf("resolve data root: %w", err)
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return roles, nil
+ }
+ return nil, fmt.Errorf("list data root: %w", err)
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() || entry.Name() == DBContainerName {
+ continue
+ }
+ if _, err := os.Stat(filepath.Join(root, entry.Name(), "metadata.json")); err != nil {
+ continue
+ }
+ roles = append(roles, entry.Name())
+ }
+ return roles, nil
+}
+
+// dataRoot resolves the directory storing installed container metadata, with
+// the same precedence as lib/containers.DataRoot (kept local to avoid an
+// import cycle — lib/containers imports dbctl).
+func dataRoot() (string, error) {
+ if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" {
+ return dir, nil
+ }
+ if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
+ return filepath.Join(xdg, ".dcntrlized"), nil
+ }
+ if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" {
+ return filepath.Join(xdgHome, ".dcntrlized"), nil
+ }
+ if home := os.Getenv("HOME"); home != "" {
+ return filepath.Join(home, ".dcntrlized"), nil
+ }
+ return filepath.Join("/data", ".dcntrlized"), nil
+}
+
+// quoteIdent renders a PostgreSQL identifier safe for interpolation.
+func quoteIdent(s string) string {
+ return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
+}
+
+// dbContainerID resolves the running canonical database container, or "".
+func (c *Client) dbContainerID(ctx context.Context) string {
+ filter := filters.NewArgs()
+ filter.Add("label", "org.dcntrlized.service="+DBContainerName)
+ containers, err := c.dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter})
+ if err != nil {
+ logger.Warn("Failed to list database containers: %v", err)
+ return ""
+ }
+ for _, cc := range containers {
+ if cc.State != "running" {
+ continue
+ }
+ for _, n := range cc.Names {
+ if strings.TrimPrefix(n, "/") == DBContainerName {
+ return cc.ID
+ }
+ }
+ }
+ return ""
+}
+
+// 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 {
+ inspect, err := c.dc.ContainerInspect(ctx, id)
+ if err != nil {
+ return false
+ }
+ for _, m := range inspect.Mounts {
+ if filepath.Clean(m.Destination) == identityMountTarget {
+ return true
+ }
+ }
+ return false
+}
+
+// waitReady polls the database until an admin statement executes or the
+// deadline is reached, tolerating the first-boot delay after compose up.
+func (c *Client) waitReady(ctx context.Context, id string) error {
+ const attempts = 30
+ for i := 0; i < attempts; i++ {
+ _, code, err := c.execPSQL(ctx, id, "SELECT 1")
+ if err == nil && code == 0 {
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(time.Second):
+ }
+ }
+ return fmt.Errorf("database %s did not become ready in time", DBContainerName)
+}
+
+// existingRoles returns the set of roles currently defined in the database.
+func (c *Client) existingRoles(ctx context.Context, id string) map[string]bool {
+ have := map[string]bool{}
+ out, code, err := c.execPSQL(ctx, id, "SELECT rolname FROM pg_catalog.pg_roles")
+ if err != nil || code != 0 {
+ return have
+ }
+ for _, line := range strings.Split(out, "\n") {
+ if name := strings.TrimSpace(line); name != "" {
+ have[name] = true
+ }
+ }
+ return have
+}
+
+// execPSQL runs a psql statement over the in-container unix socket as the
+// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro
+// administers the database). It returns trimmed stdout and the exit code.
+func (c *Client) execPSQL(ctx context.Context, id, sql string) (string, int, error) {
+ created, err := c.dc.ContainerExecCreate(ctx, id, container.ExecOptions{
+ User: "postgres",
+ AttachStdout: true,
+ Cmd: []string{"psql", "-U", "postgres", "-d", "postgres", "-v", "ON_ERROR_STOP=1", "-tA", "-c", sql},
+ })
+ if err != nil {
+ return "", -1, fmt.Errorf("create psql exec: %w", err)
+ }
+
+ resp, err := c.dc.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{})
+ if err != nil {
+ return "", -1, fmt.Errorf("attach psql exec: %w", err)
+ }
+ defer resp.Close()
+
+ var stdout, stderr bytes.Buffer
+ if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil {
+ return "", -1, fmt.Errorf("read psql output: %w", err)
+ }
+
+ for i := 0; i < 50; i++ {
+ inspect, err := c.dc.ContainerExecInspect(ctx, created.ID)
+ if err != nil {
+ return "", -1, fmt.Errorf("inspect psql exec: %w", err)
+ }
+ if !inspect.Running {
+ return strings.TrimSpace(stdout.String()), inspect.ExitCode, nil
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ return "", -1, fmt.Errorf("psql exec did not finish in time")
+}
diff --git a/src/lib/db/dbctl_integration_test.go b/src/lib/db/dbctl_integration_test.go
new file mode 100644
index 0000000..f622fc3
--- /dev/null
+++ b/src/lib/db/dbctl_integration_test.go
@@ -0,0 +1,84 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package dbctl
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
+)
+
+// TestSyncAuthorizationIntegration exercises the real exec path against a
+// running dcntrlized-db container.
+//
+// 1. docker run -d --name dcntrlized-db -e POSTGRES_DB=dcntrlized \
+// -e POSTGRES_USER=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
+// -v $PWD/PKI/ca.crt:/dcntrlized/pki/ca.crt:ro \
+// -v $PWD/PKI/tls.crt:/dcntrlized/pki/tls.crt:ro \
+// -v $PWD/PKI/tls.key:/dcntrlized/pki/tls.key:ro \
+// -v dctl-vol:/var/lib/postgresql/data dcntrlized-db
+// 2. seed the data root with install dirs for the peers
+// 3. DBCtl_INTEGRATION=1 go test ./lib/db -run TestSyncAuthorizationIntegration -v
+func TestSyncAuthorizationIntegration(t *testing.T) {
+ if os.Getenv("DBCtl_INTEGRATION") != "1" {
+ t.Skip("set DBCtl_INTEGRATION=1 to run against a live database container")
+ }
+
+ root := t.TempDir()
+ t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
+ for _, name := range []string{"keepnote", "dcntrlized-auth"} {
+ if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, name, "metadata.json"), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ dc, err := docker.NewDockerClient()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dc.Close()
+
+ c := New(dc)
+ if err := c.SyncAuthorization(context.Background()); err != nil {
+ t.Fatalf("SyncAuthorization: %v", err)
+ }
+
+ id := c.dbContainerID(context.Background())
+ if id == "" {
+ t.Fatal("database container not found")
+ }
+ assert := func(t *testing.T, sql, contains string) {
+ t.Helper()
+ out, code, err := c.execPSQL(context.Background(), id, sql)
+ if err != nil || code != 0 {
+ t.Fatalf("check %q failed (exit %d, err %v): %s", sql, code, err, out)
+ }
+ if want := contains; want != "" && out != want {
+ t.Fatalf("%s => %q, want %q", sql, out, want)
+ }
+ }
+ // Roles exist and hold CONNECT.
+ assert(t, `SELECT rolname FROM pg_catalog.pg_roles WHERE rolname IN ('maestro','keepnote','dcntrlized-auth') ORDER BY 1`, "dcntrlized-auth\nkeepnote\nmaestro")
+ assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
+ // Armed database: PUBLIC grant removed.
+ assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
+}
\ No newline at end of file
diff --git a/src/lib/db/dbctl_test.go b/src/lib/db/dbctl_test.go
new file mode 100644
index 0000000..8aa3242
--- /dev/null
+++ b/src/lib/db/dbctl_test.go
@@ -0,0 +1,78 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package dbctl
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+)
+
+func TestQuoteIdent(t *testing.T) {
+ cases := map[string]string{
+ "keepnote": `"keepnote"`,
+ "dcntrlized-db": `"dcntrlized-db"`,
+ `we"ird`: `"we""ird"`,
+ }
+ for in, want := range cases {
+ if got := quoteIdent(in); got != want {
+ t.Errorf("quoteIdent(%q) = %s, want %s", in, got, want)
+ }
+ }
+}
+
+func TestPeerRoles(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("DCNTRLIZED_INSTALL_DIR", root)
+
+ // Installed peers (metadata.json present).
+ for _, name := range []string{"keepnote", "dcntrlized-auth", "calendar"} {
+ dir := filepath.Join(root, name)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // The database itself must never be granted a role.
+ if err := os.MkdirAll(filepath.Join(root, DBContainerName), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, DBContainerName, "metadata.json"), []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ // A non-install directory (no metadata.json) is skipped.
+ if err := os.MkdirAll(filepath.Join(root, "scratch"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ roles, err := peerRoles()
+ if err != nil {
+ t.Fatalf("peerRoles: %v", err)
+ }
+ sort.Strings(roles)
+ want := []string{"calendar", "dcntrlized-auth", "keepnote", "maestro"}
+ if len(roles) != len(want) {
+ t.Fatalf("peerRoles = %v, want %v", roles, want)
+ }
+ for i := range want {
+ if roles[i] != want[i] {
+ t.Fatalf("peerRoles = %v, want %v", roles, want)
+ }
+ }
+}