@anchrd/intel-api 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -263,6 +263,21 @@ grants them.
263
263
 
264
264
  A missing capability answers **`403`**, and the screen says a permission is missing.
265
265
 
266
+ ⚠️ **`mcp.connect` is the one that gates a whole endpoint rather than an action.** Intel's own MCP
267
+ endpoint (`POST /mcp`, the one an MCP client or a portal connects to) checks it **on every request**
268
+ and answers `403` without it — before any tool runs and before anything is read. Two things follow
269
+ for an operator:
270
+
271
+ - **Somebody who should use Intel through an MCP client needs `mcp.connect` in Intel**, beside their
272
+ `nodes.*` capabilities. Without it the client authenticates successfully and every call is refused.
273
+ - **Revoking it takes effect on the next call**, with no new sign-in and nothing to press. If a
274
+ Cloudflare Access policy in front of the endpoint also compares the `mcp` claim, that layer is the
275
+ slower of the two — the claim is written once per sign-in and a running session carries it for up
276
+ to 24 hours, while this check reads the live grant rows every time.
277
+
278
+ The web UI is unaffected: it speaks the HTTP surface, which is gated by `nodes.*`, `flows.*` and
279
+ `tools.*` as before.
280
+
266
281
  ### Layer two: the grant on the node
267
282
 
268
283
  What somebody may reach *in this tree*, granted in Intel on a node. Four verbs — `read`, `write`,
@@ -348,10 +363,24 @@ both, every silent sign-in is refused and the Tools area shows "No access to the
348
363
  correct behaviour for somebody outside every policy, and a misleading one for a deployment that
349
364
  simply never enabled the setting.
350
365
 
