Files
cve-dashboard/.kiro/specs/ticket-linking/design.md

312 lines
12 KiB
Markdown

# Design Document: Ticket Linking
## Overview
The ticket-linking feature introduces a generic many-to-many association system between the three entity types in the CVE Dashboard: Archer tickets (EXC-XXXX), Jira tickets (PROJECT-XXXX), and CVEs (CVE-YYYY-NNNNN). A new `ticket_links` PostgreSQL table stores directional link records, but the system treats them as bidirectional — querying from either side returns the link. The existing `cve_id`/`vendor` foreign key columns on `archer_tickets` and `jira_tickets` remain unchanged; this feature is purely additive.
The implementation spans three layers:
1. **Database** — A `ticket_links` table with CHECK constraints, unique index, and bidirectional query indexes.
2. **Backend API** — A new Express router (`routes/links.js`) exposing POST/GET/DELETE endpoints behind `requireAuth()`.
3. **Frontend** — A reusable `LinkedItems` React component rendered on Archer and Jira detail views.
## Architecture
```mermaid
flowchart TD
subgraph Frontend
A[ArcherTicketDetail] --> C[LinkedItems Component]
B[JiraPage Detail View] --> C
C -->|POST /api/links| D[Links Router]
C -->|GET /api/links?type=&id=| D
C -->|DELETE /api/links/:id| D
end
subgraph Backend
D --> E[Validation Layer]
E --> F[PostgreSQL Pool]
end
subgraph Database
F --> G[ticket_links table]
F --> H[audit_logs table]
end
```
### Design Decisions
| Decision | Rationale |
|----------|-----------|
| TEXT columns for source/target types and IDs | Avoids polymorphic FK complexity; entity types have well-defined ID patterns validated at the application layer |
| Bidirectional queries via OR conditions | Single query returns links regardless of which side was the "source" at creation time |
| Duplicate prevention checks both (A→B) and (B→A) | Treats links as undirected edges — prevents logical duplicates stored in different directions |
| Separate `ticket_links` table (not modifying existing FKs) | Purely additive; zero risk to existing Archer/Jira/CVE workflows |
| Relationship types as TEXT with CHECK constraint | Extensible without migrations; initial set: `related`, `spawned_by`, `blocks` |
| Auto-detect entity type from key format | Reduces user friction — no need to manually select a type dropdown |
## Components and Interfaces
### Backend: `routes/links.js`
```javascript
// Express router factory — follows existing project pattern
function createLinksRouter() {
const router = express.Router();
// POST /api/links — Create a new link
router.post('/', requireAuth(), async (req, res) => { /* ... */ });
// GET /api/links?type=archer&id=EXC-6056 — Query links for an entity
router.get('/', requireAuth(), async (req, res) => { /* ... */ });
// DELETE /api/links/:id — Remove a link by ID
router.delete('/:id', requireAuth(), async (req, res) => { /* ... */ });
return router;
}
```
**Validation helpers:**
```javascript
const ENTITY_PATTERNS = {
archer: /^EXC-\d{4,}$/,
jira: /^[A-Z][A-Z0-9_]+-\d+$/,
cve: /^CVE-\d{4}-\d{4,}$/
};
const VALID_ENTITY_TYPES = ['archer', 'jira', 'cve'];
const VALID_RELATIONSHIPS = ['related', 'spawned_by', 'blocks'];
function detectEntityType(key) {
if (ENTITY_PATTERNS.cve.test(key)) return 'cve';
if (ENTITY_PATTERNS.archer.test(key)) return 'archer';
if (ENTITY_PATTERNS.jira.test(key)) return 'jira';
return null;
}
```
### Frontend: `LinkedItems` Component
```
Props:
- entityType: 'archer' | 'jira' | 'cve'
- entityId: string (e.g., 'EXC-6056')
State:
- links: Array<LinkRecord>
- loading: boolean
- showAddForm: boolean
- error: string | null
```
The component fetches links on mount via `GET /api/links?type={entityType}&id={entityId}`, renders a list of linked items with type badges and relationship labels, and provides "Add Link" / "Remove" controls.
### API Contract
**POST /api/links**
```json
// Request
{
"source_type": "archer",
"source_id": "EXC-6056",
"target_type": "jira",
"target_id": "STEAM-1234",
"relationship": "related"
}
// Response 201
{
"id": 42,
"source_type": "archer",
"source_id": "EXC-6056",
"target_type": "jira",
"target_id": "STEAM-1234",
"relationship": "related",
"created_by": 7,
"created_at": "2025-01-15T10:30:00Z"
}
```
**GET /api/links?type=archer&id=EXC-6056**
```json
// Response 200
[
{
"id": 42,
"linked_type": "jira",
"linked_id": "STEAM-1234",
"relationship": "related",
"created_by_username": "jsmith",
"created_at": "2025-01-15T10:30:00Z"
}
]
```
**DELETE /api/links/:id**
```json
// Response 200
{ "message": "Link deleted successfully" }
```
## Data Models
### `ticket_links` Table
```sql
CREATE TABLE IF NOT EXISTS ticket_links (
id SERIAL PRIMARY KEY,
source_type TEXT NOT NULL CHECK (source_type IN ('archer', 'jira', 'cve')),
source_id TEXT NOT NULL,
target_type TEXT NOT NULL CHECK (target_type IN ('archer', 'jira', 'cve')),
target_id TEXT NOT NULL,
relationship TEXT NOT NULL DEFAULT 'related'
CHECK (relationship IN ('related', 'spawned_by', 'blocks')),
created_by INTEGER REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (source_type, source_id, target_type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_ticket_links_source
ON ticket_links(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_ticket_links_target
ON ticket_links(target_type, target_id);
```
### Bidirectional Query Pattern
```sql
-- Find all links involving entity (type=?, id=?)
SELECT * FROM ticket_links
WHERE (source_type = $1 AND source_id = $2)
OR (target_type = $1 AND target_id = $2);
```
### Duplicate Prevention Query
Before inserting a new link (A→B), check that neither (A→B) nor (B→A) already exists:
```sql
SELECT id FROM ticket_links
WHERE (source_type = $1 AND source_id = $2 AND target_type = $3 AND target_id = $4)
OR (source_type = $3 AND source_id = $4 AND target_type = $1 AND target_id = $2);
```
### Migration File
A new migration `add_ticket_links_table.js` will be added to `backend/migrations/` following the existing pattern (idempotent DDL via `CREATE TABLE IF NOT EXISTS`).
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Entity Type Detection from Key Format
*For any* valid entity key matching one of the known patterns (EXC-XXXX for Archer, PROJECT-XXXX for Jira, CVE-YYYY-NNNNN for CVE), the `detectEntityType` function SHALL return the correct entity type; and *for any* string not matching any known pattern, it SHALL return null.
**Validates: Requirements 6.4, 1.3, 2.4**
### Property 2: Entity ID Format Validation
*For any* (entity_type, entity_id) pair where the entity_id does not match the expected regex pattern for that type, the Link_Service validation SHALL reject the input; and *for any* pair where the entity_id does match, validation SHALL accept it.
**Validates: Requirements 2.5**
### Property 3: Self-Link Rejection
*For any* valid entity (type, id), attempting to create a link where source and target are identical SHALL be rejected with a validation error, and the ticket_links table SHALL remain unchanged.
**Validates: Requirements 2.2**
### Property 4: Bidirectional Duplicate Prevention
*For any* two distinct entities A and B, after successfully creating a link from A to B, attempting to create either A→B again or B→A SHALL be rejected with a conflict error.
**Validates: Requirements 2.3, 8.3**
### Property 5: Bidirectional Query Completeness
*For any* entity E that appears in the ticket_links table (as either source or target), querying links for E SHALL return every link record where E appears on either side, and SHALL NOT return any link record where E does not appear.
**Validates: Requirements 3.1, 8.1, 8.2**
### Property 6: Link Creation Round-Trip
*For any* valid (source_type, source_id, target_type, target_id, relationship) tuple where source ≠ target and no duplicate exists, creating the link and then querying for it SHALL return a record with matching source_type, source_id, target_type, target_id, and relationship values.
**Validates: Requirements 2.1, 3.2**
### Property 7: Delete Removes Link from Queries
*For any* existing link record, after deleting it by ID, querying links for either the source or target entity SHALL no longer include that link in the results.
**Validates: Requirements 4.1**
## Error Handling
### Backend Error Responses
| Scenario | HTTP Status | Response Body |
|----------|-------------|---------------|
| Missing/invalid auth | 401 | `{ "error": "Authentication required" }` |
| Invalid entity type | 400 | `{ "error": "Invalid entity type. Must be one of: archer, jira, cve" }` |
| Invalid entity ID format | 400 | `{ "error": "Invalid ID format for type '{type}'. Expected: {pattern}" }` |
| Self-link attempt | 400 | `{ "error": "Cannot link an entity to itself" }` |
| Duplicate link (either direction) | 409 | `{ "error": "A link between these entities already exists", "existing_id": N }` |
| Link not found on delete | 404 | `{ "error": "Link not found" }` |
| Database error | 500 | `{ "error": "Internal server error" }` |
| Missing required fields | 400 | `{ "error": "source_type, source_id, target_type, target_id are required" }` |
### Frontend Error Handling
- API errors are caught and displayed inline in the LinkedItems component (toast or inline message).
- Network failures show a generic "Unable to reach server" message with a retry option.
- The form is NOT cleared on error so the user can correct and resubmit.
- Optimistic UI is NOT used — the list only refreshes after confirmed server success.
### Edge Cases
- **Concurrent duplicate creation**: The UNIQUE constraint on the table acts as a final safety net if two requests pass the application-layer check simultaneously. The second insert will fail with a Postgres 23505 error, which the backend maps to a 409 response.
- **Deleted entities**: Links are not cascade-deleted when an entity is removed from its source table. Orphaned links are acceptable — the UI will show the ID but may indicate "entity not found" if navigation fails.
- **Very long entity IDs**: Entity IDs are TEXT columns with no length limit at the DB level, but the regex patterns enforce reasonable lengths (Archer: 4+ digits, Jira: standard key format, CVE: standard format).
## Testing Strategy
### Unit Tests (Example-Based)
- Validation function tests: specific valid/invalid inputs for each entity type
- Default relationship value when omitted
- Auth rejection (401) for unauthenticated requests
- Empty state rendering in LinkedItems component
- Form display/hide toggle on "Add Link" button click
- Error message display on API failure
- Confirmation prompt on remove action
### Property-Based Tests
**Library**: [fast-check](https://github.com/dubzzz/fast-check) (already compatible with the project's Jest test runner)
**Configuration**: Minimum 100 iterations per property test.
Each property test will be tagged with a comment referencing the design property:
```
// Feature: ticket-linking, Property N: {property_text}
```
Properties to implement:
1. **Entity type detection** — Generate random strings matching/not-matching patterns, verify `detectEntityType` correctness
2. **Entity ID format validation** — Generate (type, id) pairs with valid/invalid formats, verify acceptance/rejection
3. **Self-link rejection** — Generate random valid entities, verify self-link is always rejected
4. **Bidirectional duplicate prevention** — Generate entity pairs, create link, verify both directions are blocked
5. **Bidirectional query completeness** — Generate a set of links, verify query returns exactly the correct subset
6. **Link creation round-trip** — Generate valid link inputs, create and query, verify data integrity
7. **Delete removes from queries** — Generate links, delete one, verify it disappears from both sides
### Integration Tests
- Full POST → GET → DELETE lifecycle against a test database
- Audit log entries created on link creation and deletion
- UNIQUE constraint fires on concurrent duplicate inserts
- Migration is idempotent (can run multiple times without error)
- Existing archer_tickets and jira_tickets schemas unchanged after migration