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:
co-authored by
Claude Sonnet 5
parent
5444fb7ef3
commit
8d5731340d
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 +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;
|
||||
@@ -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'),
|
||||
|
||||
Vendored
+1
@@ -1,2 +1,3 @@
|
||||
export * from './redis.client';
|
||||
export * from './cache.service';
|
||||
export * from './replay-guard';
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user