351
- ⚠️ **Today, somebody without the Gate `mcp.connect` capability is sent into that sign-in anyway**
352
- and can land on the portal's own error page, outside Intel, with no way back but the browser's back
353
- button. Grant `mcp.connect` alongside the Intel capabilities to everyone who should reach Tools.
354
- Tracked as anchrd/intel#434.
366
+ ⚠️ **Somebody who carries the Gate capability `mcp.connect` at no service at all is not sent into
367
+ that sign-in.** Gate answers that question service-wide, Intel asks it before the first redirect,
368
+ and the person stays here reading a sentence that says whom to ask rather than landing on the
369
+ portal's own error page, outside Intel, with no way back but the browser's back button
370
+ (anchrd/intel#434). Grant `mcp.connect` alongside the Intel capabilities to everyone who should
371
+ reach Tools.
372
+
373
+ Two things that guard deliberately does not do. It **names no server**: the answer is one bit, so
374
+ somebody who may reach a different product's MCP server walks on and sees what the portal offers
375
+ them. And it **promises nothing** — this is the part worth knowing before you grant: the portal
376
+ decides on the `mcp` claim frozen into the ID token, and a running Cloudflare Access session carries
377
+ the old claim for up to 24 hours. ⚠️ **Inside that window the old dead end is still open.** Somebody
378
+ who was granted `mcp.connect` a minute ago is not refused here, walks on, and Access then decides on
379
+ the claim state that produced the error page in the first place. The guard closes the trip that is
380
+ certainly pointless; it does not close this one. The way out is the same as before — sign out of
381
+ Access and in again, so a new ID token carries the new claim. (Not measured against a running
382
+ installation; the claim lifetime is the documented one.) Against a Gate too old to answer the
383
+ question at all, nobody is refused here.
355
384
 
356
385
  The Tools screen distinguishes four situations rather than calling all of them "no access": a
357
386
  refusal that really came back, an expired connection, a sign-in that broke, and a sign-in that was
@@ -50,7 +50,32 @@ export function createIntel(deps) {
50
50
  if (!authorization) {
51
51
  return context.redirect(`/auth/login?returnTo=${connectReturnTo}`);
52
52
  }
53
- if (!authorization.can("tools", "read")) {
53
+ // ⚠️ Two questions, and both are answered BEFORE the first redirect leaves Intel (#434).
54
+ // `tools:read` is Intel's own door. `mcpPortalRefused()` is Gate's answer to a question Intel
55
+ // cannot ask itself: does this person carry `mcp.connect` at ANY service? Measured against the
56
+ // running installation, somebody without it was sent to `mcp.anchrd.sh/servers-callback` and
57
+ // landed on a white page saying "No allowed servers available, check your Zero trust policies"
58
+ // — a sentence written for an administrator, on a foreign domain, with not one clickable
59
+ // element on it.
60
+ //
61
+ // ⚠️ The helper, never the raw `mcpConnectAnywhere` beside it. The field has THREE states, and
62
+ // `undefined` means "this Gate is older than the question" (anchrd/gate#277), not "nowhere".
63
+ // `if (!authorization.mcpConnectAnywhere)` passes `tsc --noEmit` and `biome check` alike and
64
+ // would turn every reader of such an installation away without a word — the difference is a
65
+ // value, not a type, so nothing but this line prevents it (anchrd/gate#288).
66
+ //
67
+ // ⚠️ And it is deliberately NOT `can("mcp", "connect")`, which is what this guard was first
68
+ // built as: Gate trims `rules` to the ASKING service (GATE-64, least privilege), so that asks
69
+ // `intel.mcp.connect`. The portal is a door onto every server — somebody carrying
70
+ // `dwh.mcp.connect` reaches it and sees the dwh tools, and that spelling would have locked
71
+ // them out. The bit knows no service names, on purpose; it is one bit and carries no more.
72
+ //
73
+ // ⚠️ It spares the certainly wasted trip and promises no successful one: the portal decides
74
+ // on the `mcp` claim frozen into the ID token, and a running Access session carries the old
75
+ // claim for up to 24 hours. Somebody granted a minute ago is NOT refused here, walks on, and
76
+ // meets Access deciding on the very claim state this ticket is about — inside that window the
77
+ // old dead end is still open, and this guard does not close it. It closes the certain one.
78
+ if (!authorization.can("tools", "read") || authorization.mcpPortalRefused()) {
54
79
  return context.redirect("/tools?connectError=permission_required");
55
80
  }
56
81
  try {
@@ -91,6 +116,32 @@ export function createIntel(deps) {
91
116
  context.header("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadataUrl}"`);
92
117
  return context.json({ jsonrpc: "2.0", id: null, error: { code: -32003, message: "authentication required" } }, 401);
93
118
  }
119
+ // ⚠️ Intel's OWN portal door, asked per request — and it is deliberately the service-trimmed
120
+ // `can`, the exact spelling the guard on `/auth/connect` above must never use (anchrd/gate#293).
121
+ // The two questions are different and both are needed:
122
+ //
123
+ // /auth/connect "is anything in the SHARED portal for this person" mcpPortalRefused()
124
+ // /mcp "may they reach THIS server" can("mcp", "connect")
125
+ //
126
+ // Until now only Cloudflare Access asked the second one, comparing the `mcp` claim of the Access
127
+ // application against the service name. That claim is collected when the person signs in and a
128
+ // running Access session carries it for up to 24 hours, so a revoked grant kept working for a
129
+ // day while `rules` — the same rows, read live on every call — already said no. This line moves
130
+ // the decision to where the answer is: a revocation lands on the NEXT call, with no new sign-in,
131
+ // no session revocation and nobody pressing anything. It also lets the Access policy go coarse
132
+ // ("is a Gate user") without opening the server to everyone.
133
+ //
134
+ // ⚠️ NOT `permits(...)`: `intel.admin` is the bootstrap escape for Intel's own resource ACLs
135
+ // (ADR-0004 §6), and there is no bootstrap here — this grant is set in Gate, not in Intel, so no
136
+ // admin has to be able to hand it to themselves through this door. Widening it would let people
137
+ // in whom the Access policy turns away today, which is the opposite of moving a check.
138
+ //
139
+ // ⚠️ Fails CLOSED, and that is the whole reason a `403` and not a `401`: the caller IS
140
+ // authenticated and merely may not, so a `WWW-Authenticate` challenge would send an MCP client
141
+ // round the OAuth loop it just completed — the certain dead end of #434 in another costume.
142
+ if (!authorization.can("mcp", "connect")) {
143
+ return context.json({ jsonrpc: "2.0", id: null, error: { code: -32003, message: "permission required" } }, 403);
144
+ }
94
145
  return await handleMcp(context.req.raw, {
95
146
  authorization,
96
147
  // ⚠️ The very token this call was authorized with, so a tool that has to speak to Gate does
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "dependencies": {
45
- "@anchrd/gate-sdk": "^0.7.0",
45
+ "@anchrd/gate-sdk": "^0.15.0",
46
46
  "@anchrd/intel-contract": "^0.15.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",