From f881684aa619c6259c40eebb1bd910503aba0b85 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sat, 20 Jun 2026 15:28:40 +0900 Subject: [PATCH] fix: expose entitlement guard contract metadata --- .changeset/entitlement-guard-contracts.md | 7 + .../saas-billing-golden-path/vitest.config.ts | 4 +- .../admin-react/src/tests/AdminPanel.spec.ts | 3 + packages/cli/src/tests/contractsCheck.spec.ts | 1 + packages/cli/src/tests/contractsDiff.spec.ts | 1 + packages/cli/vitest.config.ts | 22 ++ packages/entitlements-core/README.md | 33 +- packages/entitlements-core/package.json | 1 + packages/entitlements-core/src/index.ts | 28 ++ .../src/libs/EntitlementGuard.ts | 323 ++++++++++++++++-- .../src/libs/EntitlementManager.ts | 28 +- .../src/libs/EntitlementRequirement.ts | 201 +++++++++++ .../src/libs/decorators/RequireEntitlement.ts | 35 +- .../entitlements-core/src/libs/interfaces.ts | 31 ++ .../src/libs/problems/EntitlementProblems.ts | 63 ++++ packages/entitlements-core/src/libs/types.ts | 20 +- .../src/tests/EntitlementGuard.spec.ts | 271 ++++++++++++++- .../src/tests/EntitlementIntegration.spec.ts | 6 + .../src/tests/EntitlementManager.spec.ts | 10 + .../src/tests/EntitlementRequirement.spec.ts | 37 ++ .../src/__tests__/compiler.spec.ts | 2 + packages/openapi-spec/src/libs/emitOpenAPI.ts | 69 ++++ .../src/tests/emitOpenAPI.spec.ts | 46 +++ packages/protocols-core/src/index.ts | 9 +- .../protocols-core/src/libs/ContractGraph.ts | 107 ++++++ .../src/libs/ContractGraphConsumerCoverage.ts | 35 +- .../src/libs/ContractGraphDiff.ts | 52 +++ .../src/libs/ContractGraphSnapshot.ts | 26 ++ .../protocols-core/src/libs/sharedTypes.ts | 14 + .../src/tests/ContractGraph.spec.ts | 184 ++++++++++ .../src/tests/helpers/test-decorators.ts | 40 +++ .../rpc-codegen/src/tests/codegen.spec.ts | 1 + pnpm-lock.yaml | 3 + public-api-surface.snapshot.json | 161 +++++++++ 34 files changed, 1817 insertions(+), 57 deletions(-) create mode 100644 .changeset/entitlement-guard-contracts.md create mode 100644 packages/cli/vitest.config.ts create mode 100644 packages/entitlements-core/src/libs/EntitlementRequirement.ts create mode 100644 packages/entitlements-core/src/tests/EntitlementRequirement.spec.ts diff --git a/.changeset/entitlement-guard-contracts.md b/.changeset/entitlement-guard-contracts.md new file mode 100644 index 000000000..d2c01f7fa --- /dev/null +++ b/.changeset/entitlement-guard-contracts.md @@ -0,0 +1,7 @@ +--- +"@croco/entitlements-core": patch +"@croco/openapi-spec": patch +"@croco/protocols-core": patch +--- + +Entitlement guard requirements are now emitted as route contract metadata and OpenAPI extensions, with explicit guard status and evidence. diff --git a/examples/saas-billing-golden-path/vitest.config.ts b/examples/saas-billing-golden-path/vitest.config.ts index 71614d689..f675738cb 100644 --- a/examples/saas-billing-golden-path/vitest.config.ts +++ b/examples/saas-billing-golden-path/vitest.config.ts @@ -8,6 +8,7 @@ const workspacePackages = [ "diagnostics-core", "events-core", "events-inmemory", + "framework-config", "framework-context", "framework-logger", "health-core", @@ -42,6 +43,7 @@ export default defineConfig({ }, test: { environment: "node", - include: ["src/tests/**/*.spec.ts"], + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], }, }); diff --git a/packages/admin-react/src/tests/AdminPanel.spec.ts b/packages/admin-react/src/tests/AdminPanel.spec.ts index 75ad28648..9eac0a89a 100644 --- a/packages/admin-react/src/tests/AdminPanel.spec.ts +++ b/packages/admin-react/src/tests/AdminPanel.spec.ts @@ -74,6 +74,7 @@ describe("BillingEntitlementAdminPanel", () => { { featureKey: "reports", granted: true, + status: "allowed", planId: "pro", quota: 100, remaining: 80, @@ -120,6 +121,7 @@ describe("BillingEntitlementAdminPanel", () => { exceeded: true, featureKey: "api_calls", granted: false, + status: "denied", overagePolicy: "BLOCK", quota: 100, reason: "quota_exceeded", @@ -149,6 +151,7 @@ describe("BillingEntitlementAdminPanel", () => { featureKey: "advanced_exports", granted: false, reason: "entitlement_not_found", + status: "denied", type: "boolean", }; diff --git a/packages/cli/src/tests/contractsCheck.spec.ts b/packages/cli/src/tests/contractsCheck.spec.ts index 70b1985ef..3abe53d29 100644 --- a/packages/cli/src/tests/contractsCheck.spec.ts +++ b/packages/cli/src/tests/contractsCheck.spec.ts @@ -123,6 +123,7 @@ function createGraph(diagnostics: ContractDiagnostic[] = []): ContractGraph { path: "/users", controllerPath: "/users", access: { guards: [], roles: [] }, + entitlements: [], params: [], inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, diff --git a/packages/cli/src/tests/contractsDiff.spec.ts b/packages/cli/src/tests/contractsDiff.spec.ts index 5bbab2a12..ff8141f37 100644 --- a/packages/cli/src/tests/contractsDiff.spec.ts +++ b/packages/cli/src/tests/contractsDiff.spec.ts @@ -156,6 +156,7 @@ function createGraph( path: methodName === "createUser" ? "/users" : path, controllerPath: "/users", access: { guards: [], roles: [] }, + entitlements: [], params: [], inputSchema: null, inputSchemas: { body: null, path: null, query: null, headers: null }, diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 000000000..59ea5f3de --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,22 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@croco/execution-core": resolve(currentDir, "../execution-core/src/index.ts"), + "@croco/migration-runner": resolve(currentDir, "../migration-runner/src/index.ts"), + "@croco/openapi-spec": resolve(currentDir, "../openapi-spec/src/index.ts"), + "@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"), + "@croco/protocols-core": resolve(currentDir, "../protocols-core/src/index.ts"), + "@croco/rpc-codegen": resolve(currentDir, "../rpc-codegen/src/index.ts"), + }, + }, + test: { + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + exclude: ["src/tests/integration/**", "**/node_modules/**", "**/dist/**"], + }, +}); diff --git a/packages/entitlements-core/README.md b/packages/entitlements-core/README.md index 9b9c54158..119579e5c 100644 --- a/packages/entitlements-core/README.md +++ b/packages/entitlements-core/README.md @@ -37,6 +37,26 @@ const manager = new EntitlementManager( const result = await manager.check("tenant-1", "api_calls"); ``` +### 라우트와 서비스 경계 강제 + +`@RequireEntitlement`는 클래스나 메서드에 필요한 기능 키를 선언합니다. `EntitlementGuard`는 핸들러 실행 전에 tenant, user, route, resource를 명시적인 guard 입력으로 만들어 `EntitlementManager.check()`를 호출하고, 실패 시 표준 Problem을 throw합니다. + +```ts +import { EntitlementGuard, RequireEntitlement } from "@croco/entitlements-core"; + +class ReportsController { + @RequireEntitlement({ + feature: "reports.export", + resource: { type: "report", idParam: "reportId" }, + }) + exportReport() { + return "ok"; + } +} +``` + +resource id는 `resource.id`로 고정하거나 `resource.idParam`을 통해 request params에서 가져올 수 있습니다. guard는 성공과 실패 모두 `entitlement.guard.allowed` / `entitlement.guard.denied` telemetry event를 남기며, `EntitlementAuditSink`를 컨테이너에 등록하면 동일한 evidence를 audit sink로 받을 수 있습니다. + ## API 레퍼런스 ### 핵심 클래스 @@ -48,22 +68,29 @@ const result = await manager.check("tenant-1", "api_calls"); ### 데코레이터와 인터페이스 -- `@RequireEntitlement`, 엔드포인트에 필요한 기능 키를 선언합니다. +- `@RequireEntitlement`, 클래스나 메서드에 필요한 기능 키와 resource 요구사항을 선언합니다. - `SubscriptionProvider`, `PlanEntitlementRegistry`, `EntitlementQuotaChecker`, `EntitlementMeterLookup`, `EntitlementEventPublisher` +- `EntitlementAuditSink`, guard 허용/거부 evidence를 기록하는 audit sink입니다. ### 주요 타입 - `EntitlementRule`, `EntitlementCheckResult`, `EntitlementQuotaStatus` -- `EntitlementType`, `OveragePolicy`, `PlanEntitlements` +- `EntitlementCheckStatus`, `EntitlementType`, `OveragePolicy`, `PlanEntitlements` +- `EntitlementRequirement`, `EntitlementResourceRequirement`, `EntitlementGuardInput` - `UsageHistoryEntry`, `UsageHistoryPeriod` ### 이벤트와 문제 타입 - 이벤트: `EntitlementDeniedEvent`, `EntitlementQuotaExceededEvent`, `EntitlementOverageAllowedEvent` -- 문제 타입: `EntitlementDeniedProblem`, `EntitlementNotFoundProblem` +- 문제 타입: `EntitlementDeniedProblem`, `EntitlementMissingPlanProblem`, `EntitlementInactiveSubscriptionProblem`, `EntitlementQuotaExceededProblem`, `EntitlementProviderUnavailableProblem`, `EntitlementNotFoundProblem` + +## Contract artifacts + +`@RequireEntitlement` metadata는 `ENTITLEMENT_REQUIREMENTS_KEY`로 저장되며 `@croco/protocols-core`의 contract graph snapshot에 포함됩니다. `@croco/openapi-spec`는 선언된 entitlement 요구사항을 operation-level `x-croco-entitlements` extension으로 내보냅니다. 이 필드는 OpenAPI/RPC consumer coverage에서 drift gate로 검사됩니다. ## 구현 포인트 - `BLOCK`, `WARN`, `ALLOW_WITH_OVERAGE` 세 가지 overage 정책을 지원합니다. +- `EntitlementCheckResult.status`는 `allowed`, `denied`, `soft-limit`, `overage-allowed`, `unknown` 상태를 사용해 guard/audit/telemetry evidence를 정규화합니다. - `meterId`를 지정하면 metering-core의 실제 사용량과 quota를 연결할 수 있습니다. - subscription, billing, membership 같은 패키지와 조합해 플랜 제한을 중앙에서 관리할 수 있습니다. diff --git a/packages/entitlements-core/package.json b/packages/entitlements-core/package.json index a7da6c26e..c246a3022 100644 --- a/packages/entitlements-core/package.json +++ b/packages/entitlements-core/package.json @@ -44,6 +44,7 @@ "@croco/framework-context": "workspace:*", "@croco/metering-core": "workspace:*", "@croco/problems-core": "workspace:*", + "@croco/telemetry-api": "workspace:*", "reflect-metadata": "^0.2.2" }, "devDependencies": { diff --git a/packages/entitlements-core/src/index.ts b/packages/entitlements-core/src/index.ts index e978bd70f..4b2305a79 100644 --- a/packages/entitlements-core/src/index.ts +++ b/packages/entitlements-core/src/index.ts @@ -9,10 +9,33 @@ */ export { RequireEntitlement } from "./libs/decorators/RequireEntitlement"; +/** + * route/service 경계에서 공유하는 entitlement requirement metadata contract입니다. + */ +export { + appendEntitlementRequirement, + defineEntitlementRequirement, + ENTITLEMENT_REQUIRED_KEY, + ENTITLEMENT_REQUIREMENTS_KEY, + getEntitlementRequirements, +} from "./libs/EntitlementRequirement"; +export type { + EntitlementRequirement, + EntitlementRequirementMetadata, + EntitlementResourceRequirement, +} from "./libs/EntitlementRequirement"; + /** * 라우트 실행 전에 entitlement를 검사하는 가드입니다. */ export { EntitlementGuard } from "./libs/EntitlementGuard"; +export type { + EntitlementGuardInput, + EntitlementGuardResource, + EntitlementGuardRoute, + EntitlementGuardSubject, + RouteExecutionContext, +} from "./libs/EntitlementGuard"; /** * 플랜 규칙과 quota를 조합해 entitlement 결과를 계산하는 핵심 서비스입니다. @@ -43,7 +66,12 @@ export * from "./libs/interfaces"; */ export { EntitlementDeniedProblem, + EntitlementInactiveSubscriptionProblem, + EntitlementMissingPlanProblem, EntitlementNotFoundProblem, + EntitlementProviderUnavailableProblem, + EntitlementQuotaExceededProblem, + EntitlementRequirementProblem, } from "./libs/problems/EntitlementProblems"; /** diff --git a/packages/entitlements-core/src/libs/EntitlementGuard.ts b/packages/entitlements-core/src/libs/EntitlementGuard.ts index b8e870eba..03114351d 100644 --- a/packages/entitlements-core/src/libs/EntitlementGuard.ts +++ b/packages/entitlements-core/src/libs/EntitlementGuard.ts @@ -1,37 +1,60 @@ import "reflect-metadata"; import type { AuthRequest, AuthUser } from "@croco/auth-core"; import type { Guard } from "@croco/framework-context"; -import { ENTITLEMENT_REQUIRED_KEY } from "./decorators/RequireEntitlement"; +import { Container, Context } from "@croco/framework-context"; +import { recordEvent } from "@croco/telemetry-api"; import type { EntitlementManager } from "./EntitlementManager"; -import { EntitlementDeniedProblem } from "./problems/EntitlementProblems"; +import type { EntitlementRequirement } from "./EntitlementRequirement"; +import { getEntitlementRequirements } from "./EntitlementRequirement"; +import type { EntitlementGuardAuditEvent } from "./interfaces"; +import { EntitlementAuditSink } from "./interfaces"; +import { + EntitlementDeniedProblem, + EntitlementInactiveSubscriptionProblem, + EntitlementMissingPlanProblem, + EntitlementProviderUnavailableProblem, + EntitlementQuotaExceededProblem, +} from "./problems/EntitlementProblems"; +import type { EntitlementCheckResult } from "./types"; export type RouteExecutionContext = { getClass(): unknown; getHandler(): string | symbol; getRequest(): AuthRequest & { tenantId?: string }; + getHttpContext?(): { + req: { + params: Record; + }; + param(name: string): string | undefined; + get(key: string): T | undefined; + } | null; }; type EntitlementAuthUser = AuthUser & { tenantId?: string }; -function getRequiredFeature(controllerTarget: unknown, handler: string | symbol): string | null { - const classTarget = - typeof controllerTarget === "function" - ? controllerTarget - : (controllerTarget as object).constructor; - const prototypeTarget = - typeof controllerTarget === "function" ? controllerTarget.prototype : controllerTarget; - - return ( - Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, classTarget, handler) ?? - Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, prototypeTarget, handler) ?? - Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, classTarget) ?? - null - ); -} +export type EntitlementGuardSubject = { + readonly type: "user"; + readonly id: string; +}; -function isMetadataTarget(value: unknown): value is object { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} +export type EntitlementGuardResource = { + readonly type: string; + readonly id: string; +}; + +export type EntitlementGuardRoute = { + readonly controllerName: string; + readonly handlerName: string; + readonly routeId: string; +}; + +export type EntitlementGuardInput = { + readonly requirement: EntitlementRequirement; + readonly tenantId: string; + readonly subject?: EntitlementGuardSubject; + readonly resource?: EntitlementGuardResource; + readonly route: EntitlementGuardRoute; +}; export class EntitlementGuard implements Guard { constructor(private readonly entitlementManager: EntitlementManager) {} @@ -39,31 +62,271 @@ export class EntitlementGuard implements Guard { async canActivate(context: RouteExecutionContext): Promise { const target = context.getClass(); const handler = context.getHandler(); + const requirements = getEntitlementRequirements(target, handler); - if (!isMetadataTarget(target)) { + if (requirements.length === 0) { return true; } - const featureKey = getRequiredFeature(target, handler); + for (const requirement of requirements) { + const input = this.createGuardInput(context, requirement); + let result: EntitlementCheckResult; - if (featureKey === null) { - return true; + try { + result = await this.entitlementManager.check(input.tenantId, input.requirement.feature); + } catch (error) { + const problem = new EntitlementProviderUnavailableProblem( + input.requirement.feature, + error instanceof Error ? error : undefined, + ); + await this.recordDenied(input, "unknown", "provider_unavailable", problem.code); + throw problem; + } + + const problem = toEntitlementProblem(input, result); + + if (problem) { + await this.recordDenied( + input, + result.status, + result.reason ?? "not_entitled", + problem.code, + ); + throw problem; + } + + await this.recordAllowed(input, result); } + return true; + } + + private createGuardInput( + context: RouteExecutionContext, + requirement: EntitlementRequirement, + ): EntitlementGuardInput { const request = context.getRequest(); const user = request.user as EntitlementAuthUser | undefined; - const tenantId = request.tenantId ?? user?.tenantId; + const tenantId = this.resolveTenantId(context, request, user); if (!tenantId) { - throw new EntitlementDeniedProblem(featureKey, "tenantId not found in request"); + throw new EntitlementDeniedProblem(requirement.feature, "tenantId not found in request"); } - const result = await this.entitlementManager.check(tenantId, featureKey); + return { + requirement, + tenantId, + ...(user?.id ? { subject: { type: "user", id: user.id } } : {}), + ...this.resolveResource(context, request, requirement), + route: this.resolveRoute(context), + }; + } - if (!result.granted) { - throw new EntitlementDeniedProblem(featureKey, result.reason); + private resolveTenantId( + context: RouteExecutionContext, + request: AuthRequest & { tenantId?: string }, + user: EntitlementAuthUser | undefined, + ): string | null { + if (typeof request.tenantId === "string" && request.tenantId.length > 0) { + return request.tenantId; } - return true; + if (typeof user?.tenantId === "string" && user.tenantId.length > 0) { + return user.tenantId; + } + + const httpContextTenantId = context.getHttpContext?.()?.get("tenantId"); + if (typeof httpContextTenantId === "string" && httpContextTenantId.length > 0) { + return httpContextTenantId; + } + + const currentTenantId = Context.getTenantId(); + if (typeof currentTenantId === "string" && currentTenantId.length > 0) { + return currentTenantId; + } + + return null; + } + + private resolveResource( + context: RouteExecutionContext, + request: AuthRequest, + requirement: EntitlementRequirement, + ): Pick { + const resource = requirement.resource; + + if (!resource) { + return {}; + } + + const id = resource.id ?? this.resolveResourceId(context, request, resource.idParam); + + if (!id) { + throw new EntitlementDeniedProblem(requirement.feature, "resource id not found in request"); + } + + return { + resource: { + type: resource.type, + id, + }, + }; + } + + private resolveResourceId( + context: RouteExecutionContext, + request: AuthRequest, + idParam: string | undefined, + ): string | null { + if (!idParam) { + return null; + } + + const params = (request as { readonly params?: Record }).params; + const requestParam = params?.[idParam]; + if (typeof requestParam === "string" && requestParam.length > 0) { + return requestParam; + } + + const httpContext = context.getHttpContext?.(); + const contextParam = httpContext?.param(idParam) ?? httpContext?.req.params[idParam]; + + return typeof contextParam === "string" && contextParam.length > 0 ? contextParam : null; + } + + private resolveRoute(context: RouteExecutionContext): EntitlementGuardRoute { + const target = context.getClass(); + const handler = context.getHandler(); + const controllerName = + typeof target === "function" + ? target.name + : target && typeof target === "object" && target.constructor.name.length > 0 + ? target.constructor.name + : "anonymous"; + const handlerName = String(handler); + + return { + controllerName, + handlerName, + routeId: `${controllerName}.${handlerName}`, + }; + } + + private async recordAllowed( + input: EntitlementGuardInput, + result: EntitlementCheckResult, + ): Promise { + recordEvent("entitlement.guard.allowed", toTelemetryAttributes(input, result.status)); + await this.recordAuditEvent({ + type: "entitlement.guard.allowed", + tenantId: input.tenantId, + feature: input.requirement.feature, + status: result.status, + ...(input.subject ? { userId: input.subject.id } : {}), + ...(input.resource ? { resource: input.resource } : {}), + route: input.route, + metadata: { + planId: result.planId, + overagePolicy: result.overagePolicy, + }, + }); } + + private async recordDenied( + input: EntitlementGuardInput, + status: EntitlementCheckResult["status"], + reason: string, + problemCode: string, + ): Promise { + recordEvent( + "entitlement.guard.denied", + toTelemetryAttributes(input, status, reason, problemCode), + ); + await this.recordAuditEvent({ + type: "entitlement.guard.denied", + tenantId: input.tenantId, + feature: input.requirement.feature, + status, + ...(input.subject ? { userId: input.subject.id } : {}), + ...(input.resource ? { resource: input.resource } : {}), + route: input.route, + reason, + problemCode, + }); + } + + private async recordAuditEvent(event: EntitlementGuardAuditEvent): Promise { + const auditSink = Container.getOptional(EntitlementAuditSink.token); + + if (!auditSink) { + return; + } + + try { + await auditSink.recordEntitlementGuard(event); + } catch { + recordEvent("entitlement.guard.audit_failed", { + "entitlement.feature": event.feature, + "entitlement.status": event.status, + "tenant.id": event.tenantId, + "route.id": event.route?.routeId ?? "unknown", + "audit.event": event.type, + }); + } + } +} + +function toEntitlementProblem( + input: EntitlementGuardInput, + result: EntitlementCheckResult, +): + | EntitlementDeniedProblem + | EntitlementInactiveSubscriptionProblem + | EntitlementMissingPlanProblem + | EntitlementQuotaExceededProblem + | EntitlementProviderUnavailableProblem + | null { + if (result.granted) { + return null; + } + + switch (result.reason) { + case "no_subscription": + return new EntitlementMissingPlanProblem(input.requirement.feature, input.tenantId); + case "inactive_subscription": + return new EntitlementInactiveSubscriptionProblem(input.requirement.feature, input.tenantId); + case "quota_exceeded": + return new EntitlementQuotaExceededProblem( + input.requirement.feature, + result.usage, + result.quota, + ); + case "provider_unavailable": + return new EntitlementProviderUnavailableProblem(input.requirement.feature); + default: + return new EntitlementDeniedProblem(input.requirement.feature, result.reason); + } +} + +function toTelemetryAttributes( + input: EntitlementGuardInput, + status: EntitlementCheckResult["status"], + reason?: string, + problemCode?: string, +): Record { + return { + "entitlement.feature": input.requirement.feature, + "entitlement.status": status, + "tenant.id": input.tenantId, + "route.id": input.route.routeId, + ...(input.subject ? { "user.id": input.subject.id } : {}), + ...(input.resource + ? { + "resource.type": input.resource.type, + "resource.id": input.resource.id, + } + : {}), + ...(reason ? { "entitlement.reason": reason } : {}), + ...(problemCode ? { "problem.code": problemCode } : {}), + }; } diff --git a/packages/entitlements-core/src/libs/EntitlementManager.ts b/packages/entitlements-core/src/libs/EntitlementManager.ts index 4191364cd..88a33d71d 100644 --- a/packages/entitlements-core/src/libs/EntitlementManager.ts +++ b/packages/entitlements-core/src/libs/EntitlementManager.ts @@ -21,18 +21,32 @@ export class EntitlementManager { async check(tenantId: string, featureKey: string): Promise { const planId = await this.subscriptionProvider.getCurrentPlanId(tenantId); if (!planId) { - return { granted: false, featureKey, type: "boolean", reason: "no_subscription" }; + return { + granted: false, + status: "denied", + featureKey, + type: "boolean", + reason: "no_subscription", + }; } const rule = await this.registry.findRule(planId, featureKey); if (!rule) { - return { granted: false, featureKey, type: "boolean", reason: "not_entitled", planId }; + return { + granted: false, + status: "denied", + featureKey, + type: "boolean", + reason: "not_entitled", + planId, + }; } switch (rule.type) { case "boolean": return { granted: true, + status: "allowed", featureKey, type: "boolean", planId, @@ -41,6 +55,7 @@ export class EntitlementManager { case "static": return { granted: true, + status: "allowed", featureKey, type: "static", value: rule.value, @@ -67,6 +82,7 @@ export class EntitlementManager { if (quota == null) { return { granted: false, + status: "denied", featureKey, type: "metered", reason: "no_quota_defined", @@ -80,6 +96,7 @@ export class EntitlementManager { if (!quotaStatus.exceeded) { return this.createMeteredResult({ granted: true, + status: "allowed", featureKey, planId, quota, @@ -98,6 +115,7 @@ export class EntitlementManager { case "BLOCK": return this.createMeteredResult({ granted: false, + status: "denied", featureKey, planId, quota, @@ -111,6 +129,7 @@ export class EntitlementManager { case "WARN": return this.createMeteredResult({ granted: true, + status: "soft-limit", featureKey, planId, quota, @@ -133,6 +152,7 @@ export class EntitlementManager { return this.createMeteredResult({ granted: true, + status: "overage-allowed", featureKey, planId, quota, @@ -146,6 +166,7 @@ export class EntitlementManager { private createMeteredResult(options: { granted: boolean; + status: EntitlementCheckResult["status"]; featureKey: string; planId: string; quota: number; @@ -153,10 +174,11 @@ export class EntitlementManager { remaining: number; exceeded: boolean; overagePolicy: OveragePolicy; - reason?: string; + reason?: EntitlementCheckResult["reason"]; }): EntitlementCheckResult { return { granted: options.granted, + status: options.status, featureKey: options.featureKey, type: "metered", quota: options.quota, diff --git a/packages/entitlements-core/src/libs/EntitlementRequirement.ts b/packages/entitlements-core/src/libs/EntitlementRequirement.ts new file mode 100644 index 000000000..601aa8b94 --- /dev/null +++ b/packages/entitlements-core/src/libs/EntitlementRequirement.ts @@ -0,0 +1,201 @@ +import "reflect-metadata"; +import { EntitlementRequirementProblem } from "./problems/EntitlementProblems"; + +export const ENTITLEMENT_REQUIRED_KEY = "entitlement:required"; +export const ENTITLEMENT_REQUIREMENTS_KEY = Symbol.for("croco:entitlements:requirements"); + +export type EntitlementResourceRequirement = { + readonly type: string; + readonly id?: string; + readonly idParam?: string; +}; + +export type EntitlementRequirement = { + readonly feature: string; + readonly description?: string; + readonly resource?: EntitlementResourceRequirement; +}; + +export type EntitlementRequirementMetadata = EntitlementRequirement; + +export function defineEntitlementRequirement( + requirement: EntitlementRequirement, +): EntitlementRequirement { + assertValidEntitlementRequirement(requirement); + + return { + feature: requirement.feature, + ...(requirement.description ? { description: requirement.description } : {}), + ...(requirement.resource + ? { resource: normalizeResourceRequirement(requirement.resource) } + : {}), + }; +} + +export function appendEntitlementRequirement( + target: object, + requirement: EntitlementRequirement, + propertyKey?: string | symbol, +): void { + const normalized = defineEntitlementRequirement(requirement); + const existing = getOwnEntitlementRequirements(target, propertyKey); + + if (propertyKey === undefined) { + Reflect.defineMetadata(ENTITLEMENT_REQUIREMENTS_KEY, [...existing, normalized], target); + Reflect.defineMetadata(ENTITLEMENT_REQUIRED_KEY, normalized.feature, target); + return; + } + + Reflect.defineMetadata( + ENTITLEMENT_REQUIREMENTS_KEY, + [...existing, normalized], + target, + propertyKey, + ); + Reflect.defineMetadata(ENTITLEMENT_REQUIRED_KEY, normalized.feature, target, propertyKey); +} + +export function getEntitlementRequirements( + controllerTarget: unknown, + handler: string | symbol, +): readonly EntitlementRequirement[] { + if (!isMetadataTarget(controllerTarget)) { + return []; + } + + const classTarget = + typeof controllerTarget === "function" ? controllerTarget : controllerTarget.constructor; + const prototypeTarget = + typeof controllerTarget === "function" ? controllerTarget.prototype : controllerTarget; + + return [ + ...readEntitlementRequirements(classTarget), + ...readEntitlementRequirements(prototypeTarget), + ...readEntitlementRequirements(classTarget, handler), + ...readEntitlementRequirements(prototypeTarget, handler), + ]; +} + +function getOwnEntitlementRequirements( + target: object, + propertyKey?: string | symbol, +): readonly EntitlementRequirement[] { + const value = + propertyKey === undefined + ? Reflect.getOwnMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target) + : Reflect.getOwnMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target, propertyKey); + + return normalizeRequirementList(value); +} + +function readEntitlementRequirements( + target: object, + propertyKey?: string | symbol, +): readonly EntitlementRequirement[] { + const current = + propertyKey === undefined + ? Reflect.getMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target) + : Reflect.getMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target, propertyKey); + const requirements = normalizeRequirementList(current); + + if (requirements.length > 0) { + return requirements; + } + + const legacy = + propertyKey === undefined + ? Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, target) + : Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, target, propertyKey); + + return typeof legacy === "string" && legacy.length > 0 ? [{ feature: legacy }] : []; +} + +function normalizeRequirementList(value: unknown): readonly EntitlementRequirement[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isEntitlementRequirement).map(defineEntitlementRequirement); +} + +function isEntitlementRequirement(value: unknown): value is EntitlementRequirement { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + readonly feature?: unknown; + readonly resource?: unknown; + }; + + return ( + typeof candidate.feature === "string" && + candidate.feature.length > 0 && + (candidate.resource === undefined || isEntitlementResourceRequirement(candidate.resource)) + ); +} + +function assertValidEntitlementRequirement(requirement: EntitlementRequirement): void { + if (typeof requirement.feature !== "string" || requirement.feature.length === 0) { + throw new EntitlementRequirementProblem("Entitlement requirement feature must not be empty."); + } + + if (requirement.resource) { + normalizeResourceRequirement(requirement.resource); + } +} + +function normalizeResourceRequirement( + resource: EntitlementResourceRequirement, +): EntitlementResourceRequirement { + if (typeof resource.type !== "string" || resource.type.length === 0) { + throw new EntitlementRequirementProblem( + "Entitlement resource requirement type must not be empty.", + ); + } + + if (resource.id !== undefined && (typeof resource.id !== "string" || resource.id.length === 0)) { + throw new EntitlementRequirementProblem( + "Entitlement resource requirement id must not be empty.", + ); + } + + if ( + resource.idParam !== undefined && + (typeof resource.idParam !== "string" || resource.idParam.length === 0) + ) { + throw new EntitlementRequirementProblem( + "Entitlement resource requirement idParam must not be empty.", + ); + } + + return { + type: resource.type, + ...(resource.id ? { id: resource.id } : {}), + ...(resource.idParam ? { idParam: resource.idParam } : {}), + }; +} + +function isEntitlementResourceRequirement(value: unknown): value is EntitlementResourceRequirement { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + readonly type?: unknown; + readonly id?: unknown; + readonly idParam?: unknown; + }; + + return ( + typeof candidate.type === "string" && + candidate.type.length > 0 && + (candidate.id === undefined || (typeof candidate.id === "string" && candidate.id.length > 0)) && + (candidate.idParam === undefined || + (typeof candidate.idParam === "string" && candidate.idParam.length > 0)) + ); +} + +function isMetadataTarget(value: unknown): value is object { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} diff --git a/packages/entitlements-core/src/libs/decorators/RequireEntitlement.ts b/packages/entitlements-core/src/libs/decorators/RequireEntitlement.ts index 636311f56..e90404542 100644 --- a/packages/entitlements-core/src/libs/decorators/RequireEntitlement.ts +++ b/packages/entitlements-core/src/libs/decorators/RequireEntitlement.ts @@ -1,18 +1,31 @@ import "reflect-metadata"; +import type { EntitlementRequirement } from "../EntitlementRequirement"; +import { + appendEntitlementRequirement, + ENTITLEMENT_REQUIRED_KEY, + ENTITLEMENT_REQUIREMENTS_KEY, +} from "../EntitlementRequirement"; -export const ENTITLEMENT_REQUIRED_KEY = "entitlement:required"; +export { ENTITLEMENT_REQUIRED_KEY, ENTITLEMENT_REQUIREMENTS_KEY }; +export type RequireEntitlementOptions = EntitlementRequirement; -export type RequireEntitlementOptions = { - feature: string; -}; - -export function RequireEntitlement(options: RequireEntitlementOptions): MethodDecorator { - return ( +export function RequireEntitlement( + options: RequireEntitlementOptions, +): ClassDecorator & MethodDecorator { + const decorator = ( target: object, - propertyKey: string | symbol, - descriptor: PropertyDescriptor, - ): PropertyDescriptor => { - Reflect.defineMetadata(ENTITLEMENT_REQUIRED_KEY, options.feature, target, propertyKey); + propertyKey?: string | symbol, + descriptor?: PropertyDescriptor, + ): PropertyDescriptor | undefined => { + if (propertyKey === undefined) { + appendEntitlementRequirement(target, options); + return; + } + + const metadataTarget = typeof target === "function" ? target : target.constructor; + appendEntitlementRequirement(metadataTarget, options, propertyKey); return descriptor; }; + + return decorator as ClassDecorator & MethodDecorator; } diff --git a/packages/entitlements-core/src/libs/interfaces.ts b/packages/entitlements-core/src/libs/interfaces.ts index 1b6add00c..c1e037ecc 100644 --- a/packages/entitlements-core/src/libs/interfaces.ts +++ b/packages/entitlements-core/src/libs/interfaces.ts @@ -1,6 +1,7 @@ import type { DomainEvent } from "@croco/events-core"; import { Token } from "@croco/framework-context"; import type { + EntitlementCheckStatus, EntitlementQuotaStatus, EntitlementRule, UsageHistoryEntry, @@ -52,3 +53,33 @@ export abstract class EntitlementEventPublisher { abstract publish(event: DomainEvent): Promise; } + +export type EntitlementGuardAuditResource = { + readonly type: string; + readonly id: string; +}; + +export type EntitlementGuardAuditRoute = { + readonly controllerName: string; + readonly handlerName: string; + readonly routeId: string; +}; + +export type EntitlementGuardAuditEvent = { + readonly type: "entitlement.guard.allowed" | "entitlement.guard.denied"; + readonly tenantId: string; + readonly feature: string; + readonly status: EntitlementCheckStatus; + readonly userId?: string; + readonly resource?: EntitlementGuardAuditResource; + readonly route?: EntitlementGuardAuditRoute; + readonly reason?: string; + readonly problemCode?: string; + readonly metadata?: Record; +}; + +export abstract class EntitlementAuditSink { + static readonly token = new Token("EntitlementAuditSink"); + + abstract recordEntitlementGuard(event: EntitlementGuardAuditEvent): void | Promise; +} diff --git a/packages/entitlements-core/src/libs/problems/EntitlementProblems.ts b/packages/entitlements-core/src/libs/problems/EntitlementProblems.ts index 38cb4f06f..3b9c6c201 100644 --- a/packages/entitlements-core/src/libs/problems/EntitlementProblems.ts +++ b/packages/entitlements-core/src/libs/problems/EntitlementProblems.ts @@ -1,5 +1,14 @@ import { Problem, ProblemCategory } from "@croco/problems-core"; +export class EntitlementRequirementProblem extends Problem { + readonly code = "ENTITLEMENT_REQUIREMENT_INVALID"; + readonly category = ProblemCategory.ValidationError; + + constructor(detail: string) { + super(undefined, undefined, detail); + } +} + export class EntitlementDeniedProblem extends Problem { readonly code = "ENTITLEMENT_DENIED"; readonly category = ProblemCategory.Forbidden; @@ -12,6 +21,60 @@ export class EntitlementDeniedProblem extends Problem { } } +export class EntitlementMissingPlanProblem extends Problem { + readonly code = "ENTITLEMENT_MISSING_PLAN"; + readonly category = ProblemCategory.Forbidden; + + constructor(feature: string, tenantId: string) { + super( + undefined, + undefined, + `Tenant '${tenantId}' has no active plan for entitlement '${feature}'`, + ); + } +} + +export class EntitlementInactiveSubscriptionProblem extends Problem { + readonly code = "ENTITLEMENT_INACTIVE_SUBSCRIPTION"; + readonly category = ProblemCategory.Forbidden; + + constructor(feature: string, tenantId: string) { + super( + undefined, + undefined, + `Tenant '${tenantId}' has an inactive subscription for entitlement '${feature}'`, + ); + } +} + +export class EntitlementQuotaExceededProblem extends Problem { + readonly code = "ENTITLEMENT_QUOTA_EXCEEDED"; + readonly category = ProblemCategory.TooManyRequests; + + constructor(feature: string, usage?: number, quota?: number) { + const detail = + usage !== undefined && quota !== undefined + ? `Entitlement '${feature}' quota exceeded: ${usage}/${quota}` + : `Entitlement '${feature}' quota exceeded`; + + super(undefined, undefined, detail); + } +} + +export class EntitlementProviderUnavailableProblem extends Problem { + readonly code = "ENTITLEMENT_PROVIDER_UNAVAILABLE"; + readonly category = ProblemCategory.InternalServerError; + + constructor(feature: string, cause?: Error) { + super( + undefined, + undefined, + `Entitlement provider unavailable while checking '${feature}'`, + cause ? { cause } : undefined, + ); + } +} + export class EntitlementNotFoundProblem extends Problem { readonly code = "ENTITLEMENT_NOT_FOUND"; readonly category = ProblemCategory.NotFound; diff --git a/packages/entitlements-core/src/libs/types.ts b/packages/entitlements-core/src/libs/types.ts index 4dd6cf101..2a4993805 100644 --- a/packages/entitlements-core/src/libs/types.ts +++ b/packages/entitlements-core/src/libs/types.ts @@ -2,6 +2,23 @@ export type EntitlementType = "boolean" | "metered" | "static"; export type OveragePolicy = "BLOCK" | "WARN" | "ALLOW_WITH_OVERAGE"; +export type EntitlementCheckStatus = + | "allowed" + | "denied" + | "soft-limit" + | "overage-allowed" + | "unknown"; + +export type EntitlementFailureReason = + | "no_subscription" + | "inactive_subscription" + | "not_entitled" + | "no_quota_defined" + | "quota_exceeded" + | "provider_unavailable" + | "resource_not_found" + | (string & {}); + export type UsageHistoryPeriod = { startDate: Date; endDate: Date; @@ -35,6 +52,7 @@ export type PlanEntitlements = { export type EntitlementCheckResult = { granted: boolean; + status: EntitlementCheckStatus; featureKey: string; type: EntitlementType; usage?: number; @@ -43,6 +61,6 @@ export type EntitlementCheckResult = { exceeded?: boolean; value?: number; planId?: string; - reason?: string; + reason?: EntitlementFailureReason; overagePolicy?: OveragePolicy; }; diff --git a/packages/entitlements-core/src/tests/EntitlementGuard.spec.ts b/packages/entitlements-core/src/tests/EntitlementGuard.spec.ts index bbc67e446..4734f03c2 100644 --- a/packages/entitlements-core/src/tests/EntitlementGuard.spec.ts +++ b/packages/entitlements-core/src/tests/EntitlementGuard.spec.ts @@ -1,27 +1,57 @@ import { Container } from "@croco/framework-context"; +import * as telemetry from "@croco/telemetry-api"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ENTITLEMENT_REQUIRED_KEY } from "../libs/decorators/RequireEntitlement"; +import { + ENTITLEMENT_REQUIRED_KEY, + RequireEntitlement, +} from "../libs/decorators/RequireEntitlement"; import { EntitlementGuard, type RouteExecutionContext } from "../libs/EntitlementGuard"; import type { EntitlementManager } from "../libs/EntitlementManager"; -import { EntitlementDeniedProblem } from "../libs/problems/EntitlementProblems"; +import { EntitlementAuditSink, type EntitlementGuardAuditEvent } from "../libs/interfaces"; +import { + EntitlementDeniedProblem, + EntitlementMissingPlanProblem, + EntitlementProviderUnavailableProblem, + EntitlementQuotaExceededProblem, +} from "../libs/problems/EntitlementProblems"; +import type { EntitlementCheckResult } from "../libs/types"; class MockEntitlementManager { - checkResult: - | { granted: true; featureKey: string; type: string; planId: string } - | { granted: false; featureKey: string; type: string; reason: string } = { + checkResult: EntitlementCheckResult = { granted: true, + status: "allowed", featureKey: "test_feature", type: "boolean", planId: "pro", }; + error: Error | null = null; + + async check(_tenantId: string, _featureKey: string): Promise { + if (this.error) { + throw this.error; + } - async check(_tenantId: string, _featureKey: string) { return this.checkResult; } } +class MockEntitlementAuditSink extends EntitlementAuditSink { + readonly events: EntitlementGuardAuditEvent[] = []; + + recordEntitlementGuard(event: EntitlementGuardAuditEvent): void { + this.events.push(event); + } +} + +class FailingEntitlementAuditSink extends EntitlementAuditSink { + recordEntitlementGuard(_event: EntitlementGuardAuditEvent): void { + throw new Error("audit sink unavailable"); + } +} + type RequestWithTenant = RouteExecutionContext["getRequest"] extends () => infer T ? T : never; type RequestWithOptionalTenantUser = Omit & { + params?: Record; user?: RequestWithTenant["user"] & { tenantId?: string }; }; @@ -49,10 +79,14 @@ function createContext(options: { describe("EntitlementGuard", () => { let guard!: EntitlementGuard; let mockManager!: MockEntitlementManager; + let auditSink!: MockEntitlementAuditSink; beforeEach(() => { Container.reset(); + vi.restoreAllMocks(); mockManager = new MockEntitlementManager(); + auditSink = new MockEntitlementAuditSink(); + Container.set(EntitlementAuditSink.token, auditSink); guard = new EntitlementGuard(mockManager as unknown as EntitlementManager); }); @@ -83,6 +117,7 @@ describe("EntitlementGuard", () => { mockManager.checkResult = { granted: true, + status: "allowed", featureKey: "test_feature", type: "boolean", planId: "pro", @@ -101,6 +136,28 @@ describe("EntitlementGuard", () => { expect(result).toBe(true); }); + it("should read entitlement metadata from static method decorators", async () => { + class TestController { + @RequireEntitlement({ feature: "reports.export" }) + static testMethod() {} + + instanceMethod() {} + } + + const checkSpy = vi.spyOn(mockManager, "check"); + const context = createContext({ + target: TestController, + request: { + tenantId: "tenant-123", + user: createUser("tenant-123"), + }, + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + + expect(checkSpy).toHaveBeenCalledWith("tenant-123", "reports.export"); + }); + it("should throw EntitlementDeniedProblem when entitlement is denied", async () => { class TestController { testMethod() {} @@ -115,6 +172,7 @@ describe("EntitlementGuard", () => { mockManager.checkResult = { granted: false, + status: "denied", featureKey: "test_feature", type: "boolean", reason: "limit_exceeded", @@ -167,6 +225,7 @@ describe("EntitlementGuard", () => { mockManager.checkResult = { granted: true, + status: "allowed", featureKey: "test_feature", type: "boolean", planId: "pro", @@ -199,6 +258,7 @@ describe("EntitlementGuard", () => { mockManager.checkResult = { granted: true, + status: "allowed", featureKey: "test_feature", type: "boolean", planId: "pro", @@ -216,4 +276,203 @@ describe("EntitlementGuard", () => { expect(checkSpy).toHaveBeenCalledWith("tenant-from-user", "test_feature"); }); + + it("should expose route requirement, tenant, user, resource, telemetry, and audit evidence", async () => { + const recordEventSpy = vi.spyOn(telemetry, "recordEvent").mockImplementation(() => {}); + + class TestController { + @RequireEntitlement({ + feature: "reports.export", + resource: { type: "report", idParam: "reportId" }, + }) + testMethod() {} + } + + const checkSpy = vi.spyOn(mockManager, "check"); + const context = createContext({ + target: TestController, + request: { + tenantId: "tenant-123", + user: createUser("tenant-123"), + params: { reportId: "report-1" }, + }, + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + + expect(checkSpy).toHaveBeenCalledWith("tenant-123", "reports.export"); + expect(recordEventSpy).toHaveBeenCalledWith( + "entitlement.guard.allowed", + expect.objectContaining({ + "entitlement.feature": "reports.export", + "entitlement.status": "allowed", + "tenant.id": "tenant-123", + "user.id": "user-1", + "resource.type": "report", + "resource.id": "report-1", + "route.id": "TestController.testMethod", + }), + ); + expect(auditSink.events).toEqual([ + expect.objectContaining({ + type: "entitlement.guard.allowed", + tenantId: "tenant-123", + feature: "reports.export", + status: "allowed", + userId: "user-1", + resource: { type: "report", id: "report-1" }, + route: { + controllerName: "TestController", + handlerName: "testMethod", + routeId: "TestController.testMethod", + }, + }), + ]); + }); + + it("should map missing plan and quota denial results to standard Problems", async () => { + class TestController { + @RequireEntitlement({ feature: "reports.export" }) + testMethod() {} + } + + const context = createContext({ + target: TestController, + request: { + tenantId: "tenant-123", + user: createUser("tenant-123"), + }, + }); + + mockManager.checkResult = { + granted: false, + status: "denied", + featureKey: "reports.export", + type: "boolean", + reason: "no_subscription", + }; + + await expect(guard.canActivate(context)).rejects.toThrow(EntitlementMissingPlanProblem); + + mockManager.checkResult = { + granted: false, + status: "denied", + featureKey: "reports.export", + type: "metered", + reason: "quota_exceeded", + usage: 11, + quota: 10, + exceeded: true, + remaining: -1, + overagePolicy: "BLOCK", + }; + + await expect(guard.canActivate(context)).rejects.toThrow(EntitlementQuotaExceededProblem); + }); + + it("should record denied evidence when the entitlement provider is unavailable", async () => { + const recordEventSpy = vi.spyOn(telemetry, "recordEvent").mockImplementation(() => {}); + + class TestController { + @RequireEntitlement({ feature: "reports.export" }) + testMethod() {} + } + + mockManager.error = new Error("billing connection failed"); + + const context = createContext({ + target: TestController, + request: { + tenantId: "tenant-123", + user: createUser("tenant-123"), + }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow(EntitlementProviderUnavailableProblem); + expect(recordEventSpy).toHaveBeenCalledWith( + "entitlement.guard.denied", + expect.objectContaining({ + "entitlement.feature": "reports.export", + "entitlement.status": "unknown", + "entitlement.reason": "provider_unavailable", + "problem.code": "ENTITLEMENT_PROVIDER_UNAVAILABLE", + }), + ); + expect(auditSink.events).toEqual([ + expect.objectContaining({ + type: "entitlement.guard.denied", + feature: "reports.export", + status: "unknown", + problemCode: "ENTITLEMENT_PROVIDER_UNAVAILABLE", + }), + ]); + }); + + it("should not let audit sink failures override allowed guard decisions", async () => { + const recordEventSpy = vi.spyOn(telemetry, "recordEvent").mockImplementation(() => {}); + Container.set(EntitlementAuditSink.token, new FailingEntitlementAuditSink()); + + class TestController { + @RequireEntitlement({ feature: "reports.export" }) + testMethod() {} + } + + const context = createContext({ + target: TestController, + request: { + tenantId: "tenant-123", + user: createUser("tenant-123"), + }, + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(recordEventSpy).toHaveBeenCalledWith( + "entitlement.guard.audit_failed", + expect.objectContaining({ + "entitlement.feature": "reports.export", + "entitlement.status": "allowed", + "tenant.id": "tenant-123", + "route.id": "TestController.testMethod", + "audit.event": "entitlement.guard.allowed", + }), + ); + }); + + it("should not let audit sink failures override denied guard decisions", async () => { + const recordEventSpy = vi.spyOn(telemetry, "recordEvent").mockImplementation(() => {}); + Container.set(EntitlementAuditSink.token, new FailingEntitlementAuditSink()); + + class TestController { + @RequireEntitlement({ feature: "reports.export" }) + testMethod() {} + } + + mockManager.checkResult = { + granted: false, + status: "denied", + featureKey: "reports.export", + type: "boolean", + reason: "not_entitled", + }; + + const context = createContext({ + target: TestController, + request: { + tenantId: "tenant-123", + user: createUser("tenant-123"), + }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow(EntitlementDeniedProblem); + expect(recordEventSpy).toHaveBeenCalledWith( + "entitlement.guard.audit_failed", + expect.objectContaining({ + "entitlement.feature": "reports.export", + "entitlement.status": "denied", + "tenant.id": "tenant-123", + "route.id": "TestController.testMethod", + "audit.event": "entitlement.guard.denied", + }), + ); + }); }); diff --git a/packages/entitlements-core/src/tests/EntitlementIntegration.spec.ts b/packages/entitlements-core/src/tests/EntitlementIntegration.spec.ts index 855c49cff..046621289 100644 --- a/packages/entitlements-core/src/tests/EntitlementIntegration.spec.ts +++ b/packages/entitlements-core/src/tests/EntitlementIntegration.spec.ts @@ -174,6 +174,7 @@ describe("EntitlementIntegration", () => { expect(result).toEqual({ granted: true, + status: "allowed", featureKey: "api_calls", type: "metered", quota: 100, @@ -201,6 +202,7 @@ describe("EntitlementIntegration", () => { expect(result).toEqual({ granted: false, + status: "denied", featureKey: "api_calls", type: "metered", quota: 100, @@ -232,6 +234,7 @@ describe("EntitlementIntegration", () => { expect(result).toEqual({ granted: true, + status: "soft-limit", featureKey: "api_calls", type: "metered", quota: 100, @@ -262,6 +265,7 @@ describe("EntitlementIntegration", () => { expect(result).toEqual({ granted: true, + status: "overage-allowed", featureKey: "api_calls", type: "metered", quota: 100, @@ -299,6 +303,7 @@ describe("EntitlementIntegration", () => { expect(result).toEqual({ granted: true, + status: "allowed", featureKey: "storage", type: "metered", quota: 500, @@ -327,6 +332,7 @@ describe("EntitlementIntegration", () => { expect(result).toEqual({ granted: false, + status: "denied", featureKey: "events", type: "metered", reason: "no_quota_defined", diff --git a/packages/entitlements-core/src/tests/EntitlementManager.spec.ts b/packages/entitlements-core/src/tests/EntitlementManager.spec.ts index 1f518e013..d8acd30e0 100644 --- a/packages/entitlements-core/src/tests/EntitlementManager.spec.ts +++ b/packages/entitlements-core/src/tests/EntitlementManager.spec.ts @@ -107,6 +107,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: true, + status: "allowed", featureKey: "advanced_support", type: "boolean", planId: "pro", @@ -120,6 +121,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: true, + status: "allowed", featureKey: "team_members", type: "static", value: 10, @@ -148,6 +150,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: true, + status: "allowed", featureKey: "api_calls", type: "metered", quota: 50, @@ -173,6 +176,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: true, + status: "allowed", featureKey: "storage", type: "metered", quota: 250, @@ -194,6 +198,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: false, + status: "denied", featureKey: "advanced_support", type: "boolean", reason: "no_subscription", @@ -207,6 +212,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: false, + status: "denied", featureKey: "audit_logs", type: "boolean", reason: "not_entitled", @@ -223,6 +229,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: false, + status: "denied", featureKey: "events", type: "metered", reason: "no_quota_defined", @@ -245,6 +252,7 @@ describe("EntitlementManager", () => { expect(result).toEqual({ granted: false, + status: "denied", featureKey: "reports", type: "metered", quota: 3, @@ -280,6 +288,7 @@ describe("EntitlementManager", () => { const result = await manager.check("tenant-1", "reports"); expect(result.granted).toBe(true); + expect(result.status).toBe("soft-limit"); expect(result.overagePolicy).toBe("WARN"); expect(result.exceeded).toBe(true); expect(eventPublisher.publish).toHaveBeenCalledWith( @@ -301,6 +310,7 @@ describe("EntitlementManager", () => { const result = await manager.check("tenant-1", "reports"); expect(result.granted).toBe(true); + expect(result.status).toBe("overage-allowed"); expect(result.overagePolicy).toBe("ALLOW_WITH_OVERAGE"); expect(eventPublisher.publish).toHaveBeenNthCalledWith( 1, diff --git a/packages/entitlements-core/src/tests/EntitlementRequirement.spec.ts b/packages/entitlements-core/src/tests/EntitlementRequirement.spec.ts new file mode 100644 index 000000000..8a23b912f --- /dev/null +++ b/packages/entitlements-core/src/tests/EntitlementRequirement.spec.ts @@ -0,0 +1,37 @@ +import "reflect-metadata"; +import { describe, expect, it } from "vitest"; +import { + ENTITLEMENT_REQUIREMENTS_KEY, + getEntitlementRequirements, +} from "../libs/EntitlementRequirement"; + +describe("EntitlementRequirement", () => { + it("should ignore malformed resource metadata instead of throwing", () => { + class TestController { + testMethod() {} + } + + Reflect.defineMetadata( + ENTITLEMENT_REQUIREMENTS_KEY, + [ + { + feature: "reports.export", + resource: { type: 42 }, + }, + { + feature: "reports.read", + resource: { type: "report", idParam: "id" }, + }, + ], + TestController, + "testMethod", + ); + + expect(getEntitlementRequirements(TestController, "testMethod")).toEqual([ + { + feature: "reports.read", + resource: { type: "report", idParam: "id" }, + }, + ]); + }); +}); diff --git a/packages/framework-routes/src/__tests__/compiler.spec.ts b/packages/framework-routes/src/__tests__/compiler.spec.ts index f4b3b1346..ed4d54038 100644 --- a/packages/framework-routes/src/__tests__/compiler.spec.ts +++ b/packages/framework-routes/src/__tests__/compiler.spec.ts @@ -123,6 +123,7 @@ describe("compiler", () => { outputSchema: null, domain: null, access: { guards: [], roles: [] }, + entitlements: [], }, { routeId: "SampleController.createUser", @@ -138,6 +139,7 @@ describe("compiler", () => { outputSchema: null, domain: null, access: { guards: [], roles: [] }, + entitlements: [], }, ], diagnostics: [], diff --git a/packages/openapi-spec/src/libs/emitOpenAPI.ts b/packages/openapi-spec/src/libs/emitOpenAPI.ts index e9dee253a..37f957fce 100644 --- a/packages/openapi-spec/src/libs/emitOpenAPI.ts +++ b/packages/openapi-spec/src/libs/emitOpenAPI.ts @@ -11,6 +11,7 @@ import { buildContractGraph, type ContractGraph, type ContractGraphConsumerRouteField, + type ContractEntitlementRequirement, type ContractGraphObservedConsumerRoute, type ContractGraphRoute, getContractPathParams, @@ -46,6 +47,11 @@ type DeclaredProblemOpenAPI = { readonly description?: string; readonly type?: string; }; +type DeclaredEntitlementOpenAPI = { + readonly feature: string; + readonly description?: string; + readonly resource?: ContractEntitlementRequirement["resource"]; +}; export type ProblemResponseConfig = { readonly status: number | `${number}` | "default"; @@ -224,10 +230,25 @@ function toRouteConfig( summary: route.routeId, tags: [route.domain ?? route.controllerName], responses: toResponseConfig(route, defaultResponses, problemDetailsRef), + ...(route.entitlements.length > 0 + ? { "x-croco-entitlements": toOpenAPIEntitlements(route.entitlements) } + : {}), ...(route.params.length > 0 || route.inputSchema ? { request: toRequestConfig(route) } : {}), }; } +function toOpenAPIEntitlements( + entitlements: readonly ContractEntitlementRequirement[], +): readonly DeclaredEntitlementOpenAPI[] { + return entitlements + .map((entitlement) => ({ + feature: entitlement.feature, + ...(entitlement.description ? { description: entitlement.description } : {}), + ...(entitlement.resource ? { resource: entitlement.resource } : {}), + })) + .sort(compareDeclaredEntitlements); +} + function toResponseConfig( route: ContractGraphRoute, defaultResponses: RouteResponses, @@ -307,6 +328,13 @@ function compareDeclaredProblems( return left.code.localeCompare(right.code) || left.status - right.status; } +function compareDeclaredEntitlements( + left: DeclaredEntitlementOpenAPI, + right: DeclaredEntitlementOpenAPI, +): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)); +} + function toTags(routes: ContractGraphRoute[]): { name: string; description: string }[] { const tagNames = new Set(routes.map((route) => route.domain ?? route.controllerName)); @@ -453,6 +481,7 @@ function collectOpenAPICoveredRoutes( "request.headers": hasOpenAPIParameters(operation, "header") ? "present" : "absent", response: hasOpenAPIJsonSuccessResponse(operation) ? "present" : "absent", problems: openAPIProblemsFingerprint(operation), + entitlements: openAPIEntitlementsFingerprint(operation), }, }); } @@ -479,6 +508,7 @@ function collectOpenAPIConsumedFields( "request.headers", "response", "problems", + "entitlements", ]; } @@ -527,6 +557,14 @@ function openAPIProblemsFingerprint(operation: Record): string return JSON.stringify(problems.sort(compareOpenAPIProblemFingerprints)); } +function openAPIEntitlementsFingerprint(operation: Record): string { + const entitlements = Array.isArray(operation["x-croco-entitlements"]) + ? operation["x-croco-entitlements"].filter(isRecord).map(toOpenAPIEntitlementFingerprint) + : []; + + return JSON.stringify(entitlements.sort(compareOpenAPIEntitlementFingerprints)); +} + function toOpenAPIProblemFingerprint(problem: Record): DeclaredProblemOpenAPI { return { code: String(problem.code), @@ -547,3 +585,34 @@ function compareOpenAPIProblemFingerprints( left.status - right.status ); } + +function toOpenAPIEntitlementFingerprint( + entitlement: Record, +): DeclaredEntitlementOpenAPI { + return { + feature: String(entitlement.feature), + ...(typeof entitlement.description === "string" + ? { description: entitlement.description } + : {}), + ...(isRecord(entitlement.resource) + ? { resource: toOpenAPIEntitlementResourceFingerprint(entitlement.resource) } + : {}), + }; +} + +function toOpenAPIEntitlementResourceFingerprint( + resource: Record, +): NonNullable { + return { + type: String(resource.type), + ...(typeof resource.id === "string" ? { id: resource.id } : {}), + ...(typeof resource.idParam === "string" ? { idParam: resource.idParam } : {}), + }; +} + +function compareOpenAPIEntitlementFingerprints( + left: DeclaredEntitlementOpenAPI, + right: DeclaredEntitlementOpenAPI, +): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)); +} diff --git a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts index 35eac4d91..23a3dd565 100644 --- a/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts +++ b/packages/openapi-spec/src/tests/emitOpenAPI.spec.ts @@ -111,6 +111,52 @@ describe("emitOpenAPI", () => { }); }); + it("should emit entitlement requirements as OpenAPI operation extensions", () => { + const entitlementRequirementsKey = Symbol.for("croco:entitlements:requirements"); + + function RequiresEntitlement(): MethodDecorator { + return (target, propertyKey) => { + Reflect.defineMetadata( + entitlementRequirementsKey, + [ + { + feature: "reports.export", + description: "Export report data.", + resource: { type: "report", idParam: "id" }, + }, + ], + target.constructor, + propertyKey, + ); + }; + } + + @Controller("/reports") + class ReportsController { + @Get("/:id") + @RequiresEntitlement() + exportReport(@Param("id") _id: string): void {} + } + + const graph = buildContractGraph([ReportsController]); + const spec = emitOpenAPIFromContractGraph(graph); + + expect(graph.routes[0]?.entitlements).toEqual([ + { + feature: "reports.export", + description: "Export report data.", + resource: { type: "report", idParam: "id" }, + }, + ]); + expect(spec.paths?.["/reports/{id}"]?.get?.["x-croco-entitlements"]).toEqual([ + { + feature: "reports.export", + description: "Export report data.", + resource: { type: "report", idParam: "id" }, + }, + ]); + }); + it("should normalize catch-all path parameters from the canonical contract graph", () => { @Controller("/assets") class AssetsController { diff --git a/packages/protocols-core/src/index.ts b/packages/protocols-core/src/index.ts index c92905ed8..530a2b0d2 100644 --- a/packages/protocols-core/src/index.ts +++ b/packages/protocols-core/src/index.ts @@ -3,6 +3,8 @@ export type { ContractDiagnostic, ContractDiagnosticSeverity, ContractDiagnosticTarget, + ContractEntitlementRequirement, + ContractEntitlementResourceRequirement, ContractGraph, ContractGraphController, ContractGraphRoute, @@ -55,6 +57,7 @@ export { diffContractGraphSnapshots } from "./libs/ContractGraphDiff"; export type { ContractGraphSnapshot, ContractGraphSnapshotController, + ContractGraphSnapshotEntitlementRequirement, ContractGraphSnapshotParam, ContractGraphSnapshotProblemResponse, ContractGraphSnapshotRoute, @@ -75,4 +78,8 @@ export { } from "./libs/controllerDiscovery"; export { extractRouteIR } from "./libs/extractRouteIR"; export type { ParamIR, ProblemResponseIR, RouteIR } from "./libs/RouteIR"; -export type { Constructor } from "./libs/sharedTypes"; +export type { + Constructor, + EntitlementRequirementMetadata, + EntitlementResourceRequirementMetadata, +} from "./libs/sharedTypes"; diff --git a/packages/protocols-core/src/libs/ContractGraph.ts b/packages/protocols-core/src/libs/ContractGraph.ts index 18d7af4bc..e18992d75 100644 --- a/packages/protocols-core/src/libs/ContractGraph.ts +++ b/packages/protocols-core/src/libs/ContractGraph.ts @@ -6,6 +6,9 @@ import type { RouteIR } from "./RouteIR"; import { type Constructor, type ControllerMetadata, + ENTITLEMENT_REQUIRED_KEY, + ENTITLEMENT_REQUIREMENTS_KEY, + type EntitlementRequirementMetadata, REST_CONTROLLER_KEY, REST_GUARDS_KEY, REST_ROLES_KEY, @@ -55,6 +58,18 @@ export type ContractAccessMetadata = { readonly roles: readonly string[]; }; +export type ContractEntitlementResourceRequirement = { + readonly type: string; + readonly id?: string; + readonly idParam?: string; +}; + +export type ContractEntitlementRequirement = { + readonly feature: string; + readonly description?: string; + readonly resource?: ContractEntitlementResourceRequirement; +}; + export type ContractPathParam = { readonly token: string; readonly name: string; @@ -65,6 +80,7 @@ export type ContractGraphRoute = RouteIR & { readonly operationId: string; readonly controllerPath: string; readonly access: ContractAccessMetadata; + readonly entitlements: readonly ContractEntitlementRequirement[]; }; export type ContractGraph = { @@ -210,6 +226,12 @@ function toContractGraphRoute( ), ], }, + entitlements: [ + ...getEntitlementRequirements(controllerCtor), + ...getEntitlementRequirements(controllerCtor.prototype), + ...getEntitlementRequirements(controllerCtor, route.methodName), + ...getEntitlementRequirements(controllerCtor.prototype, route.methodName), + ], }; } @@ -635,3 +657,88 @@ function getMetadataStrings(value: unknown): string[] { return value.filter((item): item is string => typeof item === "string"); } + +function getEntitlementRequirements( + target: object, + propertyKey?: string | symbol, +): ContractEntitlementRequirement[] { + const current = + propertyKey === undefined + ? Reflect.getMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target) + : Reflect.getMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target, propertyKey); + const requirements = normalizeEntitlementRequirements(current); + + if (requirements.length > 0) { + return requirements; + } + + const legacy = + propertyKey === undefined + ? Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, target) + : Reflect.getMetadata(ENTITLEMENT_REQUIRED_KEY, target, propertyKey); + + return typeof legacy === "string" && legacy.length > 0 ? [{ feature: legacy }] : []; +} + +function normalizeEntitlementRequirements(value: unknown): ContractEntitlementRequirement[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter(isEntitlementRequirementMetadata).map((requirement) => ({ + feature: requirement.feature, + ...(requirement.description ? { description: requirement.description } : {}), + ...(requirement.resource + ? { resource: normalizeEntitlementResource(requirement.resource) } + : {}), + })); +} + +function isEntitlementRequirementMetadata(value: unknown): value is EntitlementRequirementMetadata { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + readonly feature?: unknown; + readonly resource?: unknown; + }; + + return ( + typeof candidate.feature === "string" && + candidate.feature.length > 0 && + (candidate.resource === undefined || isEntitlementResourceMetadata(candidate.resource)) + ); +} + +function normalizeEntitlementResource( + resource: NonNullable, +): ContractEntitlementResourceRequirement { + return { + type: resource.type, + ...(resource.id ? { id: resource.id } : {}), + ...(resource.idParam ? { idParam: resource.idParam } : {}), + }; +} + +function isEntitlementResourceMetadata( + value: unknown, +): value is NonNullable { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as { + readonly type?: unknown; + readonly id?: unknown; + readonly idParam?: unknown; + }; + + return ( + typeof candidate.type === "string" && + candidate.type.length > 0 && + (candidate.id === undefined || (typeof candidate.id === "string" && candidate.id.length > 0)) && + (candidate.idParam === undefined || + (typeof candidate.idParam === "string" && candidate.idParam.length > 0)) + ); +} diff --git a/packages/protocols-core/src/libs/ContractGraphConsumerCoverage.ts b/packages/protocols-core/src/libs/ContractGraphConsumerCoverage.ts index b3eb4d284..bc179ea82 100644 --- a/packages/protocols-core/src/libs/ContractGraphConsumerCoverage.ts +++ b/packages/protocols-core/src/libs/ContractGraphConsumerCoverage.ts @@ -18,6 +18,7 @@ export type ContractGraphConsumerRouteField = | "request.headers" | "response" | "problems" + | "entitlements" | "access.guards" | "access.roles"; @@ -102,6 +103,7 @@ export const DEFAULT_CONTRACT_GRAPH_CONSUMERS = [ "request.headers", "response", "problems", + "entitlements", ], unsupportedRouteFields: ["access.guards", "access.roles"], }, @@ -121,7 +123,7 @@ export const DEFAULT_CONTRACT_GRAPH_CONSUMERS = [ "response", "problems", ], - unsupportedRouteFields: ["access.guards", "access.roles"], + unsupportedRouteFields: ["access.guards", "access.roles", "entitlements"], }, ] as const satisfies readonly ContractGraphConsumerDefinition[]; @@ -354,6 +356,8 @@ function createRouteFieldFingerprint( return schemaPresenceFingerprint(route.outputSchema); case "problems": return problemResponsesFingerprint(route.problemResponses ?? []); + case "entitlements": + return entitlementRequirementsFingerprint(route.entitlements); case "access.guards": return accessGuardsFingerprint(route.access.guards); case "access.roles": @@ -389,6 +393,20 @@ function problemResponsesFingerprint( ); } +function entitlementRequirementsFingerprint( + entitlements: ContractGraphRoute["entitlements"], +): string { + return JSON.stringify( + entitlements + .map((entitlement) => ({ + feature: entitlement.feature, + ...(entitlement.description ? { description: entitlement.description } : {}), + ...(entitlement.resource ? { resource: entitlement.resource } : {}), + })) + .sort(compareEntitlementFingerprints), + ); +} + function accessGuardsFingerprint(guards: ContractGraphRoute["access"]["guards"]): string { return JSON.stringify([...guards].sort((left, right) => compareStrings(left.id, right.id))); } @@ -416,6 +434,17 @@ function compareProblemFingerprints( ); } +function compareEntitlementFingerprints( + left: { + readonly feature: string; + }, + right: { + readonly feature: string; + }, +): number { + return compareStrings(JSON.stringify(left), JSON.stringify(right)); +} + function hasRouteField(route: ContractGraphRoute, field: ContractGraphConsumerRouteField): boolean { switch (field) { case "routeId": @@ -438,6 +467,8 @@ function hasRouteField(route: ContractGraphRoute, field: ContractGraphConsumerRo return route.outputSchema !== undefined; case "problems": return true; + case "entitlements": + return route.entitlements !== undefined; case "access.guards": return route.access.guards !== undefined; case "access.roles": @@ -454,6 +485,8 @@ function hasUnsupportedRouteFieldValue( return route.access.guards.length > 0; case "access.roles": return route.access.roles.length > 0; + case "entitlements": + return route.entitlements.length > 0; case "routeId": case "operationId": case "httpMethod": diff --git a/packages/protocols-core/src/libs/ContractGraphDiff.ts b/packages/protocols-core/src/libs/ContractGraphDiff.ts index 9d5195b4e..325ef012b 100644 --- a/packages/protocols-core/src/libs/ContractGraphDiff.ts +++ b/packages/protocols-core/src/libs/ContractGraphDiff.ts @@ -160,6 +160,7 @@ function diffExistingRoute( changes.push(...diffRequestSchemas(baseline, current)); changes.push(...diffProblemResponses(baseline, current)); + changes.push(...diffEntitlementRequirements(baseline, current)); if (!isResponseSchemaCompatible(baseline.response, current.response)) { changes.push({ @@ -175,6 +176,57 @@ function diffExistingRoute( return changes; } +function diffEntitlementRequirements( + baseline: ContractGraphSnapshotRoute, + current: ContractGraphSnapshotRoute, +): ContractGraphDiffChange[] { + const changes: ContractGraphDiffChange[] = []; + const baselineEntitlements = new Map( + baseline.entitlements.map((entitlement) => [entitlementFingerprint(entitlement), entitlement]), + ); + const currentEntitlements = new Map( + current.entitlements.map((entitlement) => [entitlementFingerprint(entitlement), entitlement]), + ); + + for (const [fingerprint, entitlement] of currentEntitlements) { + if (!baselineEntitlements.has(fingerprint)) { + changes.push({ + code: "contract-entitlement-requirement-added", + severity: "breaking", + routeId: baseline.routeId, + operationId: baseline.operationId, + fieldPath: entitlement.feature, + message: `Route '${baseline.routeId}' added entitlement requirement '${entitlement.feature}', adding a pre-handler denial path.`, + }); + } + } + + for (const [fingerprint, entitlement] of baselineEntitlements) { + if (!currentEntitlements.has(fingerprint)) { + changes.push({ + code: "contract-entitlement-requirement-removed", + severity: "non-breaking", + routeId: baseline.routeId, + operationId: baseline.operationId, + fieldPath: entitlement.feature, + message: `Route '${baseline.routeId}' removed entitlement requirement '${entitlement.feature}'.`, + }); + } + } + + return changes; +} + +function entitlementFingerprint( + entitlement: ContractGraphSnapshotRoute["entitlements"][number], +): string { + return JSON.stringify({ + feature: entitlement.feature, + description: entitlement.description, + resource: entitlement.resource, + }); +} + function diffProblemResponses( baseline: ContractGraphSnapshotRoute, current: ContractGraphSnapshotRoute, diff --git a/packages/protocols-core/src/libs/ContractGraphSnapshot.ts b/packages/protocols-core/src/libs/ContractGraphSnapshot.ts index f4b7fbc08..66b5737fd 100644 --- a/packages/protocols-core/src/libs/ContractGraphSnapshot.ts +++ b/packages/protocols-core/src/libs/ContractGraphSnapshot.ts @@ -2,6 +2,7 @@ import type { z } from "zod"; import type { ContractAccessMetadata, ContractDiagnostic, + ContractEntitlementRequirement, ContractGraph, ContractGraphRoute, ContractGraphVersion, @@ -55,6 +56,8 @@ export type ContractGraphSnapshotProblemResponse = { readonly type?: string; }; +export type ContractGraphSnapshotEntitlementRequirement = ContractEntitlementRequirement; + export type ContractGraphSnapshotRoute = { readonly routeId: string; readonly operationId: string; @@ -65,6 +68,7 @@ export type ContractGraphSnapshotRoute = { readonly controllerPath: string; readonly domain: string | null; readonly access: ContractAccessMetadata; + readonly entitlements: readonly ContractGraphSnapshotEntitlementRequirement[]; readonly params: readonly ContractGraphSnapshotParam[]; readonly request: { readonly body: ContractSchemaSnapshot | null; @@ -155,6 +159,7 @@ function toSnapshotRoute(route: ContractGraphRoute): ContractGraphSnapshotRoute guards: sortGuards(route.access.guards), roles: [...route.access.roles].sort(compareStrings), }, + entitlements: sortEntitlements(route.entitlements), params: route.params.map((param) => ({ kind: param.kind, name: param.name, @@ -435,6 +440,27 @@ function sortGuards( return [...guards].sort((left, right) => compareStrings(left.id, right.id)); } +function sortEntitlements( + entitlements: readonly ContractEntitlementRequirement[], +): readonly ContractEntitlementRequirement[] { + return [...entitlements].sort(compareEntitlements); +} + +function compareEntitlements( + left: ContractEntitlementRequirement, + right: ContractEntitlementRequirement, +): number { + return compareStrings(entitlementFingerprint(left), entitlementFingerprint(right)); +} + +function entitlementFingerprint(entitlement: ContractEntitlementRequirement): string { + return JSON.stringify({ + feature: entitlement.feature, + description: entitlement.description, + resource: entitlement.resource, + }); +} + function compareControllers( left: ContractGraphSnapshotController, right: ContractGraphSnapshotController, diff --git a/packages/protocols-core/src/libs/sharedTypes.ts b/packages/protocols-core/src/libs/sharedTypes.ts index e69c3cd05..916a1ee99 100644 --- a/packages/protocols-core/src/libs/sharedTypes.ts +++ b/packages/protocols-core/src/libs/sharedTypes.ts @@ -11,6 +11,8 @@ export const REST_PARAMS_KEY = Symbol.for("croco:rest:params"); export const REST_GUARDS_KEY = Symbol.for("croco:rest:guards"); export const REST_ROLES_KEY = Symbol.for("croco:rest:roles"); export const PROBLEM_RESPONSES_KEY = Symbol.for("croco:rest:problemResponses"); +export const ENTITLEMENT_REQUIRED_KEY = "entitlement:required"; +export const ENTITLEMENT_REQUIREMENTS_KEY = Symbol.for("croco:entitlements:requirements"); export enum ParamType { PARAM = "param", @@ -43,6 +45,18 @@ export type ProblemResponseMetadata = { readonly type?: string; }; +export type EntitlementResourceRequirementMetadata = { + readonly type: string; + readonly id?: string; + readonly idParam?: string; +}; + +export type EntitlementRequirementMetadata = { + readonly feature: string; + readonly description?: string; + readonly resource?: EntitlementResourceRequirementMetadata; +}; + export interface ParamMetadata { type: ParamType; index: number; diff --git a/packages/protocols-core/src/tests/ContractGraph.spec.ts b/packages/protocols-core/src/tests/ContractGraph.spec.ts index a01b0e385..86ae3aff4 100644 --- a/packages/protocols-core/src/tests/ContractGraph.spec.ts +++ b/packages/protocols-core/src/tests/ContractGraph.spec.ts @@ -24,6 +24,7 @@ import { type InferRouteSchemaRequest, type InferRouteSchemaResponse, } from "../libs/RouteSchema"; +import { ENTITLEMENT_REQUIRED_KEY, ENTITLEMENT_REQUIREMENTS_KEY } from "../libs/sharedTypes"; import { Body, Controller, @@ -32,6 +33,7 @@ import { Post, ProblemResponse, Query, + RequiresEntitlement, ResponseSchema, Roles, UseGuards, @@ -595,11 +597,13 @@ describe("buildContractGraph", () => { ]); expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + "contract-consumer-missing-generated-route-field", "contract-consumer-missing-generated-route-field", "contract-consumer-route-field-mismatch", "contract-consumer-route-field-mismatch", ]); expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ + expect.stringContaining("entitlements"), expect.stringContaining("operationId"), expect.stringContaining("problems"), expect.stringContaining("response"), @@ -698,6 +702,105 @@ describe("buildContractGraph", () => { ]); }); + it("should snapshot entitlement requirements as route contract artifacts", () => { + @Controller("/reports") + class ReportsController { + @Get("/:id") + @RequiresEntitlement({ + feature: "reports.export", + description: "Export report data.", + resource: { type: "report", idParam: "id" }, + }) + exportReport(@Param("id") _id: string): void {} + } + + const graph = buildContractGraph([ReportsController]); + const snapshot = createContractGraphSnapshot(graph); + const report = createContractGraphConsumerCoverage(graph); + + expect(graph.routes[0]?.entitlements).toEqual([ + { + feature: "reports.export", + description: "Export report data.", + resource: { type: "report", idParam: "id" }, + }, + ]); + expect(snapshot.routes[0]?.entitlements).toEqual([ + { + feature: "reports.export", + description: "Export report data.", + resource: { type: "report", idParam: "id" }, + }, + ]); + + @Controller("/legacy-reports") + class LegacyReportsController { + @Get("/:id") + getReport(@Param("id") _id: string): void {} + } + + Reflect.defineMetadata( + ENTITLEMENT_REQUIRED_KEY, + "reports.read", + LegacyReportsController.prototype, + "getReport", + ); + + expect(buildContractGraph([LegacyReportsController]).routes[0]?.entitlements).toEqual([ + { feature: "reports.read" }, + ]); + expect(report.consumers.find((consumer) => consumer.consumerId === "openapi")).toMatchObject({ + requiredRouteFields: expect.arrayContaining(["entitlements"]), + }); + expect(report.consumers.find((consumer) => consumer.consumerId === "rpc-client")).toMatchObject( + { + routes: [ + expect.objectContaining({ + unsupportedFields: ["entitlements"], + }), + ], + }, + ); + + @Controller("/multi-reports") + class MultiReportsController { + @Get("/") + @RequiresEntitlement({ feature: "reports.export" }) + @RequiresEntitlement({ feature: "reports.read" }) + listReports(): void {} + } + + const multiEntitlements = buildContractGraph([MultiReportsController]).routes[0]?.entitlements; + + expect(multiEntitlements).toHaveLength(2); + expect(multiEntitlements).toEqual( + expect.arrayContaining([{ feature: "reports.export" }, { feature: "reports.read" }]), + ); + + @Controller("/invalid-reports") + class InvalidReportsController { + @Get("/:id") + getReport(@Param("id") _id: string): void {} + } + + Reflect.defineMetadata( + ENTITLEMENT_REQUIREMENTS_KEY, + [ + { feature: "reports.export", resource: { type: 42 } }, + { + feature: "reports.read", + resource: { type: "report", idParam: "id" }, + }, + ], + InvalidReportsController.prototype, + "getReport", + ); + + expect(buildContractGraph([InvalidReportsController]).routes[0]?.entitlements).toEqual([ + { feature: "reports.read", resource: { type: "report", idParam: "id" } }, + ]); + }); + it("should reject duplicate declared Problem codes on a route", () => { @Controller("/users") class UsersController { @@ -1030,6 +1133,87 @@ describe("buildContractGraph", () => { ]); }); + it("should classify added entitlement requirements as breaking contract changes", () => { + const BaselineController = (() => { + @Controller("/reports") + class ReportsController { + @Get("/:id") + getReport(@Param("id") _id: string): void {} + } + + return ReportsController; + })(); + const CurrentController = (() => { + @Controller("/reports") + class ReportsController { + @Get("/:id") + @RequiresEntitlement({ + feature: "reports.export", + resource: { type: "report", idParam: "id" }, + }) + getReport(@Param("id") _id: string): void {} + } + + return ReportsController; + })(); + const baseline = createContractGraphSnapshot(buildContractGraph([BaselineController])); + const current = createContractGraphSnapshot(buildContractGraph([CurrentController])); + + const additiveDiff = diffContractGraphSnapshots(baseline, current); + const removalDiff = diffContractGraphSnapshots(current, baseline); + + expect(additiveDiff.hasBreakingChanges).toBe(true); + expect(additiveDiff.breakingChanges).toEqual([ + expect.objectContaining({ + code: "contract-entitlement-requirement-added", + fieldPath: "reports.export", + }), + ]); + expect(removalDiff.hasBreakingChanges).toBe(false); + expect(removalDiff.nonBreakingChanges).toEqual([ + expect.objectContaining({ + code: "contract-entitlement-requirement-removed", + fieldPath: "reports.export", + }), + ]); + }); + + it("should dedupe repeated entitlement requirements before diffing snapshots", () => { + const BaselineController = (() => { + @Controller("/reports") + class ReportsController { + @Get("/:id") + getReport(@Param("id") _id: string): void {} + } + + return ReportsController; + })(); + const CurrentController = (() => { + @Controller("/reports") + class ReportsController { + @Get("/:id") + @RequiresEntitlement({ feature: "reports.export" }) + @RequiresEntitlement({ feature: "reports.export" }) + getReport(@Param("id") _id: string): void {} + } + + return ReportsController; + })(); + const baseline = createContractGraphSnapshot(buildContractGraph([BaselineController])); + const current = createContractGraphSnapshot(buildContractGraph([CurrentController])); + const diff = diffContractGraphSnapshots(baseline, current); + + expect( + diff.breakingChanges.filter( + (change) => change.code === "contract-entitlement-requirement-added", + ), + ).toEqual([ + expect.objectContaining({ + fieldPath: "reports.export", + }), + ]); + }); + it("should classify Problem category or status changes as breaking", () => { const BaselineController = (() => { @Controller("/users") diff --git a/packages/protocols-core/src/tests/helpers/test-decorators.ts b/packages/protocols-core/src/tests/helpers/test-decorators.ts index f449ef45d..0cc3f189c 100644 --- a/packages/protocols-core/src/tests/helpers/test-decorators.ts +++ b/packages/protocols-core/src/tests/helpers/test-decorators.ts @@ -2,6 +2,8 @@ import "reflect-metadata"; import type { z } from "zod"; import { type ControllerMetadata, + ENTITLEMENT_REQUIREMENTS_KEY, + type EntitlementRequirementMetadata, type ParamMetadata, ParamType, PROBLEM_RESPONSES_KEY, @@ -92,6 +94,44 @@ export function Roles(...roles: string[]): ClassDecorator & MethodDecorator { }; } +export function RequiresEntitlement( + requirement: EntitlementRequirementMetadata, +): ClassDecorator & MethodDecorator { + return (target: object, propertyKey?: string | symbol) => { + if (propertyKey !== undefined) { + const metadataTarget = typeof target === "function" ? target : target.constructor; + appendEntitlementRequirement(metadataTarget, requirement, propertyKey); + return; + } + + appendEntitlementRequirement(target, requirement); + }; +} + +function appendEntitlementRequirement( + target: object, + requirement: EntitlementRequirementMetadata, + propertyKey?: string | symbol, +): void { + const existing = + propertyKey === undefined + ? Reflect.getMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target) + : Reflect.getMetadata(ENTITLEMENT_REQUIREMENTS_KEY, target, propertyKey); + const requirements = Array.isArray(existing) ? existing : []; + + if (propertyKey === undefined) { + Reflect.defineMetadata(ENTITLEMENT_REQUIREMENTS_KEY, [...requirements, requirement], target); + return; + } + + Reflect.defineMetadata( + ENTITLEMENT_REQUIREMENTS_KEY, + [...requirements, requirement], + target, + propertyKey, + ); +} + function createRouteDecorator(method: string, path: string): MethodDecorator { return (target, propertyKey) => { const ctor = target.constructor; diff --git a/packages/rpc-codegen/src/tests/codegen.spec.ts b/packages/rpc-codegen/src/tests/codegen.spec.ts index 1155452b4..e2ef7ffde 100644 --- a/packages/rpc-codegen/src/tests/codegen.spec.ts +++ b/packages/rpc-codegen/src/tests/codegen.spec.ts @@ -155,6 +155,7 @@ describe("generateClientFiles", () => { outputSchema: z.object({ id: z.string() }) as unknown as RouteIR["outputSchema"], domain: null, access: { guards: [], roles: [] }, + entitlements: [], problemResponses: [ { code: "USER_NOT_FOUND", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 195d59f89..bda55b927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -947,6 +947,9 @@ importers: '@croco/problems-core': specifier: workspace:* version: link:../problems-core + '@croco/telemetry-api': + specifier: workspace:* + version: link:../telemetry-api reflect-metadata: specifier: ^0.2.2 version: 0.2.2 diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index b49914525..278b85b5c 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -3968,6 +3968,12 @@ "relativeDir": "packages/entitlements-core", "entrypoint": "packages/entitlements-core/src/index.ts", "runtimeExports": [ + { + "name": "EntitlementAuditSink", + "exportKind": "declaration", + "source": "./libs/interfaces", + "declarationKind": "class" + }, { "name": "EntitlementEventPublisher", "exportKind": "declaration", @@ -3998,6 +4004,30 @@ "source": "./libs/interfaces", "declarationKind": "class" }, + { + "name": "appendEntitlementRequirement", + "exportKind": "named", + "source": "./libs/EntitlementRequirement", + "declarationKind": "function" + }, + { + "name": "defineEntitlementRequirement", + "exportKind": "named", + "source": "./libs/EntitlementRequirement", + "declarationKind": "function" + }, + { + "name": "ENTITLEMENT_REQUIRED_KEY", + "exportKind": "named", + "source": "./libs/EntitlementRequirement", + "declarationKind": "const" + }, + { + "name": "ENTITLEMENT_REQUIREMENTS_KEY", + "exportKind": "named", + "source": "./libs/EntitlementRequirement", + "declarationKind": "const" + }, { "name": "EntitlementDeniedEvent", "exportKind": "named", @@ -4016,12 +4046,24 @@ "source": "./libs/EntitlementGuard", "declarationKind": "class" }, + { + "name": "EntitlementInactiveSubscriptionProblem", + "exportKind": "named", + "source": "./libs/problems/EntitlementProblems", + "declarationKind": "class" + }, { "name": "EntitlementManager", "exportKind": "named", "source": "./libs/EntitlementManager", "declarationKind": "class" }, + { + "name": "EntitlementMissingPlanProblem", + "exportKind": "named", + "source": "./libs/problems/EntitlementProblems", + "declarationKind": "class" + }, { "name": "EntitlementNotFoundProblem", "exportKind": "named", @@ -4034,12 +4076,36 @@ "source": "./libs/events", "declarationKind": "class" }, + { + "name": "EntitlementProviderUnavailableProblem", + "exportKind": "named", + "source": "./libs/problems/EntitlementProblems", + "declarationKind": "class" + }, { "name": "EntitlementQuotaExceededEvent", "exportKind": "named", "source": "./libs/events", "declarationKind": "class" }, + { + "name": "EntitlementQuotaExceededProblem", + "exportKind": "named", + "source": "./libs/problems/EntitlementProblems", + "declarationKind": "class" + }, + { + "name": "EntitlementRequirementProblem", + "exportKind": "named", + "source": "./libs/problems/EntitlementProblems", + "declarationKind": "class" + }, + { + "name": "getEntitlementRequirements", + "exportKind": "named", + "source": "./libs/EntitlementRequirement", + "declarationKind": "function" + }, { "name": "InMemoryPlanEntitlementRegistry", "exportKind": "named", @@ -4066,6 +4132,36 @@ "source": "./libs/types", "declarationKind": "type" }, + { + "name": "EntitlementCheckStatus", + "exportKind": "declaration", + "source": "./libs/types", + "declarationKind": "type" + }, + { + "name": "EntitlementFailureReason", + "exportKind": "declaration", + "source": "./libs/types", + "declarationKind": "type" + }, + { + "name": "EntitlementGuardAuditEvent", + "exportKind": "declaration", + "source": "./libs/interfaces", + "declarationKind": "type" + }, + { + "name": "EntitlementGuardAuditResource", + "exportKind": "declaration", + "source": "./libs/interfaces", + "declarationKind": "type" + }, + { + "name": "EntitlementGuardAuditRoute", + "exportKind": "declaration", + "source": "./libs/interfaces", + "declarationKind": "type" + }, { "name": "EntitlementQuotaStatus", "exportKind": "declaration", @@ -4107,6 +4203,46 @@ "exportKind": "declaration", "source": "./libs/types", "declarationKind": "type" + }, + { + "name": "EntitlementGuardInput", + "exportKind": "named", + "source": "./libs/EntitlementGuard" + }, + { + "name": "EntitlementGuardResource", + "exportKind": "named", + "source": "./libs/EntitlementGuard" + }, + { + "name": "EntitlementGuardRoute", + "exportKind": "named", + "source": "./libs/EntitlementGuard" + }, + { + "name": "EntitlementGuardSubject", + "exportKind": "named", + "source": "./libs/EntitlementGuard" + }, + { + "name": "EntitlementRequirement", + "exportKind": "named", + "source": "./libs/EntitlementRequirement" + }, + { + "name": "EntitlementRequirementMetadata", + "exportKind": "named", + "source": "./libs/EntitlementRequirement" + }, + { + "name": "EntitlementResourceRequirement", + "exportKind": "named", + "source": "./libs/EntitlementRequirement" + }, + { + "name": "RouteExecutionContext", + "exportKind": "named", + "source": "./libs/EntitlementGuard" } ] }, @@ -10072,6 +10208,16 @@ "exportKind": "named", "source": "./libs/ContractGraph" }, + { + "name": "ContractEntitlementRequirement", + "exportKind": "named", + "source": "./libs/ContractGraph" + }, + { + "name": "ContractEntitlementResourceRequirement", + "exportKind": "named", + "source": "./libs/ContractGraph" + }, { "name": "ContractGraph", "exportKind": "named", @@ -10157,6 +10303,11 @@ "exportKind": "named", "source": "./libs/ContractGraphSnapshot" }, + { + "name": "ContractGraphSnapshotEntitlementRequirement", + "exportKind": "named", + "source": "./libs/ContractGraphSnapshot" + }, { "name": "ContractGraphSnapshotParam", "exportKind": "named", @@ -10217,6 +10368,16 @@ "exportKind": "named", "source": "./libs/RouteSchema" }, + { + "name": "EntitlementRequirementMetadata", + "exportKind": "named", + "source": "./libs/sharedTypes" + }, + { + "name": "EntitlementResourceRequirementMetadata", + "exportKind": "named", + "source": "./libs/sharedTypes" + }, { "name": "InferRouteSchemaRequest", "exportKind": "named",