diff --git a/apps/api/src/auth/authenticate.spec.ts b/apps/api/src/auth/authenticate.spec.ts new file mode 100644 index 0000000..6d565bb --- /dev/null +++ b/apps/api/src/auth/authenticate.spec.ts @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, it } from 'node:test'; +import type { HttpRequest } from '@azure/functions'; +import { DEFAULT_POC_SCOPES, PostKitErrorCode, type Principal } from '@singleton-sd/post-kit-types'; +import { + ApiKeyAuthenticator, + AuthError, + extractBearerToken, + principalFromTenantKeyMap, + principalIdFromApiKey, + requireScope, + tenantContextFromPrincipal, +} from './authenticate'; +import { MCP_TOOL_SCOPES, scopeForMcpTool } from './mcp-scopes'; + +const KEY_MAP = { + tk_live_abc123: { tenantId: 'inkads', environment: 'production' as const }, + tk_dev_xyz789: { tenantId: 'inkads', environment: 'development' as const }, +}; + +function makeRequest(headers: Record): HttpRequest { + const map = new Map(); + for (const [k, v] of Object.entries(headers)) { + if (v !== undefined) map.set(k.toLowerCase(), v); + } + return { + headers: { + get: (name: string) => map.get(name.toLowerCase()) ?? null, + }, + } as unknown as HttpRequest; +} + +describe('principalIdFromApiKey', () => { + it('returns a truncated sha256 prefix and never equals the raw token', () => { + const token = 'tk_live_abc123'; + const id = principalIdFromApiKey(token); + const expected = `ak_${createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 16)}`; + assert.equal(id, expected); + assert.notEqual(id, token); + assert.ok(!id.includes(token)); + }); +}); + +describe('extractBearerToken', () => { + it('throws UNAUTHENTICATED when Authorization is missing', () => { + assert.throws( + () => extractBearerToken(null), + (err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHENTICATED, + ); + }); + + it('throws UNAUTHENTICATED for non-Bearer scheme', () => { + assert.throws( + () => extractBearerToken('Basic abc'), + (err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHENTICATED, + ); + }); + + it('accepts case-insensitive Bearer with extra spaces', () => { + assert.equal(extractBearerToken('bearer tk_live_abc123'), 'tk_live_abc123'); + }); +}); + +describe('principalFromTenantKeyMap', () => { + it('builds a Principal with default PoC scopes for legacy map entries', () => { + const principal = principalFromTenantKeyMap('tk_live_abc123', KEY_MAP); + assert.equal(principal.tenantId, 'inkads'); + assert.equal(principal.environment, 'production'); + assert.equal(principal.authType, 'api-key'); + assert.deepEqual([...principal.scopes], [...DEFAULT_POC_SCOPES]); + assert.equal(principal.id, principalIdFromApiKey('tk_live_abc123')); + }); + + it('throws UNAUTHORIZED for unknown tokens without echoing the token', () => { + try { + principalFromTenantKeyMap('tk_unknown', KEY_MAP); + assert.fail('expected throw'); + } catch (err) { + assert.ok(err instanceof AuthError); + assert.equal(err.code, PostKitErrorCode.UNAUTHORIZED); + assert.ok(!err.message.includes('tk_unknown')); + } + }); + + it('rejects prototype-chain names as tokens', () => { + assert.throws( + () => principalFromTenantKeyMap('toString', KEY_MAP), + (err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHORIZED, + ); + }); +}); + +describe('ApiKeyAuthenticator', () => { + const auth = new ApiKeyAuthenticator(KEY_MAP); + + it('authenticates a valid Bearer credential to a Principal', async () => { + const principal = await auth.authenticate( + makeRequest({ authorization: 'Bearer tk_dev_xyz789' }), + ); + assert.equal(principal.tenantId, 'inkads'); + assert.equal(principal.environment, 'development'); + assert.deepEqual([...principal.scopes], [...DEFAULT_POC_SCOPES]); + }); + + it('rejects missing credentials', async () => { + await assert.rejects( + () => auth.authenticate(makeRequest({})), + (err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHENTICATED, + ); + }); + + it('rejects unknown credentials', async () => { + await assert.rejects( + () => auth.authenticate(makeRequest({ authorization: 'Bearer wrong' })), + (err: unknown) => err instanceof AuthError && err.code === PostKitErrorCode.UNAUTHORIZED, + ); + }); +}); + +describe('requireScope', () => { + const base: Principal = { + id: 'ak_test', + tenantId: 'acme', + environment: 'development', + authType: 'api-key', + scopes: ['templates:read'], + }; + + it('allows when the principal holds the scope', () => { + assert.doesNotThrow(() => requireScope(base, 'templates:read')); + }); + + it('throws UNAUTHORIZED when the scope is missing (non-sensitive message)', () => { + try { + requireScope(base, 'email:send'); + assert.fail('expected throw'); + } catch (err) { + assert.ok(err instanceof AuthError); + assert.equal(err.code, PostKitErrorCode.UNAUTHORIZED); + assert.equal(err.message, 'The credential does not have the required permission.'); + assert.ok(!err.message.includes('ak_test')); + } + }); +}); + +describe('tenantContextFromPrincipal', () => { + it('exposes tenantId and environment from the principal', () => { + const principal: Principal = { + id: 'ak_x', + tenantId: 'acme', + environment: 'staging', + authType: 'api-key', + scopes: DEFAULT_POC_SCOPES, + }; + assert.deepEqual(tenantContextFromPrincipal(principal), { + tenantId: 'acme', + environment: 'staging', + }); + }); +}); + +describe('MCP_TOOL_SCOPES', () => { + it('maps every MCP tool to a semantic scope', () => { + assert.equal(scopeForMcpTool('postkit.list_templates'), 'templates:read'); + assert.equal(scopeForMcpTool('postkit.get_template'), 'templates:read'); + assert.equal(scopeForMcpTool('postkit.get_template_schema'), 'templates:read'); + assert.equal(scopeForMcpTool('postkit.validate_template'), 'templates:validate'); + assert.equal(scopeForMcpTool('postkit.preview_template'), 'templates:preview'); + assert.equal(Object.keys(MCP_TOOL_SCOPES).length, 5); + }); +}); diff --git a/apps/api/src/auth/authenticate.ts b/apps/api/src/auth/authenticate.ts new file mode 100644 index 0000000..5810115 --- /dev/null +++ b/apps/api/src/auth/authenticate.ts @@ -0,0 +1,135 @@ +import type { HttpRequest } from '@azure/functions'; +import { + DEFAULT_POC_SCOPES, + PostKitErrorCode, + type Principal, + type PostKitScope, +} from '@singleton-sd/post-kit-types'; +import { createHash } from 'node:crypto'; +import type { TenantKeyMap } from '../tenant'; + +/** + * Error thrown by authentication / authorization helpers. + * Carries a stable PostKitErrorCode; never includes raw credentials. + */ +export class AuthError extends Error { + readonly code: PostKitErrorCode; + + constructor(message: string, code: PostKitErrorCode) { + super(message); + this.name = 'AuthError'; + this.code = code; + } +} + +/** + * Resolves a Principal from an incoming HTTP request. + * Implementations must never accept tenant identity or scopes from the body. + */ +export interface Authenticator { + authenticate(request: HttpRequest): Promise; +} + +/** + * Opaque principal id derived from the credential without retaining plaintext. + * Truncated SHA-256 — never log the raw token. + */ +export function principalIdFromApiKey(token: string): string { + const digest = createHash('sha256').update(token, 'utf8').digest('hex'); + return `ak_${digest.slice(0, 16)}`; +} + +/** + * Extract the Bearer token from an Authorization header. + * Never returns or logs the token in error messages. + */ +export function extractBearerToken(authHeader: string | null): string { + if (!authHeader) { + throw new AuthError('Authorization header is missing.', PostKitErrorCode.UNAUTHENTICATED); + } + + // RFC 7235: auth-scheme is case-insensitive; accept one or more spaces. + const bearerPrefixMatch = /^bearer +/i.exec(authHeader); + if (!bearerPrefixMatch) { + throw new AuthError( + 'Authorization header must use the Bearer scheme.', + PostKitErrorCode.UNAUTHENTICATED, + ); + } + + const token = authHeader.slice(bearerPrefixMatch[0].length); + if (!token) { + throw new AuthError('Bearer token is empty.', PostKitErrorCode.UNAUTHENTICATED); + } + + return token; +} + +/** + * Look up a plaintext TENANT_KEY_MAP entry and build a Principal. + * Legacy map entries receive {@link DEFAULT_POC_SCOPES} so send + MCP keep working. + */ +export function principalFromTenantKeyMap( + token: string, + keyMap: TenantKeyMap, + scopes: readonly PostKitScope[] = DEFAULT_POC_SCOPES, +): Principal { + const entry = Object.prototype.hasOwnProperty.call(keyMap, token) ? keyMap[token] : undefined; + + if (!entry) { + throw new AuthError( + 'The provided credential does not map to a known tenant.', + PostKitErrorCode.UNAUTHORIZED, + ); + } + + return { + id: principalIdFromApiKey(token), + tenantId: entry.tenantId, + environment: entry.environment, + authType: 'api-key', + scopes: [...scopes], + }; +} + +/** + * Authenticate an HTTP request via Bearer token + plaintext TENANT_KEY_MAP (PoC). + * Dual-read plaintext only — hashed store lands in Slice B. + */ +export class ApiKeyAuthenticator implements Authenticator { + private readonly keyMap: TenantKeyMap; + private readonly scopes: readonly PostKitScope[]; + + constructor(keyMap: TenantKeyMap, scopes: readonly PostKitScope[] = DEFAULT_POC_SCOPES) { + this.keyMap = keyMap; + this.scopes = scopes; + } + + async authenticate(request: HttpRequest): Promise { + const token = extractBearerToken(request.headers.get('authorization')); + return principalFromTenantKeyMap(token, this.keyMap, this.scopes); + } +} + +/** + * Require that the principal holds the given scope. + * Throws AuthError with UNAUTHORIZED — message is non-sensitive (no raw keys). + */ +export function requireScope(principal: Principal, scope: PostKitScope): void { + if (!principal.scopes.includes(scope)) { + throw new AuthError( + 'The credential does not have the required permission.', + PostKitErrorCode.UNAUTHORIZED, + ); + } +} + +/** + * Tenant identity derived from the authenticated principal (never from tool/body input). + */ +export function tenantContextFromPrincipal(principal: Principal): { + tenantId: string; + environment: Principal['environment']; +} { + return { tenantId: principal.tenantId, environment: principal.environment }; +} diff --git a/apps/api/src/auth/index.ts b/apps/api/src/auth/index.ts new file mode 100644 index 0000000..56e3967 --- /dev/null +++ b/apps/api/src/auth/index.ts @@ -0,0 +1,11 @@ +export { + ApiKeyAuthenticator, + AuthError, + extractBearerToken, + principalFromTenantKeyMap, + principalIdFromApiKey, + requireScope, + tenantContextFromPrincipal, + type Authenticator, +} from './authenticate'; +export { MCP_TOOL_SCOPES, scopeForMcpTool, type McpScopedToolName } from './mcp-scopes'; diff --git a/apps/api/src/auth/mcp-scopes.ts b/apps/api/src/auth/mcp-scopes.ts new file mode 100644 index 0000000..9eba3cd --- /dev/null +++ b/apps/api/src/auth/mcp-scopes.ts @@ -0,0 +1,19 @@ +import type { PostKitScope } from '@singleton-sd/post-kit-types'; + +/** + * Central MCP tool → scope map. + * Enforced in create-server runTool — tools must not re-implement ad-hoc checks. + */ +export const MCP_TOOL_SCOPES = { + 'postkit.list_templates': 'templates:read', + 'postkit.get_template': 'templates:read', + 'postkit.get_template_schema': 'templates:read', + 'postkit.validate_template': 'templates:validate', + 'postkit.preview_template': 'templates:preview', +} as const satisfies Record; + +export type McpScopedToolName = keyof typeof MCP_TOOL_SCOPES; + +export function scopeForMcpTool(tool: McpScopedToolName): PostKitScope { + return MCP_TOOL_SCOPES[tool]; +} diff --git a/apps/api/src/functions/mcp.ts b/apps/api/src/functions/mcp.ts index 8861dc7..7fe8cec 100644 --- a/apps/api/src/functions/mcp.ts +++ b/apps/api/src/functions/mcp.ts @@ -3,7 +3,7 @@ import { createProductionMcpHandler } from '../mcp/handler'; /** * Stateless MCP Streamable HTTP endpoint on the existing Function App. - * Auth: Bearer API key via TENANT_KEY_MAP (same as REST send). PoC only — see #83. + * Auth: Bearer API key → Principal + scopes (TENANT_KEY_MAP PoC; see #83). */ app.http('mcp', { methods: ['POST', 'GET', 'DELETE', 'OPTIONS'], diff --git a/apps/api/src/functions/send.idempotency.spec.ts b/apps/api/src/functions/send.idempotency.spec.ts index 88f474b..e4197fa 100644 --- a/apps/api/src/functions/send.idempotency.spec.ts +++ b/apps/api/src/functions/send.idempotency.spec.ts @@ -7,14 +7,16 @@ import type { EmailSendResult, } from '@singleton-sd/post-kit-email'; import { + DEFAULT_POC_SCOPES, PostKitErrorCode, TEMPLATE_SCHEMA_VERSION, type CompiledTemplate, + type Principal, type TenantContext, } from '@singleton-sd/post-kit-types'; +import type { Authenticator } from '../auth'; import { MemoryIdempotencyStore } from '../idempotency'; import { resetSendRateLimiter } from '../contact-rate-limit'; -import type { TenantResolver } from '../tenant'; import type { TemplateStore } from '../templates'; import '../test/recipient-hash-env'; import { createSendHandler } from './send'; @@ -56,8 +58,18 @@ function fakeContext(): InvocationContext { return { error: () => undefined } as unknown as InvocationContext; } -function fakeResolver(tenant: TenantContext = TENANT): TenantResolver { - return { resolve: async () => tenant }; +function principalFor(tenant: TenantContext): Principal { + return { + id: `test:${tenant.tenantId}:${tenant.environment}`, + tenantId: tenant.tenantId, + environment: tenant.environment, + authType: 'api-key', + scopes: DEFAULT_POC_SCOPES, + }; +} + +function fakeAuthenticator(tenant: TenantContext = TENANT): Authenticator { + return { authenticate: async () => principalFor(tenant) }; } function fakeStore(): TemplateStore { @@ -97,7 +109,7 @@ describe('sendHandler idempotency', () => { resetSendRateLimiter(); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: fakeProvider(sent), idempotencyStore: new MemoryIdempotencyStore(), @@ -114,7 +126,7 @@ describe('sendHandler idempotency', () => { const sent: EmailSendRequest[] = []; const store = new MemoryIdempotencyStore(); const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: fakeProvider(sent), idempotencyStore: store, @@ -167,7 +179,7 @@ describe('sendHandler idempotency', () => { }); const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: { name: 'development', @@ -216,7 +228,7 @@ describe('sendHandler idempotency', () => { resetSendRateLimiter(); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: fakeProvider(sent), idempotencyStore: { @@ -252,14 +264,14 @@ describe('sendHandler idempotency', () => { const other: TenantContext = { tenantId: 'other', environment: 'development' }; const handlerA = createSendHandler({ - tenantResolver: fakeResolver(TENANT), + authenticator: fakeAuthenticator(TENANT), templateStore: fakeStore(), emailProvider: fakeProvider(sent), idempotencyStore: store, ...stubSender(), }); const handlerB = createSendHandler({ - tenantResolver: fakeResolver(other), + authenticator: fakeAuthenticator(other), templateStore: fakeStore(), emailProvider: fakeProvider(sent), idempotencyStore: store, @@ -291,7 +303,7 @@ describe('sendHandler idempotency', () => { resetSendRateLimiter(); let beginCalled = false; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: fakeProvider(), idempotencyStore: { diff --git a/apps/api/src/functions/send.security.spec.ts b/apps/api/src/functions/send.security.spec.ts index 8d54af7..904dfe7 100644 --- a/apps/api/src/functions/send.security.spec.ts +++ b/apps/api/src/functions/send.security.spec.ts @@ -8,7 +8,8 @@ import { type CompiledTemplate, type TenantContext, } from '@singleton-sd/post-kit-types'; -import { ApiKeyTenantResolver, type TenantKeyMap } from '../tenant'; +import { ApiKeyAuthenticator } from '../auth'; +import type { TenantKeyMap } from '../tenant'; import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config'; import type { TemplateStore } from '../templates'; import '../test/recipient-hash-env'; @@ -154,7 +155,7 @@ describe('sendHandler — cross-tenant isolation', () => { ); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -180,7 +181,7 @@ describe('sendHandler — cross-tenant isolation', () => { ); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -203,7 +204,7 @@ describe('sendHandler — cross-tenant isolation', () => { calls, ); const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), ...stubTenantSender(), @@ -259,7 +260,7 @@ describe('sendHandler — tenant spoofing has no effect', () => { calls, ); const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), ...stubTenantSender(), @@ -281,7 +282,7 @@ describe('sendHandler — tenant spoofing has no effect', () => { calls, ); const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), ...stubTenantSender(), @@ -311,7 +312,7 @@ describe('sendHandler — environment isolation', () => { ); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -362,7 +363,7 @@ describe('sendHandler — unsafe template keys are rejected before storage acces calls, ); const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), ...stubTenantSender(), @@ -404,7 +405,7 @@ describe('sendHandler — hostile variable values cannot inject markup', () => { ); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -444,7 +445,7 @@ describe('sendHandler — hostile variable values cannot inject markup', () => { ); const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -473,7 +474,7 @@ describe('sendHandler — malformed and oversized bodies produce stable typed er calls, ); const handler = createSendHandler({ - tenantResolver: new ApiKeyTenantResolver(KEY_MAP), + authenticator: new ApiKeyAuthenticator(KEY_MAP), templateStore: store, emailProvider: fakeProvider(), ...stubTenantSender(), diff --git a/apps/api/src/functions/send.spec.ts b/apps/api/src/functions/send.spec.ts index 5e6c2d8..e99b681 100644 --- a/apps/api/src/functions/send.spec.ts +++ b/apps/api/src/functions/send.spec.ts @@ -7,12 +7,14 @@ import type { EmailSendResult, } from '@singleton-sd/post-kit-email'; import { + DEFAULT_POC_SCOPES, PostKitErrorCode, TEMPLATE_SCHEMA_VERSION, type CompiledTemplate, + type Principal, type TenantContext, } from '@singleton-sd/post-kit-types'; -import { TenantResolverError, type TenantResolver } from '../tenant'; +import { AuthError, type Authenticator } from '../auth'; import type { ResolvedTenantEmailConfig } from '../tenant/tenant-email-config'; import { TemplateStoreError, type TemplateStore } from '../templates'; import { resetSendRateLimiter } from '../contact-rate-limit'; @@ -58,13 +60,30 @@ function fakeContext(): InvocationContext { return { error: () => undefined } as unknown as InvocationContext; } -function fakeResolver(ok = true): TenantResolver { +function principalFor( + tenant: TenantContext = TENANT, + scopes: Principal['scopes'] = DEFAULT_POC_SCOPES, +): Principal { return { - resolve: async () => { - if (!ok) { - throw new TenantResolverError('missing', PostKitErrorCode.UNAUTHENTICATED); + id: `test:${tenant.tenantId}:${tenant.environment}`, + tenantId: tenant.tenantId, + environment: tenant.environment, + authType: 'api-key', + scopes: [...scopes], + }; +} + +function fakeAuthenticator( + ok: boolean | TenantContext = true, + scopes: Principal['scopes'] = DEFAULT_POC_SCOPES, +): Authenticator { + return { + authenticate: async () => { + if (ok === false) { + throw new AuthError('missing', PostKitErrorCode.UNAUTHENTICATED); } - return TENANT; + const tenant = ok === true ? TENANT : ok; + return principalFor(tenant, scopes); }, }; } @@ -105,7 +124,7 @@ describe('sendHandler', () => { it('sends a template email and returns SendResponse', async () => { const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -132,7 +151,7 @@ describe('sendHandler', () => { it('HTML-escapes variables in the body', async () => { const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(sent), ...stubTenantSender(), @@ -155,7 +174,7 @@ describe('sendHandler', () => { it('returns 401 UNAUTHENTICATED', async () => { const handler = createSendHandler({ - tenantResolver: fakeResolver(false), + authenticator: fakeAuthenticator(false), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -168,9 +187,9 @@ describe('sendHandler', () => { it('returns 403 UNAUTHORIZED', async () => { const handler = createSendHandler({ - tenantResolver: { - resolve: async () => { - throw new TenantResolverError('unknown', PostKitErrorCode.UNAUTHORIZED); + authenticator: { + authenticate: async () => { + throw new AuthError('unknown', PostKitErrorCode.UNAUTHORIZED); }, }, templateStore: fakeStore(COMPILED), @@ -182,9 +201,25 @@ describe('sendHandler', () => { assert.equal((response.jsonBody as { code: string }).code, PostKitErrorCode.UNAUTHORIZED); }); + it('returns 403 UNAUTHORIZED when email:send scope is missing', async () => { + const handler = createSendHandler({ + authenticator: fakeAuthenticator(true, ['templates:read']), + templateStore: fakeStore(COMPILED), + emailProvider: fakeProvider(), + ...stubTenantSender(), + }); + const response = await handler(fakeRequest({ json: validBody() }), fakeContext()); + assert.equal(response.status, 403); + assert.equal((response.jsonBody as { code: string }).code, PostKitErrorCode.UNAUTHORIZED); + assert.equal( + (response.jsonBody as { error: string }).error, + 'The credential does not have the required permission.', + ); + }); + it('returns 404 TEMPLATE_NOT_FOUND', async () => { const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore( new TemplateStoreError('missing', PostKitErrorCode.TEMPLATE_NOT_FOUND), ), @@ -198,7 +233,7 @@ describe('sendHandler', () => { it('returns 400 INVALID_TEMPLATE from store', async () => { const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(new TemplateStoreError('bad', PostKitErrorCode.INVALID_TEMPLATE)), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -210,7 +245,7 @@ describe('sendHandler', () => { it('returns 400 MISSING_VARIABLES', async () => { const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -227,7 +262,7 @@ describe('sendHandler', () => { it('returns 400 INVALID_RECIPIENT', async () => { const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -245,7 +280,7 @@ describe('sendHandler', () => { it('rejects unsafe template keys before loading the store', async () => { let loaded = false; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: { load: async () => { loaded = true; @@ -280,7 +315,7 @@ describe('sendHandler', () => { manifest: { ...COMPILED.manifest, variables: ['companyName'] }, }; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(withBrandingVar), emailProvider: fakeProvider(sent), resolveBranding: async () => ({ companyName: 'InkAds' }), @@ -306,7 +341,7 @@ describe('sendHandler', () => { it('emits the full structured log contract on success', async () => { const lines: string[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -343,7 +378,7 @@ describe('sendHandler', () => { it('emits the full structured log contract on validation failure', async () => { const lines: string[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -376,7 +411,7 @@ describe('sendHandler', () => { it('emits templateKey and recipientHash when variables validation fails after template and recipient succeed', async () => { const lines: string[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -404,7 +439,7 @@ describe('sendHandler', () => { const { EmailProviderError } = await import('@singleton-sd/post-kit-email'); const lines: string[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: { name: 'development', @@ -439,7 +474,7 @@ describe('sendHandler', () => { resetSendRateLimiter(); process.env.SEND_RATE_LIMIT_PER_MIN = '1'; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -460,17 +495,17 @@ describe('sendHandler', () => { it('isolates rate limits per tenant', async () => { resetSendRateLimiter(); process.env.SEND_RATE_LIMIT_PER_MIN = '1'; - const tenantB: TenantResolver = { - resolve: async () => ({ tenantId: 'other', environment: 'development' }), + const tenantB: Authenticator = { + authenticate: async () => principalFor({ tenantId: 'other', environment: 'development' }), }; const handlerA = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), }); const handlerB = createSendHandler({ - tenantResolver: tenantB, + authenticator: tenantB, templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -488,7 +523,7 @@ describe('sendHandler', () => { process.env.SEND_MAX_BODY_BYTES = '50'; let loaded = false; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: { load: async () => { loaded = true; @@ -523,7 +558,7 @@ describe('sendHandler', () => { resetSendSizeLimitsCache(); process.env.SEND_MAX_VARIABLE_VALUE_BYTES = '10'; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -549,7 +584,7 @@ describe('sendHandler', () => { it('returns 400 when request.text() fails instead of treating it as an empty body', async () => { const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: fakeProvider(), ...stubTenantSender(), @@ -572,7 +607,7 @@ describe('sendHandler', () => { it('returns PROVIDER_FAILURE when the provider throws', async () => { const { EmailProviderError } = await import('@singleton-sd/post-kit-email'); const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(COMPILED), emailProvider: { name: 'development', diff --git a/apps/api/src/functions/send.tenant-sender.spec.ts b/apps/api/src/functions/send.tenant-sender.spec.ts index 816b3db..b893251 100644 --- a/apps/api/src/functions/send.tenant-sender.spec.ts +++ b/apps/api/src/functions/send.tenant-sender.spec.ts @@ -3,11 +3,13 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import type { HttpRequest, InvocationContext } from '@azure/functions'; import type { EmailProvider, EmailSendRequest } from '@singleton-sd/post-kit-email'; import { + DEFAULT_POC_SCOPES, PostKitErrorCode, TEMPLATE_SCHEMA_VERSION, type CompiledTemplate, type TenantContext, } from '@singleton-sd/post-kit-types'; +import type { Authenticator } from '../auth'; import { clearTenantEmailConfigCache } from '../tenant'; import { createLogger } from '../telemetry'; import '../test/recipient-hash-env'; @@ -98,7 +100,15 @@ describe('sendHandler — tenant-scoped sender configuration', () => { const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: { resolve: async () => TENANT }, + authenticator: { + authenticate: async () => ({ + id: 'test:inkads:production', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key' as const, + scopes: DEFAULT_POC_SCOPES, + }), + }, templateStore: { load: async () => COMPILED, list: async () => [] }, emailProvider: fakeProvider(sent), }); @@ -131,7 +141,15 @@ describe('sendHandler — tenant-scoped sender configuration', () => { const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: { resolve: async () => TENANT }, + authenticator: { + authenticate: async () => ({ + id: 'test:inkads:production', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key' as const, + scopes: DEFAULT_POC_SCOPES, + }), + }, templateStore: { load: async () => COMPILED, list: async () => [] }, emailProvider: fakeProvider(sent), }); @@ -162,7 +180,15 @@ describe('sendHandler — tenant-scoped sender configuration', () => { }); const handler = createSendHandler({ - tenantResolver: { resolve: async () => TENANT }, + authenticator: { + authenticate: async () => ({ + id: 'test:inkads:production', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key' as const, + scopes: DEFAULT_POC_SCOPES, + }), + }, templateStore: { load: async () => COMPILED, list: async () => [] }, emailProvider: fakeProvider(), }); @@ -200,7 +226,15 @@ describe('sendHandler — tenant-scoped sender configuration', () => { const sent: EmailSendRequest[] = []; const handler = createSendHandler({ - tenantResolver: { resolve: async () => TENANT }, + authenticator: { + authenticate: async () => ({ + id: 'test:inkads:production', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key' as const, + scopes: DEFAULT_POC_SCOPES, + }), + }, templateStore: { load: async () => COMPILED, list: async () => [] }, emailProvider: fakeProvider(sent), }); @@ -236,7 +270,15 @@ describe('sendHandler — tenant-scoped sender configuration', () => { const lines: string[] = []; const handler = createSendHandler({ - tenantResolver: { resolve: async () => TENANT }, + authenticator: { + authenticate: async () => ({ + id: 'test:inkads:production', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key' as const, + scopes: DEFAULT_POC_SCOPES, + }), + }, templateStore: { load: async () => COMPILED, list: async () => [] }, emailProvider: { name: 'development', @@ -281,7 +323,15 @@ describe('sendHandler — tenant-scoped sender configuration', () => { }); const handler = createSendHandler({ - tenantResolver: { resolve: async () => TENANT }, + authenticator: { + authenticate: async () => ({ + id: 'test:inkads:production', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key' as const, + scopes: DEFAULT_POC_SCOPES, + }), + }, templateStore: { load: async () => COMPILED, list: async () => [] }, emailProvider: fakeProvider(), }); diff --git a/apps/api/src/functions/send.timeout-retry.spec.ts b/apps/api/src/functions/send.timeout-retry.spec.ts index bb89994..6291e46 100644 --- a/apps/api/src/functions/send.timeout-retry.spec.ts +++ b/apps/api/src/functions/send.timeout-retry.spec.ts @@ -8,11 +8,14 @@ import { type EmailSendResult, } from '@singleton-sd/post-kit-email'; import { + DEFAULT_POC_SCOPES, PostKitErrorCode, TEMPLATE_SCHEMA_VERSION, type CompiledTemplate, + type Principal, type TenantContext, } from '@singleton-sd/post-kit-types'; +import type { Authenticator } from '../auth'; import { MemoryIdempotencyStore } from '../idempotency'; import { resetSendRateLimiter } from '../contact-rate-limit'; import { @@ -21,7 +24,6 @@ import { type SendDeliveryPolicy, } from '../send-delivery'; import { createLogger } from '../telemetry'; -import type { TenantResolver } from '../tenant'; import type { TemplateStore } from '../templates'; import '../test/recipient-hash-env'; import { createSendHandler } from './send'; @@ -63,8 +65,16 @@ function fakeContext(): InvocationContext { return { error: () => undefined } as unknown as InvocationContext; } -function fakeResolver(): TenantResolver { - return { resolve: async () => TENANT }; +function fakeAuthenticator(): Authenticator { + return { + authenticate: async () => ({ + id: 'test:inkads:development', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key', + scopes: DEFAULT_POC_SCOPES, + }), + }; } function fakeStore(): TemplateStore { @@ -122,7 +132,7 @@ describe('sendHandler timeout / retry / classification', () => { }; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: provider, deliveryPolicy: fastPolicy({ providerTimeoutMs: 40, maxAttempts: 1 }), @@ -158,7 +168,7 @@ describe('sendHandler timeout / retry / classification', () => { }; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: provider, deliveryPolicy: fastPolicy({ maxAttempts: 3 }), @@ -194,7 +204,7 @@ describe('sendHandler timeout / retry / classification', () => { const store = new MemoryIdempotencyStore({ ttlMs: 60_000 }); const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: provider, idempotencyStore: store, @@ -234,7 +244,7 @@ describe('sendHandler timeout / retry / classification', () => { const lines: string[] = []; const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: provider, idempotencyStore: store, @@ -281,7 +291,7 @@ describe('sendHandler timeout / retry / classification', () => { const store = new MemoryIdempotencyStore({ ttlMs: 60_000 }); const handler = createSendHandler({ - tenantResolver: fakeResolver(), + authenticator: fakeAuthenticator(), templateStore: fakeStore(), emailProvider: provider, idempotencyStore: store, diff --git a/apps/api/src/functions/send.ts b/apps/api/src/functions/send.ts index 7d051e0..15650a5 100644 --- a/apps/api/src/functions/send.ts +++ b/apps/api/src/functions/send.ts @@ -14,6 +14,13 @@ import { type TenantEnvironment, type TemplateVariables, } from '@singleton-sd/post-kit-types'; +import { + ApiKeyAuthenticator, + AuthError, + requireScope, + tenantContextFromPrincipal, + type Authenticator, +} from '../auth'; import { ensureAppConfiguration } from '../config/app-configuration'; import { getSendRateLimiter, sendRateLimitKey } from '../contact-rate-limit'; import { @@ -33,13 +40,10 @@ import { import { getSendSizeLimits, validateRequestBodySize, validateVariablesSize } from '../send-limits'; import { createLogger, hashRecipient, resolveCorrelationId, type Logger } from '../telemetry'; import { - ApiKeyTenantResolver, - TenantResolverError, resolveTenantEmailConfig, TenantEmailConfigError, type ResolvedTenantEmailConfig, type TenantKeyMap, - type TenantResolver, } from '../tenant'; import { BlobTemplateStore, @@ -53,7 +57,7 @@ const BASIC_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const SAFE_TEMPLATE_KEY = /^[a-zA-Z0-9._-]+$/; export interface SendHandlerDependencies { - tenantResolver: TenantResolver; + authenticator: Authenticator; templateStore: TemplateStore; /** Prefer injecting a factory so App Configuration can populate env first. */ createEmailProvider?: (options?: { @@ -104,8 +108,8 @@ export function createDefaultSendDependencies( ): SendHandlerDependencies { return { // Re-read env after App Configuration in the handler via factories. - get tenantResolver(): TenantResolver { - return new ApiKeyTenantResolver(parseTenantKeyMap(process.env.TENANT_KEY_MAP)); + get authenticator(): Authenticator { + return new ApiKeyAuthenticator(parseTenantKeyMap(process.env.TENANT_KEY_MAP)); }, templateStore, createEmailProvider: (options) => @@ -205,7 +209,9 @@ export function createSendHandler(deps: SendHandlerDependencies) { } try { - const tenant = await deps.tenantResolver.resolve(request); + const principal = await deps.authenticator.authenticate(request); + requireScope(principal, 'email:send'); + const tenant = tenantContextFromPrincipal(principal); tenantId = tenant.tenantId; environment = tenant.environment; @@ -510,7 +516,7 @@ export function createSendHandler(deps: SendHandlerDependencies) { }); } - if (error instanceof TenantResolverError) { + if (error instanceof AuthError) { const status = error.code === PostKitErrorCode.UNAUTHENTICATED ? 401 diff --git a/apps/api/src/mcp/auth.ts b/apps/api/src/mcp/auth.ts index 38a40eb..da23a16 100644 --- a/apps/api/src/mcp/auth.ts +++ b/apps/api/src/mcp/auth.ts @@ -1,19 +1,17 @@ import type { HttpRequest } from '@azure/functions'; -import type { TenantContext } from '@singleton-sd/post-kit-types'; -import type { TenantResolver } from '../tenant'; -import { TenantResolverError } from '../tenant'; +import type { Principal } from '@singleton-sd/post-kit-types'; +import type { Authenticator } from '../auth'; +import { AuthError } from '../auth'; /** - * Resolve the calling tenant from the Bearer API key on an MCP HTTP request. - * Reuses the same ApiKeyTenantResolver / TENANT_KEY_MAP path as REST. - * - * Iteration 1 PoC auth — replaceable by the shared principal layer in #83. + * Resolve the calling principal from the Bearer API key on an MCP HTTP request. + * Same ApiKeyAuthenticator / TENANT_KEY_MAP path as REST send. */ -export async function resolveMcpTenant( +export async function resolveMcpPrincipal( request: HttpRequest, - tenantResolver: TenantResolver, -): Promise { - return tenantResolver.resolve(request); + authenticator: Authenticator, +): Promise { + return authenticator.authenticate(request); } -export { TenantResolverError }; +export { AuthError }; diff --git a/apps/api/src/mcp/create-server.spec.ts b/apps/api/src/mcp/create-server.spec.ts index 1269088..66b3e63 100644 --- a/apps/api/src/mcp/create-server.spec.ts +++ b/apps/api/src/mcp/create-server.spec.ts @@ -1,11 +1,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + DEFAULT_POC_SCOPES, + PostKitErrorCode, TEMPLATE_SCHEMA_VERSION, type CompiledTemplate, + type Principal, type TenantContext, } from '@singleton-sd/post-kit-types'; -import { PostKitErrorCode } from '@singleton-sd/post-kit-types'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { createLogger } from '../telemetry'; @@ -19,6 +21,14 @@ import { createPostkitMcpServer, POSTKIT_MCP_TOOL_NAMES } from './create-server' const TENANT: TenantContext = { tenantId: 'acme', environment: 'production' }; +const PRINCIPAL: Principal = { + id: 'ak_test', + tenantId: TENANT.tenantId, + environment: TENANT.environment, + authType: 'api-key', + scopes: DEFAULT_POC_SCOPES, +}; + const COMPILED: CompiledTemplate = { templateHtml: '

Hello {{name}}

', metadata: { @@ -64,13 +74,16 @@ function memoryStore( }; } -async function connectClient(store: TemplateStore = memoryStore()) { +async function connectClient( + store: TemplateStore = memoryStore(), + principal: Principal = PRINCIPAL, +) { const lines: string[] = []; const logger = createLogger('mcp-test', (line) => lines.push(line)); const templates = new TemplateApplicationService({ templateStore: store }); const server = createPostkitMcpServer({ templates, - tenant: TENANT, + principal, logger, }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); @@ -95,6 +108,43 @@ describe('PostKit MCP tools', () => { } }); + it('rejects tools when the principal lacks the required scope', async () => { + const limited: Principal = { + ...PRINCIPAL, + scopes: ['templates:read'], + }; + const { client, server, lines } = await connectClient(memoryStore(), limited); + try { + const preview = await client.callTool({ + name: 'postkit.preview_template', + arguments: { templateKey: 'marketing.welcome', variables: { name: 'Ada' } }, + }); + assert.equal(preview.isError, true); + const body = JSON.parse((preview.content as { text: string }[])[0]!.text) as { + code: string; + error: string; + }; + assert.equal(body.code, PostKitErrorCode.UNAUTHORIZED); + assert.equal(body.error, 'The credential does not have the required permission.'); + + const authFailed = lines + .map((line) => JSON.parse(line) as Record) + .find( + (entry) => + entry.msg === 'mcp.tool.failed' && entry.mcpTool === 'postkit.preview_template', + ); + assert.ok(authFailed, 'expected mcp.tool.failed log for authorization denial'); + assert.equal(authFailed.outcome, 'auth_error'); + assert.equal(authFailed.errorCode, PostKitErrorCode.UNAUTHORIZED); + + const listed = await client.callTool({ name: 'postkit.list_templates', arguments: {} }); + assert.notEqual(listed.isError, true); + } finally { + await client.close(); + await server.close(); + } + }); + it('rejects invalid templateKey input before calling the store', async () => { let loadCalls = 0; const store: TemplateStore = { diff --git a/apps/api/src/mcp/create-server.ts b/apps/api/src/mcp/create-server.ts index 346dfb7..23aa65e 100644 --- a/apps/api/src/mcp/create-server.ts +++ b/apps/api/src/mcp/create-server.ts @@ -2,10 +2,17 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { PostKitErrorCode, - type TenantContext, + type Principal, type TemplateVariables, } from '@singleton-sd/post-kit-types'; import { z } from 'zod'; +import { + AuthError, + requireScope, + scopeForMcpTool, + tenantContextFromPrincipal, + type McpScopedToolName, +} from '../auth'; import type { Logger } from '../telemetry'; import { TemplateStoreError, type TemplateApplicationService } from '../templates'; @@ -16,7 +23,7 @@ export const POSTKIT_MCP_TOOL_NAMES = [ 'postkit.get_template_schema', 'postkit.validate_template', 'postkit.preview_template', -] as const; +] as const satisfies readonly McpScopedToolName[]; export type PostkitMcpToolName = (typeof POSTKIT_MCP_TOOL_NAMES)[number]; @@ -35,7 +42,8 @@ const variablesField = z.record(z.string()); export interface CreatePostkitMcpServerOptions { templates: TemplateApplicationService; - tenant: TenantContext; + /** Authenticated principal — tenant identity and scopes come from here only. */ + principal: Principal; logger: Logger; /** Optional clock for duration measurement in tests. */ now?: () => number; @@ -49,6 +57,9 @@ function toolTextResult(payload: unknown, isError = false): CallToolResult { } function mapStoreError(err: unknown): { code: PostKitErrorCode | string; error: string } { + if (err instanceof AuthError) { + return { code: err.code, error: err.message }; + } if (err instanceof TemplateStoreError) { return { code: err.code, error: err.message }; } @@ -74,10 +85,12 @@ function rejectUnsafeKey(templateKey: string): CallToolResult | undefined { /** * Build a fresh MCP server for one HTTP request (stateless). - * Tools close over the authenticated tenant — never accept tenantId from tool args. + * Tools close over the authenticated principal — never accept tenantId from tool args. + * Scope checks run centrally in runTool via {@link scopeForMcpTool}. */ export function createPostkitMcpServer(options: CreatePostkitMcpServerOptions): McpServer { - const { templates, tenant, logger } = options; + const { templates, principal, logger } = options; + const tenant = tenantContextFromPrincipal(principal); const now = options.now ?? (() => Date.now()); const server = new McpServer({ @@ -112,6 +125,9 @@ export function createPostkitMcpServer(options: CreatePostkitMcpServerOptions): ): Promise => { const startMs = now(); try { + // Scope check inside try so AuthError emits mcp.tool.failed / auth_error + // (outer tool handlers catch and return MCP error results without failing transport). + requireScope(principal, scopeForMcpTool(tool)); const result = await work(); logger.info('mcp.tool.completed', { mcpMethod: 'tools/call', @@ -131,7 +147,7 @@ export function createPostkitMcpServer(options: CreatePostkitMcpServerOptions): tenantId: tenant.tenantId, environment: tenant.environment, templateKey, - outcome: 'failed', + outcome: err instanceof AuthError ? 'auth_error' : 'failed', errorCode: mapped.code, durationMs: now() - startMs, }); @@ -209,52 +225,23 @@ export function createPostkitMcpServer(options: CreatePostkitMcpServerOptions): async (args: { templateKey: string; variables: TemplateVariables }) => { const unsafe = rejectUnsafeKey(args.templateKey); if (unsafe) return unsafe; - const startMs = now(); try { - const result = await templates.validateTemplate(tenant, args.templateKey, args.variables); + const result = await runTool('postkit.validate_template', args.templateKey, () => + templates.validateTemplate(tenant, args.templateKey, args.variables), + ); if (!result.ok) { - logger.info('mcp.tool.completed', { - mcpMethod: 'tools/call', - mcpTool: 'postkit.validate_template', - tenantId: tenant.tenantId, - environment: tenant.environment, - templateKey: args.templateKey, - outcome: 'validation_error', - errorCode: result.code, - durationMs: now() - startMs, - }); return toolTextResult( { ok: false, code: result.code, error: result.error, missing: result.missing }, true, ); } - logger.info('mcp.tool.completed', { - mcpMethod: 'tools/call', - mcpTool: 'postkit.validate_template', - tenantId: tenant.tenantId, - environment: tenant.environment, - templateKey: args.templateKey, - outcome: 'success', - durationMs: now() - startMs, - }); return toolTextResult({ ok: true, templateKey: result.templateKey, variables: result.variables, }); } catch (err) { - const mapped = mapStoreError(err); - logger.error('mcp.tool.failed', { - mcpMethod: 'tools/call', - mcpTool: 'postkit.validate_template', - tenantId: tenant.tenantId, - environment: tenant.environment, - templateKey: args.templateKey, - outcome: 'failed', - errorCode: mapped.code, - durationMs: now() - startMs, - }); - return toolTextResult(mapped, true); + return toolTextResult(mapStoreError(err), true); } }, ); diff --git a/apps/api/src/mcp/handler.spec.ts b/apps/api/src/mcp/handler.spec.ts index 5818f70..b29d65a 100644 --- a/apps/api/src/mcp/handler.spec.ts +++ b/apps/api/src/mcp/handler.spec.ts @@ -7,7 +7,7 @@ import { type TenantContext, } from '@singleton-sd/post-kit-types'; import { PostKitErrorCode } from '@singleton-sd/post-kit-types'; -import { ApiKeyTenantResolver } from '../tenant'; +import { ApiKeyAuthenticator } from '../auth'; import { TemplateStoreError, type TemplateStore } from '../templates'; import { createMcpHandler, parseTenantKeyMap } from './handler'; import { createLogger } from '../telemetry'; @@ -94,7 +94,7 @@ const context = { describe('createMcpHandler', () => { it('rejects missing Bearer credentials with 401', async () => { const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({ + authenticator: new ApiKeyAuthenticator({ [TOKEN]: { tenantId: TENANT.tenantId, environment: TENANT.environment }, }), templateStore: fakeStore(), @@ -115,7 +115,7 @@ describe('createMcpHandler', () => { it('rejects unknown Bearer credentials with 403', async () => { const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({ + authenticator: new ApiKeyAuthenticator({ [TOKEN]: { tenantId: TENANT.tenantId, environment: TENANT.environment }, }), templateStore: fakeStore(), @@ -135,7 +135,7 @@ describe('createMcpHandler', () => { it('returns 405 for GET (stateless JSON mode — no SSE standalone stream)', async () => { const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({ + authenticator: new ApiKeyAuthenticator({ [TOKEN]: { tenantId: TENANT.tenantId, environment: TENANT.environment }, }), templateStore: fakeStore(), @@ -147,7 +147,7 @@ describe('createMcpHandler', () => { it('includes CORS headers on transport failure (500)', async () => { const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({ + authenticator: new ApiKeyAuthenticator({ [TOKEN]: { tenantId: TENANT.tenantId, environment: TENANT.environment }, }), templateStore: fakeStore(), @@ -170,7 +170,7 @@ describe('createMcpHandler', () => { it('accepts initialize over POST with valid credentials', async () => { const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({ + authenticator: new ApiKeyAuthenticator({ [TOKEN]: { tenantId: TENANT.tenantId, environment: TENANT.environment }, }), templateStore: fakeStore(), @@ -210,7 +210,7 @@ describe('createMcpHandler', () => { it('logs httpMethod (not mcpMethod) on request receipt', async () => { const lines: string[] = []; const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({ + authenticator: new ApiKeyAuthenticator({ [TOKEN]: { tenantId: TENANT.tenantId, environment: TENANT.environment }, }), templateStore: fakeStore(), @@ -239,7 +239,7 @@ describe('createMcpHandler', () => { }, }; const handler = createMcpHandler({ - tenantResolver: new ApiKeyTenantResolver({}), + authenticator: new ApiKeyAuthenticator({}), templateStore: store, }); await handler(fakeRequest({ authorization: `Bearer ${TOKEN}`, body: {} }), context); diff --git a/apps/api/src/mcp/handler.ts b/apps/api/src/mcp/handler.ts index 207eb52..5ad33d9 100644 --- a/apps/api/src/mcp/handler.ts +++ b/apps/api/src/mcp/handler.ts @@ -2,14 +2,20 @@ import type { HttpRequest, HttpResponseInit, InvocationContext } from '@azure/fu import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { PostKitErrorCode, + type Principal, type TenantContext, type TenantEnvironment, } from '@singleton-sd/post-kit-types'; +import { + ApiKeyAuthenticator, + AuthError, + tenantContextFromPrincipal, + type Authenticator, +} from '../auth'; import { ensureAppConfiguration } from '../config/app-configuration'; import { createLogger, resolveCorrelationId, type Logger } from '../telemetry'; -import { ApiKeyTenantResolver, type TenantKeyMap, type TenantResolver } from '../tenant'; +import type { TenantKeyMap } from '../tenant'; import { BlobTemplateStore, TemplateApplicationService, type TemplateStore } from '../templates'; -import { TenantResolverError } from './auth'; import { createPostkitMcpServer } from './create-server'; import { azureHttpRequestToWebRequest, webResponseToAzureHttpResponse } from './http-bridge'; @@ -21,7 +27,7 @@ const MCP_CORS_HEADERS = { } as const; export interface McpHandlerDependencies { - tenantResolver: TenantResolver; + authenticator: Authenticator; templateStore: TemplateStore; resolveBranding?: ( tenant: TenantContext, @@ -87,8 +93,8 @@ function isTenantKeyMapEntry( export function createDefaultMcpDependencies(templateStore: TemplateStore): McpHandlerDependencies { return { - get tenantResolver(): TenantResolver { - return new ApiKeyTenantResolver(parseTenantKeyMap(process.env.TENANT_KEY_MAP)); + get authenticator(): Authenticator { + return new ApiKeyAuthenticator(parseTenantKeyMap(process.env.TENANT_KEY_MAP)); }, templateStore, resolveBranding: async () => ({}), @@ -179,11 +185,11 @@ export function createMcpHandler(deps: McpHandlerDependencies) { ); } - let tenant: TenantContext; + let principal: Principal; try { - tenant = await deps.tenantResolver.resolve(request); + principal = await deps.authenticator.authenticate(request); } catch (err) { - if (err instanceof TenantResolverError) { + if (err instanceof AuthError) { const status = err.code === PostKitErrorCode.UNAUTHENTICATED ? 401 : 403; logger.error('mcp.request.failed', { outcome: 'auth_error', @@ -195,6 +201,8 @@ export function createMcpHandler(deps: McpHandlerDependencies) { throw err; } + const tenant = tenantContextFromPrincipal(principal); + const templates = new TemplateApplicationService({ templateStore: deps.templateStore, resolveBranding: deps.resolveBranding, @@ -202,7 +210,7 @@ export function createMcpHandler(deps: McpHandlerDependencies) { const server = createPostkitMcpServer({ templates, - tenant, + principal, logger, }); diff --git a/apps/api/src/mcp/index.ts b/apps/api/src/mcp/index.ts index 4126b28..162684d 100644 --- a/apps/api/src/mcp/index.ts +++ b/apps/api/src/mcp/index.ts @@ -7,4 +7,4 @@ export { } from './handler'; export type { McpHandlerDependencies } from './handler'; export { azureHttpRequestToWebRequest, webResponseToAzureHttpResponse } from './http-bridge'; -export { resolveMcpTenant, TenantResolverError } from './auth'; +export { resolveMcpPrincipal, AuthError } from './auth'; diff --git a/docs/README.md b/docs/README.md index 3dfbdaa..7435004 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,7 +64,7 @@ docs/ | [`architecture/overview.md`](./architecture/overview.md) | Phase-1 architecture: Functions API, EmailProvider, consumers | | [`email-forward-email.md`](./email-forward-email.md) | Forward Email provider, DNS, `pnpm email:provision`, Function contact, branding CI | | [`guides/api-quickstart.md`](./guides/api-quickstart.md) | `POST /emails/send` contract, `PostKitClient` usage, error taxonomy and retries | -| [`guides/mcp.md`](./guides/mcp.md) | Stateless `POST /mcp` Streamable HTTP adapter, iteration-1 tools, Bearer PoC auth, local + deployed client config | +| [`guides/mcp.md`](./guides/mcp.md) | Stateless `POST /mcp` Streamable HTTP adapter, template tools, Principal + scopes (PoC `TENANT_KEY_MAP`), local + deployed client config | | [`guides/packages.md`](./guides/packages.md) | Install, pin, upgrade published `@singleton-sd/post-kit-*` packages; semver and compatibility | | [`guides/template-authoring.md`](./guides/template-authoring.md) | Consumer template layout, `metadata.json` fields, template keys, variables, local validation | | [`guides/template-publishing.md`](./guides/template-publishing.md) | `post-kit-publish` flags, blob layout, fail-fast, per-environment promotion, OIDC + RBAC | diff --git a/docs/architecture/multi-tenant-security.md b/docs/architecture/multi-tenant-security.md index a70d5a2..78e62b1 100644 --- a/docs/architecture/multi-tenant-security.md +++ b/docs/architecture/multi-tenant-security.md @@ -154,9 +154,16 @@ State these plainly; do not assume any of them exist. - **No signed webhooks and no delivery-event callbacks.** PostKit returns a synchronous `sent` status only; there is no bounce, complaint, or delivery notification surface. -- **No token expiry, rotation, or revocation mechanism.** Revocation means - editing `TENANT_KEY_MAP`. There is no expiry field, no hashing of stored - tokens, and no per-token audit trail beyond `tenantId` in the logs. +- **No token expiry, rotation, or revocation mechanism (Slice B of #83).** + Slice A introduces a shared `Principal` + scopes (`templates:read`, + `templates:validate`, `templates:preview`, `email:send`) enforced by REST + send and MCP `runTool`. Credentials still come from plaintext + `TENANT_KEY_MAP` with default PoC scopes for every map entry. Revocation + still means editing `TENANT_KEY_MAP`. Hashed storage, expiry, and per-key + revoke land in Slice B. Slice A auditing is **tenant-level only**: REST and + MCP structured logs record `tenantId` and `environment`, not `principal.id`, + so operators cannot distinguish credentials that share the same tenant and + environment. - **No per-tenant scoping of the sender identity.** `EMAIL_FROM_ADDRESS` and `EMAIL_FROM_NAME` are process-wide, so all tenants on a deployment share the configured from address. diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 106f7eb..1b1ddd2 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -1,4 +1,4 @@ -# MCP (iteration 1) +# MCP (iteration 2 Slice A) Stateless Model Context Protocol endpoint on the existing PostKit Azure Function App. MCP is an **adapter** over the same template application @@ -8,12 +8,14 @@ services used by REST — it does not send email in this iteration. | --- | --- | | Route | `POST /mcp` | | Transport | MCP Streamable HTTP (JSON responses, no sessions) | -| Auth | `Authorization: Bearer ` via `TENANT_KEY_MAP` (same PoC map as `POST /emails/send`) | +| Auth | `Authorization: Bearer ` → shared `Principal` (same `TENANT_KEY_MAP` PoC as `POST /emails/send`) | +| Scopes | Tools enforce `templates:read` / `templates:validate` / `templates:preview` centrally in `runTool` | | Tools | `postkit.list_templates`, `postkit.get_template`, `postkit.get_template_schema`, `postkit.validate_template`, `postkit.preview_template` | `send_email` is intentionally absent. Azure Function keys are **not** the PostKit authorization model — the Function route uses `authLevel: anonymous` -and PostKit validates the Bearer token itself (replaceable in #83). +and PostKit authenticates the Bearer token to a `Principal` with scopes +(`apps/api/src/auth/`; hashed keys / revoke / expiry are Slice B of #83). ## Architecture @@ -24,7 +26,8 @@ MCP client v apps/api Azure Function (functions/mcp.ts) | - +--> ApiKeyTenantResolver (TENANT_KEY_MAP) + +--> ApiKeyAuthenticator (TENANT_KEY_MAP → Principal + DEFAULT_POC_SCOPES) + +--> createPostkitMcpServer (requireScope per tool via MCP_TOOL_SCOPES) +--> TemplateApplicationService | +--> TemplateStore.list / load diff --git a/packages/post-kit-types/src/auth.ts b/packages/post-kit-types/src/auth.ts new file mode 100644 index 0000000..e99fa16 --- /dev/null +++ b/packages/post-kit-types/src/auth.ts @@ -0,0 +1,53 @@ +/** + * Authentication / authorization contracts. + * + * Transport-independent principal produced after credential verification. + * REST and MCP share the same Principal + scope checks so authorization is + * not tied to a specific route or tool name. + */ + +import type { TenantEnvironment } from './tenant'; + +/** How the caller authenticated. Entra is reserved for a later iteration. */ +export type AuthType = 'api-key' | 'entra'; + +/** + * Semantic permissions enforced at the application boundary. + * Prefer these over transport-specific route permissions. + */ +export type PostKitScope = + 'templates:read' | 'templates:validate' | 'templates:preview' | 'email:send'; + +/** All known PostKit scopes (stable order for docs/tests). */ +export const POSTKIT_SCOPES = [ + 'templates:read', + 'templates:validate', + 'templates:preview', + 'email:send', +] as const satisfies readonly PostKitScope[]; + +/** + * Default scopes granted to legacy `TENANT_KEY_MAP` entries (Iteration 2 PoC). + * Full set so existing send + MCP template tools keep working until hashed + * keys carry explicit scopes. + */ +export const DEFAULT_POC_SCOPES = [...POSTKIT_SCOPES] as const satisfies readonly PostKitScope[]; + +/** + * Authenticated caller identity for one request. + * Never accept tenantId / environment / scopes from the request body. + */ +export interface Principal { + /** + * Opaque principal id (never the raw API key). + * For API-key auth this is typically a truncated hash of the credential. + */ + id: string; + /** Tenant bound to the credential. */ + tenantId: string; + /** Environment bound to the credential. */ + environment: TenantEnvironment; + authType: AuthType; + /** Granted permissions for this credential. */ + scopes: readonly PostKitScope[]; +} diff --git a/packages/post-kit-types/src/index.spec.ts b/packages/post-kit-types/src/index.spec.ts index 2d4ee67..35a9e67 100644 --- a/packages/post-kit-types/src/index.spec.ts +++ b/packages/post-kit-types/src/index.spec.ts @@ -12,10 +12,15 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + DEFAULT_POC_SCOPES, + POSTKIT_SCOPES, PostKitErrorCode, TEMPLATE_SCHEMA_VERSION, + type AuthType, type CompiledTemplate, type PostKitErrorResponse, + type PostKitScope, + type Principal, type SendRequest, type SendResponse, type TenantBranding, @@ -139,6 +144,25 @@ const _vars = { resetUrl: 'https://example.com/reset/abc', } satisfies TemplateVariables; +// AuthType +const _authApiKey: AuthType = 'api-key'; +const _authEntra: AuthType = 'entra'; + +// PostKitScope — each semantic permission +const _scopeRead: PostKitScope = 'templates:read'; +const _scopeValidate: PostKitScope = 'templates:validate'; +const _scopePreview: PostKitScope = 'templates:preview'; +const _scopeSend: PostKitScope = 'email:send'; + +// Principal +const _principal = { + id: 'ak_abc123', + tenantId: 'inkads', + environment: 'production', + authType: 'api-key', + scopes: DEFAULT_POC_SCOPES, +} satisfies Principal; + // Suppress unused-variable warnings — values are referenced to confirm types compile void [ _metadata, @@ -158,6 +182,13 @@ void [ _brandingFull, _brandingEmpty, _vars, + _authApiKey, + _authEntra, + _scopeRead, + _scopeValidate, + _scopePreview, + _scopeSend, + _principal, ]; // --------------------------------------------------------------------------- @@ -201,3 +232,16 @@ describe('TenantEnvironment values', () => { assert.equal(unique.size, 3); }); }); + +describe('PostKit scopes', () => { + it('POSTKIT_SCOPES lists the four semantic permissions', () => { + assert.deepEqual( + [...POSTKIT_SCOPES], + ['templates:read', 'templates:validate', 'templates:preview', 'email:send'], + ); + }); + + it('DEFAULT_POC_SCOPES grants the full set for legacy map entries', () => { + assert.deepEqual([...DEFAULT_POC_SCOPES], [...POSTKIT_SCOPES]); + }); +}); diff --git a/packages/post-kit-types/src/index.ts b/packages/post-kit-types/src/index.ts index 590f247..43935fd 100644 --- a/packages/post-kit-types/src/index.ts +++ b/packages/post-kit-types/src/index.ts @@ -38,3 +38,12 @@ export { type TenantEnvironment, type TemplateVariables, } from './tenant'; + +// Auth contracts (principal + scopes shared by REST and MCP) +export { + DEFAULT_POC_SCOPES, + POSTKIT_SCOPES, + type AuthType, + type PostKitScope, + type Principal, +} from './auth';