1406 lines
50 KiB
Python
1406 lines
50 KiB
Python
import uuid
|
|
import logging
|
|
import os
|
|
import smtplib
|
|
from datetime import datetime, timedelta, timezone
|
|
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, ConfigDict, Field
|
|
from sqlalchemy import func, or_, select, text
|
|
from sqlalchemy.orm import Session as DbSession
|
|
|
|
from auth import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
create_one_time_token,
|
|
decode_access_token,
|
|
decode_firebase_id_token,
|
|
hash_password,
|
|
hash_refresh_token,
|
|
hash_one_time_token,
|
|
normalize_email,
|
|
refresh_expiry,
|
|
verify_password,
|
|
)
|
|
from db import check_database, engine
|
|
from models import (
|
|
AuditEvent,
|
|
AuthRateLimit,
|
|
EmailVerificationToken,
|
|
Entitlement,
|
|
Operator,
|
|
OpsAuditEvent,
|
|
OpsIncident,
|
|
OpsIncidentNote,
|
|
OpsSession,
|
|
PasswordResetToken,
|
|
Ride,
|
|
Session,
|
|
Subscription,
|
|
TelemetryPoint,
|
|
User,
|
|
Vehicle,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
app = FastAPI(
|
|
title="MotorSignal API",
|
|
version="1.0.0",
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
openapi_url=None,
|
|
)
|
|
|
|
|
|
class RegisterRequest(BaseModel):
|
|
email: str
|
|
password: str = Field(min_length=12, max_length=128)
|
|
device_name: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: str
|
|
password: str
|
|
device_name: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class EmailRequest(BaseModel):
|
|
email: str
|
|
|
|
|
|
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)
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
|
|
|
|
class SessionResponse(BaseModel):
|
|
id: uuid.UUID
|
|
device_name: str | None
|
|
created_at: datetime
|
|
expires_at: datetime
|
|
last_used_at: datetime | None
|
|
current: bool
|
|
|
|
|
|
class VehicleUpdate(BaseModel):
|
|
nickname: str | None = Field(default=None, min_length=1, max_length=120)
|
|
make: str | None = Field(default=None, min_length=1, max_length=120)
|
|
model: str | None = Field(default=None, min_length=1, max_length=120)
|
|
year: int | None = Field(default=None, ge=1885, le=2100)
|
|
vin: str | None = Field(default=None, min_length=11, max_length=17)
|
|
|
|
|
|
class UserVehicleCreate(BaseModel):
|
|
nickname: str = Field(min_length=1, max_length=120)
|
|
make: str = Field(min_length=1, max_length=120)
|
|
model: str = Field(min_length=1, max_length=120)
|
|
year: int | None = Field(default=None, ge=1885, le=2100)
|
|
vin: str | None = Field(default=None, min_length=11, max_length=17)
|
|
|
|
|
|
class UserRideCreate(BaseModel):
|
|
vehicle_id: uuid.UUID | None = None
|
|
started_at: datetime
|
|
ended_at: datetime | None = None
|
|
distance_miles: float | None = Field(default=None, ge=0)
|
|
top_speed_mph: float | None = Field(default=None, ge=0)
|
|
avg_speed_mph: float | None = Field(default=None, ge=0)
|
|
status: str = Field(default="completed", min_length=1, max_length=32)
|
|
|
|
|
|
class TelemetryPointInput(BaseModel):
|
|
recorded_at: datetime
|
|
latitude: float | None = Field(default=None, ge=-90, le=90)
|
|
longitude: float | None = Field(default=None, ge=-180, le=180)
|
|
speed_mph: float | None = Field(default=None, ge=0)
|
|
lean_angle_deg: float | None = Field(default=None, ge=-90, le=90)
|
|
g_force: float | None = Field(default=None, ge=0)
|
|
accel_g: float | None = Field(default=None, ge=-10, le=10)
|
|
brake_g: float | None = Field(default=None, ge=-10, le=10)
|
|
|
|
|
|
class TelemetryBatch(BaseModel):
|
|
points: list[TelemetryPointInput] = Field(min_length=1, max_length=1000)
|
|
|
|
|
|
def get_db():
|
|
db = DbSession(engine)
|
|
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def enforce_auth_rate_limit(request: Request, db: DbSession, action: str, limit: int = 10, window_seconds: int = 900) -> None:
|
|
client = request.client.host if request.client else "unknown"
|
|
key = f"{action}:{client}"
|
|
now = datetime.now(timezone.utc)
|
|
bucket = db.get(AuthRateLimit, key, with_for_update=True)
|
|
if bucket is None:
|
|
db.add(AuthRateLimit(key=key, window_started=now, attempts=1))
|
|
db.commit()
|
|
return
|
|
elapsed = (now - bucket.window_started).total_seconds()
|
|
if elapsed >= window_seconds:
|
|
bucket.window_started = now
|
|
bucket.attempts = 1
|
|
elif bucket.attempts >= limit:
|
|
retry_after = max(1, int(window_seconds - elapsed))
|
|
raise HTTPException(status_code=429, detail="Too many authentication attempts", headers={"Retry-After": str(retry_after)})
|
|
else:
|
|
bucket.attempts += 1
|
|
db.commit()
|
|
|
|
|
|
def extract_bearer_token(authorization: str | None) -> str:
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing authorization token",
|
|
)
|
|
|
|
scheme, _, token = authorization.partition(" ")
|
|
|
|
if scheme.lower() != "bearer" or not token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid authorization header",
|
|
)
|
|
|
|
return token
|
|
|
|
|
|
def get_request_context(request: Request) -> tuple[str | None, str | None]:
|
|
return request.client.host if request.client else None, request.headers.get("user-agent")
|
|
|
|
|
|
def audit(
|
|
db: DbSession,
|
|
event_type: str,
|
|
actor_user_id: uuid.UUID | None = None,
|
|
target_user_id: uuid.UUID | None = None,
|
|
request: Request | None = None,
|
|
metadata: dict | None = None,
|
|
) -> None:
|
|
ip_address = request.client.host if request and request.client else None
|
|
user_agent = request.headers.get("user-agent")[:512] if request and request.headers.get("user-agent") else None
|
|
db.add(AuditEvent(event_type=event_type, actor_user_id=actor_user_id, target_user_id=target_user_id, ip_address=ip_address, user_agent=user_agent, metadata_json=metadata))
|
|
|
|
|
|
def send_email(recipient: str, subject: str, body: str) -> None:
|
|
host = os.getenv("SMTP_HOST")
|
|
if not host:
|
|
return
|
|
try:
|
|
message = EmailMessage()
|
|
message["From"] = os.environ["SMTP_FROM"]
|
|
message["To"] = recipient
|
|
message["Subject"] = subject
|
|
message.set_content(body)
|
|
port = int(os.getenv("SMTP_PORT", "587"))
|
|
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
|
if os.getenv("SMTP_STARTTLS", "true").lower() == "true":
|
|
smtp.starttls()
|
|
if os.getenv("SMTP_USER"):
|
|
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
|
|
smtp.send_message(message)
|
|
except (KeyError, OSError, smtplib.SMTPException):
|
|
logger.exception("MotorSignal email delivery failed")
|
|
|
|
|
|
def get_current_identity(
|
|
authorization: str | None = Header(default=None),
|
|
db: DbSession = Depends(get_db),
|
|
) -> tuple[User, Session]:
|
|
token = extract_bearer_token(authorization)
|
|
|
|
try:
|
|
payload = decode_access_token(token)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired access token",
|
|
)
|
|
|
|
if payload.get("type") != "access":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid access token",
|
|
)
|
|
|
|
try:
|
|
user_id = uuid.UUID(payload["sub"])
|
|
session_id = uuid.UUID(payload["sid"])
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid access token",
|
|
)
|
|
|
|
session = db.get(Session, session_id)
|
|
|
|
if (
|
|
session is None
|
|
or session.user_id != user_id
|
|
or session.revoked_at is not None
|
|
or session.expires_at <= datetime.now(timezone.utc)
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Session is no longer valid",
|
|
)
|
|
|
|
user = db.get(User, user_id)
|
|
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User is unavailable",
|
|
)
|
|
|
|
return user, session
|
|
|
|
|
|
def get_current_user(identity: tuple[User, Session] = Depends(get_current_identity)) -> User:
|
|
return identity[0]
|
|
|
|
|
|
|
|
@app.get("/api/v1/health")
|
|
def health():
|
|
return {
|
|
"status": "ok",
|
|
"service": "motorsignal-api",
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ready")
|
|
def readiness():
|
|
try:
|
|
check_database()
|
|
|
|
return {
|
|
"status": "ready",
|
|
"database": "ok",
|
|
}
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Service unavailable",
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/auth/register",
|
|
response_model=TokenResponse,
|
|
status_code=201,
|
|
)
|
|
def register(
|
|
request: RegisterRequest,
|
|
http_request: Request,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
enforce_auth_rate_limit(http_request, db, "register")
|
|
try:
|
|
validated = validate_email(
|
|
request.email,
|
|
check_deliverability=False,
|
|
)
|
|
email = normalize_email(validated.normalized)
|
|
|
|
except EmailNotValidError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid email address",
|
|
)
|
|
|
|
existing = db.scalar(
|
|
select(User).where(User.email == email)
|
|
)
|
|
|
|
if existing is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Account already exists",
|
|
)
|
|
|
|
user = User(
|
|
email=email,
|
|
password_hash=hash_password(request.password),
|
|
)
|
|
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
raw_refresh, refresh_hash = create_refresh_token()
|
|
|
|
session = Session(
|
|
user_id=user.id,
|
|
refresh_token_hash=refresh_hash,
|
|
device_name=request.device_name,
|
|
expires_at=refresh_expiry(),
|
|
)
|
|
|
|
db.add(session)
|
|
verification_raw, verification_hash = create_one_time_token()
|
|
db.add(EmailVerificationToken(user_id=user.id, token_hash=verification_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24)))
|
|
audit(db, "auth.register", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={verification_raw}")
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=raw_refresh,
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/auth/login",
|
|
response_model=TokenResponse,
|
|
)
|
|
def login(
|
|
request: LoginRequest,
|
|
http_request: Request,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
enforce_auth_rate_limit(http_request, db, "login")
|
|
email = normalize_email(request.email)
|
|
|
|
user = db.scalar(
|
|
select(User).where(User.email == email)
|
|
)
|
|
|
|
if user is None or not verify_password(
|
|
request.password,
|
|
user.password_hash,
|
|
):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid email or password",
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Account disabled",
|
|
)
|
|
|
|
raw_refresh, refresh_hash = create_refresh_token()
|
|
|
|
session = Session(
|
|
user_id=user.id,
|
|
refresh_token_hash=refresh_hash,
|
|
device_name=request.device_name,
|
|
expires_at=refresh_expiry(),
|
|
)
|
|
|
|
db.add(session)
|
|
audit(db, "auth.login", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=raw_refresh,
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/auth/refresh",
|
|
response_model=TokenResponse,
|
|
)
|
|
def refresh(
|
|
request: RefreshRequest,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
old_hash = hash_refresh_token(request.refresh_token)
|
|
|
|
session = db.scalar(
|
|
select(Session).where(
|
|
Session.refresh_token_hash == old_hash
|
|
)
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
if (
|
|
session is None
|
|
or session.revoked_at is not None
|
|
or session.expires_at <= now
|
|
):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
user = db.get(User, session.user_id)
|
|
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
new_raw, new_hash = create_refresh_token()
|
|
|
|
session.refresh_token_hash = new_hash
|
|
session.last_used_at = now
|
|
session.expires_at = refresh_expiry()
|
|
|
|
db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=new_raw,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/auth/logout", status_code=204)
|
|
def logout(
|
|
request: RefreshRequest,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
refresh_hash = hash_refresh_token(request.refresh_token)
|
|
|
|
session = db.scalar(
|
|
select(Session).where(
|
|
Session.refresh_token_hash == refresh_hash
|
|
)
|
|
)
|
|
|
|
if session is not None and session.revoked_at is None:
|
|
session.revoked_at = datetime.now(timezone.utc)
|
|
audit(db, "auth.logout", actor_user_id=session.user_id, target_user_id=session.user_id)
|
|
db.commit()
|
|
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/me")
|
|
def me(user: User = Depends(get_current_user)):
|
|
return {
|
|
"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")
|
|
email = normalize_email(payload.email)
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is not None and not user.email_verified and user.is_active:
|
|
raw, token_hash = create_one_time_token()
|
|
db.add(EmailVerificationToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24)))
|
|
audit(db, "auth.email_verification_requested", target_user_id=user.id)
|
|
db.commit()
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={raw}")
|
|
return {"detail": "If the account can be verified, instructions have been sent."}
|
|
|
|
|
|
@app.post("/api/v1/auth/verify-email", status_code=204)
|
|
def verify_email(request: TokenRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "verify-email")
|
|
token = db.scalar(select(EmailVerificationToken).where(EmailVerificationToken.token_hash == hash_one_time_token(request.token)))
|
|
now = datetime.now(timezone.utc)
|
|
if token is None or token.used_at is not None or token.expires_at <= now:
|
|
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
|
|
user = db.get(User, token.user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=400, detail="Invalid verification token")
|
|
user.email_verified = True
|
|
token.used_at = now
|
|
audit(db, "auth.email_verified", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.post("/api/v1/auth/password-reset/request", status_code=202)
|
|
def request_password_reset(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "password-reset-request")
|
|
email = normalize_email(payload.email)
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is not None and user.is_active:
|
|
raw, token_hash = create_one_time_token()
|
|
db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=1)))
|
|
audit(db, "auth.password_reset_requested", target_user_id=user.id)
|
|
db.commit()
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(email, "Reset your MotorSignal password", f"Reset your password: {web_base}/forgot-password?token={raw}")
|
|
return {"detail": "If the account exists, reset instructions have been sent."}
|
|
|
|
|
|
@app.post("/api/v1/auth/password-reset", status_code=204)
|
|
def reset_password(request: PasswordResetRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "password-reset")
|
|
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_one_time_token(request.token)))
|
|
now = datetime.now(timezone.utc)
|
|
if token is None or token.used_at is not None or token.expires_at <= now:
|
|
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
|
user = db.get(User, token.user_id)
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(status_code=400, detail="Invalid reset token")
|
|
user.password_hash = hash_password(request.password)
|
|
token.used_at = now
|
|
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
|
|
audit(db, "auth.password_reset_completed", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/auth/sessions", response_model=list[SessionResponse])
|
|
def list_sessions(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
|
|
user, current = identity
|
|
sessions = db.scalars(select(Session).where(Session.user_id == user.id, Session.revoked_at.is_(None)).order_by(Session.created_at.desc())).all()
|
|
return [SessionResponse(id=item.id, device_name=item.device_name, created_at=item.created_at, expires_at=item.expires_at, last_used_at=item.last_used_at, current=item.id == current.id) for item in sessions]
|
|
|
|
|
|
@app.delete("/api/v1/auth/sessions/{session_id}", status_code=204)
|
|
def revoke_session(session_id: uuid.UUID, identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
|
|
user, _ = identity
|
|
session = db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
if session.revoked_at is None:
|
|
session.revoked_at = datetime.now(timezone.utc)
|
|
audit(db, "auth.session_revoked", actor_user_id=user.id, target_user_id=user.id, metadata={"session_id": str(session.id)})
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.post("/api/v1/auth/logout-all", status_code=204)
|
|
def logout_all(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
|
|
user, _ = identity
|
|
now = datetime.now(timezone.utc)
|
|
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
|
|
audit(db, "auth.logout_all", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
def vehicle_payload(vehicle: Vehicle) -> dict:
|
|
return {"id": str(vehicle.id), "user_id": str(vehicle.user_id), "nickname": vehicle.nickname, "make": vehicle.make, "model": vehicle.model, "year": vehicle.year, "vin": vehicle.vin, "created_at": vehicle.created_at, "updated_at": vehicle.updated_at}
|
|
|
|
|
|
def ride_payload(ride: Ride) -> dict:
|
|
return {"id": str(ride.id), "user_id": str(ride.user_id), "vehicle_id": str(ride.vehicle_id) if ride.vehicle_id else None, "started_at": ride.started_at, "ended_at": ride.ended_at, "distance_miles": ride.distance_miles, "top_speed_mph": ride.top_speed_mph, "avg_speed_mph": ride.avg_speed_mph, "status": ride.status, "created_at": ride.created_at}
|
|
|
|
|
|
@app.get("/api/v1/vehicles")
|
|
def list_my_vehicles(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicles = db.scalars(select(Vehicle).where(Vehicle.user_id == user.id).order_by(Vehicle.created_at.desc())).all()
|
|
return [vehicle_payload(vehicle) for vehicle in vehicles]
|
|
|
|
|
|
@app.post("/api/v1/vehicles", status_code=201)
|
|
def create_my_vehicle(request: UserVehicleCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = Vehicle(user_id=user.id, **request.model_dump())
|
|
db.add(vehicle)
|
|
db.flush()
|
|
audit(db, "vehicle.created", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)})
|
|
db.commit()
|
|
db.refresh(vehicle)
|
|
return vehicle_payload(vehicle)
|
|
|
|
|
|
@app.get("/api/v1/vehicles/{vehicle_id}")
|
|
def get_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
return vehicle_payload(vehicle)
|
|
|
|
|
|
@app.patch("/api/v1/vehicles/{vehicle_id}")
|
|
def update_my_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
changes = request.model_dump(exclude_unset=True)
|
|
for field, value in changes.items():
|
|
setattr(vehicle, field, value)
|
|
audit(db, "vehicle.updated", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)})
|
|
db.commit()
|
|
db.refresh(vehicle)
|
|
return vehicle_payload(vehicle)
|
|
|
|
|
|
@app.delete("/api/v1/vehicles/{vehicle_id}", status_code=204)
|
|
def delete_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
audit(db, "vehicle.deleted", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)})
|
|
db.delete(vehicle)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/rides")
|
|
def list_my_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
query = select(Ride).where(Ride.user_id == user.id)
|
|
total = db.scalar(select(func.count()).select_from(Ride).where(Ride.user_id == user.id)) or 0
|
|
rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
|
return {"items": [ride_payload(ride) for ride in rides], "page": page, "page_size": page_size, "total": total}
|
|
|
|
|
|
@app.post("/api/v1/rides", status_code=201)
|
|
def create_my_ride(request: UserRideCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
if request.vehicle_id is not None:
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == request.vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=400, detail="Vehicle not found")
|
|
ride = Ride(user_id=user.id, **request.model_dump())
|
|
db.add(ride)
|
|
db.flush()
|
|
audit(db, "ride.created", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride.id)})
|
|
db.commit()
|
|
db.refresh(ride)
|
|
return ride_payload(ride)
|
|
|
|
|
|
@app.get("/api/v1/rides/{ride_id}")
|
|
def get_my_ride(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user.id))
|
|
if ride is None:
|
|
raise HTTPException(status_code=404, detail="Ride not found")
|
|
return ride_payload(ride)
|
|
|
|
|
|
def telemetry_payload(point: TelemetryPoint) -> dict:
|
|
return {"id": str(point.id), "ride_id": str(point.ride_id), "recorded_at": point.recorded_at, "latitude": point.latitude, "longitude": point.longitude, "speed_mph": point.speed_mph, "lean_angle_deg": point.lean_angle_deg, "g_force": point.g_force, "accel_g": point.accel_g, "brake_g": point.brake_g}
|
|
|
|
|
|
def get_owned_ride(ride_id: uuid.UUID, user_id: uuid.UUID, db: DbSession) -> Ride:
|
|
ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user_id))
|
|
if ride is None:
|
|
raise HTTPException(status_code=404, detail="Ride not found")
|
|
return ride
|
|
|
|
|
|
@app.post("/api/v1/rides/{ride_id}/telemetry", status_code=201)
|
|
def ingest_telemetry(ride_id: uuid.UUID, request: TelemetryBatch, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
get_owned_ride(ride_id, user.id, db)
|
|
points = [TelemetryPoint(ride_id=ride_id, **point.model_dump()) for point in request.points]
|
|
db.add_all(points)
|
|
audit(db, "telemetry.ingested", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride_id), "point_count": len(points)})
|
|
db.commit()
|
|
return {"accepted": len(points)}
|
|
|
|
|
|
@app.get("/api/v1/rides/{ride_id}/telemetry")
|
|
def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
get_owned_ride(ride_id, user.id, db)
|
|
points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all()
|
|
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)
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
def entitlement_payload(entitlement: Entitlement) -> dict:
|
|
return {"id": str(entitlement.id), "user_id": str(entitlement.user_id), "key": entitlement.key, "value": entitlement.value, "expires_at": entitlement.expires_at, "created_at": entitlement.created_at}
|
|
|
|
|
|
def get_or_create_subscription(user_id: uuid.UUID, db: DbSession) -> Subscription:
|
|
subscription = db.scalar(select(Subscription).where(Subscription.user_id == user_id))
|
|
if subscription is None:
|
|
subscription = Subscription(user_id=user_id, plan="free", status="active")
|
|
db.add(subscription)
|
|
db.flush()
|
|
return subscription
|
|
|
|
|
|
@app.get("/api/v1/me/subscription")
|
|
def get_my_subscription(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
subscription = get_or_create_subscription(user.id, db)
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
return subscription_payload(subscription)
|
|
|
|
|
|
@app.get("/api/v1/me/entitlements")
|
|
def list_my_entitlements(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
entitlements = db.scalars(select(Entitlement).where(Entitlement.user_id == user.id).order_by(Entitlement.created_at.desc())).all()
|
|
return [entitlement_payload(item) for item in entitlements if item.expires_at is None or item.expires_at > datetime.now(timezone.utc)]
|
|
|
|
|
|
|
|
# ============================================================
|
|
# 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,
|
|
}
|