mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-02 04:53:16 +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.
122 lines
3.8 KiB
TypeScript
122 lines
3.8 KiB
TypeScript
/**
|
|
* AWS-backed implementations of the group-sync target + sync-state writer.
|
|
* Externalized at bundle time; only exercised in deployed (aws) runs.
|
|
*/
|
|
|
|
import {
|
|
CognitoIdentityProviderClient,
|
|
CreateGroupCommand,
|
|
ListUsersInGroupCommand,
|
|
ListUsersCommand,
|
|
AdminAddUserToGroupCommand,
|
|
AdminRemoveUserFromGroupCommand,
|
|
} from '@aws-sdk/client-cognito-identity-provider';
|
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
|
|
|
|
import type { CognitoGroupTarget, SyncStateWriter } from './clients.js';
|
|
import { normalizeEmail } from './groups.js';
|
|
import { SYNC_STATE_PK } from './constants.js';
|
|
|
|
/** Resolves emails ↔ Cognito usernames and reconciles group membership. */
|
|
export class CognitoGroupSync implements CognitoGroupTarget {
|
|
constructor(
|
|
private readonly userPoolId: string,
|
|
private readonly cognito = new CognitoIdentityProviderClient({}),
|
|
) {}
|
|
|
|
private emailOf(attrs: { Name?: string; Value?: string }[] | undefined): string | undefined {
|
|
return attrs?.find((a) => a.Name === 'email')?.Value;
|
|
}
|
|
|
|
async ensureGroup(groupName: string): Promise<void> {
|
|
try {
|
|
await this.cognito.send(
|
|
new CreateGroupCommand({ GroupName: groupName, UserPoolId: this.userPoolId }),
|
|
);
|
|
} catch (err) {
|
|
// Already exists is the expected idempotent path; rethrow anything else.
|
|
if ((err as { name?: string }).name !== 'GroupExistsException') throw err;
|
|
}
|
|
}
|
|
|
|
async listMembers(groupName: string): Promise<string[]> {
|
|
const emails: string[] = [];
|
|
let token: string | undefined;
|
|
do {
|
|
const res = await this.cognito.send(
|
|
new ListUsersInGroupCommand({
|
|
GroupName: groupName,
|
|
UserPoolId: this.userPoolId,
|
|
NextToken: token,
|
|
}),
|
|
);
|
|
for (const u of res.Users ?? []) {
|
|
const email = this.emailOf(u.Attributes);
|
|
if (email) emails.push(email);
|
|
}
|
|
token = res.NextToken;
|
|
} while (token);
|
|
return emails;
|
|
}
|
|
|
|
/** Resolve the Cognito username for a Workspace email (federated user). */
|
|
private async usernameForEmail(email: string): Promise<string | undefined> {
|
|
const res = await this.cognito.send(
|
|
new ListUsersCommand({
|
|
UserPoolId: this.userPoolId,
|
|
Filter: `email = "${normalizeEmail(email)}"`,
|
|
Limit: 1,
|
|
}),
|
|
);
|
|
return res.Users?.[0]?.Username;
|
|
}
|
|
|
|
async addMember(groupName: string, email: string): Promise<void> {
|
|
const username = await this.usernameForEmail(email);
|
|
if (!username) return; // user hasn't federated into the pool yet; next sync will add them
|
|
await this.cognito.send(
|
|
new AdminAddUserToGroupCommand({
|
|
GroupName: groupName,
|
|
UserPoolId: this.userPoolId,
|
|
Username: username,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async removeMember(groupName: string, email: string): Promise<void> {
|
|
const username = await this.usernameForEmail(email);
|
|
if (!username) return;
|
|
await this.cognito.send(
|
|
new AdminRemoveUserFromGroupCommand({
|
|
GroupName: groupName,
|
|
UserPoolId: this.userPoolId,
|
|
Username: username,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Writes the freshness marker the pre-token Lambda reads. */
|
|
export class DynamoSyncStateWriter implements SyncStateWriter {
|
|
private readonly doc: DynamoDBDocumentClient;
|
|
constructor(
|
|
private readonly tableName: string,
|
|
client: DynamoDBClient = new DynamoDBClient({}),
|
|
) {
|
|
this.doc = DynamoDBDocumentClient.from(client);
|
|
}
|
|
|
|
async writeLastSuccessfulSync(epochMs: number): Promise<void> {
|
|
await this.doc.send(
|
|
new PutCommand({
|
|
TableName: this.tableName,
|
|
Item: {
|
|
pk: SYNC_STATE_PK,
|
|
lastSuccessfulSyncMs: epochMs,
|
|
updatedAt: new Date(epochMs).toISOString(),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
}
|