diff --git a/backend/src/config.ts b/backend/src/config.ts index c3c00a10..4a4946e4 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -5,6 +5,7 @@ import { type AuthenticationConfigProvider, type AuthMode, } from "./auth/config.js"; +import { createProjectedAuthenticationConfigProvider } from "./auth/runtime-projection.js"; import type { WorkspaceRegistryConfig } from "./workspaces/types.js"; export interface AppConfig { @@ -176,13 +177,31 @@ function positiveDimension(value: string | undefined, fallback: number): number } export function loadConfig(env: Record): AppConfig { - const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? "/run/thothii-auth/auth.yaml", "file"); + const defaultAuthConfigFile = "/run/thothii-auth/auth.yaml"; + const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? defaultAuthConfigFile, "file"); const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root"); - const hasAuthenticationConfig = authConfigFileExists(authConfigFile); + const runtimeProjectionRoot = env.THT_AUTH_RUNTIME_PROJECTION_ROOT; + let hasAuthenticationConfig = false; + let authentication: AuthenticationConfigProvider | undefined; + if (runtimeProjectionRoot !== undefined) { + let projectionRoot: string; + try { + projectionRoot = absoluteAuthPath(runtimeProjectionRoot, "runtime projection root"); + } catch { + throw new Error("authentication configuration is invalid"); + } + if (env.THT_AUTH_CONFIG_FILE !== undefined && env.THT_AUTH_CONFIG_FILE !== defaultAuthConfigFile) { + throw new Error("authentication configuration is invalid"); + } + authentication = createProjectedAuthenticationConfigProvider(projectionRoot); + hasAuthenticationConfig = true; + } else { + hasAuthenticationConfig = authConfigFileExists(authConfigFile); + authentication = hasAuthenticationConfig ? createAuthenticationConfigProvider(authConfigFile) : undefined; + } if (hasAuthenticationConfig && env.AUTH_MODE !== undefined) { throw new Error("authentication configuration and AUTH_MODE cannot both be set"); } - const authentication = hasAuthenticationConfig ? createAuthenticationConfigProvider(authConfigFile) : undefined; let authMode: AuthMode; if (authentication) { authMode = authentication.current().value.mode; diff --git a/backend/test/auth-runtime-projection.test.ts b/backend/test/auth-runtime-projection.test.ts index b6bccaa6..5a9a58f2 100644 --- a/backend/test/auth-runtime-projection.test.ts +++ b/backend/test/auth-runtime-projection.test.ts @@ -20,6 +20,7 @@ import { stringify } from "yaml"; import { afterEach, expect, test, vi } from "vitest"; import { createProjectedAuthenticationConfigProvider } from "../src/auth/runtime-projection.js"; import { createCurrentLocalUserRegistryResolver } from "../src/auth/local-registry.js"; +import { loadConfig } from "../src/config.js"; const fsHook = vi.hoisted(() => ({ path: undefined as string | undefined, @@ -308,6 +309,35 @@ test("loads a complete OIDC projection without a users snapshot", () => { }); }); +test("loadConfig selects an immutable projected local provider and its in-memory registry", async () => { + const root = projectionRoot(); + writeReadyProjection(root, localProjectionFixture("projected-user", passwordHash)); + + const config = loadConfig({ + THT_AUTH_RUNTIME_PROJECTION_ROOT: root, + THT_AUTH_STATE_ROOT: "/state/auth", + }); + const loaded = config.authentication?.current(); + expect(loaded).toMatchObject({ value: { mode: "local" }, runtimeProjection: expect.any(Object) }); + const registry = createCurrentLocalUserRegistryResolver().resolve(loaded!); + expect(await registry?.findByUsername("PROJECTED-USER")).toMatchObject({ username: "projected-user" }); +}); + +test("loadConfig selects an immutable projected OIDC provider without direct-file fallback", () => { + const root = projectionRoot(); + writeReadyOidcProjection(root); + + const config = loadConfig({ + THT_AUTH_RUNTIME_PROJECTION_ROOT: root, + THT_AUTH_CONFIG_FILE: "/run/thothii-auth/auth.yaml", + THT_AUTH_STATE_ROOT: "/state/auth", + }); + expect(config.authentication?.current()).toMatchObject({ + value: { mode: "oidc" }, + runtimeProjection: expect.any(Object), + }); +}); + test("rejects a trailing-slash runtime root", () => { const root = projectionRoot(); writeReadyProjection( diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts index fc4920b1..68ba2742 100644 --- a/backend/test/config.test.ts +++ b/backend/test/config.test.ts @@ -117,6 +117,31 @@ test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE } }); +test.each([ + ["relative root", { THT_AUTH_RUNTIME_PROJECTION_ROOT: "relative" }], + ["conflicting direct file", { + THT_AUTH_RUNTIME_PROJECTION_ROOT: "/run/thothii-auth", + THT_AUTH_CONFIG_FILE: "/different/auth.yaml", + }], +])("rejects projected authentication configuration: %s", (_name, env) => { + expect(() => loadConfig(env)).toThrow("authentication configuration is invalid"); +}); + +test("keeps the direct auth-file provider when the runtime projection environment is absent", () => { + const { directory, file } = authFile(oidcAuthConfig()); + try { + const loaded = loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: "/state/auth" }); + const current = loaded.authentication?.current(); + expect(current).toMatchObject({ + sourcePath: file, + value: { mode: "oidc" }, + }); + expect(current?.runtimeProjection).toBeUndefined(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("loadConfig rejects an auth config path that exists but is not a regular file", () => { const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-app-auth-config-directory-")); try { diff --git a/deploy/compose.auth-runtime-projection.yaml b/deploy/compose.auth-runtime-projection.yaml new file mode 100644 index 00000000..aa3f88fd --- /dev/null +++ b/deploy/compose.auth-runtime-projection.yaml @@ -0,0 +1,9 @@ +services: + core: + environment: + THT_AUTH_RUNTIME_PROJECTION_ROOT: /run/thothii-auth + volumes: + - type: bind + source: ${THT_AUTH_RUNTIME_ROOT:?set THT_AUTH_RUNTIME_ROOT} + target: /run/thothii-auth + read_only: true diff --git a/scripts/test-auth-runtime-projection-compose.sh b/scripts/test-auth-runtime-projection-compose.sh new file mode 100755 index 00000000..56a21e98 --- /dev/null +++ b/scripts/test-auth-runtime-projection-compose.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd -P)" +tmp_base="${TMPDIR:-/tmp}" +tmp="$(mktemp -d "${tmp_base%/}/thoth-auth-runtime-compose.XXXXXX")" +trap 'rm -rf "$tmp"' EXIT HUP INT TERM + +canonical="$tmp/canonical-auth" +runtime="$tmp/runtime-auth" +mkdir -p "$canonical" "$runtime" "$tmp/data" "$tmp/pi-state" "$tmp/workspace-registry" +chmod 0700 "$canonical" "$runtime" +printf '%s\n' '{}' >"$tmp/pi-auth.json" +printf '%s\n' 'fixture-secret-sentinel' >"$tmp/thothii.secrets" +chmod 0600 "$tmp/pi-auth.json" "$tmp/thothii.secrets" + +write_env() { + local path="$1" + { + printf '%s\n' \ + 'THT_WORKSPACE_GIT_REMOTE=https://git.example.invalid/platform/workspaces.git' \ + "PI_AUTH_FILE=$tmp/pi-auth.json" \ + "THT_SECRETS_FILE=$tmp/thothii.secrets" \ + "THT_AUTH_CONFIG_ROOT=$canonical" \ + "THT_DATA_ROOT=$tmp/data" \ + "THT_PI_STATE_ROOT=$tmp/pi-state" \ + "THT_WORKSPACE_REGISTRY_ROOT=$tmp/workspace-registry" + } >"$path" +} + +write_env "$tmp/nonprojected.env" +cp "$tmp/nonprojected.env" "$tmp/projected.env" +printf 'THT_AUTH_RUNTIME_ROOT=%s\n' "$runtime" >>"$tmp/projected.env" + +cat >"$tmp/operator.yaml" <<'YAML' +services: + core: + environment: + THT_AUTH_RUNTIME_PROJECTION_ROOT: operator-marker +YAML + +cat >"$tmp/current-image.yaml" <<'YAML' +services: + core: + environment: + THT_AUTH_RUNTIME_PROJECTION_ROOT: current-marker +YAML + +render() { + local output="$1" + local env_file="$2" + shift 2 + local -a files=(-f "$root/compose.yaml" -f "$root/deploy/compose.server.yaml") + local file + for file in "$@"; do + files+=(-f "$file") + done + docker compose --project-directory "$root" --env-file "$env_file" "${files[@]}" \ + config --format json >"$output" +} + +render "$tmp/nonprojected.json" "$tmp/nonprojected.env" +render "$tmp/projected.json" "$tmp/projected.env" \ + "$tmp/operator.yaml" "$root/deploy/compose.auth-runtime-projection.yaml" +render "$tmp/current.json" "$tmp/projected.env" \ + "$tmp/operator.yaml" "$root/deploy/compose.auth-runtime-projection.yaml" \ + "$tmp/current-image.yaml" + +for mode in nonprojected projected current; do + node - "$tmp/$mode.json" "$mode" "$canonical" "$runtime" <<'NODE' +const fs = require("fs"); +const [path, mode, canonical, runtime] = process.argv.slice(2); +const config = JSON.parse(fs.readFileSync(path, "utf8")); +const core = config.services?.core; +if (!core) throw new Error(`${mode}: missing core service`); + +const mounts = (core.volumes || []).filter((mount) => mount.target === "/run/thothii-auth"); +if (mounts.length !== 1 || mounts[0].type !== "bind" || !mounts[0].read_only) { + throw new Error(`${mode}: expected exactly one read-only auth bind`); +} +if (mode === "nonprojected") { + if (mounts[0].source !== canonical) throw new Error("nonprojected: canonical auth source changed"); + if (Object.hasOwn(core.environment || {}, "THT_AUTH_RUNTIME_PROJECTION_ROOT")) { + throw new Error("nonprojected: projected environment unexpectedly present"); + } +} else { + if (mounts[0].source !== runtime) throw new Error(`${mode}: runtime source did not replace canonical source`); + if ((core.volumes || []).some((mount) => mount.source === canonical)) { + throw new Error(`${mode}: canonical source is still mounted`); + } + const expected = mode === "projected" ? "/run/thothii-auth" : "current-marker"; + if (core.environment?.THT_AUTH_RUNTIME_PROJECTION_ROOT !== expected) { + throw new Error(`${mode}: override ordering failed`); + } +} + +for (const [name, service] of Object.entries(config.services || {})) { + if (name !== "core" && Object.hasOwn(service.environment || {}, "THT_AUTH_RUNTIME_PROJECTION_ROOT")) { + throw new Error(`${mode}: ${name} received projected auth environment`); + } +} +const maintenance = config.services?.["workspace-maintenance"]; +if ((maintenance?.volumes || []).some( + (mount) => mount.target === "/run/thothii-auth" || mount.target === "/data/auth", +)) { + throw new Error(`${mode}: workspace-maintenance received auth mount`); +} +if (Object.keys(maintenance?.environment || {}).some((key) => key.startsWith("THT_AUTH_"))) { + throw new Error(`${mode}: workspace-maintenance received auth environment`); +} +if (JSON.stringify(config).includes("fixture-secret-sentinel")) { + throw new Error(`${mode}: rendered Compose leaked a secret sentinel`); +} +NODE +done + +echo "runtime auth projection Compose contract passed." diff --git a/scripts/test-canonical-install-compose.sh b/scripts/test-canonical-install-compose.sh index f9ccfa23..05e9b3f5 100755 --- a/scripts/test-canonical-install-compose.sh +++ b/scripts/test-canonical-install-compose.sh @@ -79,6 +79,9 @@ const authConfig = config.services.core.volumes?.filter((mount) => mount.target if (authConfig.length !== 1 || authConfig[0].type !== "bind" || !authConfig[0].read_only) { throw new Error(profile + ": core must receive one read-only authentication config bind"); } +if (Object.hasOwn(config.services.core.environment || {}, "THT_AUTH_RUNTIME_PROJECTION_ROOT")) { + throw new Error(profile + ": non-projected fixture unexpectedly selected runtime projection"); +} if (profile === "local") { const authState = config.services.core.volumes?.filter((mount) => mount.target === "/data/auth") || []; if (authState.length !== 1 || authState[0].type !== "volume" || authState[0].source !== "auth-state") { diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index fa79b1ff..bd6bfc4d 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -32,6 +32,8 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/workspaceops" ) +var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady + const usage = `Usage: tht [--installation /thothii-installation.yaml] When --installation is omitted, tht uses THOTHII_INSTALLATION or discovers one valid @@ -192,6 +194,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { if len(commandArgs) != 1 || commandArgs[0] != "--check-only" { return commandUsageError(stderr, "update currently requires --check-only") } + if err := requireRuntimeAuthProjectionReady(installation); err != nil { + return lifecycleFailure(stderr, errors.New("runtime authentication projection is unavailable"), secretValues) + } result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil) case "backup": return backupCommand(ctx, installation, commandArgs, stdout, stderr) diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index 8be1bfe1..6ebcb978 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -1049,6 +1050,31 @@ func TestRunPreservesChildExitCodes(t *testing.T) { } } +func TestRunUpdateCheckOnlyRefusesProjectedAuthenticationBeforeCompose(t *testing.T) { + for _, state := range []string{"missing", "blocked", "divergent"} { + t.Run(state, func(t *testing.T) { + fixture := projectedUpdateFixture(t) + previous := requireRuntimeAuthProjectionReady + requireRuntimeAuthProjectionReady = func(installation config.Installation) error { + if !installation.HasRuntimeAuthProjection() { + t.Fatal("update readiness gate received an unprojected installation") + } + return errors.New("synthetic " + state + " projection") + } + t.Cleanup(func() { requireRuntimeAuthProjectionReady = previous }) + + var stdout, stderr bytes.Buffer + if code := run(context.Background(), []string{"--installation", fixture.installationPath, "update", "--check-only"}, &stdout, &stderr); code == 0 { + t.Fatalf("update --check-only accepted %s projection", state) + } + assertDockerNotInvoked(t, fixture) + if strings.Contains(stdout.String()+stderr.String(), "synthetic "+state+" projection") { + t.Fatalf("update leaked readiness detail: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + }) + } +} + func TestRunPiStatusUsesImageBundledPi(t *testing.T) { fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") fixture.setEnvironment(t) @@ -1575,6 +1601,25 @@ func (f cliFixture) setProfile(t *testing.T, profile string) { } } +func projectedUpdateFixture(t *testing.T) cliFixture { + t.Helper() + fixture := newCLIFixture(t, "SAFE_VALUE=1\n") + fixture.setProfile(t, "server") + runtimeRoot := filepath.Join(fixture.root, "runtime-auth") + if err := os.Mkdir(runtimeRoot, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.projectDirectory, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + descriptor := "profile: server\nprojectDirectory: " + fixture.projectDirectory + "\nenvFile: " + fixture.envFile + "\nauthentication:\n configDirectory: " + filepath.Join(fixture.root, "auth") + "\n runtimeProjection:\n directory: " + runtimeRoot + "\n uid: 10001\n gid: 10001\n" + if err := os.WriteFile(fixture.installationPath, []byte(descriptor), 0o600); err != nil { + t.Fatal(err) + } + fixture.setEnvContents(t, "SAFE_VALUE=1\nTHT_AUTH_RUNTIME_ROOT="+strconv.Quote(runtimeRoot)+"\n") + return fixture +} + func (f cliFixture) invocations(t *testing.T) [][]string { t.Helper() contents, err := os.ReadFile(f.argsFile) diff --git a/tools/tht/internal/authconfig/commands.go b/tools/tht/internal/authconfig/commands.go index 7d6e9d48..fe4a5e7d 100644 --- a/tools/tht/internal/authconfig/commands.go +++ b/tools/tht/internal/authconfig/commands.go @@ -22,6 +22,7 @@ import ( "unicode/utf16" "unicode/utf8" + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/output" @@ -46,6 +47,8 @@ var errCommandRefused = errors.New("authentication command refused") var writeNewAuthFile = safeio.WriteCanonicalNewFile var removeAuthFile = safeio.RemoveCanonicalPrivateRegular +var runProjectedAuthMutation = RunProjectedMutation +var publishProjectedAuthCanonical = PublishProjectedCanonical // Run implements the host-only authentication operator surface. It accepts password bytes only // from an echo-free terminal or a bounded private file, and never writes them to either stream. @@ -62,27 +65,165 @@ func RunWithRunner(ctx context.Context, installation config.Installation, args [ directory := installation.AuthenticationDirectory() switch args[0] { case "configure": - if err := configure(directory, args[1:], stdin, stderr); err != nil { + if err := runInstallationAuthMutation(ctx, installation, func() error { + return configure(directory, args[1:], stdin, stderr) + }); err != nil { + return authFailure(stderr, authMessage(err)) + } + return 0 + case "publish": + if err := publishInstallationAuthentication(ctx, installation, args[1:]); err != nil { return authFailure(stderr, authMessage(err)) } return 0 case "status": + if installation.HasRuntimeAuthProjection() { + if err := projectedStatus(installation, args[1:], stdout); err != nil { + return authFailure(stderr, authMessage(err)) + } + return 0 + } if err := status(directory, args[1:], stdout); err != nil { return authFailure(stderr, authMessage(err)) } return 0 case "user": - if err := user(directory, args[1:], stdin, stdout, stderr); err != nil { + if err := runInstallationUserMutation(ctx, installation, args[1:], func() error { + return user(directory, args[1:], stdin, stdout, stderr) + }); err != nil { return authFailure(stderr, authMessage(err)) } return 0 case "check": + if err := RequireRuntimeAuthProjectionReady(installation); err != nil { + return authFailure(stderr, authMessage(err)) + } return checkCommand(ctx, installation, args[1:], stdout, stderr, runner) default: return authFailure(stderr, "unknown auth subcommand") } } +// RuntimeAuthProjectionStatus reads only public runtime-projection metadata and compares it with +// a detached, validated snapshot of the canonical authentication store. It never publishes or +// repairs the projection. +func RuntimeAuthProjectionStatus(installation config.Installation) (ProjectionStatus, error) { + projection := installation.RuntimeAuthProjection() + if projection == nil { + return ProjectionStatus{}, errCommandRefused + } + canonical, err := loadSnapshotBytes(installation.AuthenticationDirectory()) + if err != nil { + return ProjectionStatus{}, errCommandRefused + } + published, err := authprojection.Inspect(authprojection.Spec{ + RuntimeRoot: projection.Directory, + UID: projection.UID, + GID: projection.GID, + }) + if errors.Is(err, authprojection.ErrBlocked) { + return ProjectionStatus{ + State: "blocked", + CanonicalRevision: canonical.CanonicalRevision, + Equal: false, + }, nil + } + if err != nil { + return ProjectionStatus{}, errCommandRefused + } + return ProjectionStatus{ + State: published.Selector.State, + Generation: published.Snapshot.Generation, + CanonicalRevision: canonical.CanonicalRevision, + Equal: equalProjection(published, canonical), + }, nil +} + +// RequireRuntimeAuthProjectionReady is the shared fail-closed pre-admission check. It is a no-op +// for an installation that does not declare a runtime projection. +func RequireRuntimeAuthProjectionReady(installation config.Installation) error { + if !installation.HasRuntimeAuthProjection() { + return nil + } + status, err := RuntimeAuthProjectionStatus(installation) + if err != nil || status.State != "ready" || !status.Equal { + return errCommandRefused + } + return nil +} + +func runInstallationAuthMutation(ctx context.Context, installation config.Installation, mutate func() error) error { + projection := installation.RuntimeAuthProjection() + if projection == nil { + return mutate() + } + if err := requireProjectedAuthMutationPrivilege(); err != nil { + return errCommandRefused + } + return runProjectedAuthMutation(ctx, installation.AuthenticationDirectory(), ProjectionSpec{ + RuntimeRoot: projection.Directory, + UID: projection.UID, + GID: projection.GID, + }, mutate) +} + +func runInstallationUserMutation(ctx context.Context, installation config.Installation, args []string, mutate func() error) error { + if len(args) == 0 || args[0] == "list" || !installation.HasRuntimeAuthProjection() { + return mutate() + } + switch args[0] { + case "add", "set-password", "enable", "disable", "grant", "revoke", "logout-all": + return runInstallationAuthMutation(ctx, installation, mutate) + default: + return mutate() + } +} + +func publishInstallationAuthentication(ctx context.Context, installation config.Installation, args []string) error { + if len(args) != 0 || !installation.HasRuntimeAuthProjection() { + return errCommandRefused + } + if err := requireProjectedAuthMutationPrivilege(); err != nil { + return errCommandRefused + } + projection := installation.RuntimeAuthProjection() + status, err := publishProjectedAuthCanonical(ctx, installation.AuthenticationDirectory(), ProjectionSpec{ + RuntimeRoot: projection.Directory, + UID: projection.UID, + GID: projection.GID, + }) + if err != nil || status.State != "ready" || !status.Equal { + return errCommandRefused + } + return nil +} + +func projectedStatus(installation config.Installation, args []string, stdout io.Writer) error { + jsonMode := len(args) == 1 && args[0] == "--json" + if len(args) != 0 && !jsonMode { + return errCommandRefused + } + status, err := RuntimeAuthProjectionStatus(installation) + if err != nil { + return errCommandRefused + } + if jsonMode { + return json.NewEncoder(stdout).Encode(struct { + State string `json:"state"` + Generation string `json:"generation"` + CanonicalRevision string `json:"canonicalRevision"` + Equal bool `json:"equal"` + }{ + State: status.State, + Generation: status.Generation, + CanonicalRevision: status.CanonicalRevision, + Equal: status.Equal, + }) + } + _, err = fmt.Fprintf(stdout, "State: %s\nGeneration: %s\nCanonical revision: %s\nEqual: %t\n", status.State, status.Generation, status.CanonicalRevision, status.Equal) + return err +} + // AuthDiagnostic is the closed JSON contract emitted by the backend diagnostic command. type AuthDiagnostic struct { Level string `json:"level"` diff --git a/tools/tht/internal/authconfig/commands_linux_test.go b/tools/tht/internal/authconfig/commands_linux_test.go new file mode 100644 index 00000000..a2478db3 --- /dev/null +++ b/tools/tht/internal/authconfig/commands_linux_test.go @@ -0,0 +1,372 @@ +//go:build linux + +package authconfig + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" + "github.com/aritmolab/thothii/tools/tht/internal/compose" + "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" +) + +func TestProjectedAuthMutatorsBlockBeforeCanonicalWriteAndPublishOnlyEqualSnapshots(t *testing.T) { + installation, spec := projectedAuthInstallation(t) + adminPassword := writePasswordFile(t, "initial projected administrator password\n") + userPassword := writePasswordFile(t, "projected ordinary user password\n") + previous := runProjectedAuthMutation + blockedObservations := 0 + runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { + return previous(ctx, canonicalRoot, projection, func() error { + status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.RuntimeRoot, UID: projection.UID, GID: projection.GID}) + if !errors.Is(err, authprojection.ErrBlocked) || status.Selector.State != "blocked" { + t.Fatalf("projection before canonical mutation = %#v, %v; want blocked", status, err) + } + blockedObservations++ + return mutate() + }) + } + t.Cleanup(func() { runProjectedAuthMutation = previous }) + + for _, args := range [][]string{ + {"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, + {"user", "add", "operator", "--role", "user", "--password-file", userPassword}, + {"user", "set-password", "operator", "--password-file", adminPassword}, + {"user", "disable", "operator"}, + {"user", "enable", "operator"}, + {"user", "grant", "operator", "--role", "admin"}, + {"user", "revoke", "operator", "--role", "admin"}, + {"user", "logout-all", "operator", "--yes"}, + } { + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("%v = %d, stdout=%q stderr=%q", args, code, stdout.String(), stderr.String()) + } + assertProjectedCanonicalReadyAndEqual(t, installation.AuthenticationDirectory(), spec) + if strings.Contains(stdout.String()+stderr.String(), "projected administrator password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") { + t.Fatalf("%v leaked secret material", args) + } + } + if blockedObservations != 8 { + t.Fatalf("blocked observations = %d, want 8", blockedObservations) + } +} + +func TestProjectedAuthMutationLeavesBlockedAfterChangedCanonicalFailure(t *testing.T) { + installation, spec := projectedAuthInstallation(t) + adminPassword := writePasswordFile(t, "initial projected administrator password\n") + if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("configure = %d", code) + } + previous := runProjectedAuthMutation + runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { + return previous(ctx, canonicalRoot, projection, func() error { + if err := mutate(); err != nil { + return err + } + return errors.New("synthetic-password-sentinel") + }) + } + t.Cleanup(func() { runProjectedAuthMutation = previous }) + + var stdout, stderr bytes.Buffer + code := Run(context.Background(), installation, []string{"user", "logout-all", "admin", "--yes"}, strings.NewReader(""), &stdout, &stderr) + if code == 0 || strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") { + t.Fatalf("changed mutation failure was accepted or leaked: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + _, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}) + if !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked runtime projection", err) + } +} + +func TestProjectedAuthMutatorFailuresRestoreOnlyUnchangedCanonicalState(t *testing.T) { + mutators := []string{"configure", "user add", "user set-password", "user enable", "user disable", "user grant", "user revoke", "user logout-all"} + for _, mutator := range mutators { + t.Run(mutator+" restores ready before callback", func(t *testing.T) { + installation, spec, args := preparedProjectedMutation(t, mutator) + previous := runProjectedAuthMutation + runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { + return previous(ctx, canonicalRoot, projection, func() error { + return errors.New("synthetic-password-sentinel") + }) + } + t.Cleanup(func() { runProjectedAuthMutation = previous }) + + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code == 0 { + t.Fatalf("%s accepted injected pre-mutation failure", mutator) + } + if strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") { + t.Fatalf("%s leaked injected failure: stdout=%q stderr=%q", mutator, stdout.String(), stderr.String()) + } + assertProjectedCanonicalReadyAndEqual(t, installation.AuthenticationDirectory(), spec) + }) + + t.Run(mutator+" leaves blocked after changed canonical error", func(t *testing.T) { + var installation config.Installation + var spec ProjectionSpec + var args []string + if mutator == "configure" { + installation, spec = projectedAuthInstallation(t) + passwordFile := writePasswordFile(t, "fresh projected bootstrap password\n") + args = []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile} + } else { + installation, spec, args = preparedProjectedMutation(t, mutator) + } + previous := runProjectedAuthMutation + runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error { + return previous(ctx, canonicalRoot, projection, func() error { + if err := mutate(); err != nil { + return err + } + return errors.New("synthetic-password-sentinel") + }) + } + t.Cleanup(func() { runProjectedAuthMutation = previous }) + + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code == 0 { + t.Fatalf("%s accepted changed-canonical injected failure", mutator) + } + if strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") { + t.Fatalf("%s leaked injected failure: stdout=%q stderr=%q", mutator, stdout.String(), stderr.String()) + } + _, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}) + if !errors.Is(err, authprojection.ErrBlocked) { + t.Fatalf("%s Inspect() error = %v, want blocked projection", mutator, err) + } + }) + } +} + +func TestProjectedAuthPublishStatusAndCheckFailClosed(t *testing.T) { + installation, spec := projectedAuthInstallation(t) + passwordFile := writePasswordFile(t, "projected authentication password\n") + if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("configure = %d", code) + } + var stdout, stderr bytes.Buffer + if code := Run(context.Background(), installation, []string{"publish"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("publish = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if code := Run(context.Background(), installation, []string{"publish", "secret"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 { + t.Fatal("publish accepted content arguments") + } + stdout.Reset() + if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("status = %d, stderr=%q", code, stderr.String()) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil { + t.Fatalf("status JSON = %q, %v", stdout.String(), err) + } + if len(raw) != 4 { + t.Fatalf("status keys = %#v, want exactly projection public keys", raw) + } + for _, key := range []string{"state", "generation", "canonicalRevision", "equal"} { + if _, ok := raw[key]; !ok { + t.Fatalf("status keys = %#v, missing %q", raw, key) + } + } + var status struct { + State string `json:"state"` + Generation string `json:"generation"` + CanonicalRevision string `json:"canonicalRevision"` + Equal bool `json:"equal"` + } + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil || status.State != "ready" || !status.Equal || status.Generation == "" || status.CanonicalRevision == "" { + t.Fatalf("status = %q, %#v, %v", stdout.String(), status, err) + } + if strings.Contains(stdout.String(), "password") || strings.Contains(stdout.String(), "$argon2id$") { + t.Fatalf("status exposed secret material: %q", stdout.String()) + } + + transaction, err := BeginExternalProjectionTransaction(context.Background(), installation.AuthenticationDirectory(), spec) + if err != nil { + t.Fatal(err) + } + defer transaction.Close() + canonical, err := loadSnapshotBytes(installation.AuthenticationDirectory()) + if err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("blocked status JSON = %d, stderr=%q", code, stderr.String()) + } + raw = nil + if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil { + t.Fatalf("blocked status JSON = %q, %v", stdout.String(), err) + } + if len(raw) != 4 { + t.Fatalf("blocked status keys = %#v, want exactly projection public keys", raw) + } + for _, key := range []string{"state", "generation", "canonicalRevision", "equal"} { + if _, ok := raw[key]; !ok { + t.Fatalf("blocked status keys = %#v, missing %q", raw, key) + } + } + status = struct { + State string `json:"state"` + Generation string `json:"generation"` + CanonicalRevision string `json:"canonicalRevision"` + Equal bool `json:"equal"` + }{} + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil || status.State != "blocked" || status.Generation != "" || status.CanonicalRevision != canonical.CanonicalRevision || status.Equal { + t.Fatalf("blocked status = %q, %#v, %v", stdout.String(), status, err) + } + stdout.Reset() + stderr.Reset() + if code := Run(context.Background(), installation, []string{"status"}, strings.NewReader(""), &stdout, &stderr); code != 0 { + t.Fatalf("blocked status text = %d, stderr=%q", code, stderr.String()) + } + wantText := "State: blocked\nGeneration: \nCanonical revision: " + canonical.CanonicalRevision + "\nEqual: false\n" + if stdout.String() != wantText { + t.Fatalf("blocked status text = %q, want %q", stdout.String(), wantText) + } + + calls := 0 + runner := runnerFunc(func(_ context.Context, _ []string, _ io.Reader) (compose.Result, error) { + calls++ + return compose.Result{}, errors.New("backend diagnostic must not run") + }) + stdout.Reset() + stderr.Reset() + if code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner); code == 0 || calls != 0 { + t.Fatalf("check admitted blocked projection: code=%d calls=%d stdout=%q stderr=%q", code, calls, stdout.String(), stderr.String()) + } +} + +func TestRequireRuntimeAuthProjectionReadyRejectsMissingBlockedAndDivergentStates(t *testing.T) { + for _, state := range []string{"missing", "blocked", "divergent"} { + t.Run(state, func(t *testing.T) { + installation, spec := projectedAuthInstallation(t) + passwordFile := writePasswordFile(t, "projected readiness password\n") + if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("configure = %d", code) + } + if err := RequireRuntimeAuthProjectionReady(installation); err != nil { + t.Fatalf("ready projection rejected: %v", err) + } + switch state { + case "missing": + if err := os.RemoveAll(spec.RuntimeRoot); err != nil { + t.Fatal(err) + } + case "blocked": + transaction, err := BeginExternalProjectionTransaction(context.Background(), installation.AuthenticationDirectory(), spec) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = transaction.Close() }) + case "divergent": + if err := MutateUsers(installation.AuthenticationDirectory(), func(registry *Registry) error { + registry.Users[0].AuthRevision++ + return nil + }); err != nil { + t.Fatal(err) + } + } + if err := RequireRuntimeAuthProjectionReady(installation); err == nil { + t.Fatalf("RequireRuntimeAuthProjectionReady accepted %s projection", state) + } + }) + } +} + +func TestProjectedAuthCommandsRefuseBeforeMutationWhenNotRoot(t *testing.T) { + installation, _ := projectedAuthInstallation(t) + passwordFile := writePasswordFile(t, "projected authentication password\n") + previous := authProjectionEffectiveUID + authProjectionEffectiveUID = func() int { return 1000 } + t.Cleanup(func() { authProjectionEffectiveUID = previous }) + if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 { + t.Fatal("non-root projected configure succeeded") + } + if _, err := os.Lstat(filepath.Join(installation.AuthenticationDirectory(), authFileName)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("canonical auth was written after non-root refusal: %v", err) + } +} + +func projectedAuthInstallation(t *testing.T) (config.Installation, ProjectionSpec) { + t.Helper() + root := t.TempDir() + canonicalRoot, runtimeRoot := filepath.Join(root, "canonical-auth"), filepath.Join(root, "runtime-auth") + for _, directory := range []string{canonicalRoot, runtimeRoot} { + if err := safeio.EnsurePrivateDirectory(directory); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "operator.env"), []byte("SAFE_VALUE=1\n"), 0o600); err != nil { + t.Fatal(err) + } + uid, gid := uint32(os.Geteuid()), uint32(os.Getegid()) + installation := config.Installation{Profile: "server", EnvFile: filepath.Join(root, "operator.env"), Authentication: config.Authentication{ + ConfigDirectory: canonicalRoot, + RuntimeProjection: &config.RuntimeProjection{Directory: runtimeRoot, UID: uid, GID: gid}, + }} + return installation, ProjectionSpec{RuntimeRoot: runtimeRoot, UID: uid, GID: gid} +} + +func assertProjectedCanonicalReadyAndEqual(t *testing.T, canonicalRoot string, spec ProjectionSpec) { + t.Helper() + status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID}) + if err != nil || status.Selector.State != "ready" { + t.Fatalf("Inspect() = %#v, %v; want ready", status, err) + } + snapshot, err := loadSnapshotBytes(canonicalRoot) + if err != nil || status.Snapshot.Generation != snapshot.Generation || status.Snapshot.CanonicalRevision != snapshot.CanonicalRevision { + t.Fatalf("projection = %#v, canonical = %#v, %v; want equality", status.Snapshot, snapshot, err) + } +} + +func preparedProjectedMutation(t *testing.T, mutator string) (config.Installation, ProjectionSpec, []string) { + t.Helper() + installation, spec := projectedAuthInstallation(t) + adminPassword := writePasswordFile(t, "prepared projected administrator password\n") + userPassword := writePasswordFile(t, "prepared projected user password\n") + configure := []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword} + if code := Run(context.Background(), installation, configure, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("prepare %s configure = %d", mutator, code) + } + if mutator == "configure" { + return installation, spec, configure + } + if code := Run(context.Background(), installation, []string{"user", "add", "operator", "--role", "user", "--password-file", userPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("prepare %s add = %d", mutator, code) + } + if mutator == "user enable" { + if code := Run(context.Background(), installation, []string{"user", "disable", "operator"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("prepare %s disable = %d", mutator, code) + } + } + if mutator == "user revoke" { + if code := Run(context.Background(), installation, []string{"user", "grant", "operator", "--role", "admin"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 { + t.Fatalf("prepare %s grant = %d", mutator, code) + } + } + args := map[string][]string{ + "user add": {"user", "add", "second", "--role", "user", "--password-file", userPassword}, + "user set-password": {"user", "set-password", "operator", "--password-file", adminPassword}, + "user enable": {"user", "enable", "operator"}, + "user disable": {"user", "disable", "operator"}, + "user grant": {"user", "grant", "operator", "--role", "admin"}, + "user revoke": {"user", "revoke", "operator", "--role", "admin"}, + "user logout-all": {"user", "logout-all", "operator", "--yes"}, + }[mutator] + if args == nil { + t.Fatalf("unknown projected mutator %q", mutator) + } + return installation, spec, args +} diff --git a/tools/tht/internal/authconfig/commands_projection_linux.go b/tools/tht/internal/authconfig/commands_projection_linux.go new file mode 100644 index 00000000..dce19ecb --- /dev/null +++ b/tools/tht/internal/authconfig/commands_projection_linux.go @@ -0,0 +1,14 @@ +//go:build linux + +package authconfig + +import "os" + +var authProjectionEffectiveUID = os.Geteuid + +func requireProjectedAuthMutationPrivilege() error { + if authProjectionEffectiveUID() != 0 { + return errCommandRefused + } + return nil +} diff --git a/tools/tht/internal/authconfig/commands_projection_unsupported.go b/tools/tht/internal/authconfig/commands_projection_unsupported.go new file mode 100644 index 00000000..6c09fcc7 --- /dev/null +++ b/tools/tht/internal/authconfig/commands_projection_unsupported.go @@ -0,0 +1,5 @@ +//go:build !linux + +package authconfig + +func requireProjectedAuthMutationPrivilege() error { return errCommandRefused } diff --git a/tools/tht/internal/backup/create_test.go b/tools/tht/internal/backup/create_test.go index 991a7126..0a1b8d5c 100644 --- a/tools/tht/internal/backup/create_test.go +++ b/tools/tht/internal/backup/create_test.go @@ -175,6 +175,23 @@ func TestCreateReferencesAuthFilesByDefaultAndArchivesThemOnlyWithSecretCustody( } prepareBackupFixturePrivatePaths(t, []string{authDirectory}, []string{authPath, usersPath}) fixture.installation.Authentication.ConfigDirectory = authDirectory + runtimeRoot := filepath.Join(filepath.Dir(fixture.installation.Path), "auth-runtime") + if err := os.Mkdir(runtimeRoot, 0o700); err != nil { + t.Fatal(err) + } + runtimeSentinel := []byte("runtime-projection-must-not-be-archived") + runtimeFiles := []string{ + filepath.Join(runtimeRoot, "CURRENT"), filepath.Join(runtimeRoot, "manifest.json"), + filepath.Join(runtimeRoot, ".stage-test"), filepath.Join(runtimeRoot, ".current-test.tmp"), + filepath.Join(runtimeRoot, ".auth-transaction.lock"), filepath.Join(runtimeRoot, ".auth.lock"), + } + for _, path := range runtimeFiles { + if err := os.WriteFile(path, runtimeSentinel, 0o600); err != nil { + t.Fatal(err) + } + } + prepareBackupFixturePrivatePaths(t, []string{runtimeRoot}, runtimeFiles) + fixture.installation.Authentication.RuntimeProjection = &config.RuntimeProjection{Directory: runtimeRoot, UID: 10001, GID: 10001} defaultOutput := filepath.Join(t.TempDir(), "default.zip") defaultResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: defaultOutput}, testDependencies(t, newBackupRunner(fixture.installation, false))) @@ -204,6 +221,16 @@ func TestCreateReferencesAuthFilesByDefaultAndArchivesThemOnlyWithSecretCustody( t.Fatalf("secret backup warning = %q, want custody guidance", secretResult.Warning) } secretArchive := readFixtureArchive(t, secretOutput) + for path, contents := range secretArchive.files { + if bytes.Contains(contents, runtimeSentinel) { + t.Fatalf("backup payload includes runtime projection data at %q", path) + } + } + for _, entry := range secretArchive.manifest.Entries { + if strings.HasPrefix(entry.SourcePath, runtimeRoot+string(filepath.Separator)) || strings.Contains(entry.Path, "auth-runtime") { + t.Fatalf("backup manifest references runtime projection entry %#v", entry) + } + } for _, path := range []string{authPath, usersPath} { if !manifestHasArchivedSecret(secretArchive.manifest, path) { t.Fatalf("secret backup did not archive authentication file %q", path) diff --git a/tools/tht/internal/backup/restore.go b/tools/tht/internal/backup/restore.go index a4f339fb..db639f84 100644 --- a/tools/tht/internal/backup/restore.go +++ b/tools/tht/internal/backup/restore.go @@ -8,6 +8,7 @@ import ( "io" "time" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/lifecycle" ) @@ -32,13 +33,20 @@ type RestoreResult struct { type restoreVerify func(context.Context, config.Installation, archiveRunner) error +type authProjectionRestoreTransaction interface { + PublishCanonical() (authconfig.ProjectionStatus, error) + RestorePriorIfCanonicalUnchanged() error + Close() error +} + type restoreDependencies struct { preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error) // checkpoint requires the opaque capability created by lifecycle acquisition. It must not call // public Create, which would re-acquire the non-reentrant lock and deadlock the transaction. checkpoint func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) prepareRecovery func(context.Context, config.Installation, string) (PreflightResult, error) - recover func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error + recover func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error + beginAuthProjection func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) cleanupCheckpoint func(string) error acquireTransaction func(config.Installation) (*lifecycle.Transaction, error) runner archiveRunner @@ -107,6 +115,12 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati return RestoreResult{}, err } defer preflight.CloseArchive() + authRestoreRequired := installation.HasRuntimeAuthProjection() && manifestArchivesAuthentication(preflight.Manifest) + if authRestoreRequired { + if err := requireAuthProjectionRestorePrivilege(); err != nil { + return result, err + } + } checkpoint, err := deps.checkpoint(ctx, transaction, installation, CreateRequest{IncludeSecrets: true, Confirm: true}) if err != nil { @@ -150,11 +164,24 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati return result, errors.Join(err, cleanupErr) } + var authTransaction authProjectionRestoreTransaction + if authRestoreRequired { + if deps.beginAuthProjection == nil { + cleanupErr := deps.cleanupCheckpoint(checkpoint.Path) + return result, errors.Join(errors.New("restore authentication projection dependency is unavailable"), cleanupErr) + } + authTransaction, err = deps.beginAuthProjection(ctx, installation) + if err != nil { + cleanupErr := deps.cleanupCheckpoint(checkpoint.Path) + return result, errors.Join(errors.New("restore authentication projection could not be blocked"), cleanupErr) + } + } + state := restoreTransactionState{} defer func() { if state.recoveryRequired(resultErr) { recoveryContext, cancel := boundedCleanupContext() - recoveryErr := deps.recover(recoveryContext, installation, recovery, recoveryStage, state.wasRunning) + recoveryErr := deps.recover(recoveryContext, installation, recovery, recoveryStage, state.wasRunning, authTransaction) cancel() if recoveryErr != nil { resultErr = errors.Join(resultErr, fmt.Errorf("restore recovery checkpoint: %w", recoveryErr)) @@ -174,6 +201,20 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati checkpointCleanupSucceeded = false cleanupErr = errors.Join(cleanupErr, fmt.Errorf("destroy recovery checkpoint: %w", checkpointErr)) } + authCleanupSucceeded := true + if authTransaction != nil { + if resultErr != nil && !state.mutated { + if restoreErr := authTransaction.RestorePriorIfCanonicalUnchanged(); restoreErr != nil { + authCleanupSucceeded = false + cleanupErr = errors.Join(cleanupErr, errors.New("restore authentication projection could not restore its prior selector")) + } + } + if closeErr := authTransaction.Close(); closeErr != nil { + authCleanupSucceeded = false + cleanupErr = errors.Join(cleanupErr, errors.New("restore authentication projection transaction could not be closed")) + } + authTransaction = nil + } if !state.maintenanceAttempted { if cleanupErr != nil { result = RestoreResult{} @@ -204,7 +245,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati } // A failed checkpoint recovery deliberately leaves admissions blocked. Starting or // deactivating at that point would expose an unverified, possibly partial restore. - if state.mayDeactivateMaintenance() && restartCompleted { + if state.mayDeactivateMaintenance() && restartCompleted && authCleanupSucceeded { deactivateErr, deactivated := retryBoundedCleanup(func(cleanupContext context.Context) error { return maintenance(cleanupContext, installation, deps.runner, false) }) @@ -250,6 +291,11 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("reset authentication state: %w", err) } + if authTransaction != nil { + if err := publishRestoredAuthentication(authTransaction); err != nil { + return result, err + } + } if state.wasRunning { if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { return result, err @@ -265,6 +311,26 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati return result, nil } +func manifestArchivesAuthentication(manifest Manifest) bool { + for _, entry := range manifest.Entries { + if entry.Archived && entry.Kind == EntryExternalSecret && entry.Owner == "authentication-configuration" { + return true + } + } + return false +} + +func publishRestoredAuthentication(transaction authProjectionRestoreTransaction) error { + status, err := transaction.PublishCanonical() + if err != nil || status.State != "ready" || !status.Equal { + if err != nil { + return fmt.Errorf("publish restored authentication projection: %w", err) + } + return errors.New("publish restored authentication projection") + } + return nil +} + func ensureCombinedRestoreCapacity(candidate, recovery PreflightResult) error { if candidate.freeBytes == nil || candidate.stagingRoot == "" || candidate.stagingRoot != recovery.stagingRoot { return errors.New("candidate and recovery archives do not share controlled restore staging") diff --git a/tools/tht/internal/backup/restore_host.go b/tools/tht/internal/backup/restore_host.go index 68edab64..98436209 100644 --- a/tools/tht/internal/backup/restore_host.go +++ b/tools/tht/internal/backup/restore_host.go @@ -51,9 +51,20 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe }, cleanupCheckpoint: cleanupRecoveryCheckpoint, acquireTransaction: lifecycle.AcquireTransaction, - runner: runner, - sleep: time.Sleep, - restoreFile: restoreFilePayload, + beginAuthProjection: func(ctx context.Context, target config.Installation) (authProjectionRestoreTransaction, error) { + projection := target.RuntimeAuthProjection() + if projection == nil { + return nil, errors.New("runtime authentication projection is unavailable") + } + return authconfig.BeginExternalProjectionTransaction(ctx, target.AuthenticationDirectory(), authconfig.ProjectionSpec{ + RuntimeRoot: projection.Directory, + UID: projection.UID, + GID: projection.GID, + }) + }, + runner: runner, + sleep: time.Sleep, + restoreFile: restoreFilePayload, restoreVolume: func(ctx context.Context, _ config.Installation, volume VolumeMetadata, input io.Reader) error { result, err := runner.Stream(ctx, volumeRestoreCommand(volume.Name), input, io.Discard) if err != nil || result.ExitCode != 0 { @@ -75,8 +86,8 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe deps.prepareRecovery = func(ctx context.Context, target config.Installation, path string) (PreflightResult, error) { return deps.preflight(ctx, target, PreflightRequest{Archive: path, Confirm: true, AllowExternalSecrets: true}) } - deps.recover = func(ctx context.Context, target config.Installation, recovery PreflightResult, staged *stagedArchive, wasRunning bool) error { - return recoverRestoreTransaction(ctx, target, recovery, staged, wasRunning, deps) + deps.recover = func(ctx context.Context, target config.Installation, recovery PreflightResult, staged *stagedArchive, wasRunning bool, transaction authProjectionRestoreTransaction) error { + return recoverRestoreTransaction(ctx, target, recovery, staged, wasRunning, deps, transaction) } return deps } @@ -88,7 +99,7 @@ func cleanupRecoveryCheckpoint(path string) error { return nil } -func recoverRestoreTransaction(ctx context.Context, installation config.Installation, recovery PreflightResult, staged *stagedArchive, wasRunning bool, deps restoreDependencies) (resultErr error) { +func recoverRestoreTransaction(ctx context.Context, installation config.Installation, recovery PreflightResult, staged *stagedArchive, wasRunning bool, deps restoreDependencies, transaction authProjectionRestoreTransaction) (resultErr error) { if staged == nil || staged.file == nil { return errors.New("recovery checkpoint was not staged before restore mutation") } @@ -109,6 +120,11 @@ func recoverRestoreTransaction(ctx context.Context, installation config.Installa if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil { return errors.Join(resultErr, err) } + if transaction != nil { + if err := publishRestoredAuthentication(transaction); err != nil { + return errors.Join(resultErr, err) + } + } if wasRunning { if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { resultErr = errors.Join(resultErr, err) diff --git a/tools/tht/internal/backup/restore_projection_linux.go b/tools/tht/internal/backup/restore_projection_linux.go new file mode 100644 index 00000000..a646e359 --- /dev/null +++ b/tools/tht/internal/backup/restore_projection_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package backup + +import ( + "errors" + "os" +) + +var restoreProjectionEffectiveUID = os.Geteuid + +func requireAuthProjectionRestorePrivilege() error { + if restoreProjectionEffectiveUID() != 0 { + return errors.New("projected authentication restore requires root") + } + return nil +} diff --git a/tools/tht/internal/backup/restore_projection_linux_test.go b/tools/tht/internal/backup/restore_projection_linux_test.go new file mode 100644 index 00000000..cc0a5a32 --- /dev/null +++ b/tools/tht/internal/backup/restore_projection_linux_test.go @@ -0,0 +1,42 @@ +//go:build linux + +package backup + +import ( + "context" + "io" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/lifecycle" +) + +func TestRestoreAuthBearingArchiveRefusesNonRootBeforeTransactionOrWrite(t *testing.T) { + installation, archive := projectedRestoreFixture(t) + deps := restoreTestDependencies(t, newBackupRunner(installation, false)) + checkpointCalled := false + beginCalled := false + writeCalled := false + deps.checkpoint = func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) { + checkpointCalled = true + return Result{}, nil + } + deps.beginAuthProjection = func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) { + beginCalled = true + return nil, nil + } + deps.restoreFile = func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { + writeCalled = true + return nil + } + previous := restoreProjectionEffectiveUID + restoreProjectionEffectiveUID = func() int { return 1000 } + t.Cleanup(func() { restoreProjectionEffectiveUID = previous }) + + if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err == nil { + t.Fatal("projected authentication restore unexpectedly accepted non-root execution") + } + if checkpointCalled || beginCalled || writeCalled { + t.Fatalf("non-root restore crossed mutation boundary: checkpoint=%t begin=%t write=%t", checkpointCalled, beginCalled, writeCalled) + } +} diff --git a/tools/tht/internal/backup/restore_projection_unsupported.go b/tools/tht/internal/backup/restore_projection_unsupported.go new file mode 100644 index 00000000..cf9253a5 --- /dev/null +++ b/tools/tht/internal/backup/restore_projection_unsupported.go @@ -0,0 +1,9 @@ +//go:build !linux + +package backup + +import "errors" + +func requireAuthProjectionRestorePrivilege() error { + return errors.New("projected authentication restore is unsupported") +} diff --git a/tools/tht/internal/backup/restore_test.go b/tools/tht/internal/backup/restore_test.go index 3d3b919f..8fd7e4eb 100644 --- a/tools/tht/internal/backup/restore_test.go +++ b/tools/tht/internal/backup/restore_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/lifecycle" @@ -624,7 +625,7 @@ func TestRestoreLifecycleLockExcludesCompetingTransactionsUntilTerminalCleanup(t } return targetFailure } - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { if err := gate("recovery"); err != nil { return err } @@ -645,7 +646,7 @@ func TestRestoreLifecycleLockExcludesCompetingTransactionsUntilTerminalCleanup(t } return targetFailure } - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { if err := gate("recovery-failure"); err != nil { return err } @@ -666,7 +667,7 @@ func TestRestoreLifecycleLockExcludesCompetingTransactionsUntilTerminalCleanup(t cancel() return context.Canceled } - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { if err := gate("recovery"); err != nil { return err } @@ -892,7 +893,7 @@ func TestRestoreCannotApplyAStaleCheckpointOverAnInterleavedRestore(t *testing.T firstDeps.restoreFile = func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return errors.New("first target mutation failed before changing state") } - firstDeps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + firstDeps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { recoveryObserved = targetState targetState = checkpointState firstRunner.running, firstRunner.coreRunning = true, true @@ -1095,7 +1096,7 @@ func TestRestoreFileFailureRollsBackSecretAwareCheckpointBeforeCleanup(t *testin return Result{Path: "/tmp/recovery.zip"}, nil } var events []string - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { events = append(events, "recover") return nil } @@ -1135,7 +1136,7 @@ func TestRestoreFailureAfterAuthenticationMutationRollsBackAndClearsRuntimeState events = append(events, "auth-runtime-reset-failed") return resetErr } - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { events = append(events, "secret-aware-recovery-and-reauth-reset") return nil } @@ -1161,7 +1162,7 @@ func TestRestoreCleanupFailureDoesNotSuppressRollback(t *testing.T) { cleanupErr := errors.New("checkpoint cleanup failure") recovered := false deps.restoreFile = func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return mutationErr } - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { recovered = true return nil } @@ -1223,7 +1224,7 @@ func TestRestoreStartFailureRecoversPreviouslyRunningTarget(t *testing.T) { backingRunner := newBackupRunner(installation, true) deps := restoreTestDependencies(t, failStartRestoreRunner{fakeBackupRunner: backingRunner}) recovered := false - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { recovered = true backingRunner.running = true return nil @@ -1246,7 +1247,7 @@ func TestRestoreVerificationFailureRecoversPreviouslyRunningTarget(t *testing.T) verificationErr := errors.New("Pi is unavailable") deps.verify["pi"] = func(context.Context, config.Installation, archiveRunner) error { return verificationErr } recovered := false - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { recovered = true return nil } @@ -1352,7 +1353,7 @@ func TestRestoreRecoversBehindBarrierForEveryVerificationFailure(t *testing.T) { } var recoveryBarrierActive bool var recoveryContext cleanupContextObservation - deps.recover = func(ctx context.Context, _ config.Installation, _ PreflightResult, _ *stagedArchive, _ bool) error { + deps.recover = func(ctx context.Context, _ config.Installation, _ PreflightResult, _ *stagedArchive, _ bool, _ authProjectionRestoreTransaction) error { recoveryContext = observeCleanupContext(ctx) recoveryBarrierActive = runner.maintenance runner.running, runner.coreRunning = true, true @@ -1392,7 +1393,7 @@ func TestRestoreDoesNotRollbackAfterFinalDeactivationResponseLoss(t *testing.T) } deps := restoreTestDependencies(t, runner) recoveryCalls := 0 - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { recoveryCalls++ return nil } @@ -1450,7 +1451,7 @@ func TestRestoreUsesBoundedRecoveryContextAfterPostMutationCancellation(t *testi } var recoveryContext cleanupContextObservation var recoveryBarrierActive bool - deps.recover = func(ctx context.Context, _ config.Installation, _ PreflightResult, _ *stagedArchive, _ bool) error { + deps.recover = func(ctx context.Context, _ config.Installation, _ PreflightResult, _ *stagedArchive, _ bool, _ authProjectionRestoreTransaction) error { recoveryContext = observeCleanupContext(ctx) recoveryBarrierActive = runner.maintenance runner.running, runner.coreRunning = true, true @@ -1536,7 +1537,7 @@ func TestRecoverRestoreTransactionVerifiesRecoveredStateBeforeReturning(t *testi } staged := stageRecoveryForTest(t, installation, recovery) - if err := recoverRestoreTransaction(context.Background(), installation, recovery, staged, true, deps); err != nil { + if err := recoverRestoreTransaction(context.Background(), installation, recovery, staged, true, deps, nil); err != nil { t.Fatal(err) } if got, want := checks, []string{"health", "doctor", "pi", "workspace"}; !equalStrings(got, want) { @@ -1566,7 +1567,7 @@ func TestRecoverRestoreTransactionFailsClosedForEveryVerification(t *testing.T) } staged := stageRecoveryForTest(t, installation, recovery) - err = recoverRestoreTransaction(context.Background(), installation, recovery, staged, true, deps) + err = recoverRestoreTransaction(context.Background(), installation, recovery, staged, true, deps, nil) if !errors.Is(err, verificationErr) { t.Fatalf("recoverRestoreTransaction() error = %v, want %v", err, verificationErr) } @@ -1646,8 +1647,8 @@ func TestRestoreReleasesBarrierOnlyAfterVerifiedRecoveryFromLostResponse(t *test } return nil } - deps.recover = func(ctx context.Context, target config.Installation, checkpoint PreflightResult, staged *stagedArchive, wasRunning bool) error { - return recoverRestoreTransaction(ctx, target, checkpoint, staged, wasRunning, deps) + deps.recover = func(ctx context.Context, target config.Installation, checkpoint PreflightResult, staged *stagedArchive, wasRunning bool, transaction authProjectionRestoreTransaction) error { + return recoverRestoreTransaction(ctx, target, checkpoint, staged, wasRunning, deps, transaction) } _, err = restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps) @@ -1744,7 +1745,7 @@ func TestRestoreCleansMaintenanceAfterMutationAndRollbackFailures(t *testing.T) deps.restoreFile = func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return mutationErr } - deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { if test.recoveryErr == nil { backing.running, backing.coreRunning = true, true } @@ -2005,7 +2006,9 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen prepareRecovery: func(ctx context.Context, installation config.Installation, _ string) (PreflightResult, error) { return Preflight(ctx, installation, PreflightRequest{Archive: recoveryArchive, Confirm: true, AllowExternalSecrets: true}, permissivePreflightDependencies()) }, - recover: func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool) error { return nil }, + recover: func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { + return nil + }, cleanupCheckpoint: func(string) error { return nil }, acquireTransaction: lifecycle.AcquireTransaction, runner: runner, @@ -2025,3 +2028,235 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen }, } } + +type projectionRestoreStub struct { + events *[]string + blocked bool + publishErr error + restoreErr error + closeErr error +} + +func (stub *projectionRestoreStub) PublishCanonical() (authconfig.ProjectionStatus, error) { + *stub.events = append(*stub.events, "publish") + if stub.publishErr != nil { + return authconfig.ProjectionStatus{}, stub.publishErr + } + stub.blocked = false + return authconfig.ProjectionStatus{State: "ready", Generation: "g", CanonicalRevision: "sha256:g", Equal: true}, nil +} + +func (stub *projectionRestoreStub) RestorePriorIfCanonicalUnchanged() error { + *stub.events = append(*stub.events, "restore-prior") + if stub.restoreErr != nil { + return stub.restoreErr + } + stub.blocked = false + return nil +} + +func (stub *projectionRestoreStub) Close() error { + *stub.events = append(*stub.events, "close") + return stub.closeErr +} + +type restartEventRunner struct { + archiveRunner + events *[]string +} + +func (runner restartEventRunner) Run(ctx context.Context, args []string, input io.Reader) (compose.Result, error) { + if strings.HasSuffix(strings.Join(args, " "), " start") { + *runner.events = append(*runner.events, "restart") + } + return runner.archiveRunner.Run(ctx, args, input) +} + +func (runner restartEventRunner) Stream(ctx context.Context, args []string, input io.Reader, output io.Writer) (compose.Result, error) { + return runner.archiveRunner.Stream(ctx, args, input, output) +} + +func (runner restartEventRunner) SessionInventoryScope() string { + return runner.archiveRunner.SessionInventoryScope() +} + +func projectedRestoreFixture(t *testing.T) (config.Installation, string) { + t.Helper() + installation := preflightTestInstallation(t) + authRoot := filepath.Join(t.TempDir(), "canonical-auth") + if err := os.Mkdir(authRoot, 0o700); err != nil { + t.Fatal(err) + } + installation.Authentication.ConfigDirectory = authRoot + installation.Authentication.RuntimeProjection = &config.RuntimeProjection{Directory: filepath.Join(t.TempDir(), "runtime-auth"), UID: 10001, GID: 10001} + archive := filepath.Join(t.TempDir(), "auth-restore.zip") + writePreflightArchive(t, archive, preflightArchiveSpec{includeSecrets: true, entries: []preflightArchiveEntry{{ + path: "authentication-secrets/000-auth.yaml", body: []byte("candidate auth\n"), kind: EntryExternalSecret, + sensitive: true, owner: "authentication-configuration", sourcePath: filepath.Join(authRoot, "auth.yaml"), + }}}) + return installation, archive +} + +func assertOrderedEvents(t *testing.T, events []string, wants ...string) { + t.Helper() + at := 0 + for _, want := range wants { + for at < len(events) && events[at] != want { + at++ + } + if at == len(events) { + t.Fatalf("events = %v, want ordered subsequence %v", events, wants) + } + at++ + } +} + +func TestRestoreAuthBearingArchiveBlocksBeforeWritePublishesBeforeRestart(t *testing.T) { + installation, archive := projectedRestoreFixture(t) + var events []string + backing := newBackupRunner(installation, true) + runner := restartEventRunner{archiveRunner: backing, events: &events} + deps := restoreTestDependencies(t, runner) + transaction := &projectionRestoreStub{events: &events} + deps.beginAuthProjection = func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) { + transaction.blocked = true + events = append(events, "begin") + return transaction, nil + } + deps.restoreFile = func(_ context.Context, _ config.Installation, entry ArchiveEntryMetadata, _ io.Reader) error { + if entry.Owner == "authentication-configuration" { + if !transaction.blocked { + t.Fatal("auth destination write began without blocked projection") + } + events = append(events, "restore-auth") + } + return nil + } + if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err != nil { + t.Fatal(err) + } + assertOrderedEvents(t, events, "begin", "restore-auth", "publish", "restart", "close") + if transaction.blocked { + t.Fatalf("successful restore closed while projection remained blocked: %v", events) + } +} + +func TestRestoreAuthCandidatePublicationFailureRecoversThenStaysBlockedWithoutRestart(t *testing.T) { + installation, archive := projectedRestoreFixture(t) + var events []string + backing := newBackupRunner(installation, true) + runner := restartEventRunner{archiveRunner: backing, events: &events} + deps := restoreTestDependencies(t, runner) + publicationErr := errors.New("synthetic publication failure") + transaction := &projectionRestoreStub{events: &events, publishErr: publicationErr} + deps.beginAuthProjection = func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) { + transaction.blocked = true + return transaction, nil + } + deps.recover = func(_ context.Context, _ config.Installation, _ PreflightResult, _ *stagedArchive, _ bool, transaction authProjectionRestoreTransaction) error { + events = append(events, "restore-checkpoint") + _, err := transaction.PublishCanonical() + return err + } + if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); !errors.Is(err, publicationErr) { + t.Fatalf("restore error = %v, want publication failure", err) + } + assertOrderedEvents(t, events, "publish", "restore-checkpoint", "close") + if !transaction.blocked { + t.Fatal("publication failure reopened projection") + } + if backing.startCount != 0 { + t.Fatalf("restart after failed candidate/recovery publication = %d", backing.startCount) + } + if !backing.maintenance { + t.Fatal("admissions reopened after failed recovery publication") + } +} + +func TestRestoreAuthVerificationFailuresRepublishCheckpointBeforeRecoveryRestart(t *testing.T) { + for _, failed := range []string{"health", "doctor", "pi", "workspace"} { + t.Run(failed, func(t *testing.T) { + installation, archive := projectedRestoreFixture(t) + var events []string + backing := newBackupRunner(installation, true) + runner := restartEventRunner{archiveRunner: backing, events: &events} + deps := restoreTestDependencies(t, runner) + transaction := &projectionRestoreStub{events: &events} + deps.beginAuthProjection = func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) { + transaction.blocked = true + return transaction, nil + } + for _, name := range []string{"health", "doctor", "pi", "workspace"} { + name := name + deps.verify[name] = func(context.Context, config.Installation, archiveRunner) error { + events = append(events, "candidate-"+name) + if name == failed { + return errors.New("synthetic " + name + " failure") + } + return nil + } + } + deps.recover = func(ctx context.Context, target config.Installation, _ PreflightResult, _ *stagedArchive, wasRunning bool, transaction authProjectionRestoreTransaction) error { + events = append(events, "restore-checkpoint") + if _, err := transaction.PublishCanonical(); err != nil { + return err + } + events = append(events, "verify-checkpoint") + if wasRunning { + return composeStartAndVerify(ctx, target, runner) + } + return nil + } + if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err == nil { + t.Fatalf("%s failure accepted", failed) + } + assertOrderedEvents(t, events, "publish", "candidate-"+failed, "restore-checkpoint", "publish", "verify-checkpoint", "restart", "close") + if transaction.blocked { + t.Fatalf("verified checkpoint recovery remained blocked: %v", events) + } + }) + } +} + +func TestRestoreAuthPreMutationFailureRestoresPriorReadySelector(t *testing.T) { + installation, archive := projectedRestoreFixture(t) + var events []string + backing := newBackupRunner(installation, false) + runner := &commandFailureRunner{fakeBackupRunner: backing, failures: []*commandFailure{{ + match: func(command string) bool { return strings.Contains(command, " ps --all --format json") }, + err: errors.New("synthetic pre-mutation failure"), remaining: 1, + }}} + deps := restoreTestDependencies(t, runner) + transaction := &projectionRestoreStub{events: &events} + deps.beginAuthProjection = func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) { + transaction.blocked = true + return transaction, nil + } + deps.recover = func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { + t.Fatal("recovery ran before any destination mutation") + return nil + } + if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps); err == nil { + t.Fatal("pre-mutation failure accepted") + } + assertOrderedEvents(t, events, "restore-prior", "close") + if transaction.blocked { + t.Fatal("unchanged canonical pre-write failure did not restore ready selector") + } +} + +func TestRestoreNonAuthArchiveNeverBeginsProjection(t *testing.T) { + installation := preflightTestInstallation(t) + deps := restoreTestDependencies(t, newBackupRunner(installation, false)) + called := false + deps.beginAuthProjection = func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) { + called = true + return nil, errors.New("must not begin") + } + if _, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: restoreArchive(t), Confirm: true}, deps); err != nil { + t.Fatal(err) + } + if called { + t.Fatal("non-auth archive began projection transaction") + } +} diff --git a/tools/tht/internal/config/installation.go b/tools/tht/internal/config/installation.go index 9632ba40..e66598b0 100644 --- a/tools/tht/internal/config/installation.go +++ b/tools/tht/internal/config/installation.go @@ -39,7 +39,14 @@ type descriptor struct { } type authenticationDescriptor struct { - ConfigDirectory string `yaml:"configDirectory"` + ConfigDirectory string `yaml:"configDirectory"` + RuntimeProjection *runtimeProjectionDescriptor `yaml:"runtimeProjection"` +} + +type runtimeProjectionDescriptor struct { + Directory string `yaml:"directory"` + UID uint32 `yaml:"uid"` + GID uint32 `yaml:"gid"` } type workspaceRepositoryDescriptor struct { @@ -55,9 +62,17 @@ type WorkspaceRepository struct { Access string } +// RuntimeProjection is the non-secret runtime root and numeric container ownership contract. +type RuntimeProjection struct { + Directory string + UID uint32 + GID uint32 +} + // Authentication is the non-secret filesystem location for the installation auth configuration. type Authentication struct { - ConfigDirectory string + ConfigDirectory string + RuntimeProjection *RuntimeProjection } // Installation is a validated local Compose installation. It intentionally contains paths, not @@ -114,6 +129,14 @@ func Load(path string) (Installation, error) { return Installation{}, errors.New("authentication.configDirectory must be an absolute canonical path") } + authentication := Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory} + if raw.Authentication.RuntimeProjection != nil { + authentication.RuntimeProjection = &RuntimeProjection{ + Directory: raw.Authentication.RuntimeProjection.Directory, + UID: raw.Authentication.RuntimeProjection.UID, + GID: raw.Authentication.RuntimeProjection.GID, + } + } installation := Installation{ Path: path, Profile: raw.Profile, @@ -124,7 +147,7 @@ func Load(path string) (Installation, error) { Branch: raw.WorkspaceRepository.Branch, Access: raw.WorkspaceRepository.Access, }, - Authentication: Authentication{ConfigDirectory: raw.Authentication.ConfigDirectory}, + Authentication: authentication, Overrides: make([]string, 0, len(raw.Overrides)), } values, err := installation.environmentValues() @@ -140,6 +163,14 @@ func Load(path string) (Installation, error) { } installation.Overrides = append(installation.Overrides, filepath.Clean(override)) } + if err := installation.validateRuntimeAuthProjection(values); err != nil { + return Installation{}, err + } + if installation.HasRuntimeAuthProjection() { + if err := requireRegularFile(installation.runtimeAuthProjectionComposePath(), "runtime authentication Compose override"); err != nil { + return Installation{}, err + } + } for _, composeFile := range installation.ComposeFiles()[:2] { if err := requireRegularFile(composeFile, "Compose file"); err != nil { return Installation{}, err @@ -161,6 +192,49 @@ func Load(path string) (Installation, error) { // AuthenticationDirectory returns the descriptor-owned, non-secret authentication root. func (i Installation) AuthenticationDirectory() string { return i.Authentication.ConfigDirectory } +// RuntimeAuthProjection returns an independent descriptor copy when this installation uses the +// Linux-only runtime auth publication contract. +func (i Installation) RuntimeAuthProjection() *RuntimeProjection { + if i.Authentication.RuntimeProjection == nil { + return nil + } + projection := *i.Authentication.RuntimeProjection + return &projection +} + +// HasRuntimeAuthProjection reports whether the descriptor selects the runtime auth projection. +func (i Installation) HasRuntimeAuthProjection() bool { + return i.Authentication.RuntimeProjection != nil +} + +func (i Installation) validateRuntimeAuthProjection(values map[string]string) error { + projection := i.RuntimeAuthProjection() + if projection == nil { + if values["THT_AUTH_RUNTIME_ROOT"] != "" { + return errors.New("THT_AUTH_RUNTIME_ROOT requires authentication.runtimeProjection") + } + return nil + } + if i.Profile != "server" { + return errors.New("authentication.runtimeProjection requires the server profile") + } + if err := safeio.ValidateCanonicalPath(projection.Directory); err != nil || projection.Directory == i.AuthenticationDirectory() { + return errors.New("authentication.runtimeProjection.directory must be a distinct absolute canonical path") + } + if projection.UID != 10001 || projection.GID != 10001 { + return errors.New("authentication.runtimeProjection requires uid and gid 10001") + } + if values["THT_AUTH_RUNTIME_ROOT"] != projection.Directory { + return errors.New("authentication.runtimeProjection.directory must match THT_AUTH_RUNTIME_ROOT") + } + for _, override := range i.Overrides { + if filepath.Clean(override) == i.runtimeAuthProjectionComposePath() { + return errors.New("runtime authentication Compose override is automatic and must not be declared") + } + } + return nil +} + var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`) var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`) @@ -250,6 +324,9 @@ func (i Installation) ComposeFiles() []string { filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"), } files = append(files, i.Overrides...) + if i.HasRuntimeAuthProjection() { + files = append(files, i.runtimeAuthProjectionComposePath()) + } currentImage := i.CurrentImageOverridePath() if info, err := os.Lstat(currentImage); err == nil && info.Mode().IsRegular() { files = append(files, currentImage) @@ -257,6 +334,10 @@ func (i Installation) ComposeFiles() []string { return files } +func (i Installation) runtimeAuthProjectionComposePath() string { + return filepath.Join(i.ProjectDirectory, "deploy", "compose.auth-runtime-projection.yaml") +} + // ControlDirectory contains state that is private to one installation descriptor, even when // multiple installations intentionally share one source checkout. func (i Installation) ControlDirectory() string { diff --git a/tools/tht/internal/config/installation_test.go b/tools/tht/internal/config/installation_test.go index c6524b0f..13223393 100644 --- a/tools/tht/internal/config/installation_test.go +++ b/tools/tht/internal/config/installation_test.go @@ -52,6 +52,104 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) { assertStringsEqual(t, installation.ComposeFiles(), want) } +func TestLoadAcceptsServerRuntimeProjectionAndPlacesAutomaticOverrideBeforeCurrentImage(t *testing.T) { + installationPath, projectDirectory, envFile, override := writeInstallation(t, "server") + root := filepath.Dir(installationPath) + authDirectory := filepath.Join(root, "canonical-auth") + runtimeDirectory := filepath.Join(root, "runtime-auth") + automaticOverride := filepath.Join(projectDirectory, "deploy", "compose.auth-runtime-projection.yaml") + if err := os.WriteFile(automaticOverride, []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + candidate := Installation{Path: installationPath, ProjectDirectory: projectDirectory} + currentImage := candidate.CurrentImageOverridePath() + if err := os.MkdirAll(filepath.Dir(currentImage), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(currentImage, []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + writeRuntimeProjectionFixture(t, installationPath, envFile, "server", authDirectory, runtimeDirectory, runtimeDirectory, 10001, 10001, []string{override}) + + installation, err := Load(installationPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !installation.HasRuntimeAuthProjection() { + t.Fatal("HasRuntimeAuthProjection() = false, want true") + } + projection := installation.RuntimeAuthProjection() + if projection == nil || projection.Directory != runtimeDirectory || projection.UID != 10001 || projection.GID != 10001 { + t.Fatalf("RuntimeAuthProjection() = %#v", projection) + } + projection.Directory = "mutated" + if got := installation.RuntimeAuthProjection(); got == nil || got.Directory != runtimeDirectory { + t.Fatalf("RuntimeAuthProjection() did not return an independent copy: %#v", got) + } + + want := []string{ + filepath.Join(projectDirectory, "compose.yaml"), + filepath.Join(projectDirectory, "deploy", "compose.server.yaml"), + override, + automaticOverride, + currentImage, + } + assertStringsEqual(t, installation.ComposeFiles(), want) +} + +func TestLoadRejectsInvalidRuntimeProjection(t *testing.T) { + for _, test := range []struct { + name string + profile string + configDirectory func(root string) string + runtimeDirectory func(root string) string + environmentRoot func(root string) string + uid, gid uint32 + manualOverride bool + }{ + {name: "local profile", profile: "local", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10001}, + {name: "relative runtime directory", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(string) string { return "relative-runtime-auth" }, environmentRoot: func(string) string { return "relative-runtime-auth" }, uid: 10001, gid: 10001}, + {name: "noncanonical runtime directory", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return root + "/runtime-auth/../runtime-auth" }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10001}, + {name: "equal canonical and runtime directories", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "canonical-auth") }, uid: 10001, gid: 10001}, + {name: "uid mismatch", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10000, gid: 10001}, + {name: "gid mismatch", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10000}, + {name: "missing runtime environment", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return "" }, uid: 10001, gid: 10001}, + {name: "mismatched runtime environment", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "different-runtime-auth") }, uid: 10001, gid: 10001}, + {name: "manual automatic override", profile: "server", configDirectory: func(root string) string { return filepath.Join(root, "canonical-auth") }, runtimeDirectory: func(root string) string { return filepath.Join(root, "runtime-auth") }, environmentRoot: func(root string) string { return filepath.Join(root, "runtime-auth") }, uid: 10001, gid: 10001, manualOverride: true}, + } { + t.Run(test.name, func(t *testing.T) { + installationPath, projectDirectory, envFile, override := writeInstallation(t, test.profile) + root := filepath.Dir(installationPath) + automaticOverride := filepath.Join(projectDirectory, "deploy", "compose.auth-runtime-projection.yaml") + if err := os.WriteFile(automaticOverride, []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + overrides := []string{override} + if test.manualOverride { + overrides = append(overrides, automaticOverride) + } + writeRuntimeProjectionFixture(t, installationPath, envFile, test.profile, test.configDirectory(root), test.runtimeDirectory(root), test.environmentRoot(root), test.uid, test.gid, overrides) + + if _, err := Load(installationPath); err == nil { + t.Fatal("Load() unexpectedly accepted an invalid runtime authentication projection") + } + }) + } +} + +func TestLoadRejectsRuntimeProjectionEnvironmentWithoutDescriptor(t *testing.T) { + installationPath, _, envFile, _ := writeInstallation(t, "server") + authDirectory := filepath.Join(filepath.Dir(installationPath), "auth") + runtimeDirectory := filepath.Join(filepath.Dir(installationPath), "runtime-auth") + contents := "THT_AUTH_CONFIG_ROOT=" + strconv.Quote(authDirectory) + "\nTHT_AUTH_RUNTIME_ROOT=" + strconv.Quote(runtimeDirectory) + "\n" + if err := os.WriteFile(envFile, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Load(installationPath); err == nil { + t.Fatal("Load() unexpectedly accepted THT_AUTH_RUNTIME_ROOT without runtimeProjection") + } +} + func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *testing.T) { installationPath, projectDirectory, envFile, _ := writeInstallation(t, "local") gitOverride := filepath.Join(projectDirectory, "deploy", "compose.git-ssh.yaml") @@ -280,6 +378,25 @@ func TestParseAuthenticationDirectoryEnvironment(t *testing.T) { } } +func writeRuntimeProjectionFixture(t *testing.T, installationPath, envFile, profile, configDirectory, runtimeDirectory, environmentRoot string, uid, gid uint32, overrides []string) { + t.Helper() + lines := []string{"THT_AUTH_CONFIG_ROOT=" + strconv.Quote(configDirectory)} + if environmentRoot != "" { + lines = append(lines, "THT_AUTH_RUNTIME_ROOT="+strconv.Quote(environmentRoot)) + } + if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + projectDirectory := filepath.Join(filepath.Dir(installationPath), "project directory with spaces") + contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\nauthentication:\n configDirectory: " + configDirectory + "\n runtimeProjection:\n directory: " + runtimeDirectory + "\n uid: " + strconv.FormatUint(uint64(uid), 10) + "\n gid: " + strconv.FormatUint(uint64(gid), 10) + "\noverrides:\n" + for _, override := range overrides { + contents += " - " + override + "\n" + } + if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} + func writeInstallation(t *testing.T, profile string) (string, string, string, string) { t.Helper() diff --git a/tools/tht/internal/doctor/report.go b/tools/tht/internal/doctor/report.go index 0f8d0843..1cd9e4f8 100644 --- a/tools/tht/internal/doctor/report.go +++ b/tools/tht/internal/doctor/report.go @@ -28,6 +28,8 @@ const ( const probeTimeout = 5 * time.Second +var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady + const registryValidationProgram = `const fs=require("node:fs");const path="/data/workspace-registry/state/active.json";const s=JSON.parse(fs.readFileSync(path,"utf8"));const hex=/^[0-9a-f]{40}$/;if(!hex.test(s.head)||!Array.isArray(s.revisions)||s.revisions.some((r)=>!r||typeof r.id!=="string"||!r.id||!hex.test(r.commit)||!hex.test(r.blob))){process.exit(1)}for(const r of s.revisions){fs.accessSync("/data/workspace-registry/snapshots/"+r.commit+"/"+r.id+".yaml",fs.constants.R_OK)}` // Check is one named, redacted diagnostic outcome. @@ -118,6 +120,13 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner } else { add("files", StatusPassed, "declared host files have safe permissions") } + if installation.HasRuntimeAuthProjection() { + if err := requireRuntimeAuthProjectionReady(installation); err != nil { + add("auth-projection", StatusFailed, "runtime authentication projection is unavailable") + return finalize(report), nil + } + add("auth-projection", StatusPassed, "runtime authentication projection is ready and equal to canonical authentication") + } if secretErr != nil { add("docker", StatusSkipped, "declared secret files are unavailable") add("compose", StatusSkipped, "declared secret files are unavailable") diff --git a/tools/tht/internal/doctor/report_test.go b/tools/tht/internal/doctor/report_test.go index 695954ad..ac0bad33 100644 --- a/tools/tht/internal/doctor/report_test.go +++ b/tools/tht/internal/doctor/report_test.go @@ -27,6 +27,56 @@ func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing. } } +func TestRunReportsOneSanitizedRuntimeAuthProjectionFailureBeforeCompose(t *testing.T) { + installation := doctorInstallation(t, "") + installation.Profile = "server" + installation.Authentication.RuntimeProjection = &config.RuntimeProjection{ + Directory: "/runtime-auth", UID: 10001, GID: 10001, + } + previous := requireRuntimeAuthProjectionReady + requireRuntimeAuthProjectionReady = func(config.Installation) error { + return errors.New("synthetic-runtime-projection-secret") + } + t.Cleanup(func() { requireRuntimeAuthProjectionReady = previous }) + + runner := &doctorRunner{services: healthyServices} + report, err := Run(context.Background(), installation, runner) + if err != nil { + t.Fatal(err) + } + failures := 0 + for _, check := range report.Checks { + if check.Name != "auth-projection" { + continue + } + failures++ + if check.Status != StatusFailed || check.Detail != "runtime authentication projection is unavailable" { + t.Fatalf("auth-projection check = %#v", check) + } + } + if failures != 1 { + t.Fatalf("auth-projection failures = %d, report = %#v", failures, report) + } + if strings.Contains(reportText(report), "synthetic-runtime-projection-secret") { + t.Fatalf("runtime projection report leaked internal detail: %#v", report) + } + if len(runner.calls) != 0 { + t.Fatalf("doctor reached Compose diagnostics after failed projection gate: %v", runner.calls) + } +} + +func TestRunLeavesUnprojectedDoctorChecklistUnchanged(t *testing.T) { + report, err := Run(context.Background(), doctorInstallation(t, ""), &doctorRunner{services: healthyServices}) + if err != nil { + t.Fatal(err) + } + for _, check := range report.Checks { + if check.Name == "auth-projection" { + t.Fatalf("unprojected report unexpectedly contains auth-projection: %#v", report) + } + } +} + func TestValidateVolumesRequiresAuthState(t *testing.T) { legacy := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}}}` if err := ValidateVolumes(legacy); err == nil || !strings.Contains(err.Error(), "auth-state") { diff --git a/tools/tht/internal/service/service.go b/tools/tht/internal/service/service.go index a1d9d8c9..d067ba9f 100644 --- a/tools/tht/internal/service/service.go +++ b/tools/tht/internal/service/service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" ) @@ -17,6 +18,7 @@ import ( const healthTimeout = 5 * time.Minute var healthPollInterval = time.Second +var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady // HealthFailure identifies the last non-ready service after a bounded health wait. type HealthFailure struct { @@ -41,6 +43,9 @@ func Start(ctx context.Context, installation config.Installation, runner compose if runner == nil { return errors.New("start requires a Docker command runner") } + if err := requireRuntimeAuthProjectionReady(installation); err != nil { + return errors.New("runtime authentication projection is unavailable") + } if build { if err := runCompose(ctx, installation, runner, "build"); err != nil { return fmt.Errorf("image build: %w", err) diff --git a/tools/tht/internal/service/service_test.go b/tools/tht/internal/service/service_test.go index c428091b..a3917456 100644 --- a/tools/tht/internal/service/service_test.go +++ b/tools/tht/internal/service/service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "io" "strings" "testing" @@ -36,6 +37,30 @@ func TestStartSkipsBuildUnlessRequested(t *testing.T) { } } +func TestStartRefusesProjectedAuthenticationBeforeComposeWhenNotReady(t *testing.T) { + for _, state := range []string{"missing", "blocked", "divergent"} { + t.Run(state, func(t *testing.T) { + previous := requireRuntimeAuthProjectionReady + requireRuntimeAuthProjectionReady = func(installation config.Installation) error { + if !installation.HasRuntimeAuthProjection() { + t.Fatal("readiness gate received an unprojected installation") + } + return errors.New("synthetic " + state + " projection") + } + t.Cleanup(func() { requireRuntimeAuthProjectionReady = previous }) + + runner := &recordingRunner{} + err := Start(context.Background(), projectedTestInstallation(), runner, true) + if err == nil { + t.Fatalf("Start() accepted %s runtime projection", state) + } + if len(runner.stages) != 0 { + t.Fatalf("Start() reached Compose for %s projection: %v", state, runner.stages) + } + }) + } +} + type recordingRunner struct{ stages []string } func (r *recordingRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { @@ -61,6 +86,15 @@ func testInstallation() config.Installation { } } +func projectedTestInstallation() config.Installation { + installation := testInstallation() + installation.Profile = "server" + installation.Authentication.RuntimeProjection = &config.RuntimeProjection{ + Directory: "/runtime-auth", UID: 10001, GID: 10001, + } + return installation +} + const healthyServices = `[ {"Service":"core","State":"running","Health":"healthy"}, {"Service":"frontend","State":"running","Health":"healthy"}, diff --git a/tools/tht/internal/setup/files.go b/tools/tht/internal/setup/files.go index 26b481be..60200383 100644 --- a/tools/tht/internal/setup/files.go +++ b/tools/tht/internal/setup/files.go @@ -30,6 +30,10 @@ var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) // file and leaves no final target until all content is synced. var atomicWriteNewFile = writeNewFileAtomically +// effectiveUID is a package-private seam so projected server setup can prove its root gate +// happens before any filesystem mutation. +var effectiveUID = currentEffectiveUID + type answers struct { installationID, profile string workspaceRemote, workspaceBranch string @@ -51,7 +55,12 @@ type generatedDescriptor struct { Access string `yaml:"access"` } `yaml:"workspaceRepository"` Authentication struct { - ConfigDirectory string `yaml:"configDirectory"` + ConfigDirectory string `yaml:"configDirectory"` + RuntimeProjection *struct { + Directory string `yaml:"directory"` + UID uint32 `yaml:"uid"` + GID uint32 `yaml:"gid"` + } `yaml:"runtimeProjection,omitempty"` } `yaml:"authentication"` Overrides []string `yaml:"overrides"` } @@ -70,6 +79,9 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul if err := validateAnswers(values); err != nil { return FilesResult{}, err } + if values.profile == "server" && effectiveUID() != 0 { + return FilesResult{}, errors.New("projected server setup requires root") + } directory, err := installationDirectory(root, values.installationID) if err != nil { @@ -77,7 +89,11 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul } descriptorPath := filepath.Join(directory, descriptorName) environmentPath := filepath.Join(directory, environmentName) - if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil { + if values.profile == "server" { + if err := ensureProjectedAuthDirectories(filepath.Join(directory, "auth"), filepath.Join(directory, "auth-runtime")); err != nil { + return FilesResult{}, errors.New("projected authentication directories are unavailable or unsafe") + } + } else if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil { return FilesResult{}, errors.New("authentication directory is unavailable or unsafe") } result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath} @@ -305,6 +321,17 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error) descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)} descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth") + if value.profile == "server" { + descriptor.Authentication.RuntimeProjection = &struct { + Directory string `yaml:"directory"` + UID uint32 `yaml:"uid"` + GID uint32 `yaml:"gid"` + }{ + Directory: filepath.Join(filepath.Dir(descriptorPath), "auth-runtime"), + UID: 10001, + GID: 10001, + } + } descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")} descriptorBytes, err := yaml.Marshal(descriptor) if err != nil { @@ -334,6 +361,7 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error) if value.profile == "server" { installationDirectory := filepath.Dir(descriptorPath) lines = append(lines, + "THT_AUTH_RUNTIME_ROOT="+dotenvValue(descriptor.Authentication.RuntimeProjection.Directory), "THT_DATA_ROOT="+dotenvValue(filepath.Join(installationDirectory, "data")), "THT_PI_STATE_ROOT="+dotenvValue(filepath.Join(installationDirectory, "pi-state")), "THT_WORKSPACE_REGISTRY_ROOT="+dotenvValue(filepath.Join(installationDirectory, "workspace-registry")), diff --git a/tools/tht/internal/setup/files_linux.go b/tools/tht/internal/setup/files_linux.go new file mode 100644 index 00000000..02d2c1a1 --- /dev/null +++ b/tools/tht/internal/setup/files_linux.go @@ -0,0 +1,74 @@ +//go:build linux + +package setup + +import ( + "errors" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +func currentEffectiveUID() int { return os.Geteuid() } + +func ensureProjectedAuthDirectories(canonicalRoot, runtimeRoot string) error { + parent := filepath.Dir(canonicalRoot) + canonicalName, runtimeName := filepath.Base(canonicalRoot), filepath.Base(runtimeRoot) + if parent != filepath.Dir(runtimeRoot) || canonicalName == runtimeName || canonicalName == "." || runtimeName == "." { + return errors.New("projected authentication directory layout is invalid") + } + parentFD, err := unix.Open(parent, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return errors.New("projected authentication parent is unavailable") + } + defer unix.Close(parentFD) + if err := requireProjectedDirectoryMetadata(parentFD, 0, 0); err != nil { + return err + } + if err := ensureProjectedAuthDirectoryAt(parentFD, canonicalName, 0, 0); err != nil { + return err + } + if err := ensureProjectedAuthDirectoryAt(parentFD, runtimeName, 10001, 10001); err != nil { + return err + } + if err := unix.Fsync(parentFD); err != nil { + return errors.New("projected authentication parent could not be synchronized") + } + return nil +} + +func ensureProjectedAuthDirectoryAt(parentFD int, name string, uid, gid int) error { + fd, err := unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil && !errors.Is(err, unix.ENOENT) { + return errors.New("projected authentication directory is unavailable") + } + if errors.Is(err, unix.ENOENT) { + if err := unix.Mkdirat(parentFD, name, 0o700); err != nil && !errors.Is(err, unix.EEXIST) { + return errors.New("projected authentication directory could not be created") + } + fd, err = unix.Openat(parentFD, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return errors.New("projected authentication directory is unavailable") + } + } + defer unix.Close(fd) + if err := unix.Fchown(fd, uid, gid); err != nil { + return errors.New("projected authentication directory ownership could not be set") + } + if err := unix.Fchmod(fd, 0o700); err != nil { + return errors.New("projected authentication directory mode could not be set") + } + if err := unix.Fsync(fd); err != nil { + return errors.New("projected authentication directory could not be synchronized") + } + return requireProjectedDirectoryMetadata(fd, uint32(uid), uint32(gid)) +} + +func requireProjectedDirectoryMetadata(fd int, uid, gid uint32) error { + var metadata unix.Stat_t + if err := unix.Fstat(fd, &metadata); err != nil || metadata.Mode&unix.S_IFMT != unix.S_IFDIR || metadata.Mode&0o777 != 0o700 || metadata.Uid != uid || metadata.Gid != gid { + return errors.New("projected authentication directory metadata is invalid") + } + return nil +} diff --git a/tools/tht/internal/setup/files_linux_test.go b/tools/tht/internal/setup/files_linux_test.go new file mode 100644 index 00000000..40e2b23d --- /dev/null +++ b/tools/tht/internal/setup/files_linux_test.go @@ -0,0 +1,56 @@ +//go:build linux + +package setup + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" +) + +func TestEnsureFilesProjectedServerCreatesExactNumericAuthenticationRoots(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires root to verify numeric projected ownership") + } + root := newProject(t, "projected server root") + for _, name := range []string{"compose.server.yaml", "compose.auth-runtime-projection.yaml"} { + if err := os.WriteFile(filepath.Join(root, "deploy", name), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + setNonInteractiveAnswers(t, newExternalSecrets(t, root)) + result, err := EnsureFiles(Request{ + ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true, + }, nil, ioDiscard{}) + if err != nil { + t.Fatal(err) + } + installation, err := config.Load(result.DescriptorPath) + if err != nil { + t.Fatal(err) + } + projection := installation.RuntimeAuthProjection() + if projection == nil { + t.Fatal("generated server descriptor lacks runtime auth projection") + } + assertNumericDirectoryMetadata(t, installation.AuthenticationDirectory(), 0, 0, 0o700) + assertNumericDirectoryMetadata(t, projection.Directory, 10001, 10001, 0o700) +} + +func assertNumericDirectoryMetadata(t *testing.T, path string, uid, gid uint32, mode os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + metadata, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Fatalf("stat metadata for %s = %T", path, info.Sys()) + } + if metadata.Uid != uid || metadata.Gid != gid || info.Mode().Perm() != mode { + t.Fatalf("metadata for %s = uid=%d gid=%d mode=%o, want uid=%d gid=%d mode=%o", path, metadata.Uid, metadata.Gid, info.Mode().Perm(), uid, gid, mode) + } +} diff --git a/tools/tht/internal/setup/files_test.go b/tools/tht/internal/setup/files_test.go index 6d1c43f4..ca024837 100644 --- a/tools/tht/internal/setup/files_test.go +++ b/tools/tht/internal/setup/files_test.go @@ -225,6 +225,24 @@ func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) { } } +func TestEnsureFilesProjectedServerRefusesBeforeAnyWriteWhenNotRoot(t *testing.T) { + root := newProject(t, "projected server non-root") + setNonInteractiveAnswers(t, newExternalSecrets(t, root)) + previous := effectiveUID + effectiveUID = func() int { return 1000 } + t.Cleanup(func() { effectiveUID = previous }) + + _, err := EnsureFiles(Request{ + ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true, + }, strings.NewReader(""), ioDiscard{}) + if err == nil || !strings.Contains(err.Error(), "root") { + t.Fatalf("EnsureFiles() error = %v, want root refusal", err) + } + if _, statErr := os.Lstat(filepath.Join(root, "deploy", "server")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("projected server path was created before root refusal: %v", statErr) + } +} + func TestEnsureFilesRejectsUnsafeServiceEndpointsBeforeWritingConfiguration(t *testing.T) { for _, test := range []struct { name, environment, value string diff --git a/tools/tht/internal/setup/files_unsupported.go b/tools/tht/internal/setup/files_unsupported.go new file mode 100644 index 00000000..30c7ad49 --- /dev/null +++ b/tools/tht/internal/setup/files_unsupported.go @@ -0,0 +1,11 @@ +//go:build !linux + +package setup + +import "errors" + +func currentEffectiveUID() int { return -1 } + +func ensureProjectedAuthDirectories(_, _ string) error { + return errors.New("runtime authentication projection setup is unsupported") +} diff --git a/tools/tht/internal/setup/run.go b/tools/tht/internal/setup/run.go index f5f1921e..7c3cb202 100644 --- a/tools/tht/internal/setup/run.go +++ b/tools/tht/internal/setup/run.go @@ -19,6 +19,9 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/service" ) +var publishProjectedCanonical = authconfig.PublishProjectedCanonical +var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady + // Result records the completed setup phases. DescriptorPath always identifies the descriptor // selected by this invocation, including an idempotent rerun. type Result struct { @@ -85,7 +88,7 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R func configureAuthentication(ctx context.Context, installation config.Installation, request Request, input io.Reader, output io.Writer) error { directory := installation.AuthenticationDirectory() if _, _, err := authconfig.Load(directory); err == nil { - return nil + return publishConfiguredAuthentication(ctx, installation) } if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) { return errors.New("setup authentication configuration is invalid") @@ -100,6 +103,25 @@ func configureAuthentication(ctx context.Context, installation config.Installati if _, _, err := authconfig.Load(directory); err != nil { return errors.New("setup authentication configuration is invalid") } + return publishConfiguredAuthentication(ctx, installation) +} + +func publishConfiguredAuthentication(ctx context.Context, installation config.Installation) error { + projection := installation.RuntimeAuthProjection() + if projection == nil { + return nil + } + status, err := publishProjectedCanonical(ctx, installation.AuthenticationDirectory(), authconfig.ProjectionSpec{ + RuntimeRoot: projection.Directory, + UID: projection.UID, + GID: projection.GID, + }) + if err != nil || status.State != "ready" || !status.Equal { + return errors.New("setup authentication runtime projection could not be published") + } + if err := requireRuntimeAuthProjectionReady(installation); err != nil { + return errors.New("setup authentication runtime projection could not be verified") + } return nil } diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go index bd99fa83..f7e083fa 100644 --- a/tools/tht/internal/setup/run_test.go +++ b/tools/tht/internal/setup/run_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/aritmolab/thothii/tools/tht/internal/authconfig" + "github.com/aritmolab/thothii/tools/tht/internal/authprojection" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" @@ -99,6 +100,102 @@ func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *test assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config") } +func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testing.T) { + projectRoot, request := setupRunFixture(t, true) + request.Profile = "server" + if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard); err != nil { + t.Fatal(err) + } + installation, err := config.Load(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")) + if err != nil { + t.Fatal(err) + } + projection := installation.RuntimeAuthProjection() + if projection == nil { + t.Fatal("generated server installation has no runtime auth projection") + } + status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.Directory, UID: projection.UID, GID: projection.GID}) + if err != nil || status.Selector.State != "ready" { + t.Fatalf("initial runtime auth projection = %#v, %v; want ready", status, err) + } +} + +func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicationFails(t *testing.T) { + projectRoot, request := setupRunFixture(t, true) + request.Profile = "server" + if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + previous := publishProjectedCanonical + publishProjectedCanonical = func(ctx context.Context, canonicalRoot string, spec authconfig.ProjectionSpec) (authconfig.ProjectionStatus, error) { + transaction, err := authconfig.BeginExternalProjectionTransaction(ctx, canonicalRoot, spec) + if err != nil { + return authconfig.ProjectionStatus{}, err + } + if err := transaction.Close(); err != nil { + return authconfig.ProjectionStatus{}, err + } + return authconfig.ProjectionStatus{}, errors.New("synthetic-password-sentinel") + } + t.Cleanup(func() { publishProjectedCanonical = previous }) + + _, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard) + if err == nil || strings.Contains(err.Error(), "synthetic-password-sentinel") { + t.Fatalf("Run() error = %v, want sanitized publication failure", err) + } + installation, loadErr := config.Load(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")) + if loadErr != nil { + t.Fatal(loadErr) + } + projection := installation.RuntimeAuthProjection() + if projection == nil { + t.Fatal("generated server installation has no runtime auth projection") + } + _, inspectErr := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.Directory, UID: projection.UID, GID: projection.GID}) + if !errors.Is(inspectErr, authprojection.ErrBlocked) { + t.Fatalf("Inspect() error = %v, want blocked projection", inspectErr) + } +} + +func TestRunConfigureOnlyVerifiesProjectedAuthenticationAfterPublication(t *testing.T) { + projectRoot, request := setupRunFixture(t, true) + request.Profile = "server" + if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + previous := requireRuntimeAuthProjectionReady + calls := 0 + requireRuntimeAuthProjectionReady = func(installation config.Installation) error { + calls++ + projection := installation.RuntimeAuthProjection() + if projection == nil { + t.Fatal("post-publication readiness received an unprojected installation") + } + status, err := authprojection.Inspect(authprojection.Spec{ + RuntimeRoot: projection.Directory, + UID: projection.UID, + GID: projection.GID, + }) + if err != nil || status.Selector.State != "ready" { + t.Fatalf("post-publication projection = %#v, %v; want ready", status, err) + } + return errors.New("synthetic-readiness-secret") + } + t.Cleanup(func() { requireRuntimeAuthProjectionReady = previous }) + + _, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard) + if err == nil || strings.Contains(err.Error(), "synthetic-readiness-secret") { + t.Fatalf("Run() error = %v, want sanitized post-publication readiness failure", err) + } + if calls != 1 { + t.Fatalf("post-publication readiness calls = %d, want 1", calls) + } +} + func TestRunRejectsIncompleteNonInteractiveLocalAuthenticationBeforeComposeRender(t *testing.T) { _, request := setupRunFixture(t, true) request.NonInteractive = true