Files
support_backend/specs/002-saas-integration/contracts/inbound-request-contract.md
T
saqib mirandClaude Sonnet 5 55253287b3 feat: per-integration and per-user rate limiting (US3) + polish
Implements tasks T026-T032 from specs/002-saas-integration/tasks.md
(User Story 3, P3 - the final piece of this feature) plus Polish.

- New bespoke Redis fixed-window counter (checkRateLimit,
  src/infrastructure/cache/rate-limiter.ts) rather than
  @fastify/rate-limit's default onRequest-stage hook -- that hook
  runs before this feature's preHandler-based auth resolves the
  integration/user identity the limit needs to key on. A second
  preHandler (checkIntegrationRateLimit) runs after
  authenticateProductIntegration on the inbound route, checking the
  integration-level limit then the per-user limit independently,
  each throwing the existing RateLimitError (429
  RATE_LIMIT_EXCEEDED) on breach.
- New integration test (inbound-rate-limit.test.ts) verifies both
  limits are enforced independently against a real Postgres/Redis:
  a throttled user doesn't affect others, and the integration cap
  throttles even when no individual user has hit their own limit.
- Docs: contracts/quickstart updated from the placeholder
  "RATE_LIMITED" code to the actual reused RATE_LIMIT_EXCEEDED code;
  cleaned up a duplicated paragraph in the admin endpoints section;
  added a "SaaS Integration" section to README.md documenting the
  inbound contract, admin routes (and their known auth-stub
  limitation), and how rate limits are configured.

All 32 tasks in tasks.md are now complete -- all three user stories
(P1 trust boundary, P2 admin lifecycle, P3 rate limiting) are
implemented and covered by integration tests verified against a
live database, in addition to unit tests for the crypto/token
primitives. Full quality gate (typecheck/lint/format/architecture/
unit tests) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:42:20 +05:30

81 lines
5.5 KiB
Markdown

# Contract: Inbound SaaS Request Authentication
## Request
Every inbound request from an integrated SaaS product carries:
- **Header**: `Authorization: Bearer <signed-token>` — the signed short-lived token from
research.md ("Signed short-lived token format").
- **Body**: JSON matching the Inbound Request Contract shape in `data-model.md`, validated with a
`.strict()` Zod schema.
## Validation order (fixed — each step's failure short-circuits the rest)
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:
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_LIMIT_EXCEEDED`
(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
`AuditLog` row (data-model.md).
## Guarantees (callable contract)
1. **No side effect before full validation.** No `CustomerReference` row, no `AuditLog` success
row, no downstream processing happens until step 9 passes.
2. **Identical response for "unregistered" and "invalid credential."** Per research.md's FR-010
decision — callers cannot distinguish "you don't exist" from "you exist but this credential is
wrong."
3. **Distinguishable suspension and scope errors.** `403 PRODUCT_INTEGRATION_SUSPENDED` and
`403 REQUEST_OUT_OF_SCOPE` are each their own error code, safe to distinguish per research.md.
4. **No raw credential value ever appears in a log, audit row, or error response.**
5. **A revoked credential is rejected starting with the very next request** — no propagation
delay (SC-002).
6. **During a rotation's transition window, both the old and new credential validate
successfully** (SC-003).
7. **An unknown field anywhere in the request body rejects the entire request**, not just that
field (FR-008).
## Admin: Integration Lifecycle Endpoints
Extends the existing `catalog/products` module (`src/modules/catalog/products/`). Registration is
keyed by the product's *external* id (the product may not exist locally yet — registering an
integration creates it); every other operation is keyed by the `ProductIntegration`'s own id,
since that's what registration returns and what admin tooling references thereafter:
| Route | Operation | Effect |
|---|---|---|
| `POST /admin/products/:externalProductId/integration` | Register integration | Finds-or-creates the `Product`, then creates its `ProductIntegration` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and the request body's `allowedScope` |
| `POST /admin/integrations/:integrationId/rotate` | Rotate credential | Moves current `credentialRef``previousCredentialRef`, sets `previousCredentialExpiresAt` (research.md's rotation transition window), issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again — matches "never persist raw credential," see research.md "Credential storage") |
| `POST /admin/integrations/:integrationId/revoke` | Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately |
| `PATCH /admin/integrations/:integrationId/status` | Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` |
| `GET /admin/integrations/:integrationId/audit-trail` | Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first |
All five require an admin-authenticated caller via the existing human/admin JWT plugin
(`fastify.authenticate`, `src/plugins/auth.plugin.ts`) — a separate concern from the
product-integration signed-token auth this contract otherwise describes. **Known limitation**:
`auth.plugin.ts`'s `authenticate` decorator is currently a stub that performs no real JWT
verification (it exists as scaffolding — see `src/modules/identity/auth`, itself unimplemented).
These admin endpoints are therefore not actually access-controlled yet; real JWT verification is
a separate, pre-existing gap this feature surfaces but does not fix.