Clarify MCP denial: tell non-whitelisted users they need to be enabled

When a user authenticated successfully but was not on the MCP whitelist,
the MCP server returned an opaque "MCP access denied" 403 — the user could
not tell why auth was interrupted or that they simply needed to be
whitelisted by the server owner.

- access-guard: distinguish a genuine authorization denial (not whitelisted)
  from an infrastructure failure (backend unreachable / malformed) via a new
  infraError flag on AccessCheckResult. Only a genuine deny gets the
  user-facing "User not enabled for MCP usage" error plus an actionable
  message naming the account and pointing at the fix (ask to be whitelisted).
  Infra failures keep a neutral "MCP access check unavailable" message so the
  user is not sent chasing the wrong fix. The reason field still echoes the
  backend reason verbatim.
- backend McpUserService: WhitelistOnly denial reason is now self-explanatory
  ("User not enabled for MCP usage (account is not on the MCP whitelist)").
- tests: cover the actionable whitelist message and that an infra failure is
  not mislabelled as a whitelist problem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RKYavm4FAu66qnhX5nvmMT
This commit is contained in:
Claude
2026-06-22 22:12:06 +00:00
parent 5cd268e88c
commit c8ec1378ec
3 changed files with 68 additions and 5 deletions
@@ -83,7 +83,10 @@ public class McpUserService
return isMcpUser
? McpAccessCheckResult.Allowed(upn, "McpUser", false)
: McpAccessCheckResult.Denied("Not authorized for MCP access");
// Surfaced to the end user by the MCP server's access guard. Keep it
// self-explanatory so a denied colleague understands they simply need
// to be whitelisted, rather than reading it as an auth failure.
: McpAccessCheckResult.Denied("User not enabled for MCP usage (account is not on the MCP whitelist)");
}
}
@@ -190,6 +190,28 @@ describe('accessGuard — 403 (authorization + fail-closed)', () => {
expect((out.body as { reason: string }).reason).toBe('not on the MCP whitelist');
});
it('gives a genuine whitelist denial an actionable message naming the user', async () => {
const upn = uniqueUpn();
stubBackend({ body: { allowed: false, reason: 'User not enabled for MCP usage' } });
const out = await runGuard(mockReq(`Bearer ${validToken(upn)}`));
expect(out.status).toBe(403);
const body = out.body as { error: string; message: string };
expect(body.error).toBe('User not enabled for MCP usage');
// The message must name the account and point at the fix (get whitelisted).
expect(body.message).toContain(upn);
expect(body.message).toMatch(/whitelist/i);
});
it('does NOT label an infrastructure failure as a whitelist problem', async () => {
// A cold/unreachable backend must not tell the user they are "not enabled".
stubBackend({ reject: true });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.status).toBe(403);
const body = out.body as { error: string; message?: string };
expect(body.error).not.toBe('User not enabled for MCP usage');
expect(body.message).toBeUndefined();
});
it('fail-closed: denies when the backend fetch throws (tests-2)', async () => {
// scale-to-zero ⇒ a cold/unreachable backend is routine. checkAccess must
// resolve allowed:false, never allowed:true.
@@ -50,6 +50,12 @@ interface AccessCheckResult {
reason: string;
isGlobalAdmin: boolean;
isGlobalReader: boolean;
// Distinguishes a genuine authorization denial (the backend reached a verdict
// and said "no" — e.g. user not on the MCP whitelist) from an infrastructure
// failure (backend unreachable / malformed response). Both fail closed, but
// only the former should be surfaced to the user as "you are not enabled —
// ask to be whitelisted"; an infra error is not the user's fault.
infraError: boolean;
}
export function buildCacheKey(upn: string, token: string): string {
@@ -66,6 +72,9 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
reason: cached.reason,
isGlobalAdmin: cached.isGlobalAdmin,
isGlobalReader: cached.isGlobalReader,
// Only genuine backend verdicts are ever cached (infra errors return early
// below without caching), so a cache hit is never an infra error.
infraError: false,
};
}
@@ -77,7 +86,7 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
const text = await res.text();
if (!text) {
console.error(`[access-guard] Backend returned empty body for ${upn} (status=${res.status})`);
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false };
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false, infraError: true };
}
const data = JSON.parse(text) as {
@@ -94,14 +103,25 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
reason: data.allowed ? (data.accessGrant ?? 'allowed') : (data.reason ?? 'denied'),
isGlobalAdmin: data.isGlobalAdmin === true || data.globalRole === 'GlobalAdmin',
isGlobalReader: data.globalRole === 'GlobalReader',
// The backend reached a verdict — a deny here is a genuine authorization
// decision (e.g. not whitelisted), not an infrastructure problem.
infraError: false,
};
accessCache.set(cacheKey, { ...result, expiresAt: Date.now() + ACCESS_CACHE_TTL_MS });
// Cache only the persisted fields (infraError is request-derived, always
// false for a cached verdict — see the cache-hit path above).
accessCache.set(cacheKey, {
allowed: result.allowed,
reason: result.reason,
isGlobalAdmin: result.isGlobalAdmin,
isGlobalReader: result.isGlobalReader,
expiresAt: Date.now() + ACCESS_CACHE_TTL_MS,
});
return result;
} catch (err) {
console.error(`[access-guard] Backend check failed for ${upn}:`, err);
// Fail-closed: deny on backend error
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false, isGlobalReader: false };
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false, isGlobalReader: false, infraError: true };
}
}
@@ -193,7 +213,25 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
checkAccess(upn, token)
.then((result) => {
if (!result.allowed) {
res.status(403).json({ error: 'MCP access denied', reason: result.reason });
if (result.infraError) {
// Backend could not reach a verdict (unreachable / malformed). Fail
// closed, but do NOT tell the user they are "not whitelisted" — it is
// not their fault and would send them chasing the wrong fix.
res.status(403).json({ error: 'MCP access check unavailable', reason: result.reason });
return;
}
// Genuine authorization denial — most commonly the user's account is not
// on the MCP whitelist. Spell that out and tell them what to do, so they
// can ask the MCP server owner to enable their account instead of being
// left guessing why authentication "failed".
res.status(403).json({
error: 'User not enabled for MCP usage',
reason: result.reason,
message:
`Your account (${upn}) is not enabled to use this Autopilot Monitor MCP server. ` +
'Ask the MCP server owner/administrator to whitelist your account for MCP access, ' +
'then reconnect.',
});
return;
}