feat(server): activate projected authentication safely
This commit is contained in:
+22
-3
@@ -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(
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,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")),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user