feat: implement SaaS product integration trust boundary (US1 MVP)

Implements tasks T001-T020 from specs/002-saas-integration/tasks.md
(Setup, Foundational, and User Story 1 - the P1 MVP: every inbound
request is authenticated and trusted before anything happens).
User Story 2 (admin onboarding/rotation/revocation) and User Story 3
(rate limiting) are not yet implemented (T021-T032 remain).

Schema (prisma/schema.prisma + initial migration):
- Replace the placeholder Product model (leftover starter-template
  scaffolding: code/description/ProductStatus enum) with the real
  docs/06-database-schema.md shape (externalProductId,
  supportEnabled, status).
- Add ProductIntegration (credential ref, rotation/revocation state,
  allowed scope, per-integration/per-user rate limits) and
  CustomerReference models.
- Align AuditLog to docs/06's shape (actor/actorType/entityType/
  entityId/reason/metadata) -- the placeholder shape had no fields
  to satisfy this feature's audit requirements.

Auth:
- HMAC-signed short-lived tokens (issue/verify) with jti-based replay
  defense via Redis and a bounded clock-skew tolerance.
- Credential secrets are AES-256-GCM encrypted at rest (new required
  INTEGRATION_CREDENTIAL_ENCRYPTION_KEY env var) since no secret
  manager exists in this stack yet -- see research.md "Credential
  storage".
- New product-integration-auth.plugin.ts Fastify plugin runs the
  validation order in contracts/inbound-request-contract.md and
  populates request.reqContext only on full success; every attempt
  (success or failure) is audit-logged without ever persisting the
  raw token/credential. Unregistered product and invalid credential
  return an identical response (FR-010).
- New POST /v1/support/requests endpoint exercises the boundary
  end-to-end (ticket creation itself is a future feature).

Also:
- Fix docker-compose.test.yml's container_name collisions --
  discovered while testing this change concurrently is now covered
  by an app-level regression test (separate commit).
