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"; "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8";
const roots: string[] = []; const roots: string[] = [];
const linuxTest = test.runIf(process.platform === "linux");
afterEach(() => { afterEach(() => {
fsHook.path = undefined; 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 root = projectionRoot();
const fixture = localProjectionFixture("synthetic-user", passwordHash); const fixture = localProjectionFixture("synthetic-user", passwordHash);
const generation = writeReadyProjection(root, fixture); const generation = writeReadyProjection(root, fixture);
@@ -298,7 +299,7 @@ test("loads ready projection as one immutable auth and local-users snapshot", ()
).toBe(true); ).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 root = projectionRoot();
const generation = writeReadyOidcProjection(root); const generation = writeReadyOidcProjection(root);
const loaded = createProjectedAuthenticationConfigProvider(root).current(); 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(); const root = projectionRoot();
writeReadyProjection(root, localProjectionFixture("projected-user", passwordHash)); 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" }); 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(); const root = projectionRoot();
writeReadyOidcProjection(root); 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(); const root = projectionRoot();
writeReadyProjection( writeReadyProjection(
root, root,
@@ -349,7 +350,7 @@ test("rejects a trailing-slash runtime root", () => {
); );
}); });
test.each([ linuxTest.each([
["missing", undefined], ["missing", undefined],
[ [
"blocked", "blocked",
@@ -381,7 +382,7 @@ test.each([
); );
}); });
test.each([ linuxTest.each([
"root traversal", "root traversal",
"CURRENT symlink", "CURRENT symlink",
"CURRENT hardlink", "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(); const root = projectionRoot();
writeReadyProjection( writeReadyProjection(
root, 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 root = projectionRoot();
const generation = writeReadyProjection( const generation = writeReadyProjection(
root, root,
@@ -462,7 +463,7 @@ test("enumerates closed namespaces without path-based readdirSync", () => {
).toBe(generation); ).toBe(generation);
}); });
test("rejects a symlinked runtime root", () => { linuxTest("rejects a symlinked runtime root", () => {
const root = projectionRoot(); const root = projectionRoot();
writeReadyProjection( writeReadyProjection(
root, root,
@@ -476,7 +477,7 @@ test("rejects a symlinked runtime root", () => {
); );
}); });
test.each([ linuxTest.each([
"root", "root",
"generations", "generations",
"selected generation", "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", "rejects changed %s integrity data without secret disclosure",
(kind) => { (kind) => {
const root = projectionRoot(); 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 root = projectionRoot();
const first = writeReadyProjection( const first = writeReadyProjection(
root, root,
@@ -564,7 +565,7 @@ test("switches atomically to a later complete generation", () => {
expect(provider.current().runtimeProjection?.generation).toBe(second); 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 root = projectionRoot();
const first = writeReadyProjection( const first = writeReadyProjection(
root, root,
@@ -591,7 +592,7 @@ test("retries once when CURRENT is atomically replaced between lstat and open",
).toBe(second); ).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 root = projectionRoot();
const first = writeReadyProjection( const first = writeReadyProjection(
root, root,
@@ -628,7 +629,7 @@ test("retries once when CURRENT is replaced after the final identity read", () =
expect(observations).toBe(3); 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 root = projectionRoot();
const first = writeReadyProjection( const first = writeReadyProjection(
root, root,
@@ -678,7 +679,7 @@ test("retries once when CURRENT is replaced between root descriptor and path obs
expect(replacedCurrent).toBe(true); 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 root = projectionRoot();
const first = writeReadyProjection( const first = writeReadyProjection(
root, 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 root = projectionRoot();
const generation = writeReadyProjection( const generation = writeReadyProjection(
root, 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 root = projectionRoot();
const generation = writeReadyProjection( const generation = writeReadyProjection(
root, 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", "rejects a %s retained predecessor generation",
(kind) => { (kind) => {
const root = projectionRoot(); 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 root = projectionRoot();
const generation = writeReadyProjection( const generation = writeReadyProjection(
root, 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 root = projectionRoot();
const first = writeReadyProjection( const first = writeReadyProjection(
root, 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", message: "Pi smoke check timed out",
checkedAt: "2026-08-05T10:00:00.000Z", 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 // 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(); 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; let logoutCalls = 0;
server.use(http.post("/api/auth/logout", () => { server.use(http.post("/api/auth/logout", () => {
logoutCalls += 1; logoutCalls += 1;
@@ -79,7 +79,7 @@ describe("authenticated shell permissions", () => {
permissions: ["session.use"], permissions: ["session.use"],
}, false); }, 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(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument();
expect(logoutCalls).toBe(0); expect(logoutCalls).toBe(0);
}); });
+6 -4
View File
@@ -716,14 +716,16 @@ export function AppShell({ canLogout }: AppShellProps) {
<br /> <br />
Human In The Loop Human In The Loop
</p> </p>
{authenticatedUser && canLogout && ( {authenticatedUser && (
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left"> <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}> <span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
{authenticatedUser.displayName ?? authenticatedUser.subject} {authenticatedUser.displayName ?? authenticatedUser.subject}
</span> </span>
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}> {canLogout && (
Log out <Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
</Button> Log out
</Button>
)}
</div> </div>
)} )}
</div> </div>
@@ -1,3 +1,5 @@
//go:build linux
package authconfig package authconfig
import ( import (
+3 -2
View File
@@ -46,6 +46,7 @@ type restoreDependencies struct {
checkpoint func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) checkpoint func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error)
prepareRecovery func(context.Context, config.Installation, string) (PreflightResult, error) prepareRecovery func(context.Context, config.Installation, string) (PreflightResult, error)
recover func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error recover func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error
requireAuthProjection func() error
beginAuthProjection func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) beginAuthProjection func(context.Context, config.Installation) (authProjectionRestoreTransaction, error)
cleanupCheckpoint func(string) error cleanupCheckpoint func(string) error
acquireTransaction func(config.Installation) (*lifecycle.Transaction, error) acquireTransaction func(config.Installation) (*lifecycle.Transaction, error)
@@ -91,7 +92,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
if request.Archive == "" { if request.Archive == "" {
return RestoreResult{}, errors.New("restore archive is required") 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") return RestoreResult{}, errors.New("restore dependencies are incomplete")
} }
@@ -117,7 +118,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati
defer preflight.CloseArchive() defer preflight.CloseArchive()
authRestoreRequired := installation.HasRuntimeAuthProjection() && manifestArchivesAuthentication(preflight.Manifest) authRestoreRequired := installation.HasRuntimeAuthProjection() && manifestArchivesAuthentication(preflight.Manifest)
if authRestoreRequired { if authRestoreRequired {
if err := requireAuthProjectionRestorePrivilege(); err != nil { if err := deps.requireAuthProjection(); err != nil {
return result, err return result, err
} }
} }
+3 -2
View File
@@ -49,8 +49,9 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe
request.Output = path request.Output = path
return createWithDependenciesTransaction(ctx, transaction, target, request, productionCreateDependencies(target)) return createWithDependenciesTransaction(ctx, transaction, target, request, productionCreateDependencies(target))
}, },
cleanupCheckpoint: cleanupRecoveryCheckpoint, cleanupCheckpoint: cleanupRecoveryCheckpoint,
acquireTransaction: lifecycle.AcquireTransaction, acquireTransaction: lifecycle.AcquireTransaction,
requireAuthProjection: requireAuthProjectionRestorePrivilege,
beginAuthProjection: func(ctx context.Context, target config.Installation) (authProjectionRestoreTransaction, error) { beginAuthProjection: func(ctx context.Context, target config.Installation) (authProjectionRestoreTransaction, error) {
projection := target.RuntimeAuthProjection() projection := target.RuntimeAuthProjection()
if projection == nil { if projection == nil {
+6 -5
View File
@@ -2009,11 +2009,12 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
recover: func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { recover: func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error {
return nil return nil
}, },
cleanupCheckpoint: func(string) error { return nil }, cleanupCheckpoint: func(string) error { return nil },
acquireTransaction: lifecycle.AcquireTransaction, acquireTransaction: lifecycle.AcquireTransaction,
runner: runner, requireAuthProjection: func() error { return nil },
sleep: func(time.Duration) {}, runner: runner,
restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil }, 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 { restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error {
return nil return nil
}, },
+8
View File
@@ -208,6 +208,7 @@ func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) {
} }
func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) { func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
requireProjectedServerTestHost(t)
root := newProject(t, "server profile") root := newProject(t, "server profile")
setNonInteractiveAnswers(t, newExternalSecrets(t, root)) setNonInteractiveAnswers(t, newExternalSecrets(t, root))
result, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true}, strings.NewReader(""), ioDiscard{}) 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) { func TestEnsureFilesProjectedServerRefusesBeforeAnyWriteWhenNotRoot(t *testing.T) {
root := newProject(t, "projected server non-root") root := newProject(t, "projected server non-root")
setNonInteractiveAnswers(t, newExternalSecrets(t, root)) setNonInteractiveAnswers(t, newExternalSecrets(t, root))
+3
View File
@@ -101,6 +101,7 @@ func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *test
} }
func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testing.T) { func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testing.T) {
requireProjectedServerTestHost(t)
projectRoot, request := setupRunFixture(t, true) projectRoot, request := setupRunFixture(t, true)
request.Profile = "server" request.Profile = "server"
if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { 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) { func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicationFails(t *testing.T) {
requireProjectedServerTestHost(t)
projectRoot, request := setupRunFixture(t, true) projectRoot, request := setupRunFixture(t, true)
request.Profile = "server" request.Profile = "server"
if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { 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) { func TestRunConfigureOnlyVerifiesProjectedAuthenticationAfterPublication(t *testing.T) {
requireProjectedServerTestHost(t)
projectRoot, request := setupRunFixture(t, true) projectRoot, request := setupRunFixture(t, true)
request.Profile = "server" request.Profile = "server"
if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil {