# Phase 1 Data Model: SaaS Product Integration & Inbound Request Trust All models below use `cuid()` ids, matching `docs/06-database-schema.md`. This supersedes the current placeholder `Product` model in `prisma/schema.prisma` (see research.md "Reconciling the placeholder Prisma schema"). ## Product (revised) | Field | Type | Notes | |---|---|---| | id | String @id @default(cuid()) | | | externalProductId | String @unique | Reference into the SaaS — not authoritative here (Constitution Principle I) | | name | String | | | supportEnabled | Boolean @default(true) | Per spec.md/docs/01 §5: support is enabled by default per product | | status | String | `active` \| `suspended` \| `deprecated` — admin-editable, drives FR-011/FR-012 | | createdAt / updatedAt | DateTime | | **Relations added by this feature**: `integration ProductIntegration?` (1:1). Relations to `KnowledgeEntry[]`, `Runbook[]`, `Ticket[]` from doc 06 are deferred until those models exist in their owning features (Phase 3/5) — Prisma can't reference a model that doesn't exist yet. ## ProductIntegration | Field | Type | Notes | |---|---|---| | id | String @id @default(cuid()) | | | productId | String @unique | 1:1 with Product | | 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) | | rateLimitPerMinute | Int @default(60) | Integration-level limit (FR-009), admin-editable | | rateLimitPerUserPerMinute | Int @default(20) | Per-user-within-integration limit (FR-009) | | status | String @default("active") | `active` \| `suspended` — independent of Product.status so an integration can be disabled without touching the product record | | rotatedAt | DateTime? | Last rotation timestamp | | revokedAt | DateTime? | Set on revocation; a revoked integration's `credentialRef` and `previousCredentialRef` are both immediately invalid regardless of `previousCredentialExpiresAt` | | createdAt | DateTime @default(now()) | | **Validation rule**: A token is accepted only if it verifies against `credentialRef`, OR against `previousCredentialRef` AND `now() < previousCredentialExpiresAt` — AND `revokedAt IS NULL` — AND the owning `Product.status == 'active'` AND this record's own `status == 'active'`. ## CustomerReference | Field | Type | Notes | |---|---|---| | id | String @id @default(cuid()) | | | externalUserId | String | Reference only — never authoritative (Constitution Principle I) | | externalTenantId | String | | | createdAt | DateTime @default(now()) | | **Unique constraint**: `@@unique([externalUserId, externalTenantId])` — first-seen wins; repeat requests for the same user+tenant reuse the same reference row rather than creating duplicates. ## Authentication Audit Event → reuses `AuditLog` No new model. Every authentication attempt writes one `AuditLog` row: | AuditLog field | Value for an auth event | |---|---| | actor | The `ProductIntegration.id` if resolvable (even on failure, once the credential at least identifies *a* product), else `"unknown"` | | actorType | `system` | | action | `integration.auth.success` \| `integration.auth.failure` | | entityType | `ProductIntegration` | | entityId | The `ProductIntegration.id` | | reason | On failure: which check failed (`invalid_credential` \| `suspended` \| `out_of_scope` \| `expired` \| `replayed`) — never the raw token/credential | | metadata | `{ externalUserId?, externalTenantId? }` — no raw credential value, ever (FR-007/FR-010) | | createdAt | now() | ## Inbound Request Contract (validated shape, not persisted as its own table) Extends `docs/02-integration-and-security.md` §3 with the reserved idempotency field from spec.md FR-012: | Field | Type | Notes | |---|---|---| | productId | string | The *external* product id as the caller knows it — resolved to internal `Product.id` during validation | | tenantId | string | → `CustomerReference.externalTenantId` | | userId | string | → `CustomerReference.externalUserId` | | source | string | e.g. `"docuqube-web"` | | problem | string | Free-text — not validated/interpreted by this feature | | feature | string? | Optional | | referenceIds | string[]? | Optional | | context | Record? | Optional | | idempotencyKey | string? | Reserved per FR-012 — accepted and echoed if present, not deduplicated against anything yet | Validated with a Zod `.strict()` schema (research.md) — any field not in this list rejects the whole request.