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
@@ -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"
|
||||
+55
-24
@@ -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
|
||||
name String
|
||||
description String?
|
||||
status ProductStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
externalProductId String @unique // reference into SaaS, not authoritative — see
|
||||
// .specify/memory/constitution.md Principle I
|
||||
name String
|
||||
supportEnabled Boolean @default(true)
|
||||
status String @default("active") // active | suspended | deprecated
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
categories Category[]
|
||||
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?
|
||||
action String
|
||||
resource String
|
||||
payload Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
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
|
||||
entityType String
|
||||
entityId String
|
||||
oldValue Json?
|
||||
newValue Json?
|
||||
reason String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user