mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 16:18:58 +00:00
113 lines
4.7 KiB
TypeScript
113 lines
4.7 KiB
TypeScript
|
|
/**
|
||
|
|
* Injectable client interfaces for the group-sync handler, plus their real
|
||
|
|
* implementations. Keeping the handler dependent on these interfaces (not the
|
||
|
|
* concrete SDKs) is what makes the reconcile logic unit-testable without AWS or
|
||
|
|
* Google in the loop.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** Reads group membership from the Google Workspace Directory API. */
|
||
|
|
export interface DirectoryReader {
|
||
|
|
/**
|
||
|
|
* User email addresses of a Google Group, with nested groups flattened to their
|
||
|
|
* underlying users (`includeDerivedMembership`). Non-user (GROUP) rows are excluded.
|
||
|
|
*/
|
||
|
|
listGroupMembers(groupEmail: string): Promise<string[]>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Reconciles membership of a Cognito group. */
|
||
|
|
export interface CognitoGroupTarget {
|
||
|
|
/** Create the group if it does not already exist (idempotent). */
|
||
|
|
ensureGroup(groupName: string): Promise<void>;
|
||
|
|
/** Email addresses currently in the Cognito group. */
|
||
|
|
listMembers(groupName: string): Promise<string[]>;
|
||
|
|
addMember(groupName: string, email: string): Promise<void>;
|
||
|
|
removeMember(groupName: string, email: string): Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Persists the freshness marker the pre-token Lambda fails closed on. */
|
||
|
|
export interface SyncStateWriter {
|
||
|
|
writeLastSuccessfulSync(epochMs: number): Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Real implementations (externalized at bundle time; exercised only in aws mode)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
import { SignJWT, importPKCS8 } from 'jose';
|
||
|
|
|
||
|
|
/** Google service-account credentials (from Secrets Manager) for domain-wide delegation. */
|
||
|
|
export interface GoogleServiceAccount {
|
||
|
|
client_email: string;
|
||
|
|
private_key: string;
|
||
|
|
/** The admin user to impersonate for Directory reads (domain-wide delegation subject). */
|
||
|
|
subject: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Directory reader backed by a service account with domain-wide delegation.
|
||
|
|
* Mints a short-lived bearer token via the OAuth2 JWT grant (signed with `jose`,
|
||
|
|
* no googleapis dependency) and pages the Admin SDK members endpoint.
|
||
|
|
*/
|
||
|
|
export class GoogleDirectoryReader implements DirectoryReader {
|
||
|
|
constructor(private readonly sa: GoogleServiceAccount) {}
|
||
|
|
|
||
|
|
private async accessToken(): Promise<string> {
|
||
|
|
const now = Math.floor(Date.now() / 1000);
|
||
|
|
const key = await importPKCS8(this.sa.private_key, 'RS256');
|
||
|
|
const assertion = await new SignJWT({
|
||
|
|
scope: 'https://www.googleapis.com/auth/admin.directory.group.readonly',
|
||
|
|
})
|
||
|
|
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
|
||
|
|
.setIssuer(this.sa.client_email)
|
||
|
|
.setSubject(this.sa.subject)
|
||
|
|
.setAudience('https://oauth2.googleapis.com/token')
|
||
|
|
.setIssuedAt(now)
|
||
|
|
.setExpirationTime(now + 3600)
|
||
|
|
.sign(key);
|
||
|
|
|
||
|
|
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||
|
|
body: new URLSearchParams({
|
||
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||
|
|
assertion,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error(`Google token exchange failed: ${res.status}`);
|
||
|
|
const json = (await res.json()) as { access_token?: string };
|
||
|
|
if (!json.access_token) throw new Error('Google token exchange returned no access_token');
|
||
|
|
return json.access_token;
|
||
|
|
}
|
||
|
|
|
||
|
|
async listGroupMembers(groupEmail: string): Promise<string[]> {
|
||
|
|
const token = await this.accessToken();
|
||
|
|
const emails: string[] = [];
|
||
|
|
let pageToken: string | undefined;
|
||
|
|
do {
|
||
|
|
const url = new URL(
|
||
|
|
`https://admin.googleapis.com/admin/directory/v1/groups/${encodeURIComponent(groupEmail)}/members`,
|
||
|
|
);
|
||
|
|
url.searchParams.set('maxResults', '200');
|
||
|
|
// Flatten nested groups to their underlying users (the documented contract).
|
||
|
|
// Derived members come back as type USER; the nested GROUP rows themselves
|
||
|
|
// are skipped below so they are never pushed as a phantom (un-resolvable) user.
|
||
|
|
url.searchParams.set('includeDerivedMembership', 'true');
|
||
|
|
if (pageToken) url.searchParams.set('pageToken', pageToken);
|
||
|
|
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
|
||
|
|
if (!res.ok)
|
||
|
|
throw new Error(`Directory members read failed for ${groupEmail}: ${res.status}`);
|
||
|
|
const json = (await res.json()) as {
|
||
|
|
members?: { email?: string; type?: string; status?: string }[];
|
||
|
|
nextPageToken?: string;
|
||
|
|
};
|
||
|
|
for (const m of json.members ?? []) {
|
||
|
|
// Only real users carry a tier scope; GROUP/derived rows resolve to no
|
||
|
|
// Cognito user and would be silent no-op adds, so drop them here.
|
||
|
|
if (m.email && m.status !== 'SUSPENDED' && m.type !== 'GROUP') emails.push(m.email);
|
||
|
|
}
|
||
|
|
pageToken = json.nextPageToken;
|
||
|
|
} while (pageToken);
|
||
|
|
return emails;
|
||
|
|
}
|
||
|
|
}
|