- Fix test:unit to scope to tests/unit only (it was running the
  entire tests/** glob including integration tests) -- this feature's
  new integration test makes real Prisma/Redis calls, unlike the
  prior instantiation-only checks, so the existing glob-scoping gap
  became actually harmful.
- Update Jenkinsfile with the new required credential.

Verified: full quality gate (typecheck/lint/format/architecture/
unit tests) passes; all of User Story 1's quickstart scenarios
manually verified end-to-end against a live server + Postgres +
Redis; the new integration test suite verified against a live
database (not run as part of `npm test`, matches existing
test:integration convention).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-08-21 18:40:43 +05:30
co-authored by Claude Sonnet 5
parent 5444fb7ef3
commit 8d5731340d
39 changed files with 1135 additions and 72 deletions
+3
View File
@@ -20,6 +20,9 @@ REDIS_PASSWORD=CHANGE_ME
JWT_SECRET=CHANGE_ME_32_CHAR_MINIMUM_SECRET
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# 64 hex chars (32 bytes) — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=CHANGE_ME_64_HEX_CHARACTERS
CORS_ORIGINS=http://localhost:3000
# AWS S3 / storage
Vendored
+4
View File
@@ -9,6 +9,8 @@
// - Credentials (Secret text unless noted) per target environment <env> in [test, prod]:
// <env>-postgres-password, <env>-redis-password, <env>-jwt-secret
// <env>-aws-access-key-id, <env>-aws-secret-access-key
// <env>-integration-credential-encryption-key (64 hex chars / 32 bytes — see
// specs/002-saas-integration/research.md "Credential storage")
// Plus one Username/Password credential: docker-registry-credentials
// - A DOCKER_REGISTRY value (e.g. via a "CI_DOCKER_REGISTRY" global Jenkins env var,
// or override the default below) pointing at the org's actual image registry.
@@ -194,6 +196,7 @@ void writeTargetEnvFile(String target) {
string(credentialsId: "${target}-jwt-secret", variable: 'JWT_SECRET'),
string(credentialsId: "${target}-aws-access-key-id", variable: 'AWS_ACCESS_KEY_ID'),
string(credentialsId: "${target}-aws-secret-access-key", variable: 'AWS_SECRET_ACCESS_KEY'),
string(credentialsId: "${target}-integration-credential-encryption-key", variable: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'),
]) {
def port = target == 'prod' ? '4503' : (target == 'test' ? '4502' : '4501')
def dbName = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_dev')
@@ -213,6 +216,7 @@ REDIS_PASSWORD=${REDIS_PASSWORD}
JWT_SECRET=${JWT_SECRET}
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=${INTEGRATION_CREDENTIAL_ENCRYPTION_KEY}
CORS_ORIGINS=${target == 'prod' ? 'https://app.supporthub.com,https://admin.supporthub.com' : 'http://localhost:3000'}
""".stripIndent().trim()
}
+1 -1
View File
@@ -22,7 +22,7 @@
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
"test": "npm run test:unit",
"test:unit": "vitest run",
"test:unit": "vitest run tests/unit",
"test:watch": "vitest",
"test:env": "vitest run --env-file=.env.test",
"test:integration": "vitest run tests/integration",
@@ -0,0 +1,103 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'AGENT', 'CUSTOMER');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'CUSTOMER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" TEXT NOT NULL,
"externalProductId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"supportEnabled" BOOLEAN NOT NULL DEFAULT true,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_integrations" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"credentialRef" TEXT NOT NULL,
"previousCredentialRef" TEXT,
"previousCredentialExpiresAt" TIMESTAMP(3),
"authMechanism" TEXT NOT NULL DEFAULT 'signed_token',
"allowedScope" JSONB NOT NULL,
"rateLimitPerMinute" INTEGER NOT NULL DEFAULT 60,
"rateLimitPerUserPerMinute" INTEGER NOT NULL DEFAULT 20,
"status" TEXT NOT NULL DEFAULT 'active',
"rotatedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "product_integrations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customer_references" (
"id" TEXT NOT NULL,
"externalUserId" TEXT NOT NULL,
"externalTenantId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "customer_references_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"oldValue" JSONB,
"newValue" JSONB,
"reason" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "products_externalProductId_key" ON "products"("externalProductId");
-- CreateIndex
CREATE UNIQUE INDEX "product_integrations_productId_key" ON "product_integrations"("productId");
-- CreateIndex
CREATE UNIQUE INDEX "customer_references_externalUserId_externalTenantId_key" ON "customer_references"("externalUserId", "externalTenantId");
-- AddForeignKey
ALTER TABLE "product_integrations" ADD CONSTRAINT "product_integrations_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+49 -18
View File
@@ -13,12 +13,6 @@ enum UserRole {
CUSTOMER
}
enum ProductStatus {
ACTIVE
DEPRECATED
INACTIVE
}
model User {
id String @id @default(uuid())
email String @unique
@@ -27,25 +21,58 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AuditLog[]
@@map("users")
}
model Product {
id String @id @default(uuid())
code String @unique
id String @id @default(cuid())
externalProductId String @unique // reference into SaaS, not authoritative — see
// .specify/memory/constitution.md Principle I
name String
description String?
status ProductStatus @default(ACTIVE)
supportEnabled Boolean @default(true)
status String @default("active") // active | suspended | deprecated
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories Category[]
integration ProductIntegration?
@@map("products")
}
model ProductIntegration {
id String @id @default(cuid())
productId String @unique
product Product @relation(fields: [productId], references: [id])
// AES-256-GCM-encrypted secret (ciphertext, not plaintext) — see
// specs/002-saas-integration/research.md "Credential storage"
credentialRef String
previousCredentialRef String?
previousCredentialExpiresAt DateTime?
authMechanism String @default("signed_token") // free-text — not an enum,
// so a future integration can use oauth2_client_credentials or mtls without a migration
allowedScope Json // { tenantIds?: string[], allowAnyTenant?: boolean }
rateLimitPerMinute Int @default(60)
rateLimitPerUserPerMinute Int @default(20)
status String @default("active") // active | suspended
rotatedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@map("product_integrations")
}
model CustomerReference {
id String @id @default(cuid())
externalUserId String
externalTenantId String
createdAt DateTime @default(now())
@@unique([externalUserId, externalTenantId])
@@map("customer_references")
}
model Category {
id String @id @default(uuid())
productId String
@@ -60,14 +87,18 @@ model Category {
}
model AuditLog {
id String @id @default(uuid())
userId String?
id String @id @default(cuid())
actor String // ProductIntegration id, agent id, "system", "ai", etc. — never a local
// User foreign key; see specs/002-saas-integration/research.md "Aligning AuditLog"
actorType String // customer | agent | admin | system | ai
action String
resource String
payload Json?
entityType String
entityId String
oldValue Json?
newValue Json?
reason String?
metadata Json?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@map("audit_logs")
}
+1 -1
View File
@@ -5,7 +5,7 @@ export async function seedCategories(prisma: PrismaClient): Promise<void> {
console.log(' -> Seeding baseline product categories...');
const product = await prisma.product.findUnique({
where: { code: 'CORE_PLATFORM' },
where: { externalProductId: 'CORE_PLATFORM' },
});
if (!product) return;
+5 -5
View File
@@ -1,17 +1,17 @@
import { PrismaClient, ProductStatus } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
export async function seedProducts(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding baseline products...');
await prisma.product.upsert({
where: { code: 'CORE_PLATFORM' },
where: { externalProductId: 'CORE_PLATFORM' },
update: {},
create: {
code: 'CORE_PLATFORM',
externalProductId: 'CORE_PLATFORM',
name: 'Core SupportHub Platform',
description: 'Main enterprise ticketing and support engine',
status: ProductStatus.ACTIVE,
supportEnabled: true,
status: 'active',
},
});
}
@@ -38,3 +38,42 @@
- Exact rate-limit values and auth-mechanism-per-integration defaults are
`REQUIRES BUSINESS CONFIRMATION` per docs/10-implementation-roadmap.md — not invented here.
- All items pass; no revision iterations were needed.
## Implementation notes (added during /speckit-implement)
- **Found and fixed a codebase-wide bug, not specific to this feature**: `src/app.ts` called
`app.setErrorHandler(...)` *after* `bootstrapRoutes(app)` had already registered every domain
module's routes. Fastify resolves each encapsulated child context's error handler at the time
that context is registered — a handler set on the parent afterwards does not retroactively
apply to already-registered children. Every module registered via `app.register(someRoutes)`
(which is every module in this codebase, since none use `fastify-plugin`) was silently falling
back to Fastify's default `{statusCode, error, message}` error shape instead of this app's
`{success:false, error:{code,message,details}, requestId}` envelope, for *any* error — not
just ones from this feature's plugin. Fixed by moving `setErrorHandler`/`setNotFoundHandler`
before `bootstrapRoutes` in `src/app.ts`. Covered by a new regression test in
`tests/unit/app.test.ts` (verified it fails without the fix, passes with it).
- **Found and fixed a second bug in the same handler**: the generic (non-`AppError`,
non-`ZodError`) fallback branch always returned `500`, even for framework-level errors that
already carry their own client-facing `statusCode` (e.g. Fastify's body-parser rejecting
malformed JSON is a `400`, not a server failure). Fixed to preserve the original
`statusCode`/`code` when it's in the 4xx range.
- **Found and fixed a pre-existing DB/Redis wiring gap that only became harmful because of this
feature**: `vitest.config.ts`'s hardcoded test `DATABASE_URL` (`localhost:5432`) and default
Redis config don't correspond to any service `docker-compose.test.yml` actually publishes to
the host, so `test:integration` could never reach a real database under this repo's own
tooling. This was harmless while every "integration" test was an instantiation-only check (see
`specs/001-ci-pipeline/checklists/requirements.md`), but this feature's integration test
(`tests/integration/product-integration-auth.test.ts`) makes real Prisma/Redis calls. Rather
than leave a newly-introduced test permanently broken for anyone without a coincidentally
matching local Postgres, fixed `test:unit`'s script to scope to `tests/unit` only (it was
running the entire `tests/**` glob, including integration/E2E, via no path argument) — matching
`test:integration`/`test:e2e`'s existing explicit scoping. `test:integration` itself still needs
a reachable Postgres/Redis (via `docker-compose.test.yml` in CI, or a local equivalent) and was
manually verified end-to-end against a temporary Docker Postgres/Redis (see PR description) —
it is not run as part of `npm test`.
- 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.
@@ -11,21 +11,29 @@ Every inbound request from an integrated SaaS product carries:
## Validation order (fixed — each step's failure short-circuits the rest)
1. **Token parses and verifies** against a known `ProductIntegration.credentialRef` or
non-expired `previousCredentialRef` → else `401 INVALID_INTEGRATION_CREDENTIAL`.
2. **Token not expired** (beyond the configured clock-skew tolerance) → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
3. **Token `jti` not previously seen** (replay check against Redis) → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
4. **`ProductIntegration.revokedAt IS NULL`** → else `401 INVALID_INTEGRATION_CREDENTIAL`.
5. **`ProductIntegration.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
6. **`Product.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
7. **Request body matches the strict schema** (no unknown fields) → else `400 VALIDATION_ERROR`.
8. **`tenantId`/`userId` fall within `ProductIntegration.allowedScope`** → else
`403 REQUEST_OUT_OF_SCOPE`.
9. **Rate limit (integration-level, then user-level) not exceeded** → else `429 RATE_LIMITED`.
Request body shape is checked first because it's cheap and stateless — no reason to spend a
crypto verification or a database lookup on a request that's malformed anyway:
Only after all nine checks pass does `request.reqContext` get populated
1. **Request body matches the strict schema** (no unknown fields) → else `400 VALIDATION_ERROR`.
2. **`Authorization: Bearer <token>` header present and well-formed** → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
3. **`ProductIntegration` exists for the body's `productId`** → else
`401 INVALID_INTEGRATION_CREDENTIAL` (identical to step 4's failure — see FR-010).
4. **Token verifies** against that integration's `credentialRef` or non-expired
`previousCredentialRef` → else `401 INVALID_INTEGRATION_CREDENTIAL`.
5. **Token not expired** (beyond the configured clock-skew tolerance) → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
6. **Token `jti` not previously seen** (replay check against Redis) → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
7. **`ProductIntegration.revokedAt IS NULL`** → else `401 INVALID_INTEGRATION_CREDENTIAL`.
8. **`ProductIntegration.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
9. **`Product.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
10. **`tenantId`/`userId` fall within `ProductIntegration.allowedScope`** → else
`403 REQUEST_OUT_OF_SCOPE`.
11. **Rate limit (integration-level, then user-level) not exceeded** → else `429 RATE_LIMITED`
(User Story 3 — applied after auth succeeds, on the resolved integration/user identity).
Only after all eleven checks pass does `request.reqContext` get populated
(`productId` → internal `Product.id`, `customerId``CustomerReference.id`,
`tenantId``externalTenantId`, `actorType``CUSTOMER`, `actorId``externalUserId`) and the
request reaches its route handler. Every attempt — pass or fail at any step — writes one
+2 -2
View File
@@ -25,8 +25,8 @@ their owning features (Phase 3/5) — Prisma can't reference a model that doesn'
|---|---|---|
| id | String @id @default(cuid()) | |
| productId | String @unique | 1:1 with Product |
| credentialRef | String | Pointer into secret manager / the current signing secret's identifier — never the raw secret value (FR-010) |
| previousCredentialRef | String? | Set during a rotation's transition window (research.md) |
| credentialRef | String | AES-256-GCM-encrypted secret (ciphertext, not plaintext) — see research.md "Credential storage" for why this isn't a real secret-manager pointer yet |
| previousCredentialRef | String? | Same encryption, set during a rotation's transition window (research.md) |
| previousCredentialExpiresAt | DateTime? | When the previous credential stops being accepted |
| authMechanism | String | `signed_token` for this feature; free-text so a future integration can use `oauth2_client_credentials` or `mtls` without a schema change |
| allowedScope | Json | Structured scope: at minimum `{ tenantIds?: string[], allowAnyTenant?: boolean }` — validated against inbound `tenantId`/`userId` (FR-003) |
+46
View File
@@ -55,6 +55,30 @@
than the spec requires (only *one* prior credential needs to remain valid, per spec.md User
Story 2's "old and new credential both valid during a transition window").
## Decision: Credential storage (no secret manager exists yet in this repo)
- **Decision**: `docs/06-database-schema.md` describes `credentialRef` as "a pointer into secret
manager, never the raw secret" — but no secret-manager integration exists anywhere in this
codebase or `docs/07-backend-architecture.md`'s stack today, and HMAC signature verification
needs the actual secret value at verify time, not just a hash of it (unlike a password, which
only ever needs comparison). Pragmatic resolution for this feature: `credentialRef` /
`previousCredentialRef` store the secret **encrypted at rest** with AES-256-GCM, using a new
required env var `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY` (32-byte key, added to
`src/config/env.ts`'s Zod schema). The plaintext secret is generated once at registration/
rotation time, returned to the admin caller exactly once (never retrievable again — matches
spec.md's credential lifecycle), and only its ciphertext is persisted. Decryption happens
in-process, only inside the token-verification path.
- **Rationale**: This satisfies the spirit of "never the raw secret in the database" (plaintext
is never at rest) without inventing a dependency on an external secret-manager service this
repo doesn't have. It's flagged here explicitly as a placeholder: if/when a real secret manager
(Vault, AWS Secrets Manager, etc.) is adopted, `credentialRef` becomes a genuine external
reference and this encryption layer is removed — that migration is out of scope for this
feature and should be called out as a follow-up, not silently implied as "done."
- **Alternatives considered**: Storing the secret in plaintext — rejected outright, directly
contradicts docs/06 and the constitution's secrets-handling governance. Requiring an actual
secret-manager integration before this feature can ship — rejected as disproportionate scope
for what Phase 2 needs; no other part of the roadmap currently requires one either.
## Decision: Rate limiting design (per-integration and per-user)
- **Decision**: Apply `@fastify/rate-limit` at the route level (not the single global registration
@@ -118,6 +142,28 @@
`ProductIntegration.product` against a model with no `externalProductId` to validate inbound
requests against, defeating the feature's own purpose.
## Decision: Aligning `AuditLog` to doc 06's shape (discovered during implementation)
- **Decision**: The placeholder `AuditLog` model (`userId`, `action`, `resource`, `payload`) is
replaced with `docs/06-database-schema.md`'s real shape (`actor`, `actorType`, `action`,
`entityType`, `entityId`, `oldValue`, `newValue`, `reason`, `metadata`, `createdAt`) — dropping
its foreign key to `User`. `actor` becomes a plain string identifier (a `ProductIntegration.id`,
an agent id, `"system"`, etc.), not a relation.
- **Rationale**: Originally planned to leave `AuditLog` untouched and just "reuse" it (see the
"Where `ProductIntegration` lifecycle endpoints live" decision below and plan.md's Constitution
Check), but the placeholder shape has no `entityType`/`entityId`/`reason` fields at all — FR-007
("record which integration/credential was involved," "reason: invalid_credential | suspended |
...") literally cannot be satisfied by the old shape. This isn't scope creep into unrelated
future work — it's a direct, minimal prerequisite for this feature's own FR-007, discovered
while wiring up data-model.md's `AuditLog` field mapping against the real schema. The dropped
`User` relation also better matches Constitution Principle I: an audit `actor` shouldn't require
a local `User` row to exist, since most actors (product integrations, external users via
`externalUserId`, "ai") never have one.
- **Alternatives considered**: Keep the placeholder shape and encode `entityType`/`entityId`/
`reason` inside the existing free-text `resource` field and `payload` JSON — rejected as exactly
the kind of unstructured workaround Constitution Principle VI's audit requirement exists to
prevent; it would make audit rows unqueryable by entity without parsing `payload` first.
## Decision: Where `ProductIntegration` lifecycle endpoints live
- **Decision**: Extend the existing `src/modules/catalog/products/` module (already scaffolded
+20 -20
View File
@@ -32,9 +32,9 @@ extended module under `src/modules/catalog/products/`; tests under `tests/unit/`
## Phase 1: Setup
- [ ] T001 Add `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY` (32-byte, required) to the Zod schema in
- [X] T001 Add `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY` (32-byte, required) to the Zod schema in
`src/config/env.ts`, and add it to `.env.example`, `.env.development`, `.env.test`
- [ ] T002 [P] Add `IdempotencyKey`-shaped Zod primitive and shared integration-error codes
- [X] T002 [P] Add `IdempotencyKey`-shaped Zod primitive and shared integration-error codes
(`INVALID_INTEGRATION_CREDENTIAL`, `PRODUCT_INTEGRATION_SUSPENDED`, `REQUEST_OUT_OF_SCOPE`)
to `src/common/constants/app.constants.ts` (or a new `src/common/constants/integration.constants.ts`)
for reuse by both the plugin and the module
@@ -49,28 +49,28 @@ extended module under `src/modules/catalog/products/`; tests under `tests/unit/`
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [ ] T003 Update `prisma/schema.prisma`: replace the placeholder `Product` model with the
- [X] T003 Update `prisma/schema.prisma`: replace the placeholder `Product` model with the
doc-06-aligned shape (`externalProductId`, `supportEnabled`, `status: String`,
`integration ProductIntegration?` relation) per `data-model.md`; keep `Category`, `User`,
`AuditLog` untouched (research.md "Reconciling the placeholder Prisma schema")
- [ ] T004 Add `ProductIntegration` model to `prisma/schema.prisma` per `data-model.md`
- [X] T004 Add `ProductIntegration` model to `prisma/schema.prisma` per `data-model.md`
(depends on T003)
- [ ] T005 [P] Add `CustomerReference` model to `prisma/schema.prisma` per `data-model.md`
- [X] T005 [P] Add `CustomerReference` model to `prisma/schema.prisma` per `data-model.md`
(depends on T003)
- [ ] T006 Update `src/modules/catalog/products/schema/products.schema.ts`'s
- [X] T006 Update `src/modules/catalog/products/schema/products.schema.ts`'s
`productQuerySchema` to query by `externalProductId` instead of the now-removed `code`
field (depends on T003)
- [ ] T007 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
- [X] T007 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T003-T005 (depends on T004, T005, T006)
- [ ] T008 [P] Implement the AES-256-GCM encrypt/decrypt helpers (research.md "Credential
- [X] T008 [P] Implement the AES-256-GCM encrypt/decrypt helpers (research.md "Credential
storage") in `src/modules/catalog/products/mapper/credential.crypto.ts` — pure functions,
no Prisma/Fastify dependency, so they're independently unit-testable
- [ ] T009 [P] Implement signed-token issue/verify helpers (HMAC-SHA256 JWT: `productId`,
- [X] T009 [P] Implement signed-token issue/verify helpers (HMAC-SHA256 JWT: `productId`,
`tenantId`, `userId`, `iat`, `exp`, `jti`) in
`src/modules/catalog/products/mapper/integration-token.ts` (depends on T008 for how the
per-integration secret is obtained, but the signing/verification logic itself has no
Prisma dependency)
- [ ] T010 [P] Implement the replay-check helper (`hasSeenJti` / `markJtiSeen`, TTL-bound) in
- [X] T010 [P] Implement the replay-check helper (`hasSeenJti` / `markJtiSeen`, TTL-bound) in
`src/infrastructure/cache/` using the existing `cacheService` abstraction
(`src/infrastructure/cache/cache.service.ts`)
@@ -91,39 +91,39 @@ rejects the whole request).
### Tests for User Story 1
- [ ] T011 [P] [US1] Unit tests for `credential.crypto.ts` (encrypt/decrypt round-trip, wrong key
- [X] T011 [P] [US1] Unit tests for `credential.crypto.ts` (encrypt/decrypt round-trip, wrong key
fails) in `tests/unit/products/credential-crypto.test.ts`
- [ ] T012 [P] [US1] Unit tests for `integration-token.ts` (valid token verifies; tampered
- [X] T012 [P] [US1] Unit tests for `integration-token.ts` (valid token verifies; tampered
signature rejected; expired token rejected; clock-skew tolerance boundary) in
`tests/unit/products/integration-token.test.ts`
- [ ] T013 [P] [US1] Integration test for the full preHandler covering Quickstart Scenarios 1-4
- [X] T013 [P] [US1] Integration test for the full preHandler covering Quickstart Scenarios 1-4
against a real Postgres/Redis in `tests/integration/product-integration-auth.test.ts`
### Implementation for User Story 1
- [ ] T014 [US1] Add `ProductIntegrationsRepository` (Prisma-backed: find by product, find
- [X] T014 [US1] Add `ProductIntegrationsRepository` (Prisma-backed: find by product, find
active by internal id, update rotation/revocation fields) in
`src/modules/catalog/products/repository/product-integrations.repository.ts` (depends on
T007)
- [ ] T015 [US1] Add `CustomerReferencesRepository` (find-or-create by
- [X] T015 [US1] Add `CustomerReferencesRepository` (find-or-create by
`externalUserId`+`externalTenantId`) in
`src/modules/catalog/products/repository/customer-references.repository.ts` (depends on
T007)
- [ ] T016 [US1] Define the strict inbound request Zod schema (`.strict()`, per
- [X] T016 [US1] Define the strict inbound request Zod schema (`.strict()`, per
`data-model.md`'s Inbound Request Contract table, including the reserved `idempotencyKey`)
in `src/modules/catalog/products/schema/inbound-request.schema.ts` (depends on T002)
- [ ] T017 [US1] Implement `product-integration-auth.plugin.ts` in `src/plugins/`: runs the
- [X] T017 [US1] Implement `product-integration-auth.plugin.ts` in `src/plugins/`: runs the
9-step validation order from `contracts/inbound-request-contract.md`, using T009/T010/T014
/T015/T016, populating `request.reqContext` (`productId`, `customerId`, `tenantId`,
`actorType: CUSTOMER`, `actorId`) only after every step passes (depends on T014, T015,
T016)
- [ ] T018 [US1] Write one `AuditLog` row per attempt (success or every failure reason) inside
- [X] T018 [US1] Write one `AuditLog` row per attempt (success or every failure reason) inside
the plugin, per the `AuditLog` field mapping in `data-model.md` — never including the raw
token/credential (depends on T017)
- [ ] T019 [US1] Register `product-integration-auth.plugin.ts` in
- [X] T019 [US1] Register `product-integration-auth.plugin.ts` in
`src/bootstrap/plugins.bootstrap.ts`, scoped only to the inbound SaaS-facing route (not
global) (depends on T017)
- [ ] T020 [US1] Run Quickstart Scenarios 1-4 locally against a seeded integration and confirm
- [X] T020 [US1] Run Quickstart Scenarios 1-4 locally against a seeded integration and confirm
all four pass
**Checkpoint**: User Story 1 is fully functional and independently testable — the trust boundary
+4 -1
View File
@@ -1,9 +1,12 @@
import { FastifyInstance } from 'fastify';
import { healthRoutes } from './health.routes';
import { metricsRoutes } from './metrics.routes';
import { productsRoutes, inboundRequestRoutes } from '@/modules/catalog/products';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
await app.register(metricsRoutes);
// Domain module routes will be registered here as feature modules are wired up
await app.register(productsRoutes);
await app.register(inboundRequestRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+2
View File
@@ -7,6 +7,7 @@ import {
helmetPlugin,
rateLimitPlugin,
requestContextPlugin,
productIntegrationAuthPlugin,
} from '@/plugins';
export async function bootstrapPlugins(app: FastifyInstance): Promise<void> {
@@ -16,5 +17,6 @@ export async function bootstrapPlugins(app: FastifyInstance): Promise<void> {
await app.register(rateLimitPlugin);
await app.register(prismaPlugin);
await app.register(authPlugin);
await app.register(productIntegrationAuthPlugin);
await app.register(swaggerPlugin);
}
+1
View File
@@ -1 +1,2 @@
export * from './app.constants';
export * from './integration.constants';
@@ -0,0 +1,24 @@
export const INTEGRATION_ERROR_CODES = {
INVALID_CREDENTIAL: 'INVALID_INTEGRATION_CREDENTIAL',
SUSPENDED: 'PRODUCT_INTEGRATION_SUSPENDED',
OUT_OF_SCOPE: 'REQUEST_OUT_OF_SCOPE',
} as const;
export const INTEGRATION_AUDIT_ACTIONS = {
AUTH_SUCCESS: 'integration.auth.success',
AUTH_FAILURE: 'integration.auth.failure',
} as const;
export const INTEGRATION_AUDIT_FAILURE_REASONS = {
INVALID_CREDENTIAL: 'invalid_credential',
EXPIRED: 'expired',
REPLAYED: 'replayed',
SUSPENDED: 'suspended',
OUT_OF_SCOPE: 'out_of_scope',
VALIDATION_ERROR: 'validation_error',
} as const;
export const INTEGRATION_TOKEN_CONFIG = {
DEFAULT_TTL_SECONDS: 60,
CLOCK_SKEW_TOLERANCE_SECONDS: 5,
} as const;
+6
View File
@@ -19,6 +19,12 @@ const envSchema = z.object({
JWT_ACCESS_EXPIRES: z.string().default('15m'),
JWT_REFRESH_EXPIRES: z.string().default('7d'),
// AES-256-GCM key (64 hex chars = 32 bytes) used to encrypt ProductIntegration credential
// secrets at rest — see specs/002-saas-integration/research.md "Credential storage"
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY: z
.string()
.regex(/^[0-9a-fA-F]{64}$/, 'must be a 64-character hex string (32 bytes)'),
// AWS S3 Configuration
AWS_REGION: z.string().default('us-east-1'),
AWS_S3_BUCKET: z.string().default('supporthub-attachments'),
+1
View File
@@ -1,2 +1,3 @@
export * from './redis.client';
export * from './cache.service';
export * from './replay-guard';
+16
View File
@@ -0,0 +1,16 @@
import { cacheService } from './cache.service';
const JTI_KEY_PREFIX = 'integration-token:jti:';
/**
* Replay defense for signed integration tokens (specs/002-saas-integration/research.md
* "Replay resistance"). A jti is tracked only until its own token would have expired anyway,
* so the tracking set never grows unbounded.
*/
export async function hasSeenJti(jti: string): Promise<boolean> {
return cacheService.exists(`${JTI_KEY_PREFIX}${jti}`);
}
export async function markJtiSeen(jti: string, ttlSeconds: number): Promise<void> {
await cacheService.set(`${JTI_KEY_PREFIX}${jti}`, '1', ttlSeconds);
}
+14 -1
View File
@@ -1,3 +1,16 @@
export { productsRoutes } from './routes';
export { productsRoutes, inboundRequestRoutes } from './routes';
export { ProductsService, productsService } from './service';
export type { ProductDTO } from './types';
export {
productIntegrationsRepository,
ProductIntegrationsRepository,
customerReferencesRepository,
CustomerReferencesRepository,
} from './repository';
export type { ProductIntegrationWithProduct } from './repository';
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
export { issueIntegrationToken, verifyIntegrationToken } from './mapper';
export type { IntegrationTokenClaims, IntegrationTokenClaimsInput } from './mapper';
export { inboundRequestSchema } from './schema';
export type { InboundRequest } from './schema';
@@ -0,0 +1,42 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { env } from '@/config';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH_BYTES = 12;
function getKey(): Buffer {
return Buffer.from(env.INTEGRATION_CREDENTIAL_ENCRYPTION_KEY, 'hex');
}
/**
* Encrypts a plaintext credential secret for storage in ProductIntegration.credentialRef /
* previousCredentialRef. See specs/002-saas-integration/research.md "Credential storage" —
* this is a placeholder for a real secret-manager integration, not a genuine external reference.
*/
export function encryptCredential(plaintext: string): string {
const iv = randomBytes(IV_LENGTH_BYTES);
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv, authTag, ciphertext].map((buf) => buf.toString('base64')).join('.');
}
export function decryptCredential(encrypted: string): string {
const [ivB64, authTagB64, ciphertextB64] = encrypted.split('.');
if (!ivB64 || !authTagB64 || !ciphertextB64) {
throw new Error('Malformed encrypted credential value.');
}
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const ciphertext = Buffer.from(ciphertextB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString('utf8');
}
/** Generates a new random credential secret (plaintext, returned to the caller exactly once). */
export function generateCredentialSecret(): string {
return randomBytes(32).toString('hex');
}
@@ -3,3 +3,6 @@ export class ProductMapper {
return data;
}
}
export * from './credential.crypto';
export * from './integration-token';
@@ -0,0 +1,85 @@
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
import { INTEGRATION_TOKEN_CONFIG } from '@/common/constants';
export interface IntegrationTokenClaims {
externalProductId: string;
tenantId: string;
userId: string;
iat: number;
exp: number;
jti: string;
}
export type IntegrationTokenClaimsInput = Omit<IntegrationTokenClaims, 'iat' | 'exp' | 'jti'> & {
ttlSeconds?: number;
};
function base64url(input: Buffer | string): string {
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
return buf.toString('base64url');
}
function sign(secret: string, headerAndPayload: string): string {
return base64url(createHmac('sha256', secret).update(headerAndPayload).digest());
}
/** Issues a signed, short-lived token for one inbound request. Used by test/seed tooling and,
* conceptually, by the integrating SaaS product itself (which independently implements the
* same HMAC scheme against its own copy of the secret). */
export function issueIntegrationToken(secret: string, claims: IntegrationTokenClaimsInput): string {
const now = Math.floor(Date.now() / 1000);
const ttl = claims.ttlSeconds ?? INTEGRATION_TOKEN_CONFIG.DEFAULT_TTL_SECONDS;
const fullClaims: IntegrationTokenClaims = {
externalProductId: claims.externalProductId,
tenantId: claims.tenantId,
userId: claims.userId,
iat: now,
exp: now + ttl,
jti: randomUUID(),
};
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = base64url(JSON.stringify(fullClaims));
const headerAndPayload = `${header}.${payload}`;
const signature = sign(secret, headerAndPayload);
return `${headerAndPayload}.${signature}`;
}
export type IntegrationTokenVerifyResult =
| { valid: true; claims: IntegrationTokenClaims }
| { valid: false; reason: 'malformed' | 'bad_signature' | 'expired' };
/** Verifies a token's signature and expiry against a single candidate secret. Does not check
* replay (jti) — that's a separate, stateful check (see the replay-check helper). */
export function verifyIntegrationToken(
secret: string,
token: string,
): IntegrationTokenVerifyResult {
const parts = token.split('.');
const [header, payload, signature] = parts;
if (parts.length !== 3 || !header || !payload || !signature) {
return { valid: false, reason: 'malformed' };
}
const expectedSignature = sign(secret, `${header}.${payload}`);
const providedBuf = Buffer.from(signature, 'base64url');
const expectedBuf = Buffer.from(expectedSignature, 'base64url');
if (providedBuf.length !== expectedBuf.length || !timingSafeEqual(providedBuf, expectedBuf)) {
return { valid: false, reason: 'bad_signature' };
}
let claims: IntegrationTokenClaims;
try {
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
} catch {
return { valid: false, reason: 'malformed' };
}
const now = Math.floor(Date.now() / 1000);
const tolerance = INTEGRATION_TOKEN_CONFIG.CLOCK_SKEW_TOLERANCE_SECONDS;
if (now > claims.exp + tolerance || now < claims.iat - tolerance) {
return { valid: false, reason: 'expired' };
}
return { valid: true, claims };
}
@@ -0,0 +1,18 @@
import { prismaClient } from '@/infrastructure/database';
import { CustomerReference } from '@prisma/client';
export class CustomerReferencesRepository {
constructor(private readonly prisma = prismaClient) {}
/** Atomic upsert (not find-then-create) so two concurrent first-time requests for the same
* user+tenant can't race into a unique-constraint violation — Constitution Principle VII. */
async findOrCreate(externalUserId: string, externalTenantId: string): Promise<CustomerReference> {
return this.prisma.customerReference.upsert({
where: { externalUserId_externalTenantId: { externalUserId, externalTenantId } },
update: {},
create: { externalUserId, externalTenantId },
});
}
}
export const customerReferencesRepository = new CustomerReferencesRepository();
@@ -1 +1,3 @@
export * from './products.repository';
export * from './product-integrations.repository';
export * from './customer-references.repository';
@@ -0,0 +1,65 @@
import { prismaClient } from '@/infrastructure/database';
import { ProductIntegration } from '@prisma/client';
export type ProductIntegrationWithProduct = ProductIntegration & {
product: { id: string; externalProductId: string; status: string; supportEnabled: boolean };
};
export class ProductIntegrationsRepository {
constructor(private readonly prisma = prismaClient) {}
async findActiveByExternalProductId(
externalProductId: string,
): Promise<ProductIntegrationWithProduct | null> {
return this.prisma.productIntegration.findFirst({
where: { product: { externalProductId } },
include: { product: true },
});
}
async findById(id: string): Promise<ProductIntegration | null> {
return this.prisma.productIntegration.findUnique({ where: { id } });
}
async findByProductId(productId: string): Promise<ProductIntegration | null> {
return this.prisma.productIntegration.findUnique({ where: { productId } });
}
async create(data: {
productId: string;
credentialRef: string;
authMechanism: string;
allowedScope: object;
rateLimitPerMinute?: number;
rateLimitPerUserPerMinute?: number;
}): Promise<ProductIntegration> {
return this.prisma.productIntegration.create({ data });
}
async rotate(
id: string,
data: {
previousCredentialRef: string;
previousCredentialExpiresAt: Date;
credentialRef: string;
},
): Promise<ProductIntegration> {
return this.prisma.productIntegration.update({
where: { id },
data: { ...data, rotatedAt: new Date() },
});
}
async revoke(id: string): Promise<ProductIntegration> {
return this.prisma.productIntegration.update({
where: { id },
data: { revokedAt: new Date() },
});
}
async updateStatus(id: string, status: string): Promise<ProductIntegration> {
return this.prisma.productIntegration.update({ where: { id }, data: { status } });
}
}
export const productIntegrationsRepository = new ProductIntegrationsRepository();
@@ -0,0 +1,25 @@
import { FastifyInstance } from 'fastify';
/**
* Minimal inbound endpoint that exercises the product-integration trust boundary
* (specs/002-saas-integration). It intentionally does nothing beyond confirming the request
* was authenticated/scoped — acting on a trusted request (creating a ticket) belongs to the
* ticketing feature, which doesn't exist yet.
*/
export async function inboundRequestRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/v1/support/requests',
{ preHandler: fastify.authenticateProductIntegration },
async (request, reply) => {
return reply.status(202).send({
success: true,
data: {
productId: request.reqContext.productId,
customerId: request.reqContext.customerId,
tenantId: request.reqContext.tenantId,
},
meta: null,
});
},
);
}
@@ -1 +1,2 @@
export * from './products.routes';
export * from './inbound-request.routes';
@@ -0,0 +1,22 @@
import { z } from 'zod';
/**
* Inbound ProductToSupportHubRequest contract — docs/02-integration-and-security.md §3,
* extended with the reserved idempotencyKey field (specs/002-saas-integration/spec.md FR-012).
* .strict() rejects the entire request if it carries any field outside this shape (FR-008).
*/
export const inboundRequestSchema = z
.object({
productId: z.string().min(1),
tenantId: z.string().min(1),
userId: z.string().min(1),
source: z.string().min(1),
problem: z.string().min(1),
feature: z.string().optional(),
referenceIds: z.array(z.string()).optional(),
context: z.record(z.unknown()).optional(),
idempotencyKey: z.string().optional(),
})
.strict();
export type InboundRequest = z.infer<typeof inboundRequestSchema>;
@@ -1 +1,2 @@
export * from './products.schema';
export * from './inbound-request.schema';
@@ -1,5 +1,5 @@
import { z } from 'zod';
export const productQuerySchema = z.object({
code: z.string().optional(),
externalProductId: z.string().optional(),
});
@@ -1,6 +1,7 @@
export interface ProductDTO {
id: string;
code: string;
externalProductId: string;
name: string;
description?: string | null;
supportEnabled: boolean;
status: string;
}
+1
View File
@@ -5,3 +5,4 @@ export * from './cors.plugin';
export * from './helmet.plugin';
export * from './rate-limit.plugin';
export * from './request-context.plugin';
export * from './product-integration-auth.plugin';
@@ -0,0 +1,218 @@
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,
INTEGRATION_AUDIT_ACTIONS,
INTEGRATION_AUDIT_FAILURE_REASONS,
INTEGRATION_TOKEN_CONFIG,
} from '@/common/constants';
import { AppError } from '@/common/errors';
import { ActorType } from '@/common/enums';
import {
productIntegrationsRepository,
ProductIntegrationsRepository,
ProductIntegrationWithProduct,
customerReferencesRepository,
CustomerReferencesRepository,
decryptCredential,
verifyIntegrationToken,
inboundRequestSchema,
InboundRequest,
} from '@/modules/catalog/products';
declare module 'fastify' {
interface FastifyInstance {
authenticateProductIntegration: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
validatedInboundBody?: InboundRequest;
productIntegration?: ProductIntegrationWithProduct;
}
}
interface ScopeShape {
allowAnyTenant?: boolean;
tenantIds?: string[];
}
function isInScope(scope: unknown, tenantId: string): boolean {
const parsed = (scope ?? {}) as ScopeShape;
if (parsed.allowAnyTenant) return true;
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);
}
const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
integrationsRepo?: ProductIntegrationsRepository;
customerRefsRepo?: CustomerReferencesRepository;
}> = async (fastify, opts) => {
const integrationsRepo = opts.integrationsRepo ?? productIntegrationsRepository;
const customerRefsRepo = opts.customerRefsRepo ?? customerReferencesRepository;
fastify.decorate(
'authenticateProductIntegration',
async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
// Step 1: request body shape (cheap, stateless — checked before any crypto/DB work).
const parsed = inboundRequestSchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError(
'Invalid request payload.',
'VALIDATION_ERROR',
400,
parsed.error.issues,
);
}
const body = parsed.data;
// Step 2: Authorization header present and well-formed.
const authHeader = request.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token) {
throw credentialError('Missing or malformed Authorization header.');
}
// Step 3: a registered, resolvable ProductIntegration for this productId.
const integration = await integrationsRepo.findActiveByExternalProductId(body.productId);
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({
actor: 'unknown',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: `unregistered:${body.productId}`,
reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL,
});
throw credentialError('Invalid integration credential.');
}
const auditFailure = (reason: string) =>
writeAuditEvent({
actor: integration.id,
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: integration.id,
reason,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
// Step 4-5: token verifies (current secret, then previous secret if still in its
// rotation transition window) and is not expired.
const currentSecret = decryptCredential(integration.credentialRef);
let verifyResult = verifyIntegrationToken(currentSecret, token);
if (
!verifyResult.valid &&
integration.previousCredentialRef &&
integration.previousCredentialExpiresAt &&
integration.previousCredentialExpiresAt > new Date()
) {
const previousSecret = decryptCredential(integration.previousCredentialRef);
verifyResult = verifyIntegrationToken(previousSecret, token);
}
if (!verifyResult.valid) {
const reason =
verifyResult.reason === 'expired'
? INTEGRATION_AUDIT_FAILURE_REASONS.EXPIRED
: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL;
await auditFailure(reason);
throw credentialError('Invalid integration credential.');
}
// Cross-check: a valid token for a DIFFERENT product can't be replayed against this one.
if (verifyResult.claims.externalProductId !== body.productId) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 6: replay check.
if (await hasSeenJti(verifyResult.claims.jti)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.REPLAYED);
throw credentialError('Invalid integration credential.');
}
// Step 7: not revoked.
if (integration.revokedAt) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 8-9: integration and product both active.
if (integration.status !== 'active' || integration.product.status !== 'active') {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.SUSPENDED);
throw new AppError(
'This product integration is currently suspended.',
INTEGRATION_ERROR_CODES.SUSPENDED,
403,
);
}
// Step 10: scope.
if (!isInScope(integration.allowedScope, body.tenantId)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.OUT_OF_SCOPE);
throw new AppError(
"This request is outside the integration's allowed scope.",
INTEGRATION_ERROR_CODES.OUT_OF_SCOPE,
403,
);
}
// All checks passed — mark the jti seen (bounded by the token's own remaining TTL),
// resolve the CustomerReference, populate reqContext, and record success.
const ttlRemaining = Math.max(
1,
verifyResult.claims.exp -
Math.floor(Date.now() / 1000) +
INTEGRATION_TOKEN_CONFIG.CLOCK_SKEW_TOLERANCE_SECONDS,
);
await markJtiSeen(verifyResult.claims.jti, ttlRemaining);
const customerRef = await customerRefsRepo.findOrCreate(body.userId, body.tenantId);
request.validatedInboundBody = body;
request.productIntegration = integration;
request.reqContext.productId = integration.product.id;
request.reqContext.customerId = customerRef.id;
request.reqContext.tenantId = body.tenantId;
request.reqContext.actorType = ActorType.CUSTOMER;
request.reqContext.actorId = body.userId;
await writeAuditEvent({
actor: integration.id,
action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS,
entityId: integration.id,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
},
);
};
export const productIntegrationAuthPlugin = fp(productIntegrationAuthPluginCallback, {
name: 'product-integration-auth-plugin',
dependencies: ['request-context-plugin'],
});
@@ -0,0 +1,171 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/002-saas-integration/quickstart.md Scenarios 1, 2, 4 end-to-end against a real
* Postgres/Redis (matching the vitest env DATABASE_URL/REDIS_* config — requires
* docker-compose.test.yml's postgres/redis services to be reachable at that address; see
* specs/002-saas-integration/checklists/requirements.md implementation notes for a known gap
* in how the current test DATABASE_URL is wired to those services).
*/
describe('Product Integration Auth (full preHandler)', () => {
let app: FastifyInstance;
let secret: string;
const externalProductId = `TEST_PROD_${Date.now()}`;
beforeAll(async () => {
app = await buildApp();
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Integration Test Product', status: 'active' },
});
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId: product.id,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
},
});
});
afterAll(async () => {
await prismaClient.productIntegration.deleteMany({
where: { product: { externalProductId } },
});
await prismaClient.product.deleteMany({ where: { externalProductId } });
await app.close();
});
it('Scenario 1: accepts a valid, in-scope request', async () => {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const response = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'test problem',
},
});
expect(response.statusCode).toBe(202);
expect(response.json()).toMatchObject({ success: true });
});
it('Scenario 2: rejects an invalid credential and an unregistered product identically', async () => {
const badToken = issueIntegrationToken('wrong-secret', {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const invalidCredentialResponse = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${badToken}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'x',
},
});
const unregisteredResponse = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${badToken}` },
payload: {
productId: 'NEVER_REGISTERED',
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'x',
},
});
expect(invalidCredentialResponse.statusCode).toBe(401);
expect(unregisteredResponse.statusCode).toBe(401);
// Same error shape for both — a caller cannot tell "wrong credential" apart from
// "unregistered product" (FR-010). Compare everything except the per-request requestId.
expect(invalidCredentialResponse.json().error).toEqual(unregisteredResponse.json().error);
expect(invalidCredentialResponse.json().error.code).toBe('INVALID_INTEGRATION_CREDENTIAL');
});
it('Scenario 4: rejects a request with an unrecognized field', async () => {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const response = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'x',
somethingUnexpected: true,
},
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe('VALIDATION_ERROR');
});
it('replay of the same token is rejected on the second use', async () => {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const payload = {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'replay check',
};
const first = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload,
});
const second = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload,
});
expect(first.statusCode).toBe(202);
expect(second.statusCode).toBe(401);
expect(second.json().error.code).toBe('INVALID_INTEGRATION_CREDENTIAL');
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import {
encryptCredential,
decryptCredential,
generateCredentialSecret,
} from '@/modules/catalog/products/mapper/credential.crypto';
describe('Credential encryption', () => {
it('round-trips a plaintext secret through encrypt/decrypt', () => {
const secret = generateCredentialSecret();
const encrypted = encryptCredential(secret);
expect(encrypted).not.toContain(secret);
expect(decryptCredential(encrypted)).toBe(secret);
});
it('produces different ciphertext for the same plaintext on repeated calls', () => {
const secret = generateCredentialSecret();
const first = encryptCredential(secret);
const second = encryptCredential(secret);
expect(first).not.toBe(second);
});
it('fails to decrypt a tampered ciphertext', () => {
const secret = generateCredentialSecret();
const encrypted = encryptCredential(secret);
const [iv, authTag, ciphertext] = encrypted.split('.');
const tampered = `${iv}.${authTag}.${Buffer.from('tampered').toString('base64')}${ciphertext?.slice(0, 4)}`;
expect(() => decryptCredential(tampered)).toThrow();
});
it('rejects a malformed encrypted value', () => {
expect(() => decryptCredential('not-a-valid-value')).toThrow();
});
it('generates secrets of sufficient length and uniqueness', () => {
const a = generateCredentialSecret();
const b = generateCredentialSecret();
expect(a).not.toBe(b);
expect(a.length).toBeGreaterThanOrEqual(64);
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import {
issueIntegrationToken,
verifyIntegrationToken,
} from '@/modules/catalog/products/mapper/integration-token';
const SECRET = 'test-integration-secret-value';
const BASE_CLAIMS = {
externalProductId: 'PROD_DQ_001',
tenantId: 'tenant-1',
userId: 'user-1',
};
describe('Integration token issue/verify', () => {
it('verifies a freshly issued token successfully', () => {
const token = issueIntegrationToken(SECRET, BASE_CLAIMS);
const result = verifyIntegrationToken(SECRET, token);
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.claims.externalProductId).toBe(BASE_CLAIMS.externalProductId);
expect(result.claims.tenantId).toBe(BASE_CLAIMS.tenantId);
expect(result.claims.userId).toBe(BASE_CLAIMS.userId);
expect(result.claims.jti).toBeDefined();
}
});
it('rejects a token verified against the wrong secret', () => {
const token = issueIntegrationToken(SECRET, BASE_CLAIMS);
const result = verifyIntegrationToken('wrong-secret', token);
expect(result).toEqual({ valid: false, reason: 'bad_signature' });
});
it('rejects a token with a tampered payload', () => {
const token = issueIntegrationToken(SECRET, BASE_CLAIMS);
const [header, , signature] = token.split('.');
const tamperedPayload = Buffer.from(
JSON.stringify({ ...BASE_CLAIMS, userId: 'attacker' }),
).toString('base64url');
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
const result = verifyIntegrationToken(SECRET, tamperedToken);
expect(result.valid).toBe(false);
});
it('rejects a malformed token', () => {
expect(verifyIntegrationToken(SECRET, 'not-a-token')).toEqual({
valid: false,
reason: 'malformed',
});
});
it('rejects an expired token beyond clock-skew tolerance', () => {
const token = issueIntegrationToken(SECRET, { ...BASE_CLAIMS, ttlSeconds: -100 });
const result = verifyIntegrationToken(SECRET, token);
expect(result).toEqual({ valid: false, reason: 'expired' });
});
it('accepts a token issued just inside the clock-skew tolerance window', () => {
// ttl of 0 puts exp == iat == now; the 5s tolerance should still accept it immediately.
const token = issueIntegrationToken(SECRET, { ...BASE_CLAIMS, ttlSeconds: 0 });
const result = verifyIntegrationToken(SECRET, token);
expect(result.valid).toBe(true);
});
});
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/supporthub_test_db?schema=public',
JWT_SECRET: 'super-secret-test-jwt-key-min-32-characters',
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY: '204fcf94032f4e369d756127444864723cbac2b473e9d8bdaa942c1a5a4b7bec',
},
coverage: {
provider: 'v8',