Skip to content

Commit 8b6bc84

Browse files
authored
feat(mcp): add the Approvals page for step-up requests (#126)
* feat(api): refresh the api stubs for step-up approvals Copy the api repo's gen/ts at 742f05f into src/gen/fleet. It adds the ListApprovals and DecideApproval RPCs with the Approval message and the DecideApprovalResponse wrapper. The wire round-trip test now pins every Approval field, so a stale copy that would drop one on decode fails the suite. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * feat(mcp): add the Approvals page for step-up requests Approvals, next to Agent keys, lists the step-up requests agents raise through ListApprovals, pending by default, with a status filter for the rest. Approve and Deny call DecideApproval after a confirmation that repeats the summary and the full request digest, so the operator can match them against what the agent reported. The buttons are disabled unless the request is pending and the operator's level is at least its required level, the same rule the manager enforces. The manager's approval link opens /approvals?id=<id>. That view shows every status, because the request may already be decided or expired, and highlights and scrolls to the named request, or says it was not found. The nav entry carries a pending count that refreshes on navigation, every 30 seconds, and after a decision. Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * docs: describe the Approvals page Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * feat(api): refresh the api stubs to the current api main Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * chore(license): stamp the Approvals files with the Apache 2.0 header Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> * fix(mcp): have the approver check the approval ID and summary, not the digest Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --------- Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 7dfa2f5 commit 8b6bc84

18 files changed

Lines changed: 3829 additions & 61 deletions

‎AGENTS.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ woff2, strict CSP, no runtime CDN) for air-gap use.
1717

1818
- `src/main.tsx` - entry; mounts the theme + auth providers and the router.
1919
- `src/App.tsx` - route table (Fleet `/`, Nodes `/nodes`, node detail `/nodes/:name`, Operators,
20-
Agent keys `/agent-keys`, Audit, 404). The MCP sign-in consent page `/oauth/consent` is routed
20+
Agent keys `/agent-keys`, Approvals `/approvals` (accepts `?id=<approval>` from the manager's
21+
approval link), Audit, 404). The MCP sign-in consent page `/oauth/consent` is routed
2122
outside the auth gate on purpose: the manager authenticates that request with the browser's
2223
client certificate, and it talks to `/oauth2/consent/<id>` with plain `fetch`, not Connect.
2324
- `src/components/layout/` - app shell: header, sidebar nav, wordmark, theme toggle, auth gate.
@@ -49,3 +50,7 @@ woff2, strict CSP, no runtime CDN) for air-gap use.
4950
`errorCode()` from `src/lib/fleet/error-code.ts`, never on the message text, and add a code to
5051
its `ErrorCode` map when a page handles one. The Operators page does this for 1400 (no
5152
`operator_ca_node` configured) and shows a not-configured view instead of the refusal.
53+
- The Approvals nav badge counts pending approvals through `listApprovals`. It recounts on every
54+
navigation, every 30 seconds, and when `decideApproval` dispatches the `APPROVALS_CHANGED`
55+
window event (`src/lib/approvals.ts`). A new surface that changes an approval should go through
56+
`decideApproval` so the badge stays current.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The Fleet Manager serves an MCP endpoint so AI agents can manage the fleet. An a
3030

