feat(db): dbctl — reconcile per-peer CONNECT authorization on the shared DB (M2 §4)

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.
This commit is contained in:
2026-08-19 00:22:23 +01:00
parent 73fd96d698
commit 3217dca39f
5 changed files with 466 additions and 0 deletions
+11
View File
@@ -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
}
+4
View File
@@ -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
+289
View File
@@ -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 <https://www.gnu.org/licenses/>.
// 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")
}
+84
View File
@@ -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 <https://www.gnu.org/licenses/>.
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")
}
+78
View File
@@ -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 <https://www.gnu.org/licenses/>.
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)
}
}
}