From 610ae8c85a5a8fd4e8c376a027fdea62d5b566bb Mon Sep 17 00:00:00 2001
From: mptyl
Date: Tue, 25 Aug 2026 10:50:30 +0200
Subject: [PATCH] fix(auth): make local verification portable
Keep upstream identity visible while limiting logout to local auth. Inject the restore privilege gate so the deterministic core tests do not depend on the host OS, and confine descriptor-backed projection tests to Linux. Accept the real remaining Pi timeout budget instead of an exact millisecond.
---
backend/test/auth-runtime-projection.test.ts | 45 ++++++++++---------
backend/test/pi-management.test.ts | 5 ++-
frontend/src/shell/AppShell.auth.test.tsx | 4 +-
frontend/src/shell/AppShell.tsx | 10 +++--
.../authconfig/projection_transaction_test.go | 2 +
tools/tht/internal/backup/restore.go | 5 ++-
tools/tht/internal/backup/restore_host.go | 5 ++-
tools/tht/internal/backup/restore_test.go | 11 ++---
tools/tht/internal/setup/files_test.go | 8 ++++
tools/tht/internal/setup/run_test.go | 3 ++
10 files changed, 60 insertions(+), 38 deletions(-)
diff --git a/backend/test/auth-runtime-projection.test.ts b/backend/test/auth-runtime-projection.test.ts
index 5a9a58f2..06809297 100644
--- a/backend/test/auth-runtime-projection.test.ts
+++ b/backend/test/auth-runtime-projection.test.ts
@@ -70,6 +70,7 @@ const passwordHash =
"$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
const roots: string[] = [];
+const linuxTest = test.runIf(process.platform === "linux");
afterEach(() => {
fsHook.path = undefined;
@@ -276,7 +277,7 @@ function expectDenied(operation: () => unknown): void {
}
}
-test("loads ready projection as one immutable auth and local-users snapshot", () => {
+linuxTest("loads ready projection as one immutable auth and local-users snapshot", () => {
const root = projectionRoot();
const fixture = localProjectionFixture("synthetic-user", passwordHash);
const generation = writeReadyProjection(root, fixture);
@@ -298,7 +299,7 @@ test("loads ready projection as one immutable auth and local-users snapshot", ()
).toBe(true);
});
-test("loads a complete OIDC projection without a users snapshot", () => {
+linuxTest("loads a complete OIDC projection without a users snapshot", () => {
const root = projectionRoot();
const generation = writeReadyOidcProjection(root);
const loaded = createProjectedAuthenticationConfigProvider(root).current();
@@ -309,7 +310,7 @@ test("loads a complete OIDC projection without a users snapshot", () => {
});
});
-test("loadConfig selects an immutable projected local provider and its in-memory registry", async () => {
+linuxTest("loadConfig selects an immutable projected local provider and its in-memory registry", async () => {
const root = projectionRoot();
writeReadyProjection(root, localProjectionFixture("projected-user", passwordHash));
@@ -323,7 +324,7 @@ test("loadConfig selects an immutable projected local provider and its in-memory
expect(await registry?.findByUsername("PROJECTED-USER")).toMatchObject({ username: "projected-user" });
});
-test("loadConfig selects an immutable projected OIDC provider without direct-file fallback", () => {
+linuxTest("loadConfig selects an immutable projected OIDC provider without direct-file fallback", () => {
const root = projectionRoot();
writeReadyOidcProjection(root);
@@ -338,7 +339,7 @@ test("loadConfig selects an immutable projected OIDC provider without direct-fil
});
});
-test("rejects a trailing-slash runtime root", () => {
+linuxTest("rejects a trailing-slash runtime root", () => {
const root = projectionRoot();
writeReadyProjection(
root,
@@ -349,7 +350,7 @@ test("rejects a trailing-slash runtime root", () => {
);
});
-test.each([
+linuxTest.each([
["missing", undefined],
[
"blocked",
@@ -381,7 +382,7 @@ test.each([
);
});
-test.each([
+linuxTest.each([
"root traversal",
"CURRENT symlink",
"CURRENT hardlink",
@@ -436,7 +437,7 @@ test.runIf(process.geteuid?.() === 0)(
},
);
-test("rejects a foreign group with the correct owner", () => {
+linuxTest("rejects a foreign group with the correct owner", () => {
const root = projectionRoot();
writeReadyProjection(
root,
@@ -449,7 +450,7 @@ test("rejects a foreign group with the correct owner", () => {
);
});
-test("enumerates closed namespaces without path-based readdirSync", () => {
+linuxTest("enumerates closed namespaces without path-based readdirSync", () => {
const root = projectionRoot();
const generation = writeReadyProjection(
root,
@@ -462,7 +463,7 @@ test("enumerates closed namespaces without path-based readdirSync", () => {
).toBe(generation);
});
-test("rejects a symlinked runtime root", () => {
+linuxTest("rejects a symlinked runtime root", () => {
const root = projectionRoot();
writeReadyProjection(
root,
@@ -476,7 +477,7 @@ test("rejects a symlinked runtime root", () => {
);
});
-test.each([
+linuxTest.each([
"root",
"generations",
"selected generation",
@@ -514,7 +515,7 @@ test.each([
);
});
-test.each(["manifest", "generation", "size", "digest"])(
+linuxTest.each(["manifest", "generation", "size", "digest"])(
"rejects changed %s integrity data without secret disclosure",
(kind) => {
const root = projectionRoot();
@@ -542,7 +543,7 @@ test.each(["manifest", "generation", "size", "digest"])(
},
);
-test("switches atomically to a later complete generation", () => {
+linuxTest("switches atomically to a later complete generation", () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
@@ -564,7 +565,7 @@ test("switches atomically to a later complete generation", () => {
expect(provider.current().runtimeProjection?.generation).toBe(second);
});
-test("retries once when CURRENT is atomically replaced between lstat and open", () => {
+linuxTest("retries once when CURRENT is atomically replaced between lstat and open", () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
@@ -591,7 +592,7 @@ test("retries once when CURRENT is atomically replaced between lstat and open",
).toBe(second);
});
-test("retries once when CURRENT is replaced after the final identity read", () => {
+linuxTest("retries once when CURRENT is replaced after the final identity read", () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
@@ -628,7 +629,7 @@ test("retries once when CURRENT is replaced after the final identity read", () =
expect(observations).toBe(3);
});
-test("retries once when CURRENT is replaced between root descriptor and path observations", () => {
+linuxTest("retries once when CURRENT is replaced between root descriptor and path observations", () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
@@ -678,7 +679,7 @@ test("retries once when CURRENT is replaced between root descriptor and path obs
expect(replacedCurrent).toBe(true);
});
-test("rejects a second CURRENT replacement after the one permitted retry", () => {
+linuxTest("rejects a second CURRENT replacement after the one permitted retry", () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
@@ -717,7 +718,7 @@ test("rejects a second CURRENT replacement after the one permitted retry", () =>
);
});
-test("fails deterministically when generations is replaced during a load", () => {
+linuxTest("fails deterministically when generations is replaced during a load", () => {
const root = projectionRoot();
const generation = writeReadyProjection(
root,
@@ -748,7 +749,7 @@ test("fails deterministically when generations is replaced during a load", () =>
);
});
-test("fails deterministically when the selected generation directory is replaced during a load", () => {
+linuxTest("fails deterministically when the selected generation directory is replaced during a load", () => {
const root = projectionRoot();
const generation = writeReadyProjection(
root,
@@ -780,7 +781,7 @@ test("fails deterministically when the selected generation directory is replaced
);
});
-test.each(["corrupt", "symlink"])(
+linuxTest.each(["corrupt", "symlink"])(
"rejects a %s retained predecessor generation",
(kind) => {
const root = projectionRoot();
@@ -819,7 +820,7 @@ test.each(["corrupt", "symlink"])(
},
);
-test("has no direct-file fallback when CURRENT is absent", () => {
+linuxTest("has no direct-file fallback when CURRENT is absent", () => {
const root = projectionRoot();
const generation = writeReadyProjection(
root,
@@ -839,7 +840,7 @@ test("has no direct-file fallback when CURRENT is absent", () => {
);
});
-test("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => {
+linuxTest("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => {
const root = projectionRoot();
const first = writeReadyProjection(
root,
diff --git a/backend/test/pi-management.test.ts b/backend/test/pi-management.test.ts
index 452deae9..f0d79e8d 100644
--- a/backend/test/pi-management.test.ts
+++ b/backend/test/pi-management.test.ts
@@ -198,7 +198,10 @@ test("smoke uses the configured timeout and reports a sanitized timeout", async
message: "Pi smoke check timed out",
checkedAt: "2026-08-05T10:00:00.000Z",
});
- expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]).toMatchObject({ command: "/usr/local/bin/pi", args: ["--version"] });
+ expect(calls[0]!.timeout).toBeGreaterThan(0);
+ expect(calls[0]!.timeout).toBeLessThanOrEqual(750);
});
// Catches a smoke endpoint that validates only the Pi binary/model catalogue and never makes a
diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx
index 7617fd66..7146b812 100644
--- a/frontend/src/shell/AppShell.auth.test.tsx
+++ b/frontend/src/shell/AppShell.auth.test.tsx
@@ -65,7 +65,7 @@ describe("authenticated shell permissions", () => {
expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument();
});
- test("hides identity and logout outside local authentication", async () => {
+ test("keeps identity but hides logout outside local authentication", async () => {
let logoutCalls = 0;
server.use(http.post("/api/auth/logout", () => {
logoutCalls += 1;
@@ -79,7 +79,7 @@ describe("authenticated shell permissions", () => {
permissions: ["session.use"],
}, false);
- await waitFor(() => expect(screen.queryByText("portal-user")).not.toBeInTheDocument());
+ expect(await screen.findByText("portal-user")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
expect(logoutCalls).toBe(0);
});
diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx
index 388ee9e3..d7097cd8 100644
--- a/frontend/src/shell/AppShell.tsx
+++ b/frontend/src/shell/AppShell.tsx
@@ -716,14 +716,16 @@ export function AppShell({ canLogout }: AppShellProps) {
Human In The Loop
- {authenticatedUser && canLogout && (
+ {authenticatedUser && (
{authenticatedUser.displayName ?? authenticatedUser.subject}
-
+ {canLogout && (
+
+ )}
)}
diff --git a/tools/tht/internal/authconfig/projection_transaction_test.go b/tools/tht/internal/authconfig/projection_transaction_test.go
index fd082847..7c10d3bd 100644
--- a/tools/tht/internal/authconfig/projection_transaction_test.go
+++ b/tools/tht/internal/authconfig/projection_transaction_test.go
@@ -1,3 +1,5 @@
+//go:build linux
+
package authconfig
import (
diff --git a/tools/tht/internal/backup/restore.go b/tools/tht/internal/backup/restore.go
index db639f84..b96ffc6e 100644
--- a/tools/tht/internal/backup/restore.go
+++ b/tools/tht/internal/backup/restore.go
@@ -46,6 +46,7 @@ type restoreDependencies struct {
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, authProjectionRestoreTransaction) error
+ requireAuthProjection func() error
beginAuthProjection func(context.Context, config.Installation) (authProjectionRestoreTransaction, error)
cleanupCheckpoint func(string) error
acquireTransaction func(config.Installation) (*lifecycle.Transaction, error)
@@ -91,7 +92,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
if request.Archive == "" {
return RestoreResult{}, errors.New("restore archive is required")
}
- if deps.preflight == nil || deps.checkpoint == nil || deps.prepareRecovery == nil || deps.recover == nil || deps.cleanupCheckpoint == nil || deps.acquireTransaction == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
+ if deps.preflight == nil || deps.checkpoint == nil || deps.prepareRecovery == nil || deps.recover == nil || deps.requireAuthProjection == nil || deps.cleanupCheckpoint == nil || deps.acquireTransaction == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
return RestoreResult{}, errors.New("restore dependencies are incomplete")
}
@@ -117,7 +118,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
defer preflight.CloseArchive()
authRestoreRequired := installation.HasRuntimeAuthProjection() && manifestArchivesAuthentication(preflight.Manifest)
if authRestoreRequired {
- if err := requireAuthProjectionRestorePrivilege(); err != nil {
+ if err := deps.requireAuthProjection(); err != nil {
return result, err
}
}
diff --git a/tools/tht/internal/backup/restore_host.go b/tools/tht/internal/backup/restore_host.go
index 98436209..de262993 100644
--- a/tools/tht/internal/backup/restore_host.go
+++ b/tools/tht/internal/backup/restore_host.go
@@ -49,8 +49,9 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe
request.Output = path
return createWithDependenciesTransaction(ctx, transaction, target, request, productionCreateDependencies(target))
},
- cleanupCheckpoint: cleanupRecoveryCheckpoint,
- acquireTransaction: lifecycle.AcquireTransaction,
+ cleanupCheckpoint: cleanupRecoveryCheckpoint,
+ acquireTransaction: lifecycle.AcquireTransaction,
+ requireAuthProjection: requireAuthProjectionRestorePrivilege,
beginAuthProjection: func(ctx context.Context, target config.Installation) (authProjectionRestoreTransaction, error) {
projection := target.RuntimeAuthProjection()
if projection == nil {
diff --git a/tools/tht/internal/backup/restore_test.go b/tools/tht/internal/backup/restore_test.go
index 8fd7e4eb..ff90ef48 100644
--- a/tools/tht/internal/backup/restore_test.go
+++ b/tools/tht/internal/backup/restore_test.go
@@ -2009,11 +2009,12 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
recover: func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error {
return nil
},
- cleanupCheckpoint: func(string) error { return nil },
- acquireTransaction: lifecycle.AcquireTransaction,
- runner: runner,
- sleep: func(time.Duration) {},
- restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil },
+ cleanupCheckpoint: func(string) error { return nil },
+ acquireTransaction: lifecycle.AcquireTransaction,
+ requireAuthProjection: func() error { return nil },
+ runner: runner,
+ sleep: func(time.Duration) {},
+ restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil },
restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error {
return nil
},
diff --git a/tools/tht/internal/setup/files_test.go b/tools/tht/internal/setup/files_test.go
index ca024837..2f98bd68 100644
--- a/tools/tht/internal/setup/files_test.go
+++ b/tools/tht/internal/setup/files_test.go
@@ -208,6 +208,7 @@ func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) {
}
func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
+ requireProjectedServerTestHost(t)
root := newProject(t, "server profile")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
result, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
@@ -225,6 +226,13 @@ func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
}
}
+func requireProjectedServerTestHost(t *testing.T) {
+ t.Helper()
+ if runtime.GOOS != "linux" || os.Geteuid() != 0 {
+ t.Skip("projected server filesystem integration requires Linux root")
+ }
+}
+
func TestEnsureFilesProjectedServerRefusesBeforeAnyWriteWhenNotRoot(t *testing.T) {
root := newProject(t, "projected server non-root")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go
index f7e083fa..0df79581 100644
--- a/tools/tht/internal/setup/run_test.go
+++ b/tools/tht/internal/setup/run_test.go
@@ -101,6 +101,7 @@ func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *test
}
func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testing.T) {
+ requireProjectedServerTestHost(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 {
@@ -125,6 +126,7 @@ func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testin
}
func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicationFails(t *testing.T) {
+ requireProjectedServerTestHost(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 {
@@ -162,6 +164,7 @@ func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicat
}
func TestRunConfigureOnlyVerifiesProjectedAuthenticationAfterPublication(t *testing.T) {
+ requireProjectedServerTestHost(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 {