fix: enforce pairing approval scopes

This commit is contained in:
Coy Geek
2026-03-27 12:36:02 -07:00
committed by Peter Steinberger
parent 5d0562badf
commit 353d93613c
2 changed files with 135 additions and 20 deletions

View File

@@ -160,7 +160,12 @@ describe("device-pair /pair qr", () => {
it("returns an inline QR image for webchat surfaces", async () => {
const command = registerPairCommand();
const result = await command.handler(createCommandContext({ channel: "webchat" }));
const result = await command.handler(
createCommandContext({
channel: "webchat",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const text = requireText(result);
expect(pluginApiMocks.renderQrPngBase64).toHaveBeenCalledTimes(1);
@@ -208,7 +213,12 @@ describe("device-pair /pair qr", () => {
pluginApiMocks.renderQrPngBase64.mockRejectedValueOnce(new Error("render failed"));
const command = registerPairCommand();
const result = await command.handler(createCommandContext({ channel: "webchat" }));
const result = await command.handler(
createCommandContext({
channel: "webchat",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const text = requireText(result);
expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({
@@ -426,6 +436,23 @@ describe("device-pair /pair qr", () => {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
it("fails closed for cleanup when internal gateway scopes are absent", async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "cleanup",
commandBody: "/pair cleanup",
gatewayClientScopes: undefined,
}),
);
expect(pluginApiMocks.clearDeviceBootstrapTokens).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
});
describe("device-pair /pair default setup code", () => {
@@ -470,6 +497,23 @@ describe("device-pair /pair default setup code", () => {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
it("fails closed for webchat setup code issuance when scopes are absent", async () => {
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: undefined,
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
});
describe("device-pair notify pending formatting", () => {
@@ -655,7 +699,44 @@ describe("device-pair /pair approve", () => {
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });
});
it("rejects approvals above the caller scopes", async () => {
it("fails closed for approvals when internal gateway scopes are absent", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [
{
requestId: "req-1",
deviceId: "victim-phone",
publicKey: "victim-public-key",
displayName: "Victim Phone",
platform: "ios",
ts: Date.now(),
},
],
paired: [],
});
vi.mocked(approveDevicePairing).mockResolvedValueOnce({
status: "forbidden",
missingScope: "operator.admin",
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: undefined,
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
callerScopes: [],
});
expect(result).toEqual({
text: "⚠️ This command requires operator.admin to approve this pairing request.",
});
});
it("rejects approvals that request scopes above the caller session", async () => {
vi.mocked(listDevicePairing).mockResolvedValueOnce({
pending: [
{
@@ -684,8 +765,11 @@ describe("device-pair /pair approve", () => {
}),
);
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
callerScopes: ["operator.write", "operator.pairing"],
});
expect(result).toEqual({
text: "⚠️ Cannot approve a request requiring operator.admin.",
text: "⚠️ This command requires operator.admin to approve this pairing request.",
});
});
});

View File

@@ -487,12 +487,35 @@ function buildMissingPairingScopeReply(): { text: string } {
};
}
function isMissingPairingScope(gatewayClientScopes: string[] | null): boolean {
return Boolean(
gatewayClientScopes &&
!gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin"),
);
function isInternalGatewayPairingCaller(params: {
channel: string;
gatewayClientScopes?: readonly string[] | null;
}): boolean {
return params.channel === "webchat" || Array.isArray(params.gatewayClientScopes);
}
function isMissingPairingScope(params: {
channel: string;
gatewayClientScopes?: readonly string[] | null;
}): boolean {
if (!isInternalGatewayPairingCaller(params)) {
return false;
}
const gatewayClientScopes = params.gatewayClientScopes;
return !Array.isArray(gatewayClientScopes)
? true
: !gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin");
}
function resolveApprovalCallerScopes(params: {
channel: string;
gatewayClientScopes?: readonly string[] | null;
}): readonly string[] | undefined {
if (!isInternalGatewayPairingCaller(params)) {
return undefined;
}
return Array.isArray(params.gatewayClientScopes) ? params.gatewayClientScopes : [];
}
const PAIR_SETUP_NON_ISSUING_ACTIONS = new Set([
@@ -569,7 +592,7 @@ export default definePluginEntry({
const action = tokens[0]?.toLowerCase() ?? "";
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: null;
: undefined;
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
@@ -591,7 +614,7 @@ export default definePluginEntry({
}
if (action === "approve") {
if (isMissingPairingScope(gatewayClientScopes)) {
if (isMissingPairingScope({ channel: ctx.channel, gatewayClientScopes })) {
return buildMissingPairingScopeReply();
}
const requested = tokens[1]?.trim();
@@ -622,16 +645,21 @@ export default definePluginEntry({
if (!pending) {
return { text: "Pairing request not found." };
}
const approved = gatewayClientScopes
? await approveDevicePairing(pending.requestId, {
callerScopes: gatewayClientScopes,
})
: await approveDevicePairing(pending.requestId);
const callerScopes = resolveApprovalCallerScopes({
channel: ctx.channel,
gatewayClientScopes,
});
const approved =
callerScopes === undefined
? await approveDevicePairing(pending.requestId)
: await approveDevicePairing(pending.requestId, { callerScopes });
if (!approved) {
return { text: "Pairing request not found." };
}
if (approved.status === "forbidden") {
return { text: `⚠️ Cannot approve a request requiring ${approved.missingScope}.` };
return {
text: `⚠️ This command requires ${approved.missingScope} to approve this pairing request.`,
};
}
const label = approved.device.displayName?.trim() || approved.device.deviceId;
const platform = approved.device.platform?.trim();
@@ -640,7 +668,7 @@ export default definePluginEntry({
}
if (action === "cleanup" || action === "clear" || action === "revoke") {
if (isMissingPairingScope(gatewayClientScopes)) {
if (isMissingPairingScope({ channel: ctx.channel, gatewayClientScopes })) {
return buildMissingPairingScopeReply();
}
const cleared = await clearDeviceBootstrapTokens();
@@ -656,7 +684,10 @@ export default definePluginEntry({
if (authLabelResult.error) {
return { text: `Error: ${authLabelResult.error}` };
}
if (issuesPairSetupCode(action) && isMissingPairingScope(gatewayClientScopes)) {
if (
issuesPairSetupCode(action) &&
isMissingPairingScope({ channel: ctx.channel, gatewayClientScopes })
) {
return buildMissingPairingScopeReply();
}