fix(gateway): detect SecretRef auth rotations

This commit is contained in:
Peter Steinberger
2026-05-02 13:16:24 +01:00
parent 8a2207f8a1
commit 9fff2b7791
3 changed files with 47 additions and 13 deletions

View File

@@ -12,6 +12,7 @@ import {
writeRestartSentinel,
} from "../../infra/restart-sentinel.js";
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
import { resolveEffectiveSharedGatewayAuth } from "../auth.js";
import { buildGatewayReloadPlan, resolveGatewayReloadSettings } from "../config-reload.js";
import { formatControlPlaneActor, type ControlPlaneActor } from "../control-plane-audit.js";
@@ -46,6 +47,16 @@ export function didSharedGatewayAuthChange(prev: OpenClawConfig, next: OpenClawC
return prevAuth.mode !== nextAuth.mode || !isDeepStrictEqual(prevAuth.secret, nextAuth.secret);
}
export function didActiveSharedGatewayAuthChange(params: {
fallbackPrev: OpenClawConfig;
next: OpenClawConfig;
}): boolean {
return didSharedGatewayAuthChange(
getActiveSecretsRuntimeSnapshot()?.config ?? params.fallbackPrev,
params.next,
);
}
function queueSharedGatewayAuthDisconnect(
shouldDisconnect: boolean,
context?: GatewayRequestContext,

View File

@@ -41,6 +41,7 @@ vi.mock("../../config/runtime-schema.js", () => ({
}));
vi.mock("../../secrets/runtime.js", () => ({
getActiveSecretsRuntimeSnapshot: () => null,
prepareSecretsRuntimeSnapshot: prepareSecretsRuntimeSnapshotMock,
}));
@@ -69,7 +70,11 @@ beforeEach(() => {
ok: true,
config,
}));
prepareSecretsRuntimeSnapshotMock.mockResolvedValue(undefined);
prepareSecretsRuntimeSnapshotMock.mockImplementation(
async ({ config }: { config: OpenClawConfig }) => ({
config,
}),
);
restartSentinelMocks.writeRestartSentinel.mockClear();
});

View File

@@ -18,7 +18,10 @@ import { loadGatewayRuntimeConfigSchema } from "../../config/runtime-schema.js";
import { lookupConfigSchema, type ConfigSchemaResponse } from "../../config/schema.js";
import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { prepareSecretsRuntimeSnapshot } from "../../secrets/runtime.js";
import {
prepareSecretsRuntimeSnapshot,
type PreparedSecretsRuntimeSnapshot,
} from "../../secrets/runtime.js";
import { diffConfigPaths } from "../config-reload.js";
import {
formatControlPlaneActor,
@@ -40,6 +43,7 @@ import {
import { resolveBaseHashParam } from "./base-hash.js";
import {
commitGatewayConfigWrite,
didActiveSharedGatewayAuthChange,
didSharedGatewayAuthChange,
resolveGatewayConfigPath,
resolveGatewayConfigRestartWriteResult,
@@ -234,13 +238,12 @@ function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationI
async function ensureResolvableSecretRefsOrRespond(params: {
config: OpenClawConfig;
respond: RespondFn;
}): Promise<boolean> {
}): Promise<PreparedSecretsRuntimeSnapshot | null> {
try {
await prepareSecretsRuntimeSnapshot({
return await prepareSecretsRuntimeSnapshot({
config: params.config,
includeAuthStoreRefs: false,
});
return true;
} catch (error) {
const details = formatErrorMessage(error);
params.respond(
@@ -251,7 +254,7 @@ async function ensureResolvableSecretRefsOrRespond(params: {
`invalid config: active SecretRef resolution failed (${details})`,
),
);
return false;
return null;
}
}
@@ -415,7 +418,11 @@ export const configHandlers: GatewayRequestHandlers = {
);
return;
}
if (!(await ensureResolvableSecretRefsOrRespond({ config: validated.config, respond }))) {
const preparedSecretsSnapshot = await ensureResolvableSecretRefsOrRespond({
config: validated.config,
respond,
});
if (!preparedSecretsSnapshot) {
return;
}
const changedPaths = diffConfigPaths(snapshot.config, validated.config);
@@ -447,10 +454,12 @@ export const configHandlers: GatewayRequestHandlers = {
);
// Compare before the write so we invalidate clients authenticated against the
// previous shared secret immediately after the config update succeeds.
const disconnectSharedAuthClients = didSharedGatewayAuthChange(
snapshot.config,
validated.config,
);
const disconnectSharedAuthClients =
didSharedGatewayAuthChange(snapshot.config, validated.config) ||
didActiveSharedGatewayAuthChange({
fallbackPrev: snapshot.config,
next: preparedSecretsSnapshot.config,
});
const writeResult = await commitGatewayConfigWrite({
snapshot,
writeOptions,
@@ -497,7 +506,11 @@ export const configHandlers: GatewayRequestHandlers = {
if (!parsed) {
return;
}
if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) {
const preparedSecretsSnapshot = await ensureResolvableSecretRefsOrRespond({
config: parsed.config,
respond,
});
if (!preparedSecretsSnapshot) {
return;
}
const changedPaths = diffConfigPaths(snapshot.config, parsed.config);
@@ -507,7 +520,12 @@ export const configHandlers: GatewayRequestHandlers = {
);
// Compare before the write so we invalidate clients authenticated against the
// previous shared secret immediately after the config update succeeds.
const disconnectSharedAuthClients = didSharedGatewayAuthChange(snapshot.config, parsed.config);
const disconnectSharedAuthClients =
didSharedGatewayAuthChange(snapshot.config, parsed.config) ||
didActiveSharedGatewayAuthChange({
fallbackPrev: snapshot.config,
next: preparedSecretsSnapshot.config,
});
const writeResult = await commitGatewayConfigWrite({
snapshot,
writeOptions,