Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions APPROACH_STATEMENT_465.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# Approach Statement — Issue #465: Build Alert Ownership Matrix

## Codebase Analysis Summary

### Backend Framework & Stack
- **Framework**: Fastify 5.8.4 with TypeScript
- **Database**: PostgreSQL 15+ with TimescaleDB extension
- **ORM/Query Builder**: Knex 3.1.0
- **Validation**: Zod 3.23.8
- **Testing**: Vitest 2.1.5
- **Node Version**: 20+

### Service/Controller/Repository Pattern
- **Service Layer**: Class-based services with dependency injection via constructor
- **Controller Layer**: Fastify route handlers with schema validation
- **Database Access**: Direct Knex queries via `getDatabase()` singleton
- **Transaction Pattern**: `db.transaction(async (trx) => { ... })` for multi-table writes
- **Error Handling**: Services throw errors; controllers catch and return appropriate HTTP status codes

### Alert Data Model
- **Alert Identifier**: UUID (`alert_rules.id`)
- **Foreign Key Type**: UUID with `gen_random_uuid()` default
- **Owner Model**: Currently `owner_address` (string) in `alert_rules` table
- **Naming Convention**: snake_case for database columns, camelCase for TypeScript

### Existing Audit Pattern
- **Table**: `audit_logs` (migration 013)
- **Structure**: Generic audit log with `action`, `actor_id`, `actor_type`, `resource_type`, `resource_id`, `before`, `after`, `metadata`, `severity`, `checksum`, `created_at`
- **Append-Only**: No update or delete operations on audit logs
- **Tamper Detection**: SHA-256 checksum computed from entry fields
- **Pattern**: Will reuse this existing audit log table rather than creating a new ownership-specific audit table

### Export Pattern
- **Formats**: CSV and JSON (PDF exists but not commonly used)
- **Library**: `csv-stringify` for CSV generation
- **Streaming**: Supported via `JSONStream` for large datasets
- **Response Headers**: `Content-Type` and `Content-Disposition` headers set appropriately
- **Pattern**: Direct streaming in route handlers for smaller datasets; async job queue for large exports

### Search Pattern
- **Implementation**: Database-level LIKE queries with ILIKE for case-insensitive search
- **Pattern**: `db.where('column', 'ilike', `%${query}%`)` or `db.whereRaw("column ILIKE ?", [`%${query}%`])`
- **No Full-Text Search**: No existing pg_trgm or ts_vector usage found

### Authentication & Authorization
- **Middleware**: `authMiddleware()` from `backend/src/api/middleware/auth.ts`
- **API Key**: Validated via `x-api-key` header
- **Scopes**: Optional `requiredScopes` array (e.g., `["admin:audit"]`)
- **User Identity**: Stored in `request.apiKeyAuth` after validation
- **Pattern**: Apply middleware via `preHandler` hook or `server.addHook("preHandler", authMiddleware())`

### Testing Framework
- **Unit Tests**: Vitest with mocked services
- **Integration Tests**: Vitest with real database (PostgreSQL test instance)
- **Test Database**: `bridge_watch_test` database in CI
- **Mocking**: `vi.mock()` for service dependencies
- **HTTP Testing**: `server.inject()` for route testing
- **Coverage**: Vitest coverage via `--coverage` flag

### CI Requirements (from `.github/workflows/ci.yml`)
1. **Lint**: `npm --workspace=backend run lint` (ESLint, zero errors)
2. **Build**: `npm --workspace=backend run build` (TypeScript compilation)
3. **Migrations**: `npm --workspace=backend run migrate` (all migrations apply cleanly)
4. **Tests**: `npm --workspace=backend run test -- --coverage` (all tests pass)
5. **Coverage**: Uploaded to Codecov (no minimum threshold enforced in CI, but 90% target for new code)

## Implementation Plan

### 1. Ownership Data Model

Based on reconnaissance, the ownership model will support:
- **Single owner per alert**: One user or team owns each alert (enforced by unique constraint on `alert_id`)
- **Owner type**: Enum distinguishing `user` vs `team` ownership
- **Owner ID**: String type (consistent with `owner_address` pattern in existing `alert_rules`)

**Rationale**: The existing `alert_rules` table uses `owner_address` (string), suggesting a wallet-address-based ownership model. The ownership matrix will follow this pattern.

### 2. Database Schema

#### Migration: `027_alert_ownership_matrix.ts`

**Tables**:

