feat: admin lifecycle endpoints for product integrations (US2)
Implements tasks T021-T025 from specs/002-saas-integration/tasks.md (User Story 2, P2): an admin can register, rotate, revoke, and change the status of a ProductIntegration, and retrieve its audit trail. - ProductIntegrationsService: register (finds-or-creates the Product by external id), rotate (dual-credential transition window per research.md), revoke, updateStatus, getAuditTrail -- each writes its own AuditLog entry via a new shared integration-audit-log.repository.ts (extracted from the auth plugin, which now reuses it instead of writing to Prisma directly). - Routes: POST /admin/products/:externalProductId/integration, POST/admin/integrations/:id/rotate|revoke, PATCH .../status, GET .../audit-trail -- gated by the existing fastify.authenticate (human/admin JWT) decorator. - New integration test (product-integrations-admin.test.ts) covers Quickstart Scenarios 5-7 end-to-end against a real Postgres/Redis: register+rotate+audit-trail, and revoke-takes-effect-immediately. Verified passing against a live database. Known, pre-existing limitation flagged (not fixed here, out of scope): fastify.authenticate is currently a no-op stub with no real JWT verification, so these admin endpoints aren't actually access-controlled yet -- that depends on the unimplemented identity/auth module. Documented in the contract and checklist notes so it isn't mistaken for done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8d5731340d
commit
3e2a5b97a3
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-1
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './products.controller';
|
||||
export * from './product-integrations.controller';
|
||||
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './products.repository';
|
||||
export * from './product-integrations.repository';
|
||||
export * from './customer-references.repository';
|
||||
export * from './integration-audit-log.repository';
|
||||
|
||||
@@ -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<void> {
|
||||
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<AuditLog[]> {
|
||||
return prismaClient.auditLog.findMany({
|
||||
where: { entityType: 'ProductIntegration', entityId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
@@ -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<unknown[]> {
|
||||
async findAllProducts(): Promise<Product[]> {
|
||||
return this.prisma.product.findMany();
|
||||
}
|
||||
|
||||
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
|
||||
return this.prisma.product.findUnique({ where: { externalProductId } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Product | null> {
|
||||
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<Product> {
|
||||
return this.prisma.product.upsert({
|
||||
where: { externalProductId },
|
||||
update: {},
|
||||
create: { externalProductId, name, supportEnabled: true, status: 'active' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const productsRepository = new ProductsRepository();
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './products.routes';
|
||||
export * from './inbound-request.routes';
|
||||
export * from './product-integrations-admin.routes';
|
||||
|
||||
@@ -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<void> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './products.schema';
|
||||
export * from './inbound-request.schema';
|
||||
export * from './product-integrations-admin.schema';
|
||||
|
||||
@@ -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<typeof registerIntegrationSchema>;
|
||||
export type UpdateIntegrationStatusBody = z.infer<typeof updateIntegrationStatusSchema>;
|
||||
@@ -1 +1,2 @@
|
||||
export * from './products.service';
|
||||
export * from './product-integrations.service';
|
||||
|
||||
@@ -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<IssuedCredential> {
|
||||
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<IssuedCredential> {
|
||||
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<ProductIntegration> {
|
||||
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<ProductIntegration> {
|
||||
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<AuditLog[]> {
|
||||
const existing = await this.integrationsRepo.findById(integrationId);
|
||||
if (!existing) throw new NotFoundError('Product integration not found.');
|
||||
|
||||
return findIntegrationAuditTrail(integrationId);
|
||||
}
|
||||
}
|
||||
|
||||
export const productIntegrationsService = new ProductIntegrationsService();
|
||||
@@ -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<void> {
|
||||
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 },
|
||||
|
||||
@@ -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` },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user