diff --git a/backend/motorsignal_api/alembic/versions/e0f5a7123b46_link_firebase_identity.py b/backend/motorsignal_api/alembic/versions/e0f5a7123b46_link_firebase_identity.py new file mode 100644 index 0000000..8bee2d6 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/e0f5a7123b46_link_firebase_identity.py @@ -0,0 +1,24 @@ +"""link firebase identity + +Revision ID: e0f5a7123b46 +Revises: d9e4f6012a35 +Create Date: 2026-09-08 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "e0f5a7123b46" +down_revision = "d9e4f6012a35" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("firebase_uid", sa.String(length=128), nullable=True)) + op.create_index("ix_users_firebase_uid", "users", ["firebase_uid"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_users_firebase_uid", table_name="users") + op.drop_column("users", "firebase_uid") diff --git a/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e6_add_ops_schema_and_incidents.py b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e6_add_ops_schema_and_incidents.py new file mode 100644 index 0000000..139c646 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e6_add_ops_schema_and_incidents.py @@ -0,0 +1,61 @@ +"""add ops incidents + +Revision ID: f1a2b3c4d5e6 +Revises: f1a6b8234c57 +Create Date: 2026-09-10 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +revision = "f1a2b3c4d5e6" +down_revision = "f1b7c8345d68" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. ops.incident_case_seq for readable immutable case references: OPS-YYYY-NNNNNN + op.execute("CREATE SEQUENCE IF NOT EXISTS ops.incident_case_seq START 1") + + # 2. ops.incidents table + op.create_table( + "incidents", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("case_reference", sa.String(length=32), nullable=False, unique=True), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("summary", sa.Text(), nullable=False), + sa.Column("severity", sa.String(length=32), nullable=False, server_default="medium"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="open"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("created_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("closed_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True), + sa.Column("resolution", sa.Text(), nullable=True), + schema="ops", + ) + op.create_index("ix_ops_incidents_case_reference", "incidents", ["case_reference"], unique=True, schema="ops") + op.create_index("ix_ops_incidents_status", "incidents", ["status"], unique=False, schema="ops") + op.create_index("ix_ops_incidents_severity", "incidents", ["severity"], unique=False, schema="ops") + op.create_index("ix_ops_incidents_created_at", "incidents", ["created_at"], unique=False, schema="ops") + + # 3. ops.incident_notes table + op.create_table( + "incident_notes", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("incident_id", UUID(as_uuid=True), sa.ForeignKey("ops.incidents.id", ondelete="CASCADE"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("created_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + schema="ops", + ) + op.create_index("ix_ops_incident_notes_incident_id", "incident_notes", ["incident_id"], unique=False, schema="ops") + op.create_index("ix_ops_incident_notes_created_at", "incident_notes", ["created_at"], unique=False, schema="ops") + + +def downgrade() -> None: + op.drop_table("incident_notes", schema="ops") + op.drop_table("incidents", schema="ops") + op.execute("DROP SEQUENCE IF EXISTS ops.incident_case_seq") diff --git a/backend/motorsignal_api/alembic/versions/f1a6b8234c57_add_ops_foundation.py b/backend/motorsignal_api/alembic/versions/f1a6b8234c57_add_ops_foundation.py new file mode 100644 index 0000000..3ef4e8c --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/f1a6b8234c57_add_ops_foundation.py @@ -0,0 +1,108 @@ +"""Add isolated Operations foundation; no rider table changes. + +Revision ID: f1a6b8234c57 +Revises: e0f5a7123b46 +""" +from alembic import op +import sqlalchemy as sa + +revision = "f1a6b8234c57" +down_revision = "e0f5a7123b46" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("CREATE SCHEMA ops") + op.create_table( + "operators", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("firebase_uid", sa.String(128), nullable=False, unique=True), + sa.Column("verified_email", sa.String(320), nullable=False), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("authority_version", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("role IN ('operator', 'owner')", name="operator_role"), + sa.CheckConstraint("authority_version > 0", name="operator_version"), + sa.CheckConstraint("length(firebase_uid) > 0 AND length(verified_email) > 0", name="operator_identity"), + schema="ops", + ) + op.create_table( + "sessions", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("operator_id", sa.Uuid(), sa.ForeignKey("ops.operators.id"), nullable=False), + sa.Column("token_digest", sa.String(64), nullable=False, unique=True), + sa.Column("authority_version", sa.Integer(), nullable=False), + sa.Column("firebase_auth_time", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("absolute_expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("idle_timeout_seconds", sa.Integer(), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.CheckConstraint("authority_version > 0", name="session_version"), + sa.CheckConstraint("idle_timeout_seconds > 0 AND idle_timeout_seconds <= 900", name="session_idle"), + sa.CheckConstraint("token_digest ~ '^[a-f0-9]{64}$'", name="session_digest"), + sa.CheckConstraint("absolute_expires_at > created_at AND absolute_expires_at <= created_at + interval '8 hours'", name="session_absolute"), + schema="ops", + ) + op.create_index("ix_ops_sessions_operator_id", "sessions", ["operator_id"], schema="ops") + op.create_table( + "audit_events", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("schema_version", sa.Integer(), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_operator_id", sa.Uuid(), sa.ForeignKey("ops.operators.id")), + sa.Column("actor_kind", sa.String(24), nullable=False), + sa.Column("actor_role", sa.String(16)), + sa.Column("action", sa.String(32), nullable=False), + sa.Column("target_type", sa.String(16), nullable=False), + sa.Column("target_id", sa.Uuid()), + sa.Column("request_id", sa.Uuid(), nullable=False), + sa.Column("operation_id", sa.Uuid()), + sa.Column("result", sa.String(16), nullable=False), + sa.Column("reason_code", sa.String(32)), + sa.Column("case_reference", sa.String(64)), + sa.Column("sanitized_metadata", sa.JSON(), nullable=False), + sa.CheckConstraint("schema_version = 1", name="audit_schema_version"), + sa.CheckConstraint("actor_kind IN ('operator', 'unidentified', 'manual_admin')", name="audit_actor"), + sa.CheckConstraint("actor_role IS NULL OR actor_role IN ('operator', 'owner')", name="audit_role"), + sa.CheckConstraint("action IN ('session.create', 'session.logout', 'authorization.deny', 'session.failure', 'operator.bootstrap', 'operator.disable')", name="audit_action"), + sa.CheckConstraint("target_type IN ('operator', 'session')", name="audit_target"), + sa.CheckConstraint("result IN ('success', 'denied')", name="audit_result"), + sa.CheckConstraint("case_reference IS NULL OR case_reference ~ '^OPS-[0-9]{1,12}$'", name="audit_case"), + schema="ops", + ) + op.create_index("ix_ops_audit_events_occurred_at", "audit_events", ["occurred_at"], schema="ops") + # Guard immutable identity and version invalidation even for manual SQL updates. + op.execute(""" + CREATE FUNCTION ops.guard_operator_identity() RETURNS trigger + LANGUAGE plpgsql SET search_path = pg_catalog AS $$ + BEGIN + IF NEW.id IS DISTINCT FROM OLD.id OR NEW.firebase_uid IS DISTINCT FROM OLD.firebase_uid THEN + RAISE EXCEPTION 'Operations identity is immutable'; + END IF; + IF NEW.authority_version < OLD.authority_version THEN + RAISE EXCEPTION 'Operations authority version cannot decrease'; + END IF; + IF NEW.role IS DISTINCT FROM OLD.role OR NEW.active IS DISTINCT FROM OLD.active THEN + NEW.authority_version := GREATEST(NEW.authority_version, OLD.authority_version + 1); + END IF; + NEW.updated_at := CURRENT_TIMESTAMP; + RETURN NEW; + END $$ + """) + op.execute("CREATE TRIGGER guard_operator_identity BEFORE UPDATE ON ops.operators FOR EACH ROW EXECUTE FUNCTION ops.guard_operator_identity()") + op.execute("REVOKE ALL ON SCHEMA ops FROM PUBLIC") + op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA ops FROM PUBLIC") + op.execute("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ops FROM PUBLIC") + + +def downgrade(): + # Explicit downgrade removes Operations data only. Never run in production. + op.drop_table("audit_events", schema="ops") + op.drop_table("sessions", schema="ops") + op.drop_table("operators", schema="ops") + op.execute("DROP FUNCTION ops.guard_operator_identity()") + op.execute("DROP SCHEMA ops") diff --git a/backend/motorsignal_api/alembic/versions/f1b7c8345d68_evolve_ops_runtime_schema.py b/backend/motorsignal_api/alembic/versions/f1b7c8345d68_evolve_ops_runtime_schema.py new file mode 100644 index 0000000..dfca6b1 --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/f1b7c8345d68_evolve_ops_runtime_schema.py @@ -0,0 +1,99 @@ +"""evolve ops runtime schema preserving security invariants + +Revision ID: f1b7c8345d68 +Revises: f1a6b8234c57 +Create Date: 2026-09-10 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision = "f1b7c8345d68" +down_revision = "f1a6b8234c57" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Evolve ops.operators + op.add_column("operators", sa.Column("identity", sa.String(length=320), nullable=True), schema="ops") + op.execute("UPDATE ops.operators SET identity = verified_email WHERE identity IS NULL") + op.alter_column("operators", "identity", nullable=False, schema="ops") + op.create_index("ix_ops_operators_identity", "operators", ["identity"], unique=True, schema="ops") + + op.add_column("operators", sa.Column("status", sa.String(length=32), nullable=True), schema="ops") + op.execute("UPDATE ops.operators SET status = CASE WHEN active THEN 'active' ELSE 'suspended' END WHERE status IS NULL") + op.alter_column("operators", "status", nullable=False, server_default="active", schema="ops") + op.create_check_constraint("operator_status", "operators", "status IN ('active', 'suspended', 'revoked')", schema="ops") + + # Update trigger to protect new identity column and status field + op.execute(""" + CREATE OR REPLACE FUNCTION ops.guard_operator_identity() RETURNS trigger + LANGUAGE plpgsql SET search_path = pg_catalog AS $$ + BEGIN + IF NEW.id IS DISTINCT FROM OLD.id OR NEW.identity IS DISTINCT FROM OLD.identity THEN + RAISE EXCEPTION 'Operations identity is immutable'; + END IF; + IF NEW.authority_version < OLD.authority_version THEN + RAISE EXCEPTION 'Operations authority version cannot decrease'; + END IF; + IF NEW.role IS DISTINCT FROM OLD.role OR NEW.status IS DISTINCT FROM OLD.status THEN + NEW.authority_version := GREATEST(NEW.authority_version, OLD.authority_version + 1); + END IF; + NEW.updated_at := CURRENT_TIMESTAMP; + RETURN NEW; + END $$; + """) + + # 2. Evolve ops.sessions + op.add_column("sessions", sa.Column("session_token_hash", sa.String(length=128), nullable=True), schema="ops") + op.execute("UPDATE ops.sessions SET session_token_hash = token_digest WHERE session_token_hash IS NULL") + op.alter_column("sessions", "session_token_hash", nullable=False, schema="ops") + op.create_index("ix_ops_sessions_token_hash", "sessions", ["session_token_hash"], unique=True, schema="ops") + + op.add_column("sessions", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), schema="ops") + op.execute("UPDATE ops.sessions SET expires_at = absolute_expires_at WHERE expires_at IS NULL") + op.alter_column("sessions", "expires_at", nullable=False, schema="ops") + + op.add_column("sessions", sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), schema="ops") + op.execute("UPDATE ops.sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL") + + # 3. Evolve ops.audit_events + op.add_column("audit_events", sa.Column("timestamp", sa.DateTime(timezone=True), nullable=True), schema="ops") + op.execute("UPDATE ops.audit_events SET timestamp = occurred_at WHERE timestamp IS NULL") + op.alter_column("audit_events", "timestamp", nullable=False, server_default=sa.func.now(), schema="ops") + op.create_index("ix_ops_audit_events_timestamp", "audit_events", ["timestamp"], unique=False, schema="ops") + + op.add_column("audit_events", sa.Column("actor_identity", sa.String(length=320), nullable=True), schema="ops") + op.execute("UPDATE ops.audit_events SET actor_identity = actor_kind WHERE actor_identity IS NULL") + op.alter_column("audit_events", "actor_identity", nullable=False, schema="ops") + + op.add_column("audit_events", sa.Column("target_resource", sa.String(length=96), nullable=True), schema="ops") + op.execute("UPDATE ops.audit_events SET target_resource = target_type WHERE target_resource IS NULL") + + op.add_column("audit_events", sa.Column("metadata_json", JSONB, nullable=True), schema="ops") + op.execute("UPDATE ops.audit_events SET metadata_json = sanitized_metadata::jsonb WHERE metadata_json IS NULL") + + # 4. Revoke public permissions + op.execute("REVOKE ALL ON SCHEMA ops FROM PUBLIC") + op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA ops FROM PUBLIC") + op.execute("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ops FROM PUBLIC") + + +def downgrade() -> None: + op.drop_column("audit_events", "metadata_json", schema="ops") + op.drop_column("audit_events", "target_resource", schema="ops") + op.drop_column("audit_events", "actor_identity", schema="ops") + op.drop_index("ix_ops_audit_events_timestamp", table_name="audit_events", schema="ops") + op.drop_column("audit_events", "timestamp", schema="ops") + + op.drop_column("sessions", "last_activity_at", schema="ops") + op.drop_column("sessions", "expires_at", schema="ops") + op.drop_index("ix_ops_sessions_token_hash", table_name="sessions", schema="ops") + op.drop_column("sessions", "session_token_hash", schema="ops") + + op.drop_constraint("operator_status", "operators", schema="ops") + op.drop_column("operators", "status", schema="ops") + op.drop_index("ix_ops_operators_identity", table_name="operators", schema="ops") + op.drop_column("operators", "identity", schema="ops") diff --git a/backend/motorsignal_api/app/auth.py b/backend/motorsignal_api/app/auth.py index 4a84ae1..1b035f1 100644 --- a/backend/motorsignal_api/app/auth.py +++ b/backend/motorsignal_api/app/auth.py @@ -1,10 +1,14 @@ import hashlib +import json import os import secrets +import time +import urllib.request import uuid from datetime import datetime, timedelta, timezone import jwt +from cryptography import x509 from pwdlib import PasswordHash @@ -15,6 +19,9 @@ ACCESS_TOKEN_MINUTES = int(os.environ.get("ACCESS_TOKEN_MINUTES", "15")) REFRESH_TOKEN_DAYS = int(os.environ.get("REFRESH_TOKEN_DAYS", "30")) JWT_ALGORITHM = "HS256" +FIREBASE_CERTS_URL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" +_firebase_certs: dict[str, str] = {} +_firebase_certs_expires_at = 0.0 def normalize_email(email: str) -> str: @@ -87,3 +94,33 @@ def decode_access_token(token: str) -> dict: JWT_SECRET, algorithms=[JWT_ALGORITHM], ) + + +def _load_firebase_certs() -> dict[str, str]: + global _firebase_certs, _firebase_certs_expires_at + now = time.time() + if _firebase_certs and now < _firebase_certs_expires_at: + return _firebase_certs + with urllib.request.urlopen(FIREBASE_CERTS_URL, timeout=5) as response: + certs = json.loads(response.read()) + cache_control = response.headers.get("Cache-Control", "") + max_age = next((int(value.split("=", 1)[1]) for value in cache_control.split(",") if value.strip().startswith("max-age=")), 300) + _firebase_certs = certs + _firebase_certs_expires_at = now + max_age + return certs + + +def decode_firebase_id_token(token: str) -> dict: + project_id = os.environ["FIREBASE_PROJECT_ID"] + header = jwt.get_unverified_header(token) + if header.get("alg") != "RS256" or not header.get("kid"): + raise jwt.InvalidTokenError("Invalid Firebase token header") + certificate_pem = _load_firebase_certs().get(header["kid"]) + if certificate_pem is None: + raise jwt.InvalidTokenError("Unknown Firebase signing key") + public_key = x509.load_pem_x509_certificate(certificate_pem.encode("ascii")).public_key() + claims = jwt.decode(token, public_key, algorithms=["RS256"], audience=project_id, issuer=f"https://securetoken.google.com/{project_id}") + subject = claims.get("sub") + if not isinstance(subject, str) or not subject or len(subject) > 128: + raise jwt.InvalidTokenError("Invalid Firebase subject") + return claims diff --git a/backend/motorsignal_api/app/main.py b/backend/motorsignal_api/app/main.py index 80e2d3b..a8f7be3 100644 --- a/backend/motorsignal_api/app/main.py +++ b/backend/motorsignal_api/app/main.py @@ -7,8 +7,8 @@ from email.message import EmailMessage from email_validator import EmailNotValidError, validate_email from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status -from pydantic import BaseModel, Field -from sqlalchemy import func, or_, select +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import func, or_, select, text from sqlalchemy.orm import Session as DbSession from auth import ( @@ -16,6 +16,7 @@ from auth import ( create_refresh_token, create_one_time_token, decode_access_token, + decode_firebase_id_token, hash_password, hash_refresh_token, hash_one_time_token, @@ -24,7 +25,24 @@ from auth import ( verify_password, ) from db import check_database, engine -from models import AuditEvent, AuthRateLimit, EmailVerificationToken, Entitlement, PasswordResetToken, Ride, Session, Subscription, TelemetryPoint, User, Vehicle +from models import ( + AuditEvent, + AuthRateLimit, + EmailVerificationToken, + Entitlement, + Operator, + OpsAuditEvent, + OpsIncident, + OpsIncidentNote, + OpsSession, + PasswordResetToken, + Ride, + Session, + Subscription, + TelemetryPoint, + User, + Vehicle, +) logger = logging.getLogger(__name__) @@ -63,6 +81,10 @@ class TokenRequest(BaseModel): token: str = Field(min_length=20, max_length=256) +class FirebaseLinkRequest(BaseModel): + id_token: str = Field(min_length=100, max_length=8192) + + class PasswordResetRequest(TokenRequest): password: str = Field(min_length=12, max_length=128) @@ -546,10 +568,35 @@ def me(user: User = Depends(get_current_user)): "id": str(user.id), "email": user.email, "email_verified": user.email_verified, + "firebase_linked": user.firebase_uid is not None, + "role": user.role, "created_at": user.created_at, } +@app.post("/api/v1/auth/firebase/link") +def link_firebase_identity(request: FirebaseLinkRequest, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + try: + claims = decode_firebase_id_token(request.id_token) + except Exception: + raise HTTPException(status_code=401, detail="Invalid Firebase identity") + firebase_email = claims.get("email") + if claims.get("email_verified") is not True or not isinstance(firebase_email, str) or normalize_email(firebase_email) != user.email: + raise HTTPException(status_code=403, detail="Firebase email must be verified and match this account") + firebase_uid = claims["sub"] + linked_user = db.scalar(select(User).where(User.firebase_uid == firebase_uid)) + if linked_user is not None and linked_user.id != user.id: + raise HTTPException(status_code=409, detail="Firebase identity is already linked") + if user.firebase_uid is not None and user.firebase_uid != firebase_uid: + raise HTTPException(status_code=409, detail="Account is already linked to another Firebase identity") + if user.firebase_uid is None: + user.firebase_uid = firebase_uid + user.email_verified = True + audit(db, "auth.firebase_linked", actor_user_id=user.id, target_user_id=user.id) + db.commit() + return {"firebase_linked": True} + + @app.post("/api/v1/auth/verify-email/request", status_code=202) def request_email_verification(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)): enforce_auth_rate_limit(http_request, db, "verify-email-request") @@ -982,6 +1029,20 @@ def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, return {"items": [telemetry_payload(point) for point in points], "count": len(points)} +def telemetry_summary(ride_id: uuid.UUID, db: DbSession) -> dict: + count, first_recorded, last_recorded, max_speed, max_lean, max_g = db.execute( + select(func.count(TelemetryPoint.id), func.min(TelemetryPoint.recorded_at), func.max(TelemetryPoint.recorded_at), func.max(TelemetryPoint.speed_mph), func.max(func.abs(TelemetryPoint.lean_angle_deg)), func.max(TelemetryPoint.g_force)).where(TelemetryPoint.ride_id == ride_id) + ).one() + duration_seconds = (last_recorded - first_recorded).total_seconds() if first_recorded and last_recorded else 0 + return {"ride_id": str(ride_id), "point_count": count, "duration_seconds": duration_seconds, "max_speed_mph": max_speed, "max_lean_angle_deg": max_lean, "max_g_force": max_g} + + +@app.get("/api/v1/rides/{ride_id}/telemetry/summary") +def my_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): + get_owned_ride(ride_id, user.id, db) + return telemetry_summary(ride_id, db) + + @app.get("/api/v1/admin/rides/{ride_id}/telemetry") def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): if db.get(Ride, ride_id) is None: @@ -990,6 +1051,13 @@ def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge return {"items": [telemetry_payload(point) for point in points], "count": len(points)} +@app.get("/api/v1/admin/rides/{ride_id}/telemetry/summary") +def admin_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)): + if db.get(Ride, ride_id) is None: + raise HTTPException(status_code=404, detail="Ride not found") + return telemetry_summary(ride_id, db) + + def subscription_payload(subscription: Subscription) -> dict: return {"id": str(subscription.id), "user_id": str(subscription.user_id), "plan": subscription.plan, "status": subscription.status, "current_period_start": subscription.current_period_start, "current_period_end": subscription.current_period_end, "created_at": subscription.created_at, "updated_at": subscription.updated_at} @@ -1061,3 +1129,614 @@ def create_entitlement(user_id: uuid.UUID, request: EntitlementCreate, actor: Us db.commit() db.refresh(entitlement) return entitlement_payload(entitlement) + + +# ============================================================ +# Operations (OPS-3B / OPS-3C / OPS-4) Platform Endpoints +# ============================================================ + +import hashlib + + +def get_current_operator( + request: Request, + db: DbSession = Depends(get_db), +) -> tuple[Operator, OpsSession]: + auth_header = request.headers.get("authorization") + token: str | None = None + if auth_header: + scheme, _, param = auth_header.partition(" ") + if scheme.lower() == "bearer" and param: + token = param + if not token: + token = request.cookies.get("__Host-motorsignal_ops") or request.cookies.get("motorsignal_ops") + + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing Operations session credentials", + ) + + token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() + now = datetime.now(timezone.utc) + + stmt = ( + select(OpsSession, Operator) + .join(Operator, OpsSession.operator_id == Operator.id) + .where( + OpsSession.session_token_hash == token_hash, + OpsSession.revoked_at.is_(None), + OpsSession.expires_at > now, + Operator.status == "active", + ) + ) + result = db.execute(stmt).first() + if result is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Operations session expired or invalid", + ) + + ops_session, operator = result + ops_session.last_activity_at = now + db.commit() + return operator, ops_session + + +def require_ops_roles(*roles: str): + def dependency( + auth: tuple[Operator, OpsSession] = Depends(get_current_operator), + ) -> tuple[Operator, OpsSession]: + operator, session = auth + if operator.role not in roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient Operations authority", + ) + return operator, session + + return dependency + + +def ops_audit( + db: DbSession, + action: str, + actor_identity: str, + actor_role: str | None, + target_resource: str | None = None, + reason_code: str | None = None, + case_reference: str | None = None, + result: str = "success", + metadata: dict | None = None, +) -> OpsAuditEvent: + event = OpsAuditEvent( + timestamp=datetime.now(timezone.utc), + action=action, + actor_identity=actor_identity, + actor_role=actor_role, + target_resource=target_resource, + reason_code=reason_code, + case_reference=case_reference, + result=result, + metadata_json=metadata, + ) + db.add(event) + return event + + +def generate_incident_case_reference(db: DbSession) -> str: + year = datetime.now(timezone.utc).year + seq_val = db.execute(text("SELECT nextval('ops.incident_case_seq')")).scalar() + return f"OPS-{year}-{int(seq_val):06d}" + + +class OpsIncidentCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + title: str = Field(min_length=1, max_length=255) + summary: str = Field(min_length=1, max_length=4000) + severity: str = Field(default="medium") + + +class OpsIncidentPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + severity: str | None = None + status: str | None = None + resolution: str | None = Field(default=None, max_length=4000) + + +class OpsIncidentNoteCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + body: str = Field(min_length=1, max_length=4000) + + +@app.get("/api/v1/ops/me") +def ops_get_me( + auth: tuple[Operator, OpsSession] = Depends(get_current_operator), +): + operator, session = auth + return { + "identity": operator.identity, + "role": operator.role, + "status": operator.status, + "created_at": operator.created_at.isoformat(), + "last_activity_at": session.last_activity_at.isoformat() if session.last_activity_at else None, + "expires_at": session.expires_at.isoformat(), + } + + +@app.get("/api/v1/ops/health") +def ops_get_health(): + now_iso = datetime.now(timezone.utc).isoformat() + db_ok = False + try: + db_ok = check_database() + except Exception: + db_ok = False + + overall = "available" if db_ok else "degraded" + return { + "status": overall, + "checked_at": now_iso, + "components": { + "operations_api": { + "status": "available", + "description": "FastAPI core endpoint runtime & ingress", + }, + "operations_database": { + "status": "available" if db_ok else "unavailable", + "description": "PostgreSQL ops schema relational persistence", + }, + "session_validation": { + "status": "available" if db_ok else "degraded", + "description": "__Host-motorsignal_ops token verification & authority", + }, + "audit_persistence": { + "status": "available" if db_ok else "degraded", + "description": "Immutable operational security activity persistence", + }, + }, + } + + +@app.delete("/api/v1/ops/sessions/current", status_code=204) +def ops_logout_current_session( + auth: tuple[Operator, OpsSession] = Depends(get_current_operator), + db: DbSession = Depends(get_db), +): + operator, session = auth + session.revoked_at = datetime.now(timezone.utc) + ops_audit( + db=db, + action="auth.session_revoked", + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="ops.session", + result="success", + ) + db.commit() + + +@app.get("/api/v1/ops/audit-events") +def ops_list_audit_events( + cursor: str | None = None, + limit: int = Query(default=25, ge=1, le=100), + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")), + db: DbSession = Depends(get_db), +): + stmt = select(OpsAuditEvent).order_by(OpsAuditEvent.timestamp.desc()) + if cursor: + try: + cursor_dt = datetime.fromisoformat(cursor) + stmt = stmt.where(OpsAuditEvent.timestamp < cursor_dt) + except ValueError: + pass + + events = db.scalars(stmt.limit(limit + 1)).all() + has_more = len(events) > limit + returned_events = events[:limit] + + next_cursor = returned_events[-1].timestamp.isoformat() if has_more and returned_events else None + total_count = db.scalar(select(func.count(OpsAuditEvent.id))) or 0 + + items = [ + { + "id": str(e.id), + "timestamp": e.timestamp.isoformat(), + "action": e.action, + "actor_identity": e.actor_identity, + "actor_role": e.actor_role, + "target_resource": e.target_resource, + "reason_code": e.reason_code, + "case_reference": e.case_reference, + "result": e.result, + "metadata": e.metadata_json or {}, + } + for e in returned_events + ] + + return { + "items": items, + "next_cursor": next_cursor, + "total": total_count, + } + + +# ============================================================ +# Incidents Endpoints (OPS-4) +# ============================================================ + +VALID_SEVERITIES = {"low", "medium", "high", "critical"} +VALID_STATUSES = {"open", "investigating", "monitoring", "resolved", "closed", "archived"} + +ALLOWED_TRANSITIONS = { + "open": {"investigating", "resolved", "closed"}, + "investigating": {"monitoring", "resolved", "closed"}, + "monitoring": {"investigating", "resolved", "closed"}, + "resolved": {"closed", "investigating"}, + "closed": {"investigating"}, # Reopen (Owner only) + "archived": set(), # Terminal (no further mutation) +} + + +@app.get("/api/v1/ops/incidents") +def ops_list_incidents( + status: str | None = None, + severity: str | None = None, + case_reference: str | None = None, + cursor: str | None = None, + limit: int = Query(default=25, ge=1, le=100), + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + db: DbSession = Depends(get_db), +): + stmt = select(OpsIncident).order_by(OpsIncident.created_at.desc()) + + if case_reference: + stmt = stmt.where(OpsIncident.case_reference == case_reference.strip()) + if status: + stmt = stmt.where(OpsIncident.status == status.strip().lower()) + if severity: + stmt = stmt.where(OpsIncident.severity == severity.strip().lower()) + + if cursor: + try: + cursor_dt = datetime.fromisoformat(cursor) + stmt = stmt.where(OpsIncident.created_at < cursor_dt) + except ValueError: + pass + + incidents = db.scalars(stmt.limit(limit + 1)).all() + has_more = len(incidents) > limit + returned_incidents = incidents[:limit] + next_cursor = returned_incidents[-1].created_at.isoformat() if has_more and returned_incidents else None + + items = [ + { + "case_reference": inc.case_reference, + "title": inc.title, + "severity": inc.severity, + "status": inc.status, + "created_at": inc.created_at.isoformat(), + "updated_at": inc.updated_at.isoformat(), + } + for inc in returned_incidents + ] + + return { + "items": items, + "next_cursor": next_cursor, + } + + +@app.post("/api/v1/ops/incidents", status_code=201) +def ops_create_incident( + request: OpsIncidentCreate, + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + db: DbSession = Depends(get_db), +): + operator, _ = auth + sev = request.severity.strip().lower() + if sev not in VALID_SEVERITIES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid severity '{request.severity}'. Must be one of: {sorted(VALID_SEVERITIES)}", + ) + + case_ref = generate_incident_case_reference(db) + now = datetime.now(timezone.utc) + + incident = OpsIncident( + case_reference=case_ref, + title=request.title.strip(), + summary=request.summary.strip(), + severity=sev, + status="open", + created_at=now, + created_by_operator_id=operator.id, + updated_at=now, + ) + db.add(incident) + db.flush() + + # Transactional Audit + ops_audit( + db=db, + action="incident.created", + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="ops.incident", + case_reference=case_ref, + result="success", + metadata={"severity": sev}, + ) + + db.commit() + db.refresh(incident) + + return { + "case_reference": incident.case_reference, + "title": incident.title, + "summary": incident.summary, + "severity": incident.severity, + "status": incident.status, + "created_at": incident.created_at.isoformat(), + "updated_at": incident.updated_at.isoformat(), + "closed_at": None, + "resolution": None, + "created_by_identity": operator.identity, + "closed_by_identity": None, + } + + +@app.get("/api/v1/ops/incidents/{case_reference}") +def ops_get_incident_detail( + case_reference: str, + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + db: DbSession = Depends(get_db), +): + stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) + incident = db.scalar(stmt) + if incident is None: + raise HTTPException(status_code=404, detail="Incident not found") + + creator = db.get(Operator, incident.created_by_operator_id) + closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None + + return { + "case_reference": incident.case_reference, + "title": incident.title, + "summary": incident.summary, + "severity": incident.severity, + "status": incident.status, + "created_at": incident.created_at.isoformat(), + "updated_at": incident.updated_at.isoformat(), + "closed_at": incident.closed_at.isoformat() if incident.closed_at else None, + "resolution": incident.resolution, + "created_by_identity": creator.identity if creator else "operator", + "closed_by_identity": closer.identity if closer else None, + } + + +@app.patch("/api/v1/ops/incidents/{case_reference}") +def ops_patch_incident( + case_reference: str, + patch: OpsIncidentPatch, + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + db: DbSession = Depends(get_db), +): + operator, _ = auth + stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) + incident = db.scalar(stmt) + if incident is None: + raise HTTPException(status_code=404, detail="Incident not found") + + if incident.status == "archived": + raise HTTPException(status_code=409, detail="Archived incidents cannot be modified") + + now = datetime.now(timezone.utc) + target_status = patch.status.strip().lower() if patch.status else None + target_severity = patch.severity.strip().lower() if patch.severity else None + new_resolution = patch.resolution.strip() if patch.resolution is not None else None + + # Severity update + if target_severity: + if target_severity not in VALID_SEVERITIES: + raise HTTPException( + status_code=422, + detail=f"Invalid severity '{patch.severity}'. Must be: {sorted(VALID_SEVERITIES)}", + ) + if target_severity != incident.severity: + old_sev = incident.severity + incident.severity = target_severity + incident.updated_at = now + ops_audit( + db=db, + action="incident.severity_changed", + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="ops.incident", + case_reference=incident.case_reference, + result="success", + metadata={"from": old_sev, "to": target_severity}, + ) + + # Resolution update + if new_resolution is not None and new_resolution != incident.resolution: + incident.resolution = new_resolution + incident.updated_at = now + ops_audit( + db=db, + action="incident.resolved", + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="ops.incident", + case_reference=incident.case_reference, + result="success", + ) + + # Status transition + if target_status and target_status != incident.status: + if target_status not in VALID_STATUSES: + raise HTTPException( + status_code=422, + detail=f"Invalid status '{patch.status}'. Must be: {sorted(VALID_STATUSES)}", + ) + + # Owner only transitions + if target_status == "archived" and operator.role != "owner": + raise HTTPException(status_code=403, detail="Only Owners may archive incidents") + + if incident.status == "closed" and target_status == "investigating" and operator.role != "owner": + raise HTTPException(status_code=403, detail="Only Owners may reopen closed incidents") + + # Allowed transitions + allowed = ALLOWED_TRANSITIONS.get(incident.status, set()) + # Add archived as valid destination for non-archived if owner + if operator.role == "owner" and incident.status != "archived": + allowed = allowed | {"archived"} + + if target_status not in allowed: + raise HTTPException( + status_code=409, + detail=f"Invalid status transition from '{incident.status}' to '{target_status}'", + ) + + # Resolution required for resolved or closed + effective_res = new_resolution if new_resolution is not None else incident.resolution + if target_status in {"resolved", "closed"} and (not effective_res or not effective_res.strip()): + raise HTTPException( + status_code=422, + detail="Resolution text is required before resolving or closing an incident", + ) + + old_status = incident.status + incident.status = target_status + incident.updated_at = now + + if target_status == "closed": + incident.closed_at = now + incident.closed_by_operator_id = operator.id + audit_action = "incident.closed" + elif target_status == "resolved": + audit_action = "incident.resolved" + elif old_status == "closed" and target_status == "investigating": + incident.closed_at = None + incident.closed_by_operator_id = None + audit_action = "incident.reopened" + elif target_status == "archived": + audit_action = "incident.archived" + else: + audit_action = "incident.status_changed" + + ops_audit( + db=db, + action=audit_action, + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="ops.incident", + case_reference=incident.case_reference, + result="success", + metadata={"from": old_status, "to": target_status}, + ) + + db.commit() + db.refresh(incident) + + creator = db.get(Operator, incident.created_by_operator_id) + closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None + + return { + "case_reference": incident.case_reference, + "title": incident.title, + "summary": incident.summary, + "severity": incident.severity, + "status": incident.status, + "created_at": incident.created_at.isoformat(), + "updated_at": incident.updated_at.isoformat(), + "closed_at": incident.closed_at.isoformat() if incident.closed_at else None, + "resolution": incident.resolution, + "created_by_identity": creator.identity if creator else "operator", + "closed_by_identity": closer.identity if closer else None, + } + + +@app.get("/api/v1/ops/incidents/{case_reference}/notes") +def ops_list_incident_notes( + case_reference: str, + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + db: DbSession = Depends(get_db), +): + stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) + incident = db.scalar(stmt) + if incident is None: + raise HTTPException(status_code=404, detail="Incident not found") + + notes_stmt = ( + select(OpsIncidentNote, Operator.identity) + .join(Operator, OpsIncidentNote.created_by_operator_id == Operator.id) + .where(OpsIncidentNote.incident_id == incident.id) + .order_by(OpsIncidentNote.created_at.desc()) + ) + notes_rows = db.execute(notes_stmt).all() + + return { + "notes": [ + { + "created_at": note.created_at.isoformat(), + "created_by_identity": op_identity, + "body": note.body, + } + for note, op_identity in notes_rows + ] + } + + +@app.post("/api/v1/ops/incidents/{case_reference}/notes", status_code=201) +def ops_create_incident_note( + case_reference: str, + request: OpsIncidentNoteCreate, + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + db: DbSession = Depends(get_db), +): + operator, _ = auth + stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) + incident = db.scalar(stmt) + if incident is None: + raise HTTPException(status_code=404, detail="Incident not found") + + if incident.status == "archived": + raise HTTPException(status_code=409, detail="Archived incidents cannot be modified") + + body_text = request.body.strip() + if not body_text: + raise HTTPException(status_code=422, detail="Note body cannot be empty") + + now = datetime.now(timezone.utc) + note = OpsIncidentNote( + incident_id=incident.id, + created_at=now, + created_by_operator_id=operator.id, + body=body_text, + ) + db.add(note) + incident.updated_at = now + db.flush() + + # Transactional Audit + ops_audit( + db=db, + action="incident.note_added", + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="ops.incident", + case_reference=incident.case_reference, + result="success", + ) + + db.commit() + db.refresh(note) + + return { + "created_at": note.created_at.isoformat(), + "created_by_identity": operator.identity, + "body": note.body, + } diff --git a/backend/motorsignal_api/app/models.py b/backend/motorsignal_api/app/models.py index f627817..39c0052 100644 --- a/backend/motorsignal_api/app/models.py +++ b/backend/motorsignal_api/app/models.py @@ -50,6 +50,10 @@ class User(Base): index=True, ) + firebase_uid: Mapped[str | None] = mapped_column( + String(128), unique=True, nullable=True, index=True + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=datetime.utcnow, @@ -231,3 +235,146 @@ class AuthRateLimit(Base): key: Mapped[str] = mapped_column(String(255), primary_key=True) window_started: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) attempts: Mapped[int] = mapped_column(nullable=False, default=0) + + +# ============================================================ +# Operations (ops schema) Models +# ============================================================ + + +class Operator(Base): + __tablename__ = "operators" + __table_args__ = {"schema": "ops"} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + identity: Mapped[str] = mapped_column( + String(320), unique=True, nullable=False, index=True + ) + role: Mapped[str] = mapped_column( + String(32), nullable=False, default="operator", index=True + ) + status: Mapped[str] = mapped_column( + String(32), nullable=False, default="active" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + + sessions: Mapped[list["OpsSession"]] = relationship( + back_populates="operator", cascade="all, delete-orphan" + ) + + +class OpsSession(Base): + __tablename__ = "sessions" + __table_args__ = {"schema": "ops"} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + operator_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("ops.operators.id", ondelete="CASCADE"), nullable=False, index=True + ) + session_token_hash: Mapped[str] = mapped_column( + String(128), unique=True, nullable=False, index=True + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + last_activity_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, nullable=False + ) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + operator: Mapped["Operator"] = relationship(back_populates="sessions") + + +class OpsAuditEvent(Base): + __tablename__ = "audit_events" + __table_args__ = {"schema": "ops"} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + timestamp: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True + ) + action: Mapped[str] = mapped_column(String(96), nullable=False, index=True) + actor_identity: Mapped[str] = mapped_column(String(320), nullable=False) + actor_role: Mapped[str | None] = mapped_column(String(32), nullable=True) + target_resource: Mapped[str | None] = mapped_column(String(96), nullable=True) + reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True) + case_reference: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + result: Mapped[str] = mapped_column(String(32), nullable=False, default="success") + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + +class OpsIncident(Base): + __tablename__ = "incidents" + __table_args__ = {"schema": "ops"} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + case_reference: Mapped[str] = mapped_column( + String(32), unique=True, nullable=False, index=True + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + summary: Mapped[str] = mapped_column(String, nullable=False) + severity: Mapped[str] = mapped_column( + String(32), nullable=False, default="medium", index=True + ) + status: Mapped[str] = mapped_column( + String(32), nullable=False, default="open", index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True + ) + created_by_operator_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False + ) + closed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + closed_by_operator_id: Mapped[uuid.UUID | None] = mapped_column( + ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True + ) + resolution: Mapped[str | None] = mapped_column(String, nullable=True) + + notes: Mapped[list["OpsIncidentNote"]] = relationship( + back_populates="incident", cascade="all, delete-orphan", order_by="OpsIncidentNote.created_at.desc()" + ) + + +class OpsIncidentNote(Base): + __tablename__ = "incident_notes" + __table_args__ = {"schema": "ops"} + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + incident_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("ops.incidents.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True + ) + created_by_operator_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False + ) + body: Mapped[str] = mapped_column(String, nullable=False) + + incident: Mapped["OpsIncident"] = relationship(back_populates="notes") diff --git a/backend/motorsignal_api/tests/test_firebase_auth.py b/backend/motorsignal_api/tests/test_firebase_auth.py new file mode 100644 index 0000000..e670b5f --- /dev/null +++ b/backend/motorsignal_api/tests/test_firebase_auth.py @@ -0,0 +1,62 @@ +import os +import sys +import time +from datetime import datetime, timedelta, timezone + +import jwt +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography import x509 +from cryptography.x509.oid import NameOID + +ROOT = os.path.dirname(os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(ROOT, ".python-packages")) +sys.path.insert(0, os.path.join(ROOT, "app")) +os.environ.setdefault("JWT_SECRET", "local-test-secret") +os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0" + +from auth import decode_firebase_id_token # noqa: E402 +import auth # noqa: E402 + + +private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +now = datetime.now(timezone.utc) +name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "firebase-test")]) +certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(private_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .sign(private_key, algorithm=hashes.SHA256()) +) +auth._firebase_certs = {"test-key": certificate.public_bytes(serialization.Encoding.PEM).decode()} +auth._firebase_certs_expires_at = time.time() + 60 + + +def token(**overrides): + claims = { + "sub": "firebase-user-123", + "email": "rider@example.com", + "email_verified": True, + "aud": "motoapp-d22e0", + "iss": "https://securetoken.google.com/motoapp-d22e0", + "iat": now, + "exp": now + timedelta(minutes=5), + } + claims.update(overrides) + return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": "test-key"}) + + +assert decode_firebase_id_token(token())["sub"] == "firebase-user-123" +for invalid in (token(aud="other-project"), token(iss="https://securetoken.google.com/other-project"), token(sub="")): + try: + decode_firebase_id_token(invalid) + except jwt.PyJWTError: + pass + else: + raise AssertionError("Invalid Firebase token accepted") + +print("PASS: Firebase signature, project, issuer, and subject validation") diff --git a/backend/motorsignal_api/tests/test_ops_incidents.py b/backend/motorsignal_api/tests/test_ops_incidents.py new file mode 100644 index 0000000..e1123b6 --- /dev/null +++ b/backend/motorsignal_api/tests/test_ops_incidents.py @@ -0,0 +1,559 @@ +import hashlib +import os +import sys +import unittest +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +ROOT = os.path.dirname(os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(ROOT, ".python-packages")) +sys.path.insert(0, os.path.join(ROOT, "app")) + +os.environ.setdefault("JWT_SECRET", "local-test-secret") +os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0" +os.environ["DATABASE_USER"] = "test" +os.environ["DATABASE_PASSWORD"] = "test" +os.environ["DATABASE_NAME"] = "test" + +from sqlalchemy import create_engine, event, select, text +from sqlalchemy.orm import Session as DbSession +from starlette.testclient import TestClient + +import main +from main import ( + app, + generate_incident_case_reference, + get_db, + OpsIncidentCreate, + OpsIncidentPatch, + OpsIncidentNoteCreate, +) +from models import Base, Operator, OpsAuditEvent, OpsIncident, OpsIncidentNote, OpsSession + + +def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode()).hexdigest() + + +class TestOpsIncidents(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Create an isolated SQLite database attached with 'ops' schema + cls.db_path = os.path.join(ROOT, "tests", "test_ops_incidents.db") + cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_schema.db") + for p in (cls.db_path, cls.ops_db_path): + if os.path.exists(p): + try: + os.remove(p) + except OSError: + pass + + cls.engine = create_engine(f"sqlite:///{cls.db_path}") + + cls.seq_val = 0 + + @event.listens_for(cls.engine, "connect") + def attach_ops(dbapi_con, con_record): + dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops") + + def sqlite_nextval(name): + cls.seq_val += 1 + return cls.seq_val + + dbapi_con.create_function("nextval", 1, sqlite_nextval) + + Base.metadata.create_all(cls.engine) + + def override_get_db(): + db = DbSession(cls.engine) + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + cls.client = TestClient(app, raise_server_exceptions=False) + + @classmethod + def tearDownClass(cls): + app.dependency_overrides.clear() + cls.engine.dispose() + for p in (cls.db_path, cls.ops_db_path): + if os.path.exists(p): + try: + os.remove(p) + except OSError: + pass + + def setUp(self): + # Clear tables and seed standard test operators & sessions + with DbSession(self.engine) as db: + db.query(OpsIncidentNote).delete() + db.query(OpsIncident).delete() + db.query(OpsAuditEvent).delete() + db.query(OpsSession).delete() + db.query(Operator).delete() + db.commit() + + # Seed Active Operator + self.op_id = uuid.uuid4() + self.op_identity = "operator@motorsignal.internal" + self.op_token = "valid-operator-token-123" + op = Operator( + id=self.op_id, + identity=self.op_identity, + role="operator", + status="active", + created_at=datetime.now(timezone.utc), + ) + op_session = OpsSession( + operator_id=self.op_id, + session_token_hash=hash_token(self.op_token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + created_at=datetime.now(timezone.utc), + ) + + # Seed Active Owner + self.owner_id = uuid.uuid4() + self.owner_identity = "owner@motorsignal.internal" + self.owner_token = "valid-owner-token-456" + owner = Operator( + id=self.owner_id, + identity=self.owner_identity, + role="owner", + status="active", + created_at=datetime.now(timezone.utc), + ) + owner_session = OpsSession( + operator_id=self.owner_id, + session_token_hash=hash_token(self.owner_token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + created_at=datetime.now(timezone.utc), + ) + + # Seed Suspended Operator + self.suspended_id = uuid.uuid4() + self.suspended_token = "suspended-operator-token" + suspended_op = Operator( + id=self.suspended_id, + identity="suspended@motorsignal.internal", + role="operator", + status="suspended", + created_at=datetime.now(timezone.utc), + ) + suspended_session = OpsSession( + operator_id=self.suspended_id, + session_token_hash=hash_token(self.suspended_token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + created_at=datetime.now(timezone.utc), + ) + + # Seed Expired Session + self.expired_token = "expired-token-789" + expired_session = OpsSession( + operator_id=self.op_id, + session_token_hash=hash_token(self.expired_token), + expires_at=datetime.now(timezone.utc) - timedelta(minutes=5), + created_at=datetime.now(timezone.utc) - timedelta(hours=2), + ) + + # Seed Revoked Session + self.revoked_token = "revoked-token-000" + revoked_session = OpsSession( + operator_id=self.op_id, + session_token_hash=hash_token(self.revoked_token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + revoked_at=datetime.now(timezone.utc) - timedelta(minutes=1), + created_at=datetime.now(timezone.utc) - timedelta(hours=1), + ) + + db.add_all([op, owner, suspended_op, op_session, owner_session, expired_session, revoked_session]) + db.commit() + + self.op_headers = {"Authorization": f"Bearer {self.op_token}"} + self.owner_headers = {"Authorization": f"Bearer {self.owner_token}"} + + # ============================================================ + # 1. Authority and Authentication Tests + # ============================================================ + def test_unauthenticated_requests_rejected(self): + routes = [ + ("GET", "/api/v1/ops/incidents", None), + ("POST", "/api/v1/ops/incidents", {"title": "Test", "summary": "Sum"}), + ("GET", "/api/v1/ops/incidents/OPS-2026-000001", None), + ("PATCH", "/api/v1/ops/incidents/OPS-2026-000001", {"status": "investigating"}), + ("GET", "/api/v1/ops/incidents/OPS-2026-000001/notes", None), + ("POST", "/api/v1/ops/incidents/OPS-2026-000001/notes", {"body": "Note"}), + ] + for method, path, json_data in routes: + response = self.client.request(method, path, json=json_data) + self.assertEqual(response.status_code, 401, f"{method} {path} should reject unauthenticated request") + + def test_invalid_bearer_token_rejected(self): + headers = {"Authorization": "Bearer completely-invalid-token"} + res = self.client.get("/api/v1/ops/incidents", headers=headers) + self.assertEqual(res.status_code, 401) + + def test_expired_and_revoked_session_rejected(self): + res_expired = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.expired_token}"}) + self.assertEqual(res_expired.status_code, 401) + + res_revoked = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.revoked_token}"}) + self.assertEqual(res_revoked.status_code, 401) + + def test_suspended_operator_rejected(self): + res = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.suspended_token}"}) + self.assertEqual(res.status_code, 401) + + # ============================================================ + # 2. Case Reference Generation & Format Tests (Hardened) + # ============================================================ + def test_case_reference_format_and_sequence(self): + year = datetime.now(timezone.utc).year + payload = {"title": "Sequential Test Incident", "summary": "Testing format", "severity": "low"} + + res1 = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers) + self.assertEqual(res1.status_code, 201) + data1 = res1.json() + case_ref1 = data1["case_reference"] + + # Invariant checks: prefix OPS-YYYY-, numeric suffix >= 6 digits, untruncated + self.assertTrue(case_ref1.startswith(f"OPS-{year}-")) + parts1 = case_ref1.split("-") + self.assertEqual(len(parts1), 3) + self.assertEqual(parts1[0], "OPS") + self.assertEqual(parts1[1], str(year)) + self.assertTrue(parts1[2].isdigit()) + self.assertGreaterEqual(len(parts1[2]), 6) + + res2 = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers) + self.assertEqual(res2.status_code, 201) + data2 = res2.json() + case_ref2 = data2["case_reference"] + self.assertTrue(case_ref2.startswith(f"OPS-{year}-")) + self.assertNotEqual(case_ref1, case_ref2) + + def test_case_reference_sequence_greater_than_999999(self): + """Values > 999999 format properly without truncation (e.g. sequence 1000000 -> OPS-2026-1000000).""" + year = datetime.now(timezone.utc).year + with DbSession(self.engine) as db: + with patch.object(db, "execute") as mock_exec: + mock_exec.return_value.scalar.return_value = 1000000 + case_ref = generate_incident_case_reference(db) + self.assertEqual(case_ref, f"OPS-{year}-1000000") + self.assertEqual(len(case_ref), 16) + parts = case_ref.split("-") + self.assertEqual(parts[0], "OPS") + self.assertEqual(parts[1], str(year)) + self.assertEqual(parts[2], "1000000") + + def test_fail_closed_case_reference_if_sequence_errors(self): + with patch.object(main, "generate_incident_case_reference", side_effect=RuntimeError("Sequence connection lost")): + payload = {"title": "Fail Closed Test", "summary": "Should fail", "severity": "high"} + res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers) + self.assertEqual(res.status_code, 500) + + # Confirm zero incidents persisted + with DbSession(self.engine) as db: + count = db.scalar(select(OpsIncident.id)) + self.assertIsNone(count) + + # ============================================================ + # 3. Pydantic Extra-Field Hardening Tests (extra='forbid') + # ============================================================ + def test_incident_create_forbids_extra_fields(self): + forbidden_fields = [ + {"id": str(uuid.uuid4())}, + {"case_reference": "OPS-2026-999999"}, + {"created_by_operator_id": str(uuid.uuid4())}, + {"closed_by_operator_id": str(uuid.uuid4())}, + {"actor_identity": "hacker@evil.com"}, + {"actor_role": "owner"}, + {"created_at": "2026-01-01T00:00:00Z"}, + ] + for extra in forbidden_fields: + payload = {"title": "Valid Title", "summary": "Valid summary", "severity": "low"} + payload.update(extra) + res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers) + self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422") + + def test_incident_patch_forbids_extra_fields(self): + inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Patch Extra Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers) + case_ref = inc_res.json()["case_reference"] + + forbidden_fields = [ + {"id": str(uuid.uuid4())}, + {"case_reference": "OPS-2026-888888"}, + {"created_by_operator_id": str(uuid.uuid4())}, + {"actor_role": "owner"}, + {"created_at": "2026-01-01T00:00:00Z"}, + ] + for extra in forbidden_fields: + payload = {"status": "investigating"} + payload.update(extra) + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json=payload, headers=self.op_headers) + self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422") + + def test_incident_note_create_forbids_extra_fields(self): + inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Note Extra Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers) + case_ref = inc_res.json()["case_reference"] + + forbidden_fields = [ + {"id": str(uuid.uuid4())}, + {"case_reference": "OPS-2026-777777"}, + {"created_by_operator_id": str(uuid.uuid4())}, + {"actor_role": "owner"}, + {"created_at": "2026-01-01T00:00:00Z"}, + ] + for extra in forbidden_fields: + payload = {"body": "Legitimate note body"} + payload.update(extra) + res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json=payload, headers=self.op_headers) + self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422") + + # ============================================================ + # 4. Standard Input Validation Tests + # ============================================================ + def test_incident_create_validation(self): + # Empty title + res = self.client.post("/api/v1/ops/incidents", json={"title": "", "summary": "Valid summary", "severity": "low"}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + # Empty summary + res = self.client.post("/api/v1/ops/incidents", json={"title": "Valid title", "summary": "", "severity": "low"}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + # Invalid severity + res = self.client.post("/api/v1/ops/incidents", json={"title": "Valid", "summary": "Valid", "severity": "catastrophic"}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + # Title over 255 chars + res = self.client.post("/api/v1/ops/incidents", json={"title": "A" * 256, "summary": "Valid", "severity": "low"}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + def test_note_create_validation(self): + inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Note Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers) + case_ref = inc_res.json()["case_reference"] + + # Empty note + res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": ""}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + # Whitespace-only note + res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": " \n\t "}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + # Note on nonexistent incident + res = self.client.post("/api/v1/ops/incidents/OPS-9999-999999/notes", json={"body": "Note"}, headers=self.op_headers) + self.assertEqual(res.status_code, 404) + + # ============================================================ + # 5. State Machine Transitions & Role Restrictions + # ============================================================ + def test_standard_incident_lifecycle(self): + # 1. Create -> Initial status is "open" + res = self.client.post("/api/v1/ops/incidents", json={"title": "Lifecycle", "summary": "Sum", "severity": "high"}, headers=self.op_headers) + self.assertEqual(res.status_code, 201) + case_ref = res.json()["case_reference"] + self.assertEqual(res.json()["status"], "open") + + # 2. open -> investigating + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "investigating") + + # 3. investigating -> monitoring + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "monitoring"}, headers=self.op_headers) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "monitoring") + + # 4. monitoring -> resolved without resolution fails (422) + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "resolved"}, headers=self.op_headers) + self.assertEqual(res.status_code, 422) + + # 5. monitoring -> resolved with resolution succeeds + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "resolved", "resolution": "Applied hotfix patch to ingress"}, headers=self.op_headers) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "resolved") + self.assertEqual(res.json()["resolution"], "Applied hotfix patch to ingress") + + # 6. resolved -> closed succeeds + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "closed"}, headers=self.op_headers) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "closed") + self.assertIsNotNone(res.json()["closed_at"]) + self.assertEqual(res.json()["closed_by_identity"], self.op_identity) + + def test_reopen_closed_incident_requires_owner(self): + # Create and close incident + res = self.client.post("/api/v1/ops/incidents", json={"title": "Reopen Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers) + self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "closed", "resolution": "Fix verified"}, headers=self.op_headers) + + # Operator attempt to reopen -> 403 Forbidden + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers) + self.assertEqual(res.status_code, 403) + self.assertIn("Only Owners may reopen", res.json()["detail"]) + + # Owner attempt to reopen -> 200 OK + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.owner_headers) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "investigating") + self.assertIsNone(res.json()["closed_at"]) + self.assertIsNone(res.json()["closed_by_identity"]) + + def test_archive_incident_requires_owner_and_locks_modifications(self): + res = self.client.post("/api/v1/ops/incidents", json={"title": "Archive Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + + # Operator attempt to archive -> 403 Forbidden + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "archived"}, headers=self.op_headers) + self.assertEqual(res.status_code, 403) + self.assertIn("Only Owners may archive", res.json()["detail"]) + + # Owner archives -> 200 OK + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "archived"}, headers=self.owner_headers) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["status"], "archived") + + # Any subsequent patch on archived incident -> 409 Conflict + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"severity": "high"}, headers=self.owner_headers) + self.assertEqual(res.status_code, 409) + + # Note creation on archived incident -> 409 Conflict + res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Post-archive note"}, headers=self.owner_headers) + self.assertEqual(res.status_code, 409) + + def test_invalid_status_transition_rejected(self): + res = self.client.post("/api/v1/ops/incidents", json={"title": "Invalid Transition", "summary": "Sum", "severity": "low"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + + # open -> monitoring is disallowed (must go through investigating) + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "monitoring"}, headers=self.op_headers) + self.assertEqual(res.status_code, 409) + + # ============================================================ + # 6. Append-Only Notes Tests + # ============================================================ + def test_append_only_notes_chronology(self): + res = self.client.post("/api/v1/ops/incidents", json={"title": "Notes Chronology", "summary": "Sum", "severity": "high"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + + # Add Note 1 (by operator) + n1 = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Initial triage complete."}, headers=self.op_headers) + self.assertEqual(n1.status_code, 201) + self.assertEqual(n1.json()["created_by_identity"], self.op_identity) + + # Add Note 2 (by owner) + n2 = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Infrastructure team engaged."}, headers=self.owner_headers) + self.assertEqual(n2.status_code, 201) + self.assertEqual(n2.json()["created_by_identity"], self.owner_identity) + + # Fetch notes list -> returned in reverse chronological order (newest first) + notes_res = self.client.get(f"/api/v1/ops/incidents/{case_ref}/notes", headers=self.op_headers) + self.assertEqual(notes_res.status_code, 200) + notes = notes_res.json()["notes"] + self.assertEqual(len(notes), 2) + self.assertEqual(notes[0]["body"], "Infrastructure team engaged.") + self.assertEqual(notes[0]["created_by_identity"], self.owner_identity) + self.assertEqual(notes[1]["body"], "Initial triage complete.") + self.assertEqual(notes[1]["created_by_identity"], self.op_identity) + + # ============================================================ + # 7. Transactional Audit Logging Tests + # ============================================================ + def test_audit_events_recorded_on_incident_lifecycle(self): + # Create incident + res = self.client.post("/api/v1/ops/incidents", json={"title": "Audit Lifecycle", "summary": "Sum", "severity": "medium"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + + # Change severity + self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"severity": "critical"}, headers=self.op_headers) + + # Add note + self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Investigating spike in 5xx errors"}, headers=self.op_headers) + + # Transition status + self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers) + + # Check audit events recorded in database + with DbSession(self.engine) as db: + events = db.scalars( + select(OpsAuditEvent) + .where(OpsAuditEvent.case_reference == case_ref) + .order_by(OpsAuditEvent.timestamp.asc()) + ).all() + + actions = [e.action for e in events] + self.assertIn("incident.created", actions) + self.assertIn("incident.severity_changed", actions) + self.assertIn("incident.note_added", actions) + self.assertIn("incident.status_changed", actions) + + # Verify actor identity and safe metadata + for e in events: + self.assertEqual(e.actor_identity, self.op_identity) + self.assertEqual(e.target_resource, "ops.incident") + self.assertEqual(e.result, "success") + + # ============================================================ + # 8. Audit-Failure Rollback Tests (P1 Critical Requirement) + # ============================================================ + def test_audit_failure_rolls_back_incident_creation(self): + """If ops_audit fails during incident creation, database transaction rolls back completely.""" + payload = {"title": "Rollback Create Incident", "summary": "Should never persist", "severity": "high"} + + # Force ops_audit to raise RuntimeError + with patch("main.ops_audit", side_effect=RuntimeError("Audit persistence failure!")): + res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers) + self.assertEqual(res.status_code, 500) + + # Assert ZERO records were committed to ops.incidents + with DbSession(self.engine) as db: + incident = db.scalar(select(OpsIncident).where(OpsIncident.title == "Rollback Create Incident")) + self.assertIsNone(incident, "Incident must NOT be committed if audit fails") + + def test_audit_failure_rolls_back_note_creation(self): + """If ops_audit fails during note creation, database transaction rolls back completely.""" + res = self.client.post("/api/v1/ops/incidents", json={"title": "Rollback Note Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + + with patch("main.ops_audit", side_effect=RuntimeError("Audit disk full!")): + res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Phantom note"}, headers=self.op_headers) + self.assertEqual(res.status_code, 500) + + with DbSession(self.engine) as db: + notes = db.scalars(select(OpsIncidentNote).where(OpsIncidentNote.body == "Phantom note")).all() + self.assertEqual(len(notes), 0, "Note must NOT be committed if audit fails") + + def test_audit_failure_rolls_back_status_patch(self): + """If ops_audit fails during status mutation, database transaction rolls back completely.""" + res = self.client.post("/api/v1/ops/incidents", json={"title": "Rollback Patch Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers) + case_ref = res.json()["case_reference"] + + with patch("main.ops_audit", side_effect=RuntimeError("Audit stream broken!")): + res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers) + self.assertEqual(res.status_code, 500) + + with DbSession(self.engine) as db: + incident = db.scalar(select(OpsIncident).where(OpsIncident.case_reference == case_ref)) + self.assertIsNotNone(incident) + self.assertEqual(incident.status, "open", "Status must remain 'open' following audit failure rollback") + + # ============================================================ + # 9. Foundation Security Invariants Tests + # ============================================================ + def test_session_revocation_endpoint(self): + """DELETE /api/v1/ops/sessions/current revokes active session and logs audit event.""" + res = self.client.delete("/api/v1/ops/sessions/current", headers=self.op_headers) + self.assertEqual(res.status_code, 204) + + # Subsequent request with revoked session fails + res2 = self.client.get("/api/v1/ops/incidents", headers=self.op_headers) + self.assertEqual(res2.status_code, 401) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/app/admin/page.tsx b/website/app/admin/page.tsx index 4c059a8..336b10d 100644 --- a/website/app/admin/page.tsx +++ b/website/app/admin/page.tsx @@ -12,11 +12,12 @@ import { interface AdminPageProps { searchParams?: Promise<{ nav?: string; + case?: string; }>; } function parseNavParam(nav?: string): OperationsNavId { - if (nav === 'audit' || nav === 'operators' || nav === 'session') { + if (nav === 'incidents' || nav === 'audit' || nav === 'operators' || nav === 'session') { return nav; } return 'overview'; @@ -25,9 +26,10 @@ function parseNavParam(nav?: string): OperationsNavId { export default async function AdminPage({ searchParams }: AdminPageProps) { const resolvedParams = searchParams ? await searchParams : {}; const initialNav = parseNavParam(resolvedParams.nav); + const initialCaseRef = typeof resolvedParams.case === 'string' ? resolvedParams.case : null; const cookieStore = await cookies(); - const isProduction = process.env.NODE_ENV === 'production'; + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; const opsToken = isProduction ? cookieStore.get('__Host-motorsignal_ops')?.value : cookieStore.get('__Host-motorsignal_ops')?.value ?? @@ -148,6 +150,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) { total: auditTotal, }} initialNav={initialNav} + initialCaseRef={initialCaseRef} /> ); } catch { diff --git a/website/app/api/ops/incidents/[caseRef]/notes/route.ts b/website/app/api/ops/incidents/[caseRef]/notes/route.ts new file mode 100644 index 0000000..c15ca42 --- /dev/null +++ b/website/app/api/ops/incidents/[caseRef]/notes/route.ts @@ -0,0 +1,173 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { sanitizeIncidentNote } from '../../../../../../lib/ops-data'; +import { hasValidOrigin } from '../../../../../../lib/request-origin'; +import { SafeIncidentNote } from '../../../../../../components/ops/types'; + +interface RouteContext { + params: Promise<{ + caseRef: string; + }>; +} + +export async function GET(request: NextRequest, context: RouteContext) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { caseRef } = await context.params; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}/notes`, { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view incident notes.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Incident not found.' }, + { status: 404 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + const rawNotes = Array.isArray(data?.notes) ? data.notes : []; + const notes: SafeIncidentNote[] = rawNotes + .map(sanitizeIncidentNote) + .filter((n: SafeIncidentNote | null): n is SafeIncidentNote => n !== null); + + return NextResponse.json({ notes }); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { caseRef } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}/notes`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to add notes.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Incident not found.' }, + { status: 404 } + ); + } + if (response.status === 409) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Archived incidents cannot be modified.' }, + { status: 409 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Review the note fields and try again.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + const sanitized = sanitizeIncidentNote(data); + return NextResponse.json(sanitized ?? data, { status: 201 }); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/incidents/[caseRef]/route.ts b/website/app/api/ops/incidents/[caseRef]/route.ts new file mode 100644 index 0000000..8489a60 --- /dev/null +++ b/website/app/api/ops/incidents/[caseRef]/route.ts @@ -0,0 +1,176 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { sanitizeIncidentDetail } from '../../../../../lib/ops-data'; +import { hasValidOrigin } from '../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + caseRef: string; + }>; +} + +export async function GET(request: NextRequest, context: RouteContext) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { caseRef } = await context.params; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}`, { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view this Incident.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Incident not found.' }, + { status: 404 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + const sanitized = sanitizeIncidentDetail(data); + if (!sanitized) { + return NextResponse.json( + { detail: 'Invalid incident response from backend.' }, + { status: 502 } + ); + } + + return NextResponse.json(sanitized); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { caseRef } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}`, { + method: 'PATCH', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'You do not have authority for this action.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Incident not found.' }, + { status: 404 } + ); + } + if (response.status === 409) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'That status transition is not permitted.' }, + { status: 409 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Review the incident fields and try again.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + const sanitized = sanitizeIncidentDetail(data); + return NextResponse.json(sanitized ?? data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/incidents/route.ts b/website/app/api/ops/incidents/route.ts new file mode 100644 index 0000000..f222323 --- /dev/null +++ b/website/app/api/ops/incidents/route.ts @@ -0,0 +1,158 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { sanitizeIncidentSummary } from '../../../../lib/ops-data'; +import { hasValidOrigin } from '../../../../lib/request-origin'; +import { SafeIncidentSummary } from '../../../../components/ops/types'; + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const searchParams = request.nextUrl.searchParams; + const status = searchParams.get('status'); + const severity = searchParams.get('severity'); + const caseRef = searchParams.get('case_reference'); + const cursor = searchParams.get('cursor'); + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const targetUrl = new URL(`${baseUrl}/api/v1/ops/incidents`); + if (status) targetUrl.searchParams.set('status', status); + if (severity) targetUrl.searchParams.set('severity', severity); + if (caseRef) targetUrl.searchParams.set('case_reference', caseRef); + if (cursor) targetUrl.searchParams.set('cursor', cursor); + + try { + const response = await fetch(targetUrl.toString(), { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view Incidents.' }, + { status: 403 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + const rawItems = Array.isArray(data?.items) ? data.items : []; + const items: SafeIncidentSummary[] = rawItems + .map(sanitizeIncidentSummary) + .filter((i: SafeIncidentSummary | null): i is SafeIncidentSummary => i !== null); + + return NextResponse.json({ + items, + next_cursor: data?.next_cursor ?? null, + }); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} + +export async function POST(request: NextRequest) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + try { + const response = await fetch(`${baseUrl}/api/v1/ops/incidents`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to create Incidents.' }, + { status: 403 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid incident fields.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 201 }); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/components/ops/operations-create-incident.tsx b/website/components/ops/operations-create-incident.tsx new file mode 100644 index 0000000..2e3526b --- /dev/null +++ b/website/components/ops/operations-create-incident.tsx @@ -0,0 +1,212 @@ +'use client'; + +import React, { useState } from 'react'; +import { IncidentSeverity } from './types'; + +interface OperationsCreateIncidentProps { + onIncidentCreated: (caseRef: string) => void; + onCancel: () => void; +} + +export function OperationsCreateIncident({ + onIncidentCreated, + onCancel, +}: OperationsCreateIncidentProps) { + const [title, setTitle] = useState(''); + const [summary, setSummary] = useState(''); + const [severity, setSeverity] = useState('medium'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!title.trim()) { + setErrorMessage('Incident title is required.'); + return; + } + if (!summary.trim()) { + setErrorMessage('Incident summary is required.'); + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + + try { + const response = await fetch('/api/ops/incidents', { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + title: title.trim(), + summary: summary.trim(), + severity, + }), + }); + + if (response.status === 401) { + setErrorMessage('Your Operations session has expired.'); + setIsSubmitting(false); + return; + } + if (response.status === 403) { + setErrorMessage('You do not have authority to create incidents.'); + setIsSubmitting(false); + return; + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + setErrorMessage(data?.detail ?? 'Please check the incident fields and try again.'); + setIsSubmitting(false); + return; + } + if (!response.ok) { + setErrorMessage('Operations platform is temporarily unavailable.'); + setIsSubmitting(false); + return; + } + + const data = await response.json(); + if (data?.case_reference) { + onIncidentCreated(data.case_reference); + } else { + onCancel(); + } + } catch { + setErrorMessage('Unable to connect to Operations platform.'); + setIsSubmitting(false); + } + } + + return ( +
+ {/* Header */} +
+
+

+ Create Operational Incident +

+

+ Case reference and open status will be generated automatically. +

+
+ + +
+ + {/* Error Alert */} + {errorMessage && ( +
+ error + {errorMessage} +
+ )} + + {/* Form Card */} +
+ {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="e.g. Ingress latency spike on edge cluster" + className="w-full bg-surface-container px-3.5 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none" + /> +
+ + {/* Severity Selection */} +
+ +
+ {(['low', 'medium', 'high', 'critical'] as IncidentSeverity[]).map((sev) => { + const isSelected = severity === sev; + return ( + + ); + })} +
+
+ + {/* Summary */} +
+
+ + + {summary.length} / 4000 + +
+