diff --git a/.github/workflows/alertdesk.yml b/.github/workflows/alertdesk.yml index f603784..73015c9 100644 --- a/.github/workflows/alertdesk.yml +++ b/.github/workflows/alertdesk.yml @@ -2,12 +2,12 @@ name: AlertDesk on: push: - branches: [main, "intern/**"] + branches: [main, "intern/**", "interns/**"] paths: - "alertdesk/**" - ".github/workflows/alertdesk.yml" pull_request: - branches: [main, "intern/**"] + branches: [main, "intern/**", "interns/**"] paths: - "alertdesk/**" - ".github/workflows/alertdesk.yml" @@ -38,7 +38,6 @@ jobs: container-scan: runs-on: ubuntu-latest needs: [test] - if: hashFiles('alertdesk/Dockerfile') != '' steps: - uses: actions/checkout@v4 - run: docker build -t alertdesk:ci . diff --git a/alertdesk/.dockerignore b/alertdesk/.dockerignore new file mode 100644 index 0000000..2343052 --- /dev/null +++ b/alertdesk/.dockerignore @@ -0,0 +1,14 @@ +.git +.venv +venv +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +.coverage +htmlcov +tests +data/* +!data/.gitkeep +*.db +*.sqlite diff --git a/alertdesk/Dockerfile b/alertdesk/Dockerfile new file mode 100644 index 0000000..fdcfcd4 --- /dev/null +++ b/alertdesk/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY alertdesk ./alertdesk +RUN pip install --no-cache-dir . \ + && groupadd --system app \ + && useradd --system --gid app --create-home app \ + && mkdir -p /app/data \ + && chown app:app /app/data + +COPY config ./config + +USER app +EXPOSE 8000 + +CMD ["uvicorn", "alertdesk.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/alertdesk/README.md b/alertdesk/README.md index 8156c6d..c157d0a 100644 --- a/alertdesk/README.md +++ b/alertdesk/README.md @@ -33,6 +33,13 @@ curl -s -H "Authorization: Bearer analyst-token" \ Lead token `lead-token` may transition; `analyst-token` may not. +## Run with Docker Compose + +From this directory, run `docker compose up --build`. Compose exposes the API +on port 8000 and keeps SQLite data in the named `alertdesk-data` volume. See +[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for persistence and schema-change +notes. + ## Git / PR workflow | Work | PR target | diff --git a/alertdesk/SECURITY.md b/alertdesk/SECURITY.md index f1ab565..7411989 100644 --- a/alertdesk/SECURITY.md +++ b/alertdesk/SECURITY.md @@ -1,29 +1,64 @@ # Security Policy -> **Week 2 task:** Replace the placeholders below with your own policy. -> Delete this callout when you commit the finished file. - ## Reporting a vulnerability - +If you find a vulnerability in AlertDesk, report it privately to the +maintainer: + +- **Name:** George +- **Email:** baningeorge@gmail.com -[Describe your reporting process here.] +Do not publicly disclose vulnerabilities or include sensitive information +in public issues. ## Authentication (scaffold vs Month 4) - - +The current lab uses stub bearer tokens for authentication. + +The lab tokens and their associated roles are defined in: -[Describe auth handling here.] +`config/client-spec.json` + +These tokens are intended for the internship lab environment only and +must not be reused as production credentials. + +Proper authentication is planned for Month 4. ## Database and audit log - +AlertDesk stores ticket data in the configured SQLite database. + +The database location is controlled by the `ALERTDESK_DB` environment +variable. -[Describe data handling here.] +Security-relevant actions are recorded in the `audit_events` table. Ticket +creation, assignment, and status transitions are audited. While the SQLite +`BEFORE UPDATE` and `BEFORE DELETE` triggers remain in place, they block +ordinary attempts to modify or delete audit records. This is not tamper-proof +protection against someone with sufficient access to the database file or +schema. + +Audit logs must not contain passwords, bearer tokens, or other sensitive +secrets. + +Database backups should be performed according to the deployment +environment's backup and recovery requirements. ## Scope limitations - +This security policy applies to the AlertDesk internship/scaffold +environment. + +AlertDesk is **not a SOAR (Security Orchestration, Automation and +Response) platform or a SIEM (Security Information and Event Management) +platform**. + +The scaffold uses stub tokens for testing only and should not be used +for real security incidents or production security operations. + +The following are outside the current scaffold scope: -[Describe scope and limitations here.] +- Slack integration +- SIEM connectors +- SSO +- Multi-region operation \ No newline at end of file diff --git a/alertdesk/alertdesk/app.py b/alertdesk/alertdesk/app.py index 03ea4d0..6d18b2c 100644 --- a/alertdesk/alertdesk/app.py +++ b/alertdesk/alertdesk/app.py @@ -9,9 +9,12 @@ from pydantic import BaseModel, Field from . import db +from .observability import RequestLoggingMiddleware, configure_logging from .auth import require from .spec import can_transition +configure_logging() + @asynccontextmanager async def lifespan(_app: FastAPI): @@ -28,6 +31,8 @@ async def lifespan(_app: FastAPI): lifespan=lifespan, ) +app.add_middleware(RequestLoggingMiddleware) + class TicketCreate(BaseModel): title: str = Field(min_length=1) diff --git a/alertdesk/alertdesk/db.py b/alertdesk/alertdesk/db.py index fbabfa2..7420a34 100644 --- a/alertdesk/alertdesk/db.py +++ b/alertdesk/alertdesk/db.py @@ -46,6 +46,18 @@ def init_schema(conn: sqlite3.Connection) -> None: created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (ticket_id) REFERENCES tickets(id) ); + + CREATE TRIGGER IF NOT EXISTS audit_events_no_update + BEFORE UPDATE ON audit_events + BEGIN + SELECT RAISE(ABORT, 'audit_events rows are append-only'); + END; + + CREATE TRIGGER IF NOT EXISTS audit_events_no_delete + BEFORE DELETE ON audit_events + BEGIN + SELECT RAISE(ABORT, 'audit_events rows are append-only'); + END; """ ) conn.commit() diff --git a/alertdesk/alertdesk/observability.py b/alertdesk/alertdesk/observability.py new file mode 100644 index 0000000..d4dd9b4 --- /dev/null +++ b/alertdesk/alertdesk/observability.py @@ -0,0 +1,58 @@ +"""Structured request logging that excludes credentials and request bodies.""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Any + +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + fields: dict[str, Any] = { + "timestamp": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname.lower(), + "logger": record.name, + "message": record.getMessage(), + } + fields.update(getattr(record, "event_fields", {})) + return json.dumps(fields, separators=(",", ":"), default=str) + + +def configure_logging() -> None: + logger = logging.getLogger("alertdesk.http") + if not any(getattr(handler, "_alertdesk_json", False) for handler in logger.handlers): + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + handler._alertdesk_json = True # type: ignore[attr-defined] + logger.addHandler(handler) + logger.setLevel(logging.INFO) + logger.propagate = False + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + started = time.perf_counter() + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + return response + finally: + elapsed_ms = round((time.perf_counter() - started) * 1000, 3) + logging.getLogger("alertdesk.http").info( + "http_request", + extra={"event_fields": { + "method": request.method, + "path": request.url.path, + "status_code": status_code, + "duration_ms": elapsed_ms, + }}, + ) diff --git a/alertdesk/compose.yaml b/alertdesk/compose.yaml new file mode 100644 index 0000000..fc9578f --- /dev/null +++ b/alertdesk/compose.yaml @@ -0,0 +1,12 @@ +services: + alertdesk: + build: + context: . + ports: + - "127.0.0.1:8001:8000" + volumes: + - alertdesk-data:/app/data + restart: unless-stopped + +volumes: + alertdesk-data: diff --git a/alertdesk/docs/BENCHMARKS.md b/alertdesk/docs/BENCHMARKS.md new file mode 100644 index 0000000..d754bef --- /dev/null +++ b/alertdesk/docs/BENCHMARKS.md @@ -0,0 +1,44 @@ +# Week 5 performance baseline and observability + +## Benchmark + +`scripts/benchmark.py` measures end-to-end HTTP latency for ticket creation and +listing against a running AlertDesk instance. It performs five warm-up pairs by +default, then records 100 measured create/list pairs. Results report mean, +median, p95, minimum, and maximum latency in milliseconds as JSON. The script +uses Python's standard library and the analyst bearer token configured by the +sample client spec. + +Start the service in one terminal with `python -m alertdesk.cli`, then run +`python scripts/benchmark.py` in another. Options include `--base-url`, `--token`, +`--iterations`, and `--warmup`. Benchmark records use unique synthetic titles +and are persisted by the target service; use a disposable database for repeatable +runs. Listing time naturally rises as the table grows because the endpoint returns +all tickets. Results depend on machine load, database size, and whether the API +is local, so compare runs only under similar conditions. + +## Baseline + +No latency target is specified in the client brief. Capture a baseline on the +intended development environment and append the command, date, database size, +iteration count, and emitted JSON summary here. Do not treat measurements from a +single developer machine as a production service-level objective. + +## Request logging + +The API emits one JSON log event per HTTP request with timestamp, method, URL +path, status code, and elapsed milliseconds. The logger intentionally excludes +headers, bearer tokens, query strings, request bodies, and ticket data. These +fields are enough to identify slow routes and error rates without copying alert +contents into general application logs. Logs go to stderr through Python's +standard logging handler and can be collected by the process supervisor or +container runtime. + +## Operational notes + +- The benchmark includes HTTP client, API, SQLite, and response serialization time. +- The list endpoint has no pagination, so its latency and response size grow with + the number of stored tickets. +- The benchmark creates records and does not clean them up; point it at a + disposable database when measuring repeatedly. +- This is a local development baseline, not a capacity or concurrency test. diff --git a/alertdesk/docs/DEPLOYMENT.md b/alertdesk/docs/DEPLOYMENT.md new file mode 100644 index 0000000..60b5d7d --- /dev/null +++ b/alertdesk/docs/DEPLOYMENT.md @@ -0,0 +1,35 @@ +# Local container deployment + +From the `alertdesk/` directory, start the API with: + +```bash +docker compose up --build +``` + +The API is available at . Compose stores SQLite's +`data/alertdesk.sqlite` in the named `alertdesk-data` volume, so recreating the +container does not remove ticket data. Back up that volume before upgrading or +removing it. `docker compose down -v` deletes the volume and its data. + +The image runs Uvicorn as the unprivileged `app` user. The application image +contains the runtime client specification and creates the SQLite database on +startup. + +## Schema changes + +Week 7 does not add or change database columns, so no migration is needed for +this release. Before a future column change, add a versioned migration that +records the current schema version, applies the new schema in a transaction, +and is safe to rerun. Back up the SQLite volume before applying it; test both a +fresh database and a copy of the prior release's database. + +## Request logging + +The API writes one structured JSON log event for each HTTP request. Each event +includes the request method, URL path, response status code, and duration in +milliseconds. The middleware does not log credentials or request bodies. These logs +are emitted to the container's standard error stream (stderr) and can be +collected by the container runtime's logging system. + +The CI `container-scan` job builds this Dockerfile and fails on Trivy HIGH or +CRITICAL findings. diff --git a/alertdesk/docs/SECURITY_ASSESSMENT.md b/alertdesk/docs/SECURITY_ASSESSMENT.md new file mode 100644 index 0000000..ba3b2fa --- /dev/null +++ b/alertdesk/docs/SECURITY_ASSESSMENT.md @@ -0,0 +1,85 @@ +# AlertDesk Security Assessment + + +## Summary + +AlertDesk checks bearer tokens against role mappings in `config/client-spec.json` and applies role checks to ticket actions. The tokens are fixed lab values, so they identify a configured role rather than securely verifying a real user's identity. This is suitable only for the stated lab scaffold. + +Do not deploy this authentication setup for real users or sensitive data. Before production use, replace stub tokens with an authentication system that supports identity, token expiry and revocation, and secure transport. + +## Findings by relevant OWASP Top 10:2025 categories + +### A01:2025 — Broken Access Control + +**Current safeguards:** Routes require role permissions for create, list, get, assign, and transition actions. Status changes are checked against the configured transition rules. + +**Risk:** Every role allowed to list or get tickets can access all tickets. The current code does not show per-user, team, or tenant restrictions. This may be acceptable for the lab, but would expose tickets if users should only see records within their assigned scope. + +**Next step:** Define the intended ticket visibility rules and enforce them on list, get, assign, and transition operations before adding real users. + +### A02:2025 — Security Misconfiguration + +**Current safeguards:** The project describes its tokens as lab stubs, and the API documents its purpose. + +**Risk:** OpenAPI and Swagger documentation are enabled by default. The sample tokens are stored in the repository's client specification. This is convenient for the lab but should not be treated as safe production configuration. + +**Next step:** For any deployment with real data, review documentation exposure and move credentials to an appropriate secret-management mechanism. + +### A03:2025 — Software Supply Chain Failures + +**Observation:** The project specifies minimum dependency versions in `pyproject.toml`. This assessment did not verify a lock file, dependency scanning, or the integrity of installed packages. + +**Next step:** Add dependency review and update checks to the project workflow before production use. + +### A04:2025 — Cryptographic Failures + +**Risk:** Bearer tokens are credentials. Anyone who obtains a token can use its associated role. The application code does not configure transport encryption; HTTPS must be provided by the deployment environment. + +**Next step:** Require HTTPS wherever tokens are sent, and replace the fixed lab tokens with credentials that can be rotated and revoked. + +### A05:2025 — Injection + +**Current safeguard:** Database operations shown in the application use SQL parameters for user-controlled values, which reduces SQL injection risk in those queries. + +**Limit:** This is a code review of the visible queries, not a complete security test. + +**Next step:** Keep using parameterized queries for all database operations. + +### A06:2025 — Insecure Design + +**Risk:** The authentication design intentionally maps static shared tokens directly to roles. It does not provide individual user identity, token expiry, or revocation. This limits accountability and incident response. + +**Next step:** Treat the scaffold as lab-only and design production identity, authorization, and credential lifecycle requirements before expanding deployment. + +### A07:2025 — Authentication Failures + +**Risk:** The tokens in `config/client-spec.json` are fixed sample values. The code checks whether a supplied token appears in that file, but the tokens do not expire or have a revocation mechanism. The HTTP Bearer scheme documents how clients send credentials; it does not make the tokens cryptographically secure. + +**Next step:** Do not reuse the sample tokens outside the lab. Replace them with an authentication system that supports individual identities, expiration, rotation, and revocation. + +### A09:2025 — Security Logging and Alerting Failures + +**Current safeguards:** Ticket creation, assignment, and status transitions write audit rows. + +**Risks:** Audit rows record the actor's role rather than a distinct user identity. Event details include ticket titles, assignees, and status changes, which may contain sensitive information. The table schema does not enforce append-only behavior, and this review did not verify log access controls, retention, or alerting. + +**Next step:** Define what information may be logged, minimize sensitive details, and add appropriate access, retention, and integrity controls before production use. + +### A10:2025 — Mishandling of Exceptional Conditions + +**Current safeguards:** The application returns explicit responses for several expected conditions, including missing or unknown tokens, insufficient role permissions, missing tickets, invalid severities, and illegal status transitions. + +**Limit:** This assessment did not evaluate every unexpected database or runtime failure. + +**Next step:** Review error handling for unexpected failures and ensure responses do not expose internal details. + +## Scope and limitations + +This assessment covers the current code scaffold and its documented lab configuration. It does not verify deployment settings, HTTPS termination, host security, dependency integrity, or operational monitoring. AlertDesk is an internship project scaffold, not a production security platform. + +## OWASP reference + +- [OWASP Top 10:2025](https://top10.owasp.org/2025/) +- [A01:2025 — Broken Access Control](https://top10.owasp.org/2025/A01_2025-Broken_Access_Control/) +- [A07:2025 — Authentication Failures](https://top10.owasp.org/2025/A07_2025-Authentication_Failures/) +- [A09:2025 — Security Logging and Alerting Failures](https://top10.owasp.org/2025/A09_2025-Security_Logging_and_Alerting_Failures/) \ No newline at end of file diff --git a/alertdesk/docs/SPEC.md b/alertdesk/docs/SPEC.md new file mode 100644 index 0000000..09d94bd --- /dev/null +++ b/alertdesk/docs/SPEC.md @@ -0,0 +1,51 @@ +# AlertDesk Specification + +## 1. Purpose + +AlertDesk is a web service that security analysts use to file and progress security alerts through a controlled ticket workflow. + +## 2. API Resources + +The main API resource is **tickets**. + +A ticket contains: + +- **title** +- **description** +- **severity** + +Supported ticket operations include: + +- Creating tickets +- Listing tickets +- Retrieving individual tickets +- Assigning tickets + +Authorized roles may also change ticket status. + +## 3. Severity + +Severity values are client-defined. + +The current sample values are: + +- `low` +- `medium` +- `high` +- `critical` + +The implementation must read client-defined values from configuration rather than hardcoding them in Python. + +## 4. Ticket Status Workflow + +The configured statuses are `new`, `triaged`, `assigned`, `resolved`, and +`closed`. Legal transitions, as defined in `config/client-spec.json`, are: + +- `new` -> `triaged` or `closed` +- `triaged` -> `assigned` or `closed` +- `assigned` -> `resolved` or `closed` +- `resolved` -> `closed` +- `closed` -> no further status + +The service reads this transition map from the client specification and rejects +status changes not listed there. diff --git a/alertdesk/docs/functional_requirements b/alertdesk/docs/functional_requirements new file mode 100644 index 0000000..fdc0600 --- /dev/null +++ b/alertdesk/docs/functional_requirements @@ -0,0 +1,187 @@ +1. Purpose + +This document defines the functional requirements for AlertDesk. These requirements describe the functions the service must provide based on the current client brief and configuration-driven workflow requirement. + +2. Functional Requirements + +ID + +Function Requirement + +FR-01 + +Create ticket + +The system shall allow an authorized analyst to create a security ticket. + +FR-02 + +Ticket information + +The system shall allow a ticket to contain a title, description, and severity. + +FR-03 + +Severity selection + +The system shall allow ticket severity to be selected from the values defined by the client specification. + +FR-04 + +List tickets + +The system shall allow an authorized analyst to list/retrieve security tickets. + +FR-05 + +Get ticket + +The system shall allow an authorized analyst to retrieve the details of an individual ticket. + +FR-06 + +Assign ticket + +The system shall allow an authorized analyst to assign a ticket to an analyst identifier, such as an email address or handle. + +FR-07 + +Change ticket status + +The system shall allow only authorized Lead and Admin roles to change a ticket status. + +FR-08 + +Validate status transition + +The system shall reject a ticket-status change when the transition is not defined as legal in the client specification. + +FR-09 + +Configuration-driven workflow + +The system shall obtain ticket statuses, roles, severity values, and legal status transitions from the client JSON specification rather than hardcoding them in Python. + +FR-10 + +Role-based authorization + +The system shall enforce permissions according to the authenticated user’s role. + +FR-11 + +Audit ticket creation + +The system shall record ticket-creation actions in an audit trail. + +FR-12 + +Audit assignment + +The system shall record ticket-assignment actions in an audit trail. + +FR-13 + +Audit status transition + +The system shall record ticket-status transitions in an audit trail. + +FR-14 + +Audit actor and time + +The system shall record who performed an audited action and when it occurred. + +FR-15 + +API service + +The system shall expose the ticket functions through a web service/API rather than a CLI-only interface. + +FR-16 + +OpenAPI documentation + +The running application shall expose OpenAPI documentation describing the available API operations and schemas. + +3. Role Permissions + +Role Create List/Get Assign Change status + +Analyst Yes Yes Yes No + +Lead Yes Yes Yes Yes + +Admin Yes Yes Yes Yes + +4. Current Sample Workflow + +The current sample workflow is defined by `config/client-spec.json`. + +Legal transitions are: + +- `new` → `triaged` or `closed` +- `triaged` → `assigned` or `closed` +- `assigned` → `resolved` or `closed` +- `resolved` → `closed` +- `closed` → no further status + +The application shall read the legal transitions from the JSON specification and reject status changes that are not permitted. Direct transitions to `closed` are allowed where defined by the client specification. + + + +5. Acceptance Criteria + +.Area + +.Acceptance criterion + +.Ticket creation + +An authorized analyst can create a ticket with a title, description, and a valid client-defined severity. + +Ticket retrieval + +An authorized analyst can list tickets and retrieve an individual ticket. + +Assignment + +An authorized analyst can assign a ticket to an analyst identifier. + +Status authorization + +An Analyst cannot change status; a Lead or Admin can change status when otherwise valid. + +Transition validation + +An attempted illegal status transition is rejected. + +Audit trail + +Creating, assigning, or transitioning a ticket creates an audit record identifying the actor and time. + +Configuration + +Changing the client JSON specification can change supported statuses/roles/transitions without hardcoding those values in Python. + +OpenAPI + +The running service exposes its API contract through OpenAPI documentation. + +6. Functional Scope Exclusions + +Slack integration + +SIEM connectors + +Single Sign-On (SSO) + +Multi-region operation + +7. Important Implementation Rule + +The client specification is the source of truth for configurable workflow elements. Application code and tests should use the current specification rather than duplicating status or role values as hardcoded Python enums. + +8. Summary + +AlertDesk must provide a service for creating, viewing, assigning, and progressing security tickets. It must enforce role permissions, reject illegal status transitions, maintain an audit trail, and expose OpenAPI documentation. The workflow and role rules must remain configurable through the client specification. \ No newline at end of file diff --git a/alertdesk/docs/openapi.json b/alertdesk/docs/openapi.json new file mode 100644 index 0000000..b22d2a1 --- /dev/null +++ b/alertdesk/docs/openapi.json @@ -0,0 +1,375 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "AlertDesk", + "description": "Security alert and ticket intake. Statuses and roles come from client-spec.json.", + "version": "0.1.0" + }, + "paths": { + "/health": { + "get": { + "summary": "Health", + "operationId": "health_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Health Health Get" + } + } + } + } + } + } + }, + "/tickets": { + "get": { + "summary": "List Tickets", + "operationId": "list_tickets_tickets_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Response List Tickets Tickets Get" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + }, + "post": { + "summary": "Create Ticket", + "operationId": "create_ticket_tickets_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Create Ticket Tickets Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/tickets/{ticket_id}": { + "get": { + "summary": "Get Ticket", + "operationId": "get_ticket_tickets__ticket_id__get", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "ticket_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Ticket Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Ticket Tickets Ticket Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tickets/{ticket_id}/assign": { + "post": { + "summary": "Assign Ticket", + "operationId": "assign_ticket_tickets__ticket_id__assign_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "ticket_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Ticket Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssignBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Assign Ticket Tickets Ticket Id Assign Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/tickets/{ticket_id}/transition": { + "post": { + "summary": "Transition Ticket", + "operationId": "transition_ticket_tickets__ticket_id__transition_post", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "ticket_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Ticket Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransitionBody" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Transition Ticket Tickets Ticket Id Transition Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AssignBody": { + "properties": { + "assignee": { + "type": "string", + "minLength": 1, + "title": "Assignee" + } + }, + "type": "object", + "required": [ + "assignee" + ], + "title": "AssignBody" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "TicketCreate": { + "properties": { + "title": { + "type": "string", + "minLength": 1, + "title": "Title" + }, + "description": { + "type": "string", + "title": "Description", + "default": "" + }, + "severity": { + "type": "string", + "title": "Severity" + } + }, + "type": "object", + "required": [ + "title", + "severity" + ], + "title": "TicketCreate" + }, + "TransitionBody": { + "properties": { + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "TransitionBody" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + }, + "securitySchemes": { + "HTTPBearer": { + "type": "http", + "scheme": "bearer" + } + } + } +} diff --git a/alertdesk/scripts/benchmark.py b/alertdesk/scripts/benchmark.py new file mode 100644 index 0000000..db0ad28 --- /dev/null +++ b/alertdesk/scripts/benchmark.py @@ -0,0 +1,92 @@ +"""Measure AlertDesk create and list request latency against a running API. + +Example: python scripts/benchmark.py --base-url http://127.0.0.1:8000 --iterations 100 +The service must already be running. Each run creates uniquely named tickets. +""" + +from __future__ import annotations + +import argparse +import json +import math +import statistics +import time +import urllib.error +import urllib.request +import uuid + + +def request(base_url: str, path: str, token: str, payload: dict | None = None) -> tuple[int, float]: + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request( + f"{base_url.rstrip('/')}{path}", data=data, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + method="POST" if data is not None else "GET", + ) + started = time.perf_counter() + try: + with urllib.request.urlopen(req, timeout=30) as response: + response.read() + status = response.status + except urllib.error.HTTPError as exc: + exc.read() + status = exc.code + elapsed_ms = (time.perf_counter() - started) * 1000 + if not 200 <= status < 300: + raise RuntimeError(f"{req.method} {path} returned HTTP {status}") + return status, elapsed_ms + + +def percentile(values: list[float], percentile_value: float) -> float: + ordered = sorted(values) + index = max(0, math.ceil(percentile_value * len(ordered)) - 1) + return ordered[index] + + +def summarize(values: list[float]) -> dict[str, float]: + return { + "count": len(values), + "mean_ms": round(statistics.mean(values), 3), + "median_ms": round(statistics.median(values), 3), + "p95_ms": round(percentile(values, 0.95), 3), + "min_ms": round(min(values), 3), + "max_ms": round(max(values), 3), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:8000") + parser.add_argument("--token", default="analyst-token") + parser.add_argument("--iterations", type=int, default=100) + parser.add_argument("--warmup", type=int, default=5) + args = parser.parse_args() + if args.iterations < 1 or args.warmup < 0: + parser.error("--iterations must be positive and --warmup cannot be negative") + + create_times: list[float] = [] + list_times: list[float] = [] + for index in range(args.warmup + args.iterations): + payload = { + "title": f"benchmark-{uuid.uuid4().hex}", + "description": "Synthetic benchmark record", + "severity": "low", + } + _, create_ms = request(args.base_url, "/tickets", args.token, payload) + _, list_ms = request(args.base_url, "/tickets", args.token) + if index >= args.warmup: + create_times.append(create_ms) + list_times.append(list_ms) + + print(json.dumps({ + "base_url": args.base_url.rstrip("/"), + "iterations": args.iterations, + "warmup": args.warmup, + "units": "ms", + "create_ticket": summarize(create_times), + "list_tickets": summarize(list_times), + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/alertdesk/tests/test_auth.py b/alertdesk/tests/test_auth.py index a2bf2b9..ab861ec 100644 --- a/alertdesk/tests/test_auth.py +++ b/alertdesk/tests/test_auth.py @@ -8,14 +8,102 @@ - malformed JSON / extra fields """ + +import os + import pytest +from fastapi.testclient import TestClient + +from alertdesk import db +from alertdesk.app import app +@pytest.fixture() +def client(tmp_path, monkeypatch): + dbfile = tmp_path / "test.sqlite" + monkeypatch.setenv("ALERTDESK_DB", str(dbfile)) + monkeypatch.setenv( + "ALERTDESK_SPEC", + str(os.path.abspath("config/client-spec.json")) + ) + + conn = db.connect(dbfile) + db.init_schema(conn) + conn.close() + + with TestClient(app) as c: + yield c +def test_analyst_cannot_transition(client: TestClient): + created = client.post( + "/tickets", + json={"title": "Test ticket", "severity": "high"}, + headers={"Authorization": "Bearer analyst-token"}, + ) + assert created.status_code == 200 + + ticket_id = created.json()["id"] + + res = client.post( + f"/tickets/{ticket_id}/transition", + json={"status": "triaged"}, + headers={"Authorization": "Bearer analyst-token"}, + ) + + assert res.status_code == 403 + + +def test_unknown_token_is_401(client: TestClient): + res = client.get( + "/tickets", + headers={"Authorization": "Bearer fake-token"}, + ) + + assert res.status_code == 401 + + +def test_assign_then_triaged_transition(client: TestClient): + created = client.post( + "/tickets", + json={"title": "Assigned ticket", "severity": "high"}, + headers={"Authorization": "Bearer admin-token"}, + ) + + assert created.status_code == 200 + + ticket_id = created.json()["id"] + + assigned = client.post( + f"/tickets/{ticket_id}/assign", + json={"assignee": "analyst"}, + headers={"Authorization": "Bearer admin-token"}, + ) + + assert assigned.status_code == 200 + + transitioned = client.post( + f"/tickets/{ticket_id}/transition", + json={"status": "triaged"}, + headers={"Authorization": "Bearer admin-token"}, + ) + + assert transitioned.status_code == 200 + +def test_invalid_severity_is_400(client: TestClient): + res = client.post( + "/tickets", + json={"title": "Invalid severity", "severity": "urgent"}, + headers={"Authorization": "Bearer admin-token"}, + ) + assert res.status_code == 400 -@pytest.mark.skip(reason="Week 3 — implement analyst-cannot-transition") -def test_analyst_cannot_transition(): - pass +def test_malformed_json_is_rejected(client: TestClient): + res = client.post( + "/tickets", + content='{"title": "Broken JSON", "severity": "high"', + headers={ + "Authorization": "Bearer admin-token", + "Content-Type": "application/json", + }, + ) -@pytest.mark.skip(reason="Week 3 — implement unknown bearer token") -def test_unknown_token_is_401(): - pass + assert res.status_code == 422 diff --git a/alertdesk/tests/test_tickets.py b/alertdesk/tests/test_tickets.py index 4c35ca5..499c938 100644 --- a/alertdesk/tests/test_tickets.py +++ b/alertdesk/tests/test_tickets.py @@ -1,8 +1,9 @@ -"""Starter API tests — one happy path and one illegal transition.""" +"""Starter API tests — ticket creation, transitions, and audit protection.""" from __future__ import annotations import os +import sqlite3 import pytest from fastapi.testclient import TestClient @@ -19,6 +20,7 @@ def client(tmp_path, monkeypatch): conn = db.connect(dbfile) db.init_schema(conn) conn.close() + with TestClient(app) as c: yield c @@ -48,3 +50,37 @@ def test_illegal_transition_new_to_resolved(client: TestClient): headers={"Authorization": "Bearer lead-token"}, ) assert res.status_code == 409 + + +def test_audit_events_cannot_be_updated(client: TestClient): + conn = db.connect() + conn.execute( + "INSERT INTO audit_events (actor, action, detail) VALUES (?, ?, ?)", + ("admin", "test", "original detail"), + ) + conn.commit() + + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "UPDATE audit_events SET detail = ? WHERE action = ?", + ("modified detail", "test"), + ) + + conn.close() + + +def test_audit_events_cannot_be_deleted(client: TestClient): + conn = db.connect() + conn.execute( + "INSERT INTO audit_events (actor, action, detail) VALUES (?, ?, ?)", + ("admin", "test", "original detail"), + ) + conn.commit() + + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "DELETE FROM audit_events WHERE action = ?", + ("test",), + ) + + conn.close()