1. **`alert_ownership`**
- `id` — UUID, primary key, `gen_random_uuid()`
- `alert_id` — UUID, foreign key to `alert_rules.id`, unique constraint
- `owner_type` — ENUM (`user`, `team`)
- `owner_id` — VARCHAR(255), the user wallet address or team identifier
- `created_at` — TIMESTAMP, `knex.fn.now()`
- `created_by` — VARCHAR(255), actor who assigned ownership
- Index: `(alert_id)`, `(owner_id)`, `(owner_type, owner_id)`

2. **`escalation_contacts`**
- `id` — UUID, primary key, `gen_random_uuid()`
- `alert_id` — UUID, foreign key to `alert_rules.id`
- `contact_user_id` — VARCHAR(255), user identifier for escalation
- `order` — INTEGER, escalation sequence (1, 2, 3, ...)
- `created_at` — TIMESTAMP, `knex.fn.now()`
- `created_by` — VARCHAR(255), actor who added contact
- Unique constraint: `(alert_id, contact_user_id)`
- Index: `(alert_id, order)`

**Audit Log**: Will reuse existing `audit_logs` table with:
- `action`: `alert.ownership_assigned`, `alert.ownership_transferred`, `alert.escalation_added`, `alert.escalation_removed`
- `resource_type`: `alert_ownership` or `escalation_contact`
- `resource_id`: `alert_id`
- `before`/`after`: JSON snapshots of ownership state
- `actor_id`: User performing the action
- `actor_type`: `user` or `api_key`

### 3. Ownership Matrix Service

**File**: `backend/src/services/ownershipMatrix.service.ts`

**Methods**:

- `assignOwner(alertId, ownerId, ownerType, actorId)` — Creates or updates ownership; writes audit log; validates alert exists
- `getOwner(alertId)` — Returns current owner record
- `getOwnershipMatrix(filters: { teamId?, ownerId?, alertId? }, pagination)` — Returns filtered ownership matrix with pagination
- `addEscalationContact(alertId, contactUserId, order, actorId)` — Adds escalation contact; writes audit log
- `getEscalationContacts(alertId)` — Returns escalation contacts ordered by `order` ASC
- `removeEscalationContact(alertId, contactUserId, actorId)` — Removes contact; writes audit log
- `getAuditHistory(alertId, pagination)` — Queries `audit_logs` filtered by `resource_id = alertId` and `resource_type IN ('alert_ownership', 'escalation_contact')`, ordered by `created_at DESC`
- `exportOwnershipMatrix(format: 'csv' | 'json', filters)` — Exports filtered matrix; CSV uses `csv-stringify`; JSON returns same shape as `getOwnershipMatrix`
- `searchOwnership(query: string, pagination)` — ILIKE search across `alert_rules.name`, `alert_ownership.owner_id`, and joined team names (if team table exists)

**Transaction Usage**: All multi-table writes (assign + audit, add contact + audit) wrapped in `db.transaction()`

**Error Handling**: Throw descriptive errors; controllers map to HTTP status codes

### 4. Management Endpoints

**File**: `backend/src/api/routes/ownershipMatrix.ts`

