feat(server): activate projected authentication safely

This commit is contained in:
User
2026-08-22 01:01:36 +02:00
parent 903c0b4de5
commit 3d9a9f0675
32 changed files with 1837 additions and 38 deletions
+22 -3
View File
@@ -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<string, string | undefined>): 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;
@@ -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(
+25
View File
@@ -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 {
@@ -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
+117
View File
@@ -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."
@@ -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") {
+5
View File
@@ -32,6 +32,8 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/workspaceops"
)
var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady
const usage = `Usage: tht [--installation <absolute-path>/thothii-installation.yaml] <command>
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)
+45
View File
@@ -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)
+143 -2
View File
@@ -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"`
@@ -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
}
@@ -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
}
@@ -0,0 +1,5 @@
//go:build !linux
package authconfig
func requireProjectedAuthMutationPrivilege() error { return errCommandRefused }
+27
View File
@@ -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)
+69 -3
View File
@@ -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")
+22 -6
View File
@@ -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)
@@ -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
}
@@ -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)
}
}
@@ -0,0 +1,9 @@
//go:build !linux
package backup
import "errors"
func requireAuthProjectionRestorePrivilege() error {
return errors.New("projected authentication restore is unsupported")
}
+253 -18
View File
@@ -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")
}
}
+84 -3
View File
@@ -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 {
@@ -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()
+9
View File
@@ -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")
+50
View File
@@ -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") {
+5
View File
@@ -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)
@@ -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"},
+30 -2
View File
@@ -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")),
+74
View File
@@ -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
}
@@ -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)
}
}
+18
View File
@@ -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
@@ -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")
}
+23 -1
View File
@@ -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
}
+97
View File
@@ -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