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
@@ -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