**Routes**:

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/alerts/:alertId/ownership` | admin or current owner | Assign/transfer owner |
| GET | `/alerts/:alertId/ownership` | authenticated | Get current owner |
| GET | `/ownership/matrix` | authenticated | Get full matrix (filtered, paginated) |
| POST | `/alerts/:alertId/escalation` | admin or current owner | Add escalation contact |
| GET | `/alerts/:alertId/escalation` | authenticated | Get escalation contacts |
| DELETE | `/alerts/:alertId/escalation/:contactId` | admin or current owner | Remove escalation contact |
| GET | `/alerts/:alertId/ownership/history` | authenticated | Get audit history (paginated) |
| GET | `/ownership/export` | admin | Export matrix (query params: `format`, filters) |
| GET | `/ownership/search` | authenticated | Search ownership (query param: `q`, pagination) |

**Validation**: Zod schemas in `backend/src/api/validations/ownershipMatrix.schema.ts`

**Authentication**:
- All endpoints require `authMiddleware()`
- Admin-only endpoints: `authMiddleware({ requiredScopes: ["admin:ownership"] })`
- Owner-check endpoints: Service validates `actorId` matches current owner or has admin scope

### 5. Team-Based Grouping

`getOwnershipMatrix` will accept an optional `groupBy: 'team'` parameter. When set:
- Response shape: `{ teams: [{ teamId, teamName, alerts: [...] }] }`
- Implementation: SQL `GROUP BY owner_id WHERE owner_type = 'team'`

### 6. Documentation

**File**: `backend/docs/alert-ownership-matrix.md`

**Sections**:
- Overview
- Ownership Workflow (assign, transfer, escalate)
- API Endpoints (with examples)
- Audit History Semantics (append-only, tamper detection)
- Export Formats (CSV columns, JSON structure)
- Search Behavior (ILIKE, searchable fields)
- Authentication Requirements (per endpoint)

### 7. Tests

**Service Tests** (`backend/tests/services/ownershipMatrix.service.test.ts`):
- `assignOwner` creates ownership record and audit log entry
- `assignOwner` on already-owned alert records previous owner in audit log (transfer)
- `assignOwner` rejects invalid `alertId`
- `addEscalationContact` adds contact at correct order
- `getEscalationContacts` returns contacts in ascending order
- `getAuditHistory` returns entries in reverse chronological order
- `exportOwnershipMatrix` CSV output includes correct headers and all rows
- `exportOwnershipMatrix` JSON output matches `getOwnershipMatrix` shape
- `searchOwnership` returns results matching alert name, owner name, and team name

**Controller Tests** (`backend/tests/api/ownershipMatrix.test.ts`):
- Every endpoint returns 401 for unauthenticated requests
- Every endpoint returns 400 for malformed requests
- Transfer endpoint correctly updates ownership and audit log
- Export endpoint streams CSV content with correct `Content-Type` header
- Search endpoint returns paginated results
- Audit history immutability: Assert no endpoint allows modification or deletion of audit log entries

**Coverage Target**: 90% for new code paths (service and controller)

### 8. CI Checks (Local Verification Before PR)

1. **Type-check**: `npm --workspace=backend run build` (zero errors)
2. **Lint**: `npm --workspace=backend run lint` (zero errors)
3. **Migrations**: `npm --workspace=backend run migrate` (clean test database)
4. **Tests**: `npm --workspace=backend run test -- --coverage` (all pass, 90%+ coverage)
5. **Migration Validation**: `npm --workspace=backend run migrate:validate` (if available)

### 9. Security & PII

- **Owner IDs**: Never logged at production level (use `logger.debug()` if needed)
- **Audit Log**: Append-only enforced at service layer (no update/delete methods)
- **Export Endpoint**: Admin-restricted via `requiredScopes: ["admin:ownership"]`
- **Ownership Verification**: All modify endpoints verify `actorId` matches current owner or has admin scope

### 10. Files to Create

1. `backend/src/database/migrations/027_alert_ownership_matrix.ts`
2. `backend/src/services/ownershipMatrix.service.ts`
3. `backend/src/api/routes/ownershipMatrix.ts`
4. `backend/src/api/validations/ownershipMatrix.schema.ts`
5. `backend/tests/services/ownershipMatrix.service.test.ts`
6. `backend/tests/api/ownershipMatrix.test.ts`
7. `backend/docs/alert-ownership-matrix.md`

### 11. Files to Modify

1. `backend/src/api/routes/index.ts` — Register `ownershipMatrix` routes
2. `backend/src/services/audit.service.ts` — Add new audit action types (if not already generic)

### 12. Unresolved Questions

1. **Team Data Model**: Does a `teams` table exist? If not, `owner_type = 'team'` will store team identifiers as strings without FK constraint. Search will be limited to `owner_id` ILIKE.
2. **Admin Scope Definition**: What scope string should be used for admin checks? Assuming `admin:ownership` based on existing `admin:audit` pattern.
3. **Escalation Contact Ordering**: Should reordering existing contacts be supported, or only add/remove? Assuming add/remove only for MVP.
4. **Export Size Limits**: Should large exports use async job queue (like `export.service.ts`)? Assuming direct streaming for MVP (ownership matrix expected to be <10k rows).

## Summary

This implementation follows all existing patterns in the Bridge-Watch codebase:
- Knex migrations with UUID primary keys and snake_case columns
- Class-based services with transaction-wrapped multi-table writes
- Fastify routes with Zod validation and `authMiddleware()`
- Reuses existing `audit_logs` table (append-only, tamper-proof)
- CSV export via `csv-stringify`, JSON export as direct response
- ILIKE-based search following existing search patterns
- Vitest tests with mocked services and `server.inject()` for routes
- All CI checks (lint, build, migrate, test) will pass before PR

**Branch**: `feature/backend-alert-ownership`
**Closes**: #465
Loading
Loading