mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-09-30 08:53:18 +00:00
Some checks failed
deploy / deploy (push) Has been cancelled
* 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.
196 lines
7.5 KiB
TypeScript
196 lines
7.5 KiB
TypeScript
import { describe, it, expect, beforeAll } from 'vitest';
|
|
import { App } from 'aws-cdk-lib';
|
|
import { Template, Match } from 'aws-cdk-lib/assertions';
|
|
|
|
import { ShMcpAuthStack } from '../lib/auth-stack.js';
|
|
|
|
let template: Template;
|
|
|
|
beforeAll(() => {
|
|
const app = new App();
|
|
const stack = new ShMcpAuthStack(app, 'TestAuth', {
|
|
stackName: 'sh-mcp-auth',
|
|
env: { account: '328440206208', region: 'us-east-1' },
|
|
});
|
|
template = Template.fromStack(stack);
|
|
});
|
|
|
|
describe('Cognito user pool', () => {
|
|
it('uses the ESSENTIALS feature plan (required for the V2 pre-token trigger)', () => {
|
|
template.hasResourceProperties('AWS::Cognito::UserPool', {
|
|
UserPoolTier: 'ESSENTIALS',
|
|
});
|
|
});
|
|
|
|
it('attaches the pre-token Lambda as a V2_0 trigger', () => {
|
|
template.hasResourceProperties('AWS::Cognito::UserPool', {
|
|
LambdaConfig: {
|
|
PreTokenGenerationConfig: Match.objectLike({ LambdaVersion: 'V2_0' }),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('defines the four managed groups', () => {
|
|
for (const g of ['sh-mcp-ops', 'sh-mcp-assistant', 'sh-mcp-finance', 'sh-mcp-admin']) {
|
|
template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { GroupName: g });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('app clients — AllowedOAuthScopes is the trust-tier boundary', () => {
|
|
function clientScopes(name: string): string[] {
|
|
const clients = template.findResources('AWS::Cognito::UserPoolClient');
|
|
const entry = Object.values(clients).find((c) => c.Properties?.ClientName === name);
|
|
expect(entry, `client ${name} exists`).toBeDefined();
|
|
return (entry!.Properties.AllowedOAuthScopes as unknown[]).map((s) =>
|
|
typeof s === 'string' ? s : JSON.stringify(s),
|
|
);
|
|
}
|
|
|
|
it('finance client carries finance:read ONLY — no ops/gmail/finance:admin', () => {
|
|
const joined = JSON.stringify(clientScopes('sh-agentforce-finance'));
|
|
expect(joined).toContain('finance:read');
|
|
expect(joined).not.toContain('finance:admin');
|
|
expect(joined).not.toContain('ops:read');
|
|
expect(joined).not.toContain('gmail:self');
|
|
});
|
|
|
|
it('exec client has ops + gmail/calendar but NEVER finance (lethal-trifecta separation)', () => {
|
|
const joined = JSON.stringify(clientScopes('sh-agentforce-exec'));
|
|
expect(joined).toContain('ops:read');
|
|
expect(joined).toContain('gmail:self');
|
|
expect(joined).toContain('calendar:self');
|
|
expect(joined).not.toContain('finance:read');
|
|
expect(joined).not.toContain('finance:admin');
|
|
});
|
|
|
|
it('ops client has ops:read + ops:tasks, no finance/gmail', () => {
|
|
const joined = JSON.stringify(clientScopes('sh-agentforce-ops'));
|
|
expect(joined).toContain('ops:read');
|
|
expect(joined).toContain('ops:tasks');
|
|
expect(joined).not.toContain('finance');
|
|
expect(joined).not.toContain('gmail:self');
|
|
});
|
|
|
|
it('finance client has a 15-minute access token TTL', () => {
|
|
const clients = template.findResources('AWS::Cognito::UserPoolClient');
|
|
const fin = Object.values(clients).find(
|
|
(c) => c.Properties?.ClientName === 'sh-agentforce-finance',
|
|
);
|
|
expect(fin!.Properties.AccessTokenValidity).toBe(15);
|
|
expect(fin!.Properties.TokenValidityUnits.AccessToken).toBe('minutes');
|
|
});
|
|
|
|
it('finance client has a short refresh window (≤24h, not the 30-day default)', () => {
|
|
const toMinutes: Record<string, number> = { minutes: 1, hours: 60, days: 1440 };
|
|
const refreshMinutes = (name: string): number => {
|
|
const clients = template.findResources('AWS::Cognito::UserPoolClient');
|
|
const c = Object.values(clients).find((x) => x.Properties?.ClientName === name)!;
|
|
return (
|
|
c.Properties.RefreshTokenValidity * toMinutes[c.Properties.TokenValidityUnits.RefreshToken]
|
|
);
|
|
};
|
|
// A stolen finance refresh token must die in hours; ops/exec keep 30 days.
|
|
expect(refreshMinutes('sh-agentforce-finance')).toBeLessThanOrEqual(24 * 60);
|
|
expect(refreshMinutes('sh-agentforce-ops')).toBe(30 * 1440);
|
|
expect(refreshMinutes('sh-agentforce-exec')).toBe(30 * 1440);
|
|
});
|
|
});
|
|
|
|
describe('DynamoDB tables', () => {
|
|
it('pins stable logical IDs (overrideLogicalId) so refactors cannot replace them', () => {
|
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
|
expect(Object.keys(tables)).toEqual(
|
|
expect.arrayContaining(['SyncStateTable', 'DenyListTable']),
|
|
);
|
|
});
|
|
|
|
it('deny-list has a TTL attribute for auto-expiring revocations', () => {
|
|
template.hasResourceProperties('AWS::DynamoDB::Table', {
|
|
TableName: 'sh-mcp-deny-list',
|
|
TimeToLiveSpecification: { AttributeName: 'expiresAt', Enabled: true },
|
|
});
|
|
});
|
|
|
|
it('both tables RETAIN on stack delete', () => {
|
|
const tables = template.findResources('AWS::DynamoDB::Table');
|
|
for (const t of Object.values(tables)) expect(t.DeletionPolicy).toBe('Retain');
|
|
});
|
|
});
|
|
|
|
describe('Lambdas', () => {
|
|
it('run on arm64 with explicit 60-day log retention', () => {
|
|
template.allResourcesProperties('AWS::Lambda::Function', {
|
|
Architectures: ['arm64'],
|
|
});
|
|
template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 60 });
|
|
});
|
|
|
|
it('pre-token Lambda is wired to the deny-list (env var) for hard revocation', () => {
|
|
const fns = template.findResources('AWS::Lambda::Function');
|
|
const pre = Object.values(fns).find(
|
|
(f) => f.Properties?.FunctionName === 'sh-mcp-pre-token-gen',
|
|
);
|
|
expect(pre!.Properties.Environment.Variables.DENY_LIST_TABLE).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('IAM least privilege', () => {
|
|
it('no Lambda policy grants a wildcard action or wildcard resource', () => {
|
|
const policies = template.findResources('AWS::IAM::Policy');
|
|
for (const p of Object.values(policies)) {
|
|
for (const stmt of p.Properties.PolicyDocument.Statement as {
|
|
Effect: string;
|
|
Action: unknown;
|
|
Resource: unknown;
|
|
}[]) {
|
|
if (stmt.Effect !== 'Allow') continue;
|
|
const actions = Array.isArray(stmt.Action) ? stmt.Action : [stmt.Action];
|
|
for (const a of actions) expect(a, 'no wildcard action').not.toBe('*');
|
|
const resources = Array.isArray(stmt.Resource) ? stmt.Resource : [stmt.Resource];
|
|
for (const r of resources) expect(r, 'no bare wildcard resource').not.toBe('*');
|
|
}
|
|
}
|
|
});
|
|
|
|
it('the Google SA secret grant is scoped to that one secret', () => {
|
|
template.hasResourceProperties('AWS::IAM::Policy', {
|
|
PolicyDocument: {
|
|
Statement: Match.arrayWith([
|
|
Match.objectLike({
|
|
Action: 'secretsmanager:GetSecretValue',
|
|
Resource: Match.stringLikeRegexp('secret:sh-mcp/google-directory-sa'),
|
|
}),
|
|
]),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('alarms', () => {
|
|
it('every alarm has an alarm action (CMK-encrypted SNS topic)', () => {
|
|
const alarms = template.findResources('AWS::CloudWatch::Alarm');
|
|
expect(Object.keys(alarms).length).toBeGreaterThanOrEqual(3);
|
|
for (const a of Object.values(alarms)) {
|
|
expect(Array.isArray(a.Properties.AlarmActions)).toBe(true);
|
|
expect(a.Properties.AlarmActions.length).toBeGreaterThanOrEqual(1);
|
|
// ALARM-state actions only — never OKActions.
|
|
expect(a.Properties.OKActions).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('the alarm SNS topic is encrypted with a KMS key (not unencrypted)', () => {
|
|
template.hasResourceProperties('AWS::SNS::Topic', {
|
|
TopicName: 'sh-mcp-alarms',
|
|
KmsMasterKeyId: Match.anyValue(),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('group-sync schedule', () => {
|
|
it('runs every 5 minutes', () => {
|
|
template.hasResourceProperties('AWS::Events::Rule', {
|
|
ScheduleExpression: 'rate(5 minutes)',
|
|
});
|
|
});
|
|
});
|