mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Infra: fail closed without device scope baseline
This commit is contained in:
@@ -69,6 +69,28 @@ async function overwritePairedOperatorTokenScopes(baseDir: string, scopes: strin
|
|||||||
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
|
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mutatePairedOperatorDevice(baseDir: string, mutate: (device: PairedDevice) => void) {
|
||||||
|
const { pairedPath } = resolvePairingPaths(baseDir, "devices");
|
||||||
|
const pairedByDeviceId = JSON.parse(await readFile(pairedPath, "utf8")) as Record<
|
||||||
|
string,
|
||||||
|
PairedDevice
|
||||||
|
>;
|
||||||
|
const device = pairedByDeviceId["device-1"];
|
||||||
|
expect(device).toBeDefined();
|
||||||
|
if (!device) {
|
||||||
|
throw new Error("expected paired operator device");
|
||||||
|
}
|
||||||
|
mutate(device);
|
||||||
|
await writeFile(pairedPath, JSON.stringify(pairedByDeviceId, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPairedOperatorApprovalBaseline(baseDir: string) {
|
||||||
|
await mutatePairedOperatorDevice(baseDir, (device) => {
|
||||||
|
delete device.approvedScopes;
|
||||||
|
delete device.scopes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("device pairing tokens", () => {
|
describe("device pairing tokens", () => {
|
||||||
test("reuses existing pending requests for the same device", async () => {
|
test("reuses existing pending requests for the same device", async () => {
|
||||||
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
@@ -250,6 +272,19 @@ describe("device pairing tokens", () => {
|
|||||||
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fails closed when the paired device approval baseline is missing during verification", async () => {
|
||||||
|
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||||
|
await clearPairedOperatorApprovalBaseline(baseDir);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyOperatorToken({
|
||||||
|
baseDir,
|
||||||
|
token,
|
||||||
|
scopes: ["operator.read"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: false, reason: "scope-mismatch" });
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts operator.read/operator.write requests with an operator.admin token scope", async () => {
|
test("accepts operator.read/operator.write requests with an operator.admin token scope", async () => {
|
||||||
const { baseDir, token } = await setupOperatorToken(["operator.admin"]);
|
const { baseDir, token } = await setupOperatorToken(["operator.admin"]);
|
||||||
|
|
||||||
@@ -268,6 +303,57 @@ describe("device pairing tokens", () => {
|
|||||||
expect(writeOk.ok).toBe(true);
|
expect(writeOk.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts custom operator scopes under an operator.admin approval baseline", async () => {
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
|
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||||
|
|
||||||
|
const rotated = await rotateDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.talk.secrets"],
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
expect(rotated?.scopes).toEqual(["operator.talk.secrets"]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
verifyOperatorToken({
|
||||||
|
baseDir,
|
||||||
|
token: requireToken(rotated?.token),
|
||||||
|
scopes: ["operator.talk.secrets"],
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails closed when the paired device approval baseline is missing during ensure", async () => {
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
|
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||||
|
await clearPairedOperatorApprovalBaseline(baseDir);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fails closed when the paired device approval baseline is missing during rotation", async () => {
|
||||||
|
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
|
||||||
|
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
|
||||||
|
await clearPairedOperatorApprovalBaseline(baseDir);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
rotateDeviceToken({
|
||||||
|
deviceId: "device-1",
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test("treats multibyte same-length token input as mismatch without throwing", async () => {
|
test("treats multibyte same-length token input as mismatch without throwing", async () => {
|
||||||
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
|
||||||
const multibyteToken = "é".repeat(token.length);
|
const multibyteToken = "é".repeat(token.length);
|
||||||
|
|||||||
@@ -181,44 +181,6 @@ function mergePendingDevicePairingRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopesAllow(requested: string[], allowed: string[]): boolean {
|
|
||||||
if (requested.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (allowed.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const allowedSet = new Set(allowed);
|
|
||||||
return requested.every((scope) => allowedSet.has(scope));
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEVICE_SCOPE_IMPLICATIONS: Readonly<Record<string, readonly string[]>> = {
|
|
||||||
"operator.admin": ["operator.read", "operator.write", "operator.approvals", "operator.pairing"],
|
|
||||||
"operator.write": ["operator.read"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function expandScopeImplications(scopes: string[]): string[] {
|
|
||||||
const expanded = new Set(scopes);
|
|
||||||
const queue = [...scopes];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const scope = queue.pop();
|
|
||||||
if (!scope) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const impliedScope of DEVICE_SCOPE_IMPLICATIONS[scope] ?? []) {
|
|
||||||
if (!expanded.has(impliedScope)) {
|
|
||||||
expanded.add(impliedScope);
|
|
||||||
queue.push(impliedScope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...expanded];
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopesAllowWithImplications(requested: string[], allowed: string[]): boolean {
|
|
||||||
return scopesAllow(expandScopeImplications(requested), expandScopeImplications(allowed));
|
|
||||||
}
|
|
||||||
|
|
||||||
function newToken() {
|
function newToken() {
|
||||||
return generatePairingToken();
|
return generatePairingToken();
|
||||||
}
|
}
|
||||||
@@ -252,6 +214,29 @@ function buildDeviceAuthToken(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApprovedDeviceScopeBaseline(device: PairedDevice): string[] | null {
|
||||||
|
const baseline = device.approvedScopes ?? device.scopes;
|
||||||
|
if (!Array.isArray(baseline)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeDeviceAuthScopes(baseline);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopesWithinApprovedDeviceBaseline(params: {
|
||||||
|
role: string;
|
||||||
|
scopes: readonly string[];
|
||||||
|
approvedScopes: readonly string[] | null;
|
||||||
|
}): boolean {
|
||||||
|
if (!params.approvedScopes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return roleScopesAllow({
|
||||||
|
role: params.role,
|
||||||
|
requestedScopes: params.scopes,
|
||||||
|
allowedScopes: params.approvedScopes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
export async function listDevicePairing(baseDir?: string): Promise<DevicePairingList> {
|
||||||
const state = await loadState(baseDir);
|
const state = await loadState(baseDir);
|
||||||
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
|
const pending = Object.values(state.pendingById).toSorted((a, b) => b.ts - a.ts);
|
||||||
@@ -494,10 +479,14 @@ export async function verifyDeviceToken(params: {
|
|||||||
if (!verifyPairingToken(params.token, entry.token)) {
|
if (!verifyPairingToken(params.token, entry.token)) {
|
||||||
return { ok: false, reason: "token-mismatch" };
|
return { ok: false, reason: "token-mismatch" };
|
||||||
}
|
}
|
||||||
const approvedScopes = normalizeDeviceAuthScopes(
|
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
|
||||||
device.approvedScopes ?? device.scopes ?? entry.scopes,
|
if (
|
||||||
);
|
!scopesWithinApprovedDeviceBaseline({
|
||||||
if (!scopesAllowWithImplications(entry.scopes, approvedScopes)) {
|
role,
|
||||||
|
scopes: entry.scopes,
|
||||||
|
approvedScopes,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return { ok: false, reason: "scope-mismatch" };
|
return { ok: false, reason: "scope-mismatch" };
|
||||||
}
|
}
|
||||||
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
|
const requestedScopes = normalizeDeviceAuthScopes(params.scopes);
|
||||||
@@ -531,14 +520,22 @@ export async function ensureDeviceToken(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const { device, role, tokens, existing } = context;
|
const { device, role, tokens, existing } = context;
|
||||||
const approvedScopes = normalizeDeviceAuthScopes(
|
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
|
||||||
device.approvedScopes ?? device.scopes ?? existing?.scopes,
|
if (
|
||||||
);
|
!scopesWithinApprovedDeviceBaseline({
|
||||||
if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) {
|
role,
|
||||||
|
scopes: requestedScopes,
|
||||||
|
approvedScopes,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (existing && !existing.revokedAtMs) {
|
if (existing && !existing.revokedAtMs) {
|
||||||
const existingWithinApproved = scopesAllowWithImplications(existing.scopes, approvedScopes);
|
const existingWithinApproved = scopesWithinApprovedDeviceBaseline({
|
||||||
|
role,
|
||||||
|
scopes: existing.scopes,
|
||||||
|
approvedScopes,
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
existingWithinApproved &&
|
existingWithinApproved &&
|
||||||
roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })
|
roleScopesAllow({ role, requestedScopes, allowedScopes: existing.scopes })
|
||||||
@@ -605,10 +602,14 @@ export async function rotateDeviceToken(params: {
|
|||||||
const requestedScopes = normalizeDeviceAuthScopes(
|
const requestedScopes = normalizeDeviceAuthScopes(
|
||||||
params.scopes ?? existing?.scopes ?? device.scopes,
|
params.scopes ?? existing?.scopes ?? device.scopes,
|
||||||
);
|
);
|
||||||
const approvedScopes = normalizeDeviceAuthScopes(
|
const approvedScopes = resolveApprovedDeviceScopeBaseline(device);
|
||||||
device.approvedScopes ?? device.scopes ?? existing?.scopes,
|
if (
|
||||||
);
|
!scopesWithinApprovedDeviceBaseline({
|
||||||
if (!scopesAllowWithImplications(requestedScopes, approvedScopes)) {
|
role,
|
||||||
|
scopes: requestedScopes,
|
||||||
|
approvedScopes,
|
||||||
|
})
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
Reference in New Issue
Block a user