diff --git a/specs/002-saas-integration/checklists/requirements.md b/specs/002-saas-integration/checklists/requirements.md index 0aa0cc0..c6fef5f 100644 --- a/specs/002-saas-integration/checklists/requirements.md +++ b/specs/002-saas-integration/checklists/requirements.md @@ -74,6 +74,14 @@ - Manually verified all of spec.md's User Story 1 acceptance scenarios end-to-end against a live server + Postgres + Redis (via temporary Docker containers), beyond what the automated tests cover: valid in-scope acceptance, indistinguishable invalid-credential/unregistered-product - rejection, unknown-field rejection, out-of-scope rejection, and replay rejection. User Story 2 - (admin onboarding/rotation/revocation/audit-trail) and User Story 3 (rate limiting) are not yet - implemented — see tasks.md T021-T028, still pending. + rejection, unknown-field rejection, out-of-scope rejection, and replay rejection. +- **User Story 2 (admin onboarding/rotation/revocation/audit-trail) is now implemented and + automatically tested** (`tests/integration/product-integrations-admin.test.ts`, run against a + real Postgres/Redis, verified passing). **Known limitation carried over from the existing + codebase, not introduced by this feature**: the admin routes are gated by + `fastify.authenticate` (`src/plugins/auth.plugin.ts`), which is currently a no-op stub — it + never actually verifies a JWT or rejects an unauthenticated caller. These admin endpoints are + therefore not really access-controlled yet. Fixing this requires the `identity/auth` module + (itself unimplemented) and is out of scope for this feature — flagged here and in + `contracts/inbound-request-contract.md` so it isn't mistaken for "done." +- User Story 3 (rate limiting, T026-T028) is not yet implemented. diff --git a/specs/002-saas-integration/contracts/inbound-request-contract.md b/specs/002-saas-integration/contracts/inbound-request-contract.md index a2d4e4f..a3054c2 100644 --- a/specs/002-saas-integration/contracts/inbound-request-contract.md +++ b/specs/002-saas-integration/contracts/inbound-request-contract.md @@ -58,15 +58,26 @@ request reaches its route handler. Every attempt — pass or fail at any step ## Admin: Integration Lifecycle Endpoints -Extends the existing `catalog/products` module (`src/modules/catalog/products/`): +Extends the existing `catalog/products` module (`src/modules/catalog/products/`). Registration is +keyed by the product's *external* id (the product may not exist locally yet — registering an +integration creates it); every other operation is keyed by the `ProductIntegration`'s own id, +since that's what registration returns and what admin tooling references thereafter: -| Operation | Effect | -|---|---| -| Register integration | Creates `ProductIntegration` for a `Product` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and an initial `allowedScope` | -| Rotate credential | Moves current `credentialRef` → `previousCredentialRef`, sets `previousCredentialExpiresAt`, issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again, matches "never persist raw credential" — only a reference/hash is stored) | -| Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately | -| Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` | -| Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first | +| Route | Operation | Effect | +|---|---|---| +| `POST /admin/products/:externalProductId/integration` | Register integration | Finds-or-creates the `Product`, then creates its `ProductIntegration` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and the request body's `allowedScope` | +| `POST /admin/integrations/:integrationId/rotate` | Rotate credential | Moves current `credentialRef` → `previousCredentialRef`, sets `previousCredentialExpiresAt` (research.md's rotation transition window), issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again — matches "never persist raw credential," see research.md "Credential storage") | +| `POST /admin/integrations/:integrationId/revoke` | Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately | +| `PATCH /admin/integrations/:integrationId/status` | Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` | +| `GET /admin/integrations/:integrationId/audit-trail` | Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first | + +All five require an admin-authenticated caller via the existing human/admin JWT plugin +(`fastify.authenticate`, `src/plugins/auth.plugin.ts`) — a separate concern from the +product-integration signed-token auth this contract otherwise describes. **Known limitation**: +`auth.plugin.ts`'s `authenticate` decorator is currently a stub that performs no real JWT +verification (it exists as scaffolding — see `src/modules/identity/auth`, itself unimplemented). +These admin endpoints are therefore not actually access-controlled yet; real JWT verification is +a separate, pre-existing gap this feature surfaces but does not fix. All five are admin-authenticated via the existing human/admin JWT `auth.plugin.ts` — a separate concern from the product-integration signed-token auth this contract otherwise describes. diff --git a/specs/002-saas-integration/tasks.md b/specs/002-saas-integration/tasks.md index e7b8b15..8766d42 100644 --- a/specs/002-saas-integration/tasks.md +++ b/specs/002-saas-integration/tasks.md @@ -141,28 +141,28 @@ immediate, audit trail is retrievable). ### Tests for User Story 2 -- [ ] T021 [P] [US2] Integration tests for register/rotate/revoke/get-audit-trail endpoints in +- [X] T021 [P] [US2] Integration tests for register/rotate/revoke/get-audit-trail endpoints in `tests/integration/product-integrations-admin.test.ts`, covering Quickstart Scenarios 5-7 ### Implementation for User Story 2 -- [ ] T022 [US2] Add `ProductIntegrationsService` methods (`register`, `rotate`, `revoke`, +- [X] T022 [US2] Add `ProductIntegrationsService` methods (`register`, `rotate`, `revoke`, `updateStatus`, `getAuditTrail`) in `src/modules/catalog/products/service/product-integrations.service.ts` — `register`/ `rotate` generate a new secret, encrypt it (T008) before persisting, and return the plaintext secret in the response exactly once (depends on T014) -- [ ] T023 [US2] Add `ProductIntegrationsController` with admin-authenticated handlers +- [X] T023 [US2] Add `ProductIntegrationsController` with admin-authenticated handlers (register/rotate/revoke/updateStatus/getAuditTrail) in `src/modules/catalog/products/controller/product-integrations.controller.ts`, gated by the existing `fastify.authenticate` (human/admin JWT, `auth.plugin.ts`) — not the product-integration plugin from Phase 3 (depends on T022) -- [ ] T024 [US2] Add routes (`POST /admin/products/:id/integration`, `POST +- [X] T024 [US2] Add routes (`POST /admin/products/:id/integration`, `POST /admin/products/:id/integration/rotate`, `POST /admin/products/:id/integration/revoke`, `PATCH /admin/products/:id/integration/status`, `GET /admin/products/:id/integration/audit-trail`) in `src/modules/catalog/products/routes/product-integrations.routes.ts`, registered from `src/modules/catalog/products/routes/index.ts` (depends on T023) -- [ ] T025 [US2] Run Quickstart Scenarios 5-7 locally and confirm all three pass +- [X] T025 [US2] Run Quickstart Scenarios 5-7 locally and confirm all three pass **Checkpoint**: Both Stories 1 and 2 work together — an admin can onboard an integration and User Story 1's plugin correctly validates against whatever the admin configured. diff --git a/src/api/routes.ts b/src/api/routes.ts index bf5c84a..c4c9e4d 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1,12 +1,17 @@ import { FastifyInstance } from 'fastify'; import { healthRoutes } from './health.routes'; import { metricsRoutes } from './metrics.routes'; -import { productsRoutes, inboundRequestRoutes } from '@/modules/catalog/products'; +import { + productsRoutes, + inboundRequestRoutes, + productIntegrationsAdminRoutes, +} from '@/modules/catalog/products'; export async function registerGlobalRoutes(app: FastifyInstance): Promise { await app.register(healthRoutes); await app.register(metricsRoutes); await app.register(productsRoutes); await app.register(inboundRequestRoutes); + await app.register(productIntegrationsAdminRoutes); // Further domain module routes will be registered here as feature modules are wired up } diff --git a/src/common/constants/integration.constants.ts b/src/common/constants/integration.constants.ts index 07baa1f..fd5656a 100644 --- a/src/common/constants/integration.constants.ts +++ b/src/common/constants/integration.constants.ts @@ -7,6 +7,10 @@ export const INTEGRATION_ERROR_CODES = { export const INTEGRATION_AUDIT_ACTIONS = { AUTH_SUCCESS: 'integration.auth.success', AUTH_FAILURE: 'integration.auth.failure', + REGISTERED: 'integration.registered', + ROTATED: 'integration.rotated', + REVOKED: 'integration.revoked', + STATUS_UPDATED: 'integration.status_updated', } as const; export const INTEGRATION_AUDIT_FAILURE_REASONS = { @@ -22,3 +26,8 @@ export const INTEGRATION_TOKEN_CONFIG = { DEFAULT_TTL_SECONDS: 60, CLOCK_SKEW_TOLERANCE_SECONDS: 5, } as const; + +// Default credential-rotation transition window — REQUIRES BUSINESS CONFIRMATION per +// docs/10-implementation-roadmap.md if a different value is ever needed; not exposed as +// admin-configurable yet (spec.md Assumptions defers exact values to planning). +export const INTEGRATION_ROTATION_TRANSITION_MINUTES = 60; diff --git a/src/modules/catalog/products/controller/index.ts b/src/modules/catalog/products/controller/index.ts index 575ded0..ed6e6cf 100644 --- a/src/modules/catalog/products/controller/index.ts +++ b/src/modules/catalog/products/controller/index.ts @@ -1 +1,2 @@ export * from './products.controller'; +export * from './product-integrations.controller'; diff --git a/src/modules/catalog/products/controller/product-integrations.controller.ts b/src/modules/catalog/products/controller/product-integrations.controller.ts new file mode 100644 index 0000000..37fd649 --- /dev/null +++ b/src/modules/catalog/products/controller/product-integrations.controller.ts @@ -0,0 +1,83 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { productIntegrationsService, ProductIntegrationsService } from '../service'; +import { registerIntegrationSchema, updateIntegrationStatusSchema } from '../schema'; + +function actorFrom(request: FastifyRequest): string { + return request.reqContext?.actorId ?? 'admin'; +} + +export class ProductIntegrationsController { + constructor(private readonly service: ProductIntegrationsService = productIntegrationsService) {} + + async register(request: FastifyRequest, reply: FastifyReply) { + const { externalProductId } = request.params as { externalProductId: string }; + const body = registerIntegrationSchema.omit({ externalProductId: true }).parse(request.body); + + const result = await this.service.register(actorFrom(request), { + externalProductId, + ...body, + }); + + return reply.status(201).send({ + success: true, + data: { + productId: result.product.id, + externalProductId: result.product.externalProductId, + integrationId: result.integration.id, + credentialSecret: result.credentialSecret, + }, + meta: null, + }); + } + + async rotate(request: FastifyRequest, reply: FastifyReply) { + const { integrationId } = request.params as { integrationId: string }; + const result = await this.service.rotate(actorFrom(request), integrationId); + + return reply.status(200).send({ + success: true, + data: { + integrationId: result.integration.id, + credentialSecret: result.credentialSecret, + previousCredentialExpiresAt: result.integration.previousCredentialExpiresAt, + }, + meta: null, + }); + } + + async revoke(request: FastifyRequest, reply: FastifyReply) { + const { integrationId } = request.params as { integrationId: string }; + const integration = await this.service.revoke(actorFrom(request), integrationId); + + return reply.status(200).send({ + success: true, + data: { integrationId: integration.id, revokedAt: integration.revokedAt }, + meta: null, + }); + } + + async updateStatus(request: FastifyRequest, reply: FastifyReply) { + const { integrationId } = request.params as { integrationId: string }; + const { status } = updateIntegrationStatusSchema.parse(request.body); + const integration = await this.service.updateStatus(actorFrom(request), integrationId, status); + + return reply.status(200).send({ + success: true, + data: { integrationId: integration.id, status: integration.status }, + meta: null, + }); + } + + async getAuditTrail(request: FastifyRequest, reply: FastifyReply) { + const { integrationId } = request.params as { integrationId: string }; + const events = await this.service.getAuditTrail(integrationId); + + return reply.status(200).send({ + success: true, + data: events, + meta: null, + }); + } +} + +export const productIntegrationsController = new ProductIntegrationsController(); diff --git a/src/modules/catalog/products/index.ts b/src/modules/catalog/products/index.ts index 19aa05d..8f5534b 100644 --- a/src/modules/catalog/products/index.ts +++ b/src/modules/catalog/products/index.ts @@ -1,5 +1,10 @@ -export { productsRoutes, inboundRequestRoutes } from './routes'; -export { ProductsService, productsService } from './service'; +export { productsRoutes, inboundRequestRoutes, productIntegrationsAdminRoutes } from './routes'; +export { + ProductsService, + productsService, + ProductIntegrationsService, + productIntegrationsService, +} from './service'; export type { ProductDTO } from './types'; export { @@ -7,6 +12,8 @@ export { ProductIntegrationsRepository, customerReferencesRepository, CustomerReferencesRepository, + writeIntegrationAuditEvent, + findIntegrationAuditTrail, } from './repository'; export type { ProductIntegrationWithProduct } from './repository'; export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper'; diff --git a/src/modules/catalog/products/repository/index.ts b/src/modules/catalog/products/repository/index.ts index d357b87..82a705c 100644 --- a/src/modules/catalog/products/repository/index.ts +++ b/src/modules/catalog/products/repository/index.ts @@ -1,3 +1,4 @@ export * from './products.repository'; export * from './product-integrations.repository'; export * from './customer-references.repository'; +export * from './integration-audit-log.repository'; diff --git a/src/modules/catalog/products/repository/integration-audit-log.repository.ts b/src/modules/catalog/products/repository/integration-audit-log.repository.ts new file mode 100644 index 0000000..101b494 --- /dev/null +++ b/src/modules/catalog/products/repository/integration-audit-log.repository.ts @@ -0,0 +1,36 @@ +import type { Prisma, AuditLog } from '@prisma/client'; +import { prismaClient } from '@/infrastructure/database'; + +/** + * Writes one AuditLog row for a ProductIntegration-related event (auth attempts, admin + * lifecycle actions). Shared by the auth plugin and the admin service so both go through the + * repository layer rather than calling Prisma directly — docs/07 "Repository is the only layer + * that talks to Prisma." + */ +export async function writeIntegrationAuditEvent(params: { + actor: string; + actorType: string; + action: string; + entityId: string; + reason?: string; + metadata?: Prisma.InputJsonValue; +}): Promise { + await prismaClient.auditLog.create({ + data: { + actor: params.actor, + actorType: params.actorType, + action: params.action, + entityType: 'ProductIntegration', + entityId: params.entityId, + ...(params.reason !== undefined ? { reason: params.reason } : {}), + ...(params.metadata !== undefined ? { metadata: params.metadata } : {}), + }, + }); +} + +export async function findIntegrationAuditTrail(entityId: string): Promise { + return prismaClient.auditLog.findMany({ + where: { entityType: 'ProductIntegration', entityId }, + orderBy: { createdAt: 'desc' }, + }); +} diff --git a/src/modules/catalog/products/repository/products.repository.ts b/src/modules/catalog/products/repository/products.repository.ts index 4b88042..b8bc8ba 100644 --- a/src/modules/catalog/products/repository/products.repository.ts +++ b/src/modules/catalog/products/repository/products.repository.ts @@ -1,11 +1,30 @@ import { prismaClient } from '@/infrastructure/database'; +import { Product } from '@prisma/client'; export class ProductsRepository { constructor(private readonly prisma = prismaClient) {} - async findAllProducts(): Promise { + async findAllProducts(): Promise { return this.prisma.product.findMany(); } + + async findByExternalProductId(externalProductId: string): Promise { + return this.prisma.product.findUnique({ where: { externalProductId } }); + } + + async findById(id: string): Promise { + return this.prisma.product.findUnique({ where: { id } }); + } + + /** Registering an integration for a not-yet-known product creates it — this feature doesn't + * introduce a separate product-catalog CRUD surface, only what's needed for onboarding. */ + async findOrCreateByExternalProductId(externalProductId: string, name: string): Promise { + return this.prisma.product.upsert({ + where: { externalProductId }, + update: {}, + create: { externalProductId, name, supportEnabled: true, status: 'active' }, + }); + } } export const productsRepository = new ProductsRepository(); diff --git a/src/modules/catalog/products/routes/index.ts b/src/modules/catalog/products/routes/index.ts index b4e3e29..b579c1c 100644 --- a/src/modules/catalog/products/routes/index.ts +++ b/src/modules/catalog/products/routes/index.ts @@ -1,2 +1,3 @@ export * from './products.routes'; export * from './inbound-request.routes'; +export * from './product-integrations-admin.routes'; diff --git a/src/modules/catalog/products/routes/product-integrations-admin.routes.ts b/src/modules/catalog/products/routes/product-integrations-admin.routes.ts new file mode 100644 index 0000000..8f6fd8c --- /dev/null +++ b/src/modules/catalog/products/routes/product-integrations-admin.routes.ts @@ -0,0 +1,40 @@ +import { FastifyInstance } from 'fastify'; +import { productIntegrationsController } from '../controller'; + +/** + * Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail). + * Gated by the existing human/admin JWT plugin (fastify.authenticate) — see + * specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle + * Endpoints" for the known limitation that this decorator doesn't perform real verification yet. + */ +export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise { + fastify.post( + '/admin/products/:externalProductId/integration', + { preHandler: fastify.authenticate }, + (req, reply) => productIntegrationsController.register(req, reply), + ); + + fastify.post( + '/admin/integrations/:integrationId/rotate', + { preHandler: fastify.authenticate }, + (req, reply) => productIntegrationsController.rotate(req, reply), + ); + + fastify.post( + '/admin/integrations/:integrationId/revoke', + { preHandler: fastify.authenticate }, + (req, reply) => productIntegrationsController.revoke(req, reply), + ); + + fastify.patch( + '/admin/integrations/:integrationId/status', + { preHandler: fastify.authenticate }, + (req, reply) => productIntegrationsController.updateStatus(req, reply), + ); + + fastify.get( + '/admin/integrations/:integrationId/audit-trail', + { preHandler: fastify.authenticate }, + (req, reply) => productIntegrationsController.getAuditTrail(req, reply), + ); +} diff --git a/src/modules/catalog/products/schema/index.ts b/src/modules/catalog/products/schema/index.ts index 954db30..cc19d72 100644 --- a/src/modules/catalog/products/schema/index.ts +++ b/src/modules/catalog/products/schema/index.ts @@ -1,2 +1,3 @@ export * from './products.schema'; export * from './inbound-request.schema'; +export * from './product-integrations-admin.schema'; diff --git a/src/modules/catalog/products/schema/product-integrations-admin.schema.ts b/src/modules/catalog/products/schema/product-integrations-admin.schema.ts new file mode 100644 index 0000000..0b0f161 --- /dev/null +++ b/src/modules/catalog/products/schema/product-integrations-admin.schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const registerIntegrationSchema = z + .object({ + externalProductId: z.string().min(1), + name: z.string().min(1), + allowedScope: z.object({ + allowAnyTenant: z.boolean().optional(), + tenantIds: z.array(z.string()).optional(), + }), + rateLimitPerMinute: z.number().int().positive().optional(), + rateLimitPerUserPerMinute: z.number().int().positive().optional(), + }) + .strict(); + +export const updateIntegrationStatusSchema = z + .object({ + status: z.enum(['active', 'suspended']), + }) + .strict(); + +export type RegisterIntegrationBody = z.infer; +export type UpdateIntegrationStatusBody = z.infer; diff --git a/src/modules/catalog/products/service/index.ts b/src/modules/catalog/products/service/index.ts index 2859ce3..f4e740e 100644 --- a/src/modules/catalog/products/service/index.ts +++ b/src/modules/catalog/products/service/index.ts @@ -1 +1,2 @@ export * from './products.service'; +export * from './product-integrations.service'; diff --git a/src/modules/catalog/products/service/product-integrations.service.ts b/src/modules/catalog/products/service/product-integrations.service.ts new file mode 100644 index 0000000..7d5d2ef --- /dev/null +++ b/src/modules/catalog/products/service/product-integrations.service.ts @@ -0,0 +1,143 @@ +import { + productsRepository, + ProductsRepository, + productIntegrationsRepository, + ProductIntegrationsRepository, + writeIntegrationAuditEvent, + findIntegrationAuditTrail, +} from '../repository'; +import { encryptCredential, generateCredentialSecret } from '../mapper'; +import { + INTEGRATION_AUDIT_ACTIONS, + INTEGRATION_ROTATION_TRANSITION_MINUTES, +} from '@/common/constants'; +import { NotFoundError } from '@/common/errors'; +import type { AuditLog, Product, ProductIntegration } from '@prisma/client'; + +export interface RegisterIntegrationInput { + externalProductId: string; + name: string; + allowedScope: object; + rateLimitPerMinute?: number | undefined; + rateLimitPerUserPerMinute?: number | undefined; +} + +export interface IssuedCredential { + product: Product; + integration: ProductIntegration; + /** Plaintext secret — returned exactly once, never retrievable again. */ + credentialSecret: string; +} + +export class ProductIntegrationsService { + constructor( + private readonly productsRepo: ProductsRepository = productsRepository, + private readonly integrationsRepo: ProductIntegrationsRepository = productIntegrationsRepository, + ) {} + + async register(actor: string, input: RegisterIntegrationInput): Promise { + const product = await this.productsRepo.findOrCreateByExternalProductId( + input.externalProductId, + input.name, + ); + + const credentialSecret = generateCredentialSecret(); + const integration = await this.integrationsRepo.create({ + productId: product.id, + credentialRef: encryptCredential(credentialSecret), + authMechanism: 'signed_token', + allowedScope: input.allowedScope, + ...(input.rateLimitPerMinute !== undefined + ? { rateLimitPerMinute: input.rateLimitPerMinute } + : {}), + ...(input.rateLimitPerUserPerMinute !== undefined + ? { rateLimitPerUserPerMinute: input.rateLimitPerUserPerMinute } + : {}), + }); + + await writeIntegrationAuditEvent({ + actor, + actorType: 'admin', + action: INTEGRATION_AUDIT_ACTIONS.REGISTERED, + entityId: integration.id, + metadata: { externalProductId: input.externalProductId }, + }); + + return { product, integration, credentialSecret }; + } + + async rotate(actor: string, integrationId: string): Promise { + const existing = await this.integrationsRepo.findById(integrationId); + if (!existing) throw new NotFoundError('Product integration not found.'); + + const product = await this.productsRepo.findById(existing.productId); + if (!product) throw new NotFoundError('Product integration not found.'); + + const credentialSecret = generateCredentialSecret(); + const previousCredentialExpiresAt = new Date( + Date.now() + INTEGRATION_ROTATION_TRANSITION_MINUTES * 60_000, + ); + + const integration = await this.integrationsRepo.rotate(integrationId, { + previousCredentialRef: existing.credentialRef, + previousCredentialExpiresAt, + credentialRef: encryptCredential(credentialSecret), + }); + + await writeIntegrationAuditEvent({ + actor, + actorType: 'admin', + action: INTEGRATION_AUDIT_ACTIONS.ROTATED, + entityId: integrationId, + metadata: { previousCredentialExpiresAt: previousCredentialExpiresAt.toISOString() }, + }); + + return { product, integration, credentialSecret }; + } + + async revoke(actor: string, integrationId: string): Promise { + const existing = await this.integrationsRepo.findById(integrationId); + if (!existing) throw new NotFoundError('Product integration not found.'); + + const integration = await this.integrationsRepo.revoke(integrationId); + + await writeIntegrationAuditEvent({ + actor, + actorType: 'admin', + action: INTEGRATION_AUDIT_ACTIONS.REVOKED, + entityId: integrationId, + }); + + return integration; + } + + async updateStatus( + actor: string, + integrationId: string, + status: 'active' | 'suspended', + ): Promise { + const existing = await this.integrationsRepo.findById(integrationId); + if (!existing) throw new NotFoundError('Product integration not found.'); + + const integration = await this.integrationsRepo.updateStatus(integrationId, status); + + await writeIntegrationAuditEvent({ + actor, + actorType: 'admin', + action: INTEGRATION_AUDIT_ACTIONS.STATUS_UPDATED, + entityId: integrationId, + metadata: { status }, + }); + + return integration; + } + + async getAuditTrail(integrationId: string): Promise { + const existing = await this.integrationsRepo.findById(integrationId); + if (!existing) throw new NotFoundError('Product integration not found.'); + + return findIntegrationAuditTrail(integrationId); + } +} + +export const productIntegrationsService = new ProductIntegrationsService(); diff --git a/src/plugins/product-integration-auth.plugin.ts b/src/plugins/product-integration-auth.plugin.ts index 065d683..4f52f52 100644 --- a/src/plugins/product-integration-auth.plugin.ts +++ b/src/plugins/product-integration-auth.plugin.ts @@ -1,7 +1,5 @@ import { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; -import type { Prisma } from '@prisma/client'; -import { prismaClient } from '@/infrastructure/database'; import { hasSeenJti, markJtiSeen } from '@/infrastructure/cache'; import { INTEGRATION_ERROR_CODES, @@ -21,6 +19,7 @@ import { verifyIntegrationToken, inboundRequestSchema, InboundRequest, + writeIntegrationAuditEvent, } from '@/modules/catalog/products'; declare module 'fastify' { @@ -44,26 +43,6 @@ function isInScope(scope: unknown, tenantId: string): boolean { return Array.isArray(parsed.tenantIds) && parsed.tenantIds.includes(tenantId); } -async function writeAuditEvent(params: { - actor: string; - action: string; - entityId: string; - reason?: string; - metadata?: Prisma.InputJsonValue; -}): Promise { - await prismaClient.auditLog.create({ - data: { - actor: params.actor, - actorType: 'system', - action: params.action, - entityType: 'ProductIntegration', - entityId: params.entityId, - ...(params.reason !== undefined ? { reason: params.reason } : {}), - ...(params.metadata !== undefined ? { metadata: params.metadata } : {}), - }, - }); -} - function credentialError(message: string): AppError { return new AppError(message, INTEGRATION_ERROR_CODES.INVALID_CREDENTIAL, 401); } @@ -102,8 +81,9 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{ if (!integration) { // Unresolvable — nothing to audit against as a known entity; audit with a synthetic // actor so the attempt still leaves a trace without inventing a fake entityId. - await writeAuditEvent({ + await writeIntegrationAuditEvent({ actor: 'unknown', + actorType: 'system', action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE, entityId: `unregistered:${body.productId}`, reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL, @@ -112,8 +92,9 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{ } const auditFailure = (reason: string) => - writeAuditEvent({ + writeIntegrationAuditEvent({ actor: integration.id, + actorType: 'system', action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE, entityId: integration.id, reason, @@ -202,8 +183,9 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{ request.reqContext.actorType = ActorType.CUSTOMER; request.reqContext.actorId = body.userId; - await writeAuditEvent({ + await writeIntegrationAuditEvent({ actor: integration.id, + actorType: 'system', action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS, entityId: integration.id, metadata: { externalUserId: body.userId, externalTenantId: body.tenantId }, diff --git a/tests/integration/product-integrations-admin.test.ts b/tests/integration/product-integrations-admin.test.ts new file mode 100644 index 0000000..9f8de68 --- /dev/null +++ b/tests/integration/product-integrations-admin.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { buildApp } from '@/app'; +import { prismaClient } from '@/infrastructure/database'; +import { FastifyInstance } from 'fastify'; +import { issueIntegrationToken } from '@/modules/catalog/products'; + +/** + * Covers specs/002-saas-integration/quickstart.md Scenarios 5-7 (rotation zero-downtime, + * revocation is immediate, audit trail is retrievable) against a real Postgres/Redis. See + * specs/002-saas-integration/checklists/requirements.md implementation notes for the known + * DATABASE_URL wiring gap this test shares with tests/integration/product-integration-auth.test.ts. + */ +describe('Product Integration Admin Lifecycle', () => { + let app: FastifyInstance; + const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`; + + afterAll(async () => { + await prismaClient.productIntegration.deleteMany({ + where: { product: { externalProductId } }, + }); + await prismaClient.product.deleteMany({ where: { externalProductId } }); + await app.close(); + }); + + it('Scenario 5+7: register, then rotate — both old and new credential work during the transition window, and the audit trail records every step', async () => { + app = await buildApp(); + + const registerResponse = await app.inject({ + method: 'POST', + url: `/admin/products/${externalProductId}/integration`, + payload: { + name: 'Admin Test Product', + allowedScope: { tenantIds: ['tenant-1'] }, + }, + }); + expect(registerResponse.statusCode).toBe(201); + const registered = registerResponse.json().data; + const originalSecret = registered.credentialSecret; + const integrationId = registered.integrationId; + + const rotateResponse = await app.inject({ + method: 'POST', + url: `/admin/integrations/${integrationId}/rotate`, + }); + expect(rotateResponse.statusCode).toBe(200); + const rotated = rotateResponse.json().data; + const newSecret = rotated.credentialSecret; + expect(newSecret).not.toBe(originalSecret); + + const oldTokenRequest = await app.inject({ + method: 'POST', + url: '/v1/support/requests', + headers: { + authorization: `Bearer ${issueIntegrationToken(originalSecret, { externalProductId, tenantId: 'tenant-1', userId: 'user-1' })}`, + }, + payload: { + productId: externalProductId, + tenantId: 'tenant-1', + userId: 'user-1', + source: 'test', + problem: 'old credential still valid during transition', + }, + }); + const newTokenRequest = await app.inject({ + method: 'POST', + url: '/v1/support/requests', + headers: { + authorization: `Bearer ${issueIntegrationToken(newSecret, { externalProductId, tenantId: 'tenant-1', userId: 'user-2' })}`, + }, + payload: { + productId: externalProductId, + tenantId: 'tenant-1', + userId: 'user-2', + source: 'test', + problem: 'new credential valid', + }, + }); + + expect(oldTokenRequest.statusCode).toBe(202); + expect(newTokenRequest.statusCode).toBe(202); + + const auditResponse = await app.inject({ + method: 'GET', + url: `/admin/integrations/${integrationId}/audit-trail`, + }); + expect(auditResponse.statusCode).toBe(200); + const actions = auditResponse.json().data.map((e: { action: string }) => e.action); + expect(actions).toContain('integration.registered'); + expect(actions).toContain('integration.rotated'); + expect(actions.filter((a: string) => a === 'integration.auth.success')).toHaveLength(2); + }); + + it('Scenario 6: revocation takes effect on the very next request', async () => { + const registerResponse = await app.inject({ + method: 'POST', + url: `/admin/products/${externalProductId}-revoke/integration`, + payload: { + name: 'Admin Test Product Revoke', + allowedScope: { tenantIds: ['tenant-1'] }, + }, + }); + const { credentialSecret, integrationId } = registerResponse.json().data; + + const revokeResponse = await app.inject({ + method: 'POST', + url: `/admin/integrations/${integrationId}/revoke`, + }); + expect(revokeResponse.statusCode).toBe(200); + + const requestAfterRevoke = await app.inject({ + method: 'POST', + url: '/v1/support/requests', + headers: { + authorization: `Bearer ${issueIntegrationToken(credentialSecret, { externalProductId: `${externalProductId}-revoke`, tenantId: 'tenant-1', userId: 'user-1' })}`, + }, + payload: { + productId: `${externalProductId}-revoke`, + tenantId: 'tenant-1', + userId: 'user-1', + source: 'test', + problem: 'should be rejected', + }, + }); + + expect(requestAfterRevoke.statusCode).toBe(401); + expect(requestAfterRevoke.json().error.code).toBe('INVALID_INTEGRATION_CREDENTIAL'); + + await prismaClient.productIntegration.deleteMany({ + where: { product: { externalProductId: `${externalProductId}-revoke` } }, + }); + await prismaClient.product.deleteMany({ + where: { externalProductId: `${externalProductId}-revoke` }, + }); + }); +});