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>
81 lines
5.5 KiB
Markdown
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.
|