3131
- 🔑 **Sign-in consent** at `/oauth/consent?req=<id>`. An MCP client's OAuth sign-in lands here in the operator's browser. The page shows the client's name, the loopback host the code returns to, and the operator certificate that will own the key, and lets the operator pick a level ceiling (up to their own level) and a label before choosing Approve or Deny. It sits outside the console's Log in step, because the manager authenticates the request with the certificate the browser presents. It talks to the manager's `/oauth2/consent/<id>` endpoint as plain JSON on the same origin.
3232
- 🗝️ **Agent keys** (next to Operators) lists your keys with their client, ceiling, creation time, last use and status, and lets you revoke one. An admin can switch to every operator's keys. **Create key** mints a key for a client that cannot open the browser sign-in. The key is shown once, with a copy button, and is discarded when the dialog closes; the manager keeps only a hash of it.
33+
- ✋ **Approvals** (next to Agent keys, with a count of pending requests) is where a person decides the step-up requests an agent raises before a tool that changes the fleet runs. It lists pending requests by default, and a status filter switches to approved, denied, expired, used or all. Each row shows the tool, a summary, who asked (CN), the agent key, the required level, when it was created and expires, and its status. **Approve** and **Deny** each ask for confirmation, repeating the approval ID and summary so they can be matched against what the agent showed you. The request digest (SHA-256 of the call's arguments) is shown as a detail; the agent never receives it, and the audit log records it. The buttons are disabled unless the request is pending and your level is at least the one it requires; the manager enforces the same rule, and an agent key can never list or decide an approval. The manager's approval link opens `/approvals?id=<id>`, which shows every status and highlights that request.
3334
- 📜 **Audit** shows who acted (the certificate CN, and whether it acted directly or through an agent key), the surface it came through (web, mcp or api), the MCP tool and the outcome. Entries recorded before the manager captured an actor show a dash.
3435

3536
A key is bound to the operator certificate that created it. It stops working when that certificate is revoked or renewed, and its effective level is the certificate's live level or its ceiling, whichever is lower.

‎src/App.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { AppShell } from "@/components/layout/app-shell";
2020
import { AuthGate } from "@/components/layout/auth-gate";
2121
import { AdoptPage } from "@/pages/adopt";
2222
import { AgentKeysPage } from "@/pages/agent-keys";
23+
import { ApprovalsPage } from "@/pages/approvals";
2324
import { AuditPage } from "@/pages/audit";
2425
import { CertificatesPage } from "@/pages/certificates";
2526
import { DashboardPage } from "@/pages/dashboard";
@@ -71,6 +72,7 @@ export const App = () => {
7172
<Route element={<AuditPage />} path="audit" />
7273
<Route element={<OperatorsPage />} path="operators" />
7374
<Route element={<AgentKeysPage />} path="agent-keys" />
75+
<Route element={<ApprovalsPage />} path="approvals" />
7476
<Route element={<AdoptPage />} path="adopt" />
7577
<Route element={<CertificatesPage />} path="certificates" />
7678
<Route element={<ProfilesPage />} path="profiles" />
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
18+
import { beforeEach, describe, expect, it, vi } from "vitest";
19+
20+
import { ApprovalDecideDialog } from "@/components/approval-decide-dialog";
21+
22+
const decideApproval = vi.fn();
23+
vi.mock("@/lib/approvals", async (importOriginal) => ({
24+
...(await importOriginal<typeof import("@/lib/approvals")>()),
25+
decideApproval: (...a: unknown[]) => decideApproval(...a),
26+
}));
27+
28+
const digest = "0123456789abcdef".repeat(4);
29+
const target = {
30+
id: "ap-1",
31+
requestDigest: digest,
32+
requestedByCn: "operator@example.org",
33+
requiredLevel: "operator",
34+
summary: "Revoke certificate 0a1b on issuing-1",
35+
tool: "cert_revoke",
36+
};
37+
38+
describe("ApprovalDecideDialog", () => {
39+
beforeEach(() => decideApproval.mockReset());
40+
41+
it("repeats the approval ID and summary to check, and the digest as an audit detail", async () => {
42+
decideApproval.mockResolvedValue({ ...target, status: "approved" });
43+
const onDecided = vi.fn();
44+
const onClose = vi.fn();
45+
render(
46+
<ApprovalDecideDialog approve onClose={onClose} onDecided={onDecided} target={target} />,
47+
);
48+
49+
const dialog = screen.getByRole("dialog");
50+
expect(dialog).toHaveTextContent("ap-1");
51+
expect(dialog).toHaveTextContent("Revoke certificate 0a1b on issuing-1");
52+
expect(dialog).toHaveTextContent(
53+
"Check that the approval ID and summary match what the agent showed you.",
54+
);
55+
expect(dialog).toHaveTextContent(digest);
56+
expect(dialog).toHaveTextContent("Recorded in the audit log");
57+
expect(dialog).not.toHaveTextContent(/digest match/i);
58+
expect(decideApproval).not.toHaveBeenCalled();
59+
60+
fireEvent.click(screen.getByRole("button", { name: /^approve$/i }));
61+
62+
await waitFor(() => expect(onDecided).toHaveBeenCalled());
63+
expect(decideApproval).toHaveBeenCalledWith({ approve: true, id: "ap-1" });
64+
expect(onClose).toHaveBeenCalled();
65+
});
66+
67+
it("denies through the same confirmation", async () => {
68+
decideApproval.mockResolvedValue({ ...target, status: "denied" });
69+
const onDecided = vi.fn();
70+
render(
71+
<ApprovalDecideDialog
72+
approve={false}
73+
onClose={vi.fn()}
74+
onDecided={onDecided}
75+
target={target}
76+
/>,
77+
);
78+
79+
expect(screen.getByRole("dialog")).toHaveTextContent(digest);
80+
fireEvent.click(screen.getByRole("button", { name: /^deny$/i }));
81+
82+
await waitFor(() => expect(onDecided).toHaveBeenCalled());
83+
expect(decideApproval).toHaveBeenCalledWith({ approve: false, id: "ap-1" });
84+
});
85+
86+
it("cancels without deciding", () => {
87+
const onClose = vi.fn();
88+
render(<ApprovalDecideDialog approve onClose={onClose} onDecided={vi.fn()} target={target} />);
89+
90+
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
91+
92+
expect(onClose).toHaveBeenCalled();
93+
expect(decideApproval).not.toHaveBeenCalled();
94+
});
95+
96+
it("shows a refusal inline and stays open", async () => {
97+
decideApproval.mockRejectedValueOnce(new Error("approval expired"));
98+
const onClose = vi.fn();
99+
render(<ApprovalDecideDialog approve onClose={onClose} onDecided={vi.fn()} target={target} />);
100+
101+
fireEvent.click(screen.getByRole("button", { name: /^approve$/i }));
102+
103+
expect(await screen.findByRole("alert")).toHaveTextContent("approval expired");
104+
expect(onClose).not.toHaveBeenCalled();
105+
});
106+
});
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
Copyright The CryptOS Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { useState } from "react";
18+
19+
import { Button } from "@/components/ui/button";
20+
import { type ApprovalRow, decideApproval } from "@/lib/approvals";
21+
22+
// ApprovalDecideDialog confirms an approve or deny. It repeats the approval ID
23+
// and summary, which the manager's pending_approval response also gives the
24+
// agent, so the operator can match them against what the agent showed before
25+
// letting the action through. The request digest is shown as a detail only: the
26+
// agent never receives it, and the audit log records it with the decision.
27+
export const ApprovalDecideDialog = ({
28+
approve,
29+
onClose,
30+
onDecided,
31+
target,
32+
}: {
33+
approve: boolean;
34+
onClose: () => void;
35+
onDecided: () => void;
36+
target: Pick<
37+
ApprovalRow,
38+
"id" | "requestDigest" | "requestedByCn" | "requiredLevel" | "summary" | "tool"
39+
>;
40+
}) => {
41+
const [pending, setPending] = useState(false);
42+
const [error, setError] = useState<null | string>(null);
43+
44+
const verb = approve ? "Approve" : "Deny";
45+
46+
const confirm = () => {
47+
setPending(true);
48+
setError(null);
49+
decideApproval({ approve, id: target.id })
50+
.then(() => {
51+
onDecided();
52+
onClose();
53+
})
54+
.catch((error_: unknown) => {
55+
setError(error_ instanceof Error ? error_.message : `${verb} failed`);
56+
setPending(false);
57+
});
58+
};
59+
60+
return (
61+
<div
62+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
63+
onClick={onClose}
64+
role="presentation"
65+
>
66+
<div
67+
aria-labelledby="approval-decide-title"
68+
aria-modal="true"
69+
className="w-full max-w-lg space-y-4 rounded-xl border bg-card p-5 shadow-xl"
70+
onClick={(e) => e.stopPropagation()}
71+
role="dialog"
72+
>
73+
<div className="space-y-1">
74+
<h2 className="text-lg font-bold" id="approval-decide-title">
75+
{verb} request
76+
</h2>
77+
<p className="font-mono text-xs text-muted-foreground">
78+
{target.tool} &middot; {target.requestedByCn} &middot; needs {target.requiredLevel}
79+
</p>
80+
</div>
81+
<div className="space-y-1">
82+
<p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
83+
Approval ID
84+
</p>
85+
<p className="break-all font-mono text-sm">{target.id}</p>
86+
</div>
87+
<p className="text-sm">{target.summary}</p>
88+
<div className="space-y-1">
89+
<p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">
90+
Request digest (SHA-256)
91+
</p>
92+
<p className="break-all rounded-md border bg-secondary/50 p-2 font-mono text-xs">
93+
{target.requestDigest}
94+
</p>
95+
<p className="text-xs text-muted-foreground">
96+
Recorded in the audit log with the decision and the agent&apos;s call.
97+
</p>
98+
</div>
99+
<p className="text-sm text-muted-foreground">
100+
{approve
101+
? "The agent can run this exact request once. Check that the approval ID and summary match what the agent showed you."
102+
: "The agent's request is refused and cannot be retried with this approval."}
103+
</p>
104+
{error ? (
105+
<p className="font-mono text-xs text-destructive" role="alert">
106+
{error}
107+
</p>
108+
) : null}
109+
<div className="flex justify-end gap-2">
110+
<Button disabled={pending} onClick={onClose} size="sm" variant="outline">
111+
Cancel
112+
</Button>
113+
<Button
114+
disabled={pending}
115+
onClick={confirm}
116+
size="sm"
117+
variant={approve ? "default" : "destructive"}
118+
>
119+
{pending ? (approve ? "Approving…" : "Denying…") : verb}
120+
</Button>
121+
</div>
122+
</div>
123+
</div>
124+
);
125+
};

‎src/components/layout/top-nav.test.tsx‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,23 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { render, screen } from "@testing-library/react";
17+
import { act, render, screen } from "@testing-library/react";
1818
import { MemoryRouter } from "react-router-dom";
19-
import { describe, expect, it } from "vitest";
19+
import { beforeEach, describe, expect, it, vi } from "vitest";
2020

2121
import { TopNav } from "@/components/layout/top-nav";
22+
import { APPROVALS_CHANGED } from "@/lib/approvals";
23+
24+
const listApprovals = vi.fn();
25+
vi.mock("@/lib/approvals", async (importOriginal) => ({
26+
...(await importOriginal<typeof import("@/lib/approvals")>()),
27+
listApprovals: (...a: unknown[]) => listApprovals(...a),
28+
}));
29+
30+
beforeEach(() => {
31+
listApprovals.mockReset();
32+
listApprovals.mockResolvedValue([]);
33+
});
2234

2335
describe("TopNav", () => {
2436
// Root before Nodes: that is the order the hierarchy reads, and /nodes
@@ -69,4 +81,55 @@ describe("TopNav", () => {
6981
const hrefs = screen.getAllByRole("link").map((a) => a.getAttribute("href"));
7082
expect(hrefs.indexOf("/agent-keys")).toBe(hrefs.indexOf("/operators") + 1);
7183
});
84+
85+
it("places Approvals right after Agent keys", () => {
86+
render(
87+
<MemoryRouter>
88+
<TopNav />
89+
</MemoryRouter>,
90+
);
91+
const hrefs = screen.getAllByRole("link").map((a) => a.getAttribute("href"));
92+
expect(hrefs.indexOf("/approvals")).toBe(hrefs.indexOf("/agent-keys") + 1);
93+
});
94+
95+
it("badges Approvals with the pending count", async () => {
96+
listApprovals.mockResolvedValue([{ id: "ap-1" }, { id: "ap-2" }]);
97+
render(
98+
<MemoryRouter>
99+
<TopNav />
100+
</MemoryRouter>,
101+
);
102+
103+
expect(await screen.findByLabelText("2 pending approvals")).toHaveTextContent("2");
104+
expect(listApprovals).toHaveBeenCalledWith({ status: "pending" });
105+
});
106+
107+
it("shows no badge when nothing is pending or the count cannot be read", async () => {
108+
listApprovals.mockRejectedValue(new Error("manager unreachable"));
109+
render(
110+
<MemoryRouter>
111+
<TopNav />
112+
</MemoryRouter>,
113+
);
114+
await act(async () => {});
115+
116+
expect(screen.queryByLabelText(/pending approvals/)).not.toBeInTheDocument();
117+
});
118+
119+
it("recounts after a decision", async () => {
120+
listApprovals.mockResolvedValue([{ id: "ap-1" }, { id: "ap-2" }]);
121+
render(
122+
<MemoryRouter>
123+
<TopNav />
124+
</MemoryRouter>,
125+
);
126+
await screen.findByLabelText("2 pending approvals");
127+
128+
listApprovals.mockResolvedValue([{ id: "ap-2" }]);
129+
act(() => {
130+
globalThis.dispatchEvent(new Event(APPROVALS_CHANGED));
131+
});
132+
133+
expect(await screen.findByLabelText("1 pending approval")).toHaveTextContent("1");
134+
});
72135
});

0 commit comments

Comments
 (0)