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:
@@ -20,6 +20,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"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"
|
||||||
libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry"
|
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 {
|
if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil {
|
||||||
return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
|
"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/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"
|
||||||
@@ -59,6 +60,9 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
|
|||||||
if fingerprint != "" {
|
if fingerprint != "" {
|
||||||
authmgmt.RevokeServiceBestEffort(ctx, serviceName, 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 {
|
if _, err := os.Stat(targetDir); err == nil {
|
||||||
// Stop and remove the project containers/networks via compose down when
|
// Stop and remove the project containers/networks via compose down when
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user