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.
This commit is contained in:
2026-08-25 10:50:30 +02:00
parent e9c65ef2db
commit 610ae8c85a
10 changed files with 60 additions and 38 deletions
+23 -22
View File
@@ -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,
+4 -1
View File
@@ -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
+2 -2
View File
@@ -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);
});
+3 -1
View File
@@ -716,14 +716,16 @@ export function AppShell({ canLogout }: AppShellProps) {
<br />
Human In The Loop
</p>
{authenticatedUser && canLogout && (
{authenticatedUser && (
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
<span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
{authenticatedUser.displayName ?? authenticatedUser.subject}
</span>
{canLogout && (
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
Log out
</Button>
)}
</div>
)}
</div>
@@ -1,3 +1,5 @@
//go:build linux
package authconfig
import (
+3 -2
View File
@@ -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
}
}
@@ -51,6 +51,7 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe
},
cleanupCheckpoint: cleanupRecoveryCheckpoint,
acquireTransaction: lifecycle.AcquireTransaction,
requireAuthProjection: requireAuthProjectionRestorePrivilege,
beginAuthProjection: func(ctx context.Context, target config.Installation) (authProjectionRestoreTransaction, error) {
projection := target.RuntimeAuthProjection()
if projection == nil {
@@ -2011,6 +2011,7 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
},
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 },
+8
View File
@@ -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))
+3
View File
@@ -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 {