312 lines
12 KiB
Markdown
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
|