sh-mcp/auth/pre-token-gen/test/scopes.test.ts
Adam Moussa b5e604dabe
Some checks failed
deploy / deploy (push) Has been cancelled
Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas (#4)
* Phase 2a: Cognito auth substrate (CDK) + pre-token & group-sync Lambdas

Stands up the real AWS auth broker the servers already validate against
(SH_MCP_ENV=aws), surface-agnostic. Nothing deployed yet (gated on Google
secrets); CI synthesizes the stack.

infra/ — root CDK app, stack sh-mcp-auth:
  - Cognito user pool, ESSENTIALS feature plan (required for the V2 pre-token
    trigger), Google external OIDC IdP (client_id/secret resolved from Secrets
    Manager at deploy via CFN dynamic reference, never inlined).
  - Resource servers + per-tier app clients whose AllowedOAuthScopes ARE the
    trust-tier boundary: ops=(read,tasks), exec=(ops+gmail/calendar, NO finance),
    finance=(finance:read ONLY, 15-min access TTL). offline refresh 30d.
  - Cognito groups sh-mcp-ops/-assistant/-finance/-admin.
  - sync-state + deny-list DynamoDB tables (overrideLogicalId pinned so a future
    refactor cannot replace+drop them; deny-list TTL attr 'expiresAt').
  - Least-priv IAM (no wildcard action/resource; Google SA secret grant scoped to
    the one secret), arm64 Lambdas, explicit 60-day log groups, alarms on the
    seahaven-alarm-topics CMK (ALARM-state actions only, two-alarm group-sync).

auth/pre-token-gen — SUPPRESS-ONLY V2 Lambda. Maps Cognito group entitlement to
  scopesToSuppress; NEVER scopesToAdd a tier scope (AllowedOAuthScopes stays the
  ceiling). Reads last_successful_sync; fail-closed to base ops:read when stale.

auth/group-sync — mirrors Google Group membership into Cognito groups every 5 min
  (jose-signed SA JWT -> Directory API, no googleapis dep); writes the freshness
  marker ONLY on full success so a partial failure keeps the pre-token Lambda
  failing closed.

37 new tests (suppress-only policy, fail-closed, reconcile diff, 16 CDK
assertions incl. Essentials/V2/per-client-scope/no-wildcard-IAM). 448 total pass;
tsc -b + infra typecheck + cdk synth + prettier clean; CI run-cdk-synth re-enabled.

App-client callback URLs are a context placeholder pending the surface decision.
Confluence map (1540098) + project memory updates owed once this deploys.

* Phase 2a: harden auth substrate per security-review + IAM cross-review

Both mandatory gates run on the 2a diff. GPT-4.1 IAM/Lambda cross-review: the
suppress-only invariant is now an executable fail-closed guard (a future edit
that sets scopesToAdd throws → no token minted). /sh-security-review fan-out +
proof-or-kill verifier: PASS (0 confirmed critical/high). The verifier refuted
the two "high" candidates (the email-case revocation "bypass" is symmetric — the
add path uses the same lowercasing filter, so an un-removable user could never
have been added; the empty-directory purge is a non-200 throw → stale marker →
fail closed). Three confirmed findings remediated:

- C2 (deny-list was inert): the sh-mcp-deny-list table was provisioned and
  documented as "hard revocation" but no code read it. The pre-token Lambda now
  reads it on every mint (DENY_LIST_TABLE env + grantReadData) and strips a
  deny-listed sub to NO tier scopes, ahead of the next group sync. Fail-OPEN on
  a DDB read error (logs deny_list_read_failed) so a blip can't lock everyone
  out — group membership + its fail-closed 30-min window stay authoritative.
- C5 (finance 30-day refresh nullified the 15-min access TTL): refresh window is
  now per-tier; finance caps at 8h, ops/exec keep 30d.
- C7 (nested Google-group members silently dropped): listGroupMembers now sets
  includeDerivedMembership and skips non-USER rows, honoring the documented
  "nested resolved" contract instead of pushing a phantom group address.

Also corrects the sync.ts comment that overstated fail-closed as instantaneous
(it is bounded by MAX_SYNC_AGE_MS). +8 tests (deny-list unit, hard-revocation
handler path, finance refresh window, deny-list env wiring); 456 pass. tsc -b,
cdk synth, prettier, eslint all clean.
2026-06-26 14:33:46 -04:00

75 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import {
computeScopesToSuppress,
entitledScopes,
ALL_TIER_SCOPES,
BASE_SCOPES,
OPS_READ,
OPS_TASKS,
FINANCE_READ,
FINANCE_ADMIN,
OPS_GMAIL,
OPS_CALENDAR,
} from '../src/scopes.js';
describe('entitledScopes', () => {
it('unions the scopes across a user’s groups', () => {
// Lauren: assistant + finance (design.md §2.3 worked example).
const got = entitledScopes(['sh-mcp-assistant', 'sh-mcp-finance']).sort();
expect(got).toEqual([OPS_CALENDAR, OPS_GMAIL, OPS_READ, OPS_TASKS, FINANCE_READ].sort());
// She does NOT get finance:admin.
expect(got).not.toContain(FINANCE_ADMIN);
});
it('ignores unknown groups (a stray group grants nothing)', () => {
expect(entitledScopes(['sh-mcp-not-a-real-group'])).toEqual([]);
expect(entitledScopes([])).toEqual([]);
});
it('admin gets every tier scope', () => {
expect(entitledScopes(['sh-mcp-admin']).sort()).toEqual([...ALL_TIER_SCOPES].sort());
});
});
describe('computeScopesToSuppress (suppress-only)', () => {
it('suppresses exactly the tier scopes the groups do not grant', () => {
// ops-only user: keeps ops:read, loses everything else.
const suppress = computeScopesToSuppress(['sh-mcp-ops'], true);
expect(suppress).toContain(OPS_TASKS);
expect(suppress).toContain(FINANCE_READ);
expect(suppress).toContain(FINANCE_ADMIN);
expect(suppress).not.toContain(OPS_READ);
});
it('finance group keeps finance:read but never finance:admin', () => {
const suppress = computeScopesToSuppress(['sh-mcp-finance'], true);
expect(suppress).not.toContain(FINANCE_READ);
expect(suppress).not.toContain(OPS_READ);
expect(suppress).toContain(FINANCE_ADMIN);
expect(suppress).toContain(OPS_TASKS);
});
it('admin suppresses nothing', () => {
expect(computeScopesToSuppress(['sh-mcp-admin'], true)).toEqual([]);
});
it('FAIL CLOSED: stale sync drops everyone to base ops:read regardless of groups', () => {
// Even a finance admin is reduced to ops:read when sync is stale.
const suppress = computeScopesToSuppress(['sh-mcp-admin'], false);
const kept = ALL_TIER_SCOPES.filter((s) => !suppress.includes(s));
expect(kept).toEqual([...BASE_SCOPES]);
expect(suppress).toContain(FINANCE_READ);
expect(suppress).toContain(FINANCE_ADMIN);
expect(suppress).toContain(OPS_TASKS);
});
it('only ever returns scopes drawn from the issued tier-scope set (never invents one)', () => {
for (const groups of [[], ['sh-mcp-ops'], ['sh-mcp-finance'], ['sh-mcp-admin']]) {
for (const fresh of [true, false]) {
const suppress = computeScopesToSuppress(groups, fresh);
for (const s of suppress) expect(ALL_TIER_SCOPES).toContain(s);
}
}
});
});