Skip to content
Open
151 changes: 151 additions & 0 deletions prisma/migrations/20260701212209_issue_and_notes/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
Warnings:

- The `status` column on the `advisory` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- The `status` column on the `issue` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- A unique constraint covering the columns `[deviceGroupMatchingId,vulnerabilityId]` on the table `issue` will be added. If there are existing duplicate values, this will fail.

*/
-- CreateEnum
CREATE TYPE "IssueStatus_new" AS ENUM ('NOT_AFFECTED', 'AFFECTED', 'FIXED', 'UNDER_INVESTIGATION');

-- CreateEnum
CREATE TYPE "NotAffectedJustification" AS ENUM ('COMPONENT_NOT_PRESENT', 'VULNERABLE_CODE_NOT_PRESENT', 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH', 'VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY', 'INLINE_MITIGATIONS_ALREADY_EXIST', 'HOSPITAL_COMPENSATING_CONTROL', 'HOSPITAL_ACCEPTS_RISK');

-- CreateEnum
CREATE TYPE "NoteStatus" AS ENUM ('PERSISTENT', 'SCOPED');

-- CreateEnum
CREATE TYPE "ScopeTargetModel" AS ENUM ('DEVICE_GROUP_MATCHING', 'ASSET', 'VULNERABILITY', 'REMEDIATION');

-- AlterTable
-- Remap "advisory"."status" to the new IssueStatus values without dropping
-- the column, so existing data is preserved instead of discarded.
ALTER TABLE "advisory" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "advisory" ALTER COLUMN "status" TYPE "IssueStatus_new"
USING (
CASE "status"::text
WHEN 'ACTIVE' THEN 'AFFECTED'
WHEN 'FALSE_POSITIVE' THEN 'NOT_AFFECTED'
WHEN 'REMEDIATED' THEN 'FIXED'
END
)::"IssueStatus_new";
ALTER TABLE "advisory" ALTER COLUMN "status" SET DEFAULT 'AFFECTED';

-- AlterTable
ALTER TABLE "issue" ADD COLUMN "deviceGroupMatchingId" TEXT,
ADD COLUMN "notAffectedJustification" "NotAffectedJustification",
ADD COLUMN "statusConfidence" "ConfidenceLevel",
ADD COLUMN "statusNotes" TEXT,
ALTER COLUMN "assetId" DROP NOT NULL;

-- Remap "issue"."status" to the new IssueStatus values without dropping
-- the column, so existing per-asset issues keep their status
-- (ACTIVE->AFFECTED, FALSE_POSITIVE->NOT_AFFECTED, REMEDIATED->FIXED).
ALTER TABLE "issue" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "issue" ALTER COLUMN "status" TYPE "IssueStatus_new"
USING (
CASE "status"::text
WHEN 'ACTIVE' THEN 'AFFECTED'
WHEN 'FALSE_POSITIVE' THEN 'NOT_AFFECTED'
WHEN 'REMEDIATED' THEN 'FIXED'
END
)::"IssueStatus_new";
ALTER TABLE "issue" ALTER COLUMN "status" SET DEFAULT 'AFFECTED';

-- DropEnum (old IssueStatus, now unreferenced since both columns were
-- repointed to IssueStatus_new above)
DROP TYPE "IssueStatus";

-- Rename the new enum into the name the old one vacated
ALTER TYPE "IssueStatus_new" RENAME TO "IssueStatus";

-- CreateTable
CREATE TABLE "note" (
"id" TEXT NOT NULL,
"text" TEXT NOT NULL,
"status" "NoteStatus" NOT NULL DEFAULT 'SCOPED',
"userId" TEXT,
"targetModel" "ScopeTargetModel",
"instanceId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "note_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "entity_filter" (
"id" TEXT NOT NULL,
"label" TEXT,
"targetModel" "ScopeTargetModel" NOT NULL,
"filter" JSONB NOT NULL,
"noteId" TEXT,
"issueId" TEXT,
"lastResolvedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "entity_filter_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "entity_filter_match" (
"id" TEXT NOT NULL,
"entityFilterId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "entity_filter_match_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "note_targetModel_instanceId_idx" ON "note"("targetModel", "instanceId");

-- CreateIndex
CREATE INDEX "note_userId_idx" ON "note"("userId");

-- CreateIndex
CREATE INDEX "note_status_idx" ON "note"("status");

-- CreateIndex
CREATE INDEX "entity_filter_noteId_idx" ON "entity_filter"("noteId");

-- CreateIndex
CREATE INDEX "entity_filter_issueId_idx" ON "entity_filter"("issueId");

-- CreateIndex
CREATE INDEX "entity_filter_targetModel_idx" ON "entity_filter"("targetModel");

-- CreateIndex
CREATE INDEX "entity_filter_match_targetId_idx" ON "entity_filter_match"("targetId");

-- CreateIndex
CREATE UNIQUE INDEX "entity_filter_match_entityFilterId_targetId_key" ON "entity_filter_match"("entityFilterId", "targetId");

-- CreateIndex
CREATE INDEX "issue_assetId_idx" ON "issue"("assetId");

-- CreateIndex
CREATE INDEX "issue_deviceGroupMatchingId_idx" ON "issue"("deviceGroupMatchingId");

-- CreateIndex
CREATE INDEX "issue_vulnerabilityId_idx" ON "issue"("vulnerabilityId");

-- CreateIndex
CREATE UNIQUE INDEX "issue_deviceGroupMatchingId_vulnerabilityId_key" ON "issue"("deviceGroupMatchingId", "vulnerabilityId");

-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_deviceGroupMatchingId_fkey" FOREIGN KEY ("deviceGroupMatchingId") REFERENCES "device_group_matching"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "note" ADD CONSTRAINT "note_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "entity_filter" ADD CONSTRAINT "entity_filter_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "entity_filter" ADD CONSTRAINT "entity_filter_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "entity_filter_match" ADD CONSTRAINT "entity_filter_match_entityFilterId_fkey" FOREIGN KEY ("entityFilterId") REFERENCES "entity_filter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
116 changes: 106 additions & 10 deletions prisma/schema.prisma

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ model User {
watchedTickets TicketWatch[]
ticketSeen TicketSeen[]
ticketActivities TicketActivity[]
notes Note[]

@@unique([email])
@@index([departmentId])
Expand Down Expand Up @@ -286,7 +287,6 @@ model DeviceGroup {
@@map("device_group")
}


model DeviceGroupMatching {
id String @id @default(cuid())

Expand All @@ -303,6 +303,7 @@ model DeviceGroupMatching {
remediations Remediation[] @relation("RemediationMatchings")
advisories Advisory[] @relation("AdvisoryMatchings")
deviceArtifacts DeviceArtifact[] @relation("DeviceArtifactMatchings")
issues Issue[]
notificationMappings NotificationDeviceGroupMapping[]

createdAt DateTime @default(now())
Expand Down Expand Up @@ -456,6 +457,7 @@ model ArtifactWrapper {

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([remediationId])
@@index([deviceArtifactId])
@@index([userId])
Expand Down Expand Up @@ -487,9 +489,10 @@ model Artifact {

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([wrapperId, versionNumber])
@@index([wrapperId])
@@index([userId])
@@unique([wrapperId, versionNumber])
@@map("artifact")
}

Expand Down Expand Up @@ -601,32 +604,125 @@ model Vulnerability {
}

enum IssueStatus {
ACTIVE
FALSE_POSITIVE
REMEDIATED
NOT_AFFECTED
AFFECTED
FIXED
UNDER_INVESTIGATION
}

enum NotAffectedJustification {
COMPONENT_NOT_PRESENT
VULNERABLE_CODE_NOT_PRESENT
VULNERABLE_CODE_NOT_IN_EXECUTE_PATH
VULNERABLE_CODE_CANNOT_BE_CONTROLLED_BY_ADVERSARY
INLINE_MITIGATIONS_ALREADY_EXIST
HOSPITAL_COMPENSATING_CONTROL
HOSPITAL_ACCEPTS_RISK
}

model Issue {
id String @id @default(cuid())

assetId String
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
deviceGroupMatchingId String?
deviceGroupMatching DeviceGroupMatching? @relation(fields: [deviceGroupMatchingId], references: [id], onDelete: Cascade)

assetId String? // now optional; when set, overrides the deviceGroupMatching-level Issue for this vuln
asset Asset? @relation(fields: [assetId], references: [id], onDelete: Cascade)

vulnerabilityId String
vulnerability Vulnerability @relation(fields: [vulnerabilityId], references: [id], onDelete: Cascade)

status IssueStatus @default(AFFECTED)
notAffectedJustification NotAffectedJustification?
statusConfidence ConfidenceLevel?
statusNotes String? @db.Text

issueRemediations IssueRemediation[]
workOrderTickets WorkOrderTicket[] @relation("WorkOrderTicketIssues")

status IssueStatus @default(ACTIVE)
filters EntityFilter[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([assetId, vulnerabilityId])
@@unique([deviceGroupMatchingId, vulnerabilityId])
@@index([assetId])
@@index([deviceGroupMatchingId])
@@index([vulnerabilityId])
@@map("issue")
}

enum NoteStatus {
PERSISTENT // always included in relevant LLM prompts, regardless of scope
SCOPED
}

enum ScopeTargetModel {
DEVICE_GROUP_MATCHING
ASSET
VULNERABILITY
REMEDIATION
}

model Note {
id String @id @default(cuid())
text String @db.Text
status NoteStatus @default(SCOPED)

userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)

filters EntityFilter[]

targetModel ScopeTargetModel?
instanceId String? // weak reference — app validates this id exists in targetModel's table

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([targetModel, instanceId])
@@index([userId])
@@index([status])
@@map("note")
}

model EntityFilter {
id String @id @default(cuid())
label String? // e.g. "All infusion pumps in the ICU"
targetModel ScopeTargetModel
filter Json // WHERE-clause fragment evaluated against targetModel's table

noteId String?
note Note? @relation(fields: [noteId], references: [id], onDelete: Cascade)
issueId String?
issue Issue? @relation(fields: [issueId], references: [id], onDelete: Cascade)
// Exactly one of noteId/issueId must be set

matches EntityFilterMatch[]
lastResolvedAt DateTime? // null until a future memoization job runs

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([noteId])
@@index([issueId])
@@index([targetModel])
@@map("entity_filter")
}

model EntityFilterMatch {
id String @id @default(cuid())
entityFilterId String
entityFilter EntityFilter @relation(fields: [entityFilterId], references: [id], onDelete: Cascade)
targetId String // weak reference into whatever table entityFilter.targetModel points to

createdAt DateTime @default(now())

@@unique([entityFilterId, targetId])
@@index([targetId])
@@map("entity_filter_match")
}

model Remediation {
id String @id @default(cuid())
artifacts ArtifactWrapper[]
Expand Down Expand Up @@ -854,7 +950,7 @@ model Advisory {
upstreamUrl String? @unique
severity Severity
summary String? @db.Text
status IssueStatus @default(ACTIVE)
status IssueStatus @default(AFFECTED)
tlp Tlp?
publishedAt DateTime?
createdAt DateTime @default(now())
Expand Down
Loading
Loading