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) + } + } +}