From 1c344ff3fa5f795260e38334fd5f1a1ee946f08a Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Thu, 13 Aug 2026 20:05:16 -0500 Subject: [PATCH 1/2] audit functionality --- ARCHITECTURE.md | 4 +- README.md | 2 +- SECURITY.md | 4 +- .../b8f4c2d6e1a0_add_platform_audit_events.py | 59 +++ backend/app/api/routes/audit_events.py | 334 ++++++++++++++++ backend/app/api/routes/courses.py | 23 ++ backend/app/api/routes/invites.py | 28 ++ backend/app/api/routes/posts.py | 11 + backend/app/api/routes/threads.py | 11 + backend/app/api/routes/users.py | 24 ++ backend/app/api/routes/v2_platform.py | 41 ++ backend/app/audit.py | 238 +++++++++++ backend/app/main.py | 2 + backend/app/models.py | 50 +++ backend/app/operational_backup.py | 32 ++ backend/app/rate_limit.py | 1 + backend/app/schemas.py | 27 ++ backend/scripts/manage_audit_events.py | 91 +++++ backend/tests/test_audit_events.py | 373 ++++++++++++++++++ docs/audit/audit-event-policy.md | 48 +++ docs/audit/phase-12-baseline.md | 15 + docs/audit/phase-12-verification.md | 57 +++ docs/operations/audit-events-runbook.md | 39 ++ docs/operations/backup-and-restore.md | 4 +- .../incident-observability-guide.md | 2 +- .../future-openspec-roadmap.md | 4 +- .../audit-event-privacy-and-access.md | 7 + docs/security/security-event-logging.md | 2 +- frontend/src/app/app.routes.spec.ts | 3 + frontend/src/app/app.routes.ts | 2 + frontend/src/app/models/audit-event.ts | 32 ++ .../admin-audit-events.component.html | 29 ++ .../admin-audit-events.component.scss | 5 + .../admin-audit-events.component.spec.ts | 55 +++ .../admin-audit-events.component.ts | 98 +++++ .../app/services/audit-events.service.spec.ts | 36 ++ .../src/app/services/audit-events.service.ts | 29 ++ .../app/services/shell-navigation.service.ts | 1 + .../tests/demo/admin-platform-smoke.spec.ts | 6 + .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../platform-operational-readiness/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../implement-platform-audit-events/design.md | 82 ++++ .../proposal.md | 30 ++ .../specs/platform-audit-events/spec.md | 112 ++++++ .../platform-operational-readiness/spec.md | 16 + .../implement-platform-audit-events/tasks.md | 42 ++ .../platform-operational-readiness/spec.md | 119 ++++++ 51 files changed, 2223 insertions(+), 9 deletions(-) create mode 100644 backend/alembic/versions/b8f4c2d6e1a0_add_platform_audit_events.py create mode 100644 backend/app/api/routes/audit_events.py create mode 100644 backend/app/audit.py create mode 100644 backend/scripts/manage_audit_events.py create mode 100644 backend/tests/test_audit_events.py create mode 100644 docs/audit/audit-event-policy.md create mode 100644 docs/audit/phase-12-baseline.md create mode 100644 docs/audit/phase-12-verification.md create mode 100644 docs/operations/audit-events-runbook.md create mode 100644 docs/security/audit-event-privacy-and-access.md create mode 100644 frontend/src/app/models/audit-event.ts create mode 100644 frontend/src/app/pages/admin-audit-events/admin-audit-events.component.html create mode 100644 frontend/src/app/pages/admin-audit-events/admin-audit-events.component.scss create mode 100644 frontend/src/app/pages/admin-audit-events/admin-audit-events.component.spec.ts create mode 100644 frontend/src/app/pages/admin-audit-events/admin-audit-events.component.ts create mode 100644 frontend/src/app/services/audit-events.service.spec.ts create mode 100644 frontend/src/app/services/audit-events.service.ts rename openspec/changes/{establish-operational-readiness => archive/2026-08-13-establish-operational-readiness}/.openspec.yaml (100%) rename openspec/changes/{establish-operational-readiness => archive/2026-08-13-establish-operational-readiness}/design.md (100%) rename openspec/changes/{establish-operational-readiness => archive/2026-08-13-establish-operational-readiness}/proposal.md (100%) rename openspec/changes/{establish-operational-readiness => archive/2026-08-13-establish-operational-readiness}/specs/platform-operational-readiness/spec.md (100%) rename openspec/changes/{establish-operational-readiness => archive/2026-08-13-establish-operational-readiness}/tasks.md (100%) create mode 100644 openspec/changes/implement-platform-audit-events/.openspec.yaml create mode 100644 openspec/changes/implement-platform-audit-events/design.md create mode 100644 openspec/changes/implement-platform-audit-events/proposal.md create mode 100644 openspec/changes/implement-platform-audit-events/specs/platform-audit-events/spec.md create mode 100644 openspec/changes/implement-platform-audit-events/specs/platform-operational-readiness/spec.md create mode 100644 openspec/changes/implement-platform-audit-events/tasks.md create mode 100644 openspec/specs/platform-operational-readiness/spec.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae154b3..d00a0b2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,7 +67,9 @@ Important paths: - `backend/alembic/versions/` contains database migrations. - `backend/tests/` contains the backend test suite. -The application exposes process-only `/health/live` and database-backed `/health/ready`. HTTP responses include a privacy-safe request ID and optional bounded correlation ID. Backend middleware records normalized route, method, status family, and duration through the structured/redacted logging and in-process metrics foundation. Optional Prometheus text export at `/internal/metrics` is disabled by default and token protected when enabled. These are vendor-neutral operational foundations, not distributed tracing, a durable audit ledger, or a production hosting architecture; see [the observability policy](docs/observability/logging-policy.md) and [runbook](docs/operations/observability-runbook.md). +The application exposes process-only `/health/live` and database-backed `/health/ready`. HTTP responses include a privacy-safe request ID and optional bounded correlation ID. Backend middleware records normalized route, method, status family, and duration through the structured/redacted logging and in-process metrics foundation. Optional Prometheus text export at `/internal/metrics` is disabled by default and token protected when enabled. These are vendor-neutral operational foundations, not distributed tracing or a production hosting architecture; see [the observability policy](docs/observability/logging-policy.md) and [runbook](docs/operations/observability-runbook.md). + +High-impact supported mutations additionally write a transaction-bound `audit_events` record with allowlisted metadata and minimized before/after state. Platform and organization review APIs apply explicit scopes; per-scope SHA-256 chains support integrity verification after backup/restore. This is application-level tamper evidence, not externally anchored tamper-proof storage. See [the audit-event policy](docs/audit/audit-event-policy.md). Backend authorization uses explicit platform/organization role allowlists plus reusable organization, section, course-content, forum-ownership, and learner-progress scope checks. Sensitive routes use a configurable process-local fixed-window limiter; it is suitable only for the checked-in single-Uvicorn-process topology and must be replaced by shared storage before horizontal scaling. The canonical policy and limitations are in [docs/security/role-authorization-policy.md](docs/security/role-authorization-policy.md) and [docs/security/rate-limiting-policy.md](docs/security/rate-limiting-policy.md). diff --git a/README.md b/README.md index 5950265..1aa0f0f 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ For a deeper overview, see [ARCHITECTURE.md](ARCHITECTURE.md). Platform maturity evidence, including the measured frontend bundle work and the security, observability, operations, dependency, and backend-capability baselines, is indexed in [docs/platform-maturity](docs/platform-maturity/phase-7-baseline.md). These documents are readiness inputs; they do not claim that EchoEd 1.0 is production-ready. -Phase 10 adds vendor-neutral structured logging, request correlation, liveness/readiness, protected optional metrics export, safe frontend support references, and operational guidance. Configuration and endpoint policy are documented under [docs/observability](docs/observability/logging-policy.md); this is not a commercial monitoring integration or durable audit system. +Phase 10 adds vendor-neutral structured logging, request correlation, liveness/readiness, protected optional metrics export, safe frontend support references, and operational guidance. Configuration and endpoint policy are documented under [docs/observability](docs/observability/logging-policy.md). High-impact administrative mutations are separately captured under [the audit-event policy](docs/audit/audit-event-policy.md). Phase 8 security-hardening evidence and operator-facing limitations are indexed in [docs/security/phase-8-security-baseline.md](docs/security/phase-8-security-baseline.md). Configure rate limits with the documented `RATE_LIMIT__LIMIT` and `RATE_LIMIT__WINDOW_SECONDS` variables; the current store is process-local and forwarded client-IP headers are intentionally ignored. diff --git a/SECURITY.md b/SECURITY.md index 33980c6..465995d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,7 +17,7 @@ Security reports may cover: The focused [Phase 7 security baseline](docs/platform-maturity/security-baseline.md) records the prior evidence. The [Phase 8 security baseline](docs/security/phase-8-security-baseline.md), [threat model](docs/security/phase-8-threat-model.md), and linked policies document backend-enforced forum ownership, privileged-user invariants, role allowlists, configurable rate limits, upload signature checks, minimized responses, and expanded object/organization tests. These are scoped hardening controls, not a penetration test or production-readiness certification. -Durable audit events, distributed rate-limit storage, private/scanned asset delivery, session revocation, production proxy/host/CSP/HSTS validation, and formal privacy/retention work remain explicit future work. Do not use the current demo with real learner or production data. +High-impact supported mutations now produce transaction-bound, privacy-minimized audit records with scoped review/export and application-level integrity verification. External anchoring, WORM storage, legal-retention guarantees, distributed rate-limit storage, private/scanned asset delivery, and session revocation remain future work. Do not use the current demo with real learner or production data. ## Reporting a Vulnerability @@ -63,7 +63,7 @@ Please do not publicly disclose a suspected vulnerability until there has been a ## Diagnostic References and Sensitive Evidence -Unexpected API failures may display a bounded request reference. It is safe to include that reference, the approximate time, the action, and a non-sensitive page name in a report. Do not provide passwords, tokens, cookies, authorization headers, invitation/reset links, uploaded files, learner records, assessment responses, or private course content. Backend operational logs and metrics are privacy-redacted diagnostics; they are not a durable or tamper-resistant audit record. See the [observability runbook](docs/operations/observability-runbook.md). +Unexpected API failures may display a bounded request reference. It is safe to include that reference, the approximate time, the action, and a non-sensitive page name in a report. Do not provide passwords, tokens, cookies, authorization headers, invitation/reset links, uploaded files, learner records, assessment responses, or private course content. Backend operational logs and metrics remain privacy-redacted diagnostics; durable high-impact action records are governed separately by [the audit-event policy](docs/audit/audit-event-policy.md). Production configuration fails closed and never loads dotenv. Allowed hosts are enforced, and forwarded client/protocol/host metadata is ignored unless the direct peer belongs to an explicitly configured CIDR. Operators must never attach secrets, database URLs, backup contents, or raw environment dumps to issues; share only setting categories, safe request references, release identifiers, timestamps, and pass/fail results. See the [production configuration contract](docs/operations/production-configuration.md). diff --git a/backend/alembic/versions/b8f4c2d6e1a0_add_platform_audit_events.py b/backend/alembic/versions/b8f4c2d6e1a0_add_platform_audit_events.py new file mode 100644 index 0000000..d66b978 --- /dev/null +++ b/backend/alembic/versions/b8f4c2d6e1a0_add_platform_audit_events.py @@ -0,0 +1,59 @@ +"""Add durable platform audit events. + +Revision ID: b8f4c2d6e1a0 +Revises: 9a7b6c5d4e3f +Create Date: 2026-08-13 18:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "b8f4c2d6e1a0" +down_revision = "9a7b6c5d4e3f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False), + sa.Column("scope_key", sa.String(length=80), nullable=False), + sa.Column("scope_sequence", sa.Integer(), nullable=False), + sa.Column("organization_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("actor_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("actor_role", sa.String(length=40), nullable=False), + sa.Column("action", sa.String(length=100), nullable=False), + sa.Column("category", sa.String(length=40), nullable=False), + sa.Column("outcome", sa.String(length=24), nullable=False), + sa.Column("target_type", sa.String(length=60), nullable=False), + sa.Column("target_id", sa.String(length=100), nullable=False), + sa.Column("request_id", sa.String(length=128), nullable=True), + sa.Column("correlation_id", sa.String(length=64), nullable=True), + sa.Column("reason_code", sa.String(length=80), nullable=True), + sa.Column("before_state", sa.JSON(), nullable=False), + sa.Column("after_state", sa.JSON(), nullable=False), + sa.Column("previous_hash", sa.String(length=64), nullable=False), + sa.Column("event_hash", sa.String(length=64), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("scope_key", "scope_sequence", name="uq_audit_events_scope_sequence"), + sa.UniqueConstraint("event_hash"), + ) + op.create_index("ix_audit_events_scope_created", "audit_events", ["scope_key", "created_at", "id"]) + op.create_index("ix_audit_events_action_created", "audit_events", ["action", "created_at"]) + op.create_index("ix_audit_events_actor_created", "audit_events", ["actor_id", "created_at"]) + op.create_index( + "ix_audit_events_target_created", "audit_events", ["target_type", "target_id", "created_at"] + ) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_target_created", table_name="audit_events") + op.drop_index("ix_audit_events_actor_created", table_name="audit_events") + op.drop_index("ix_audit_events_action_created", table_name="audit_events") + op.drop_index("ix_audit_events_scope_created", table_name="audit_events") + op.drop_table("audit_events") diff --git a/backend/app/api/routes/audit_events.py b/backend/app/api/routes/audit_events.py new file mode 100644 index 0000000..195c52a --- /dev/null +++ b/backend/app/api/routes/audit_events.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import base64 +import csv +from datetime import datetime +from io import StringIO +import json +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Query as SqlQuery, Session + +from app.audit import AUDIT_ACTIONS, append_audit_event, verify_audit_chain +from app.database import get_db +from app.deps import get_active_org_id, get_current_user, require_roles +from app.enum import MembershipStatus, OrganizationRole +from app.models import AuditEvent, OrganizationMembership, User +from app.observability import emit_event, metrics +from app.rate_limit import enforce_rate_limit +from app.schemas import AuditEventPage, AuditEventResponse + + +router = APIRouter() +MAX_PAGE_SIZE = 100 +MAX_EXPORT_ROWS = 5_000 +_OUTCOMES = {"succeeded", "failed"} + + +def _serialize(event: AuditEvent) -> AuditEventResponse: + return AuditEventResponse( + id=event.id, + created_at=event.created_at, + schema_version=event.schema_version, + actor_id=event.actor_id, + actor_role=event.actor_role, + action=event.action, + category=event.category, + outcome=event.outcome, + target_type=event.target_type, + target_id=event.target_id, + organization_id=event.organization_id, + request_id=event.request_id, + correlation_id=event.correlation_id, + reason_code=event.reason_code, + before_state=event.before_state or {}, + after_state=event.after_state or {}, + integrity_verified=True, + ) + + +def _encode_cursor(event: AuditEvent) -> str: + raw = json.dumps( + {"created_at": event.created_at.isoformat(timespec="microseconds"), "id": str(event.id)}, + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _decode_cursor(value: str) -> tuple[datetime, UUID]: + try: + padded = value + "=" * (-len(value) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded.encode("ascii"))) + return datetime.fromisoformat(payload["created_at"]), UUID(payload["id"]) + except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=422, detail="Invalid audit cursor.") from exc + + +def _filters( + query: SqlQuery, + *, + action: str | None, + category: str | None, + outcome: str | None, + actor_id: UUID | None, + target_type: str | None, + target_id: str | None, + since: datetime | None, + until: datetime | None, +) -> SqlQuery: + if action is not None: + if action not in AUDIT_ACTIONS: + raise HTTPException(status_code=422, detail="Unsupported audit action filter.") + query = query.filter(AuditEvent.action == action) + if category is not None: + categories = {definition.category for definition in AUDIT_ACTIONS.values()} + if category not in categories: + raise HTTPException(status_code=422, detail="Unsupported audit category filter.") + query = query.filter(AuditEvent.category == category) + if outcome is not None: + if outcome not in _OUTCOMES: + raise HTTPException(status_code=422, detail="Unsupported audit outcome filter.") + query = query.filter(AuditEvent.outcome == outcome) + if actor_id is not None: + query = query.filter(AuditEvent.actor_id == actor_id) + if target_type is not None: + query = query.filter(AuditEvent.target_type == target_type) + if target_id is not None: + query = query.filter(AuditEvent.target_id == target_id) + if since is not None: + query = query.filter(AuditEvent.created_at >= since) + if until is not None: + query = query.filter(AuditEvent.created_at <= until) + if since is not None and until is not None and since > until: + raise HTTPException(status_code=422, detail="Audit time range is invalid.") + return query + + +def _verify_query_scopes(db: Session, events: list[AuditEvent]) -> None: + organizations = {event.organization_id for event in events} + scopes = organizations | ({None} if any(event.organization_id is None for event in events) else set()) + for organization_id in scopes: + result = verify_audit_chain(db, organization_id=organization_id) + if not result.valid: + emit_event( + "audit.integrity.failed", + level=40, + component="audit", + scope="organization" if organization_id else "platform", + result="failure", + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Audit integrity verification failed. Contact an operator.", + ) + + +def _page( + db: Session, + query: SqlQuery, + *, + limit: int, + cursor: str | None, +) -> AuditEventPage: + if cursor: + cursor_time, cursor_id = _decode_cursor(cursor) + query = query.filter( + or_( + AuditEvent.created_at < cursor_time, + and_(AuditEvent.created_at == cursor_time, AuditEvent.id < cursor_id), + ) + ) + rows = query.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(limit + 1).all() + has_more = len(rows) > limit + rows = rows[:limit] + _verify_query_scopes(db, rows) + metrics.increment("echoed_audit_operations_total", operation="read", result="success") + return AuditEventPage( + items=[_serialize(row) for row in rows], + next_cursor=_encode_cursor(rows[-1]) if has_more and rows else None, + ) + + +def _organization_scope(db: Session, current_user: User, requested: UUID | None) -> UUID: + if requested is None: + raise HTTPException(status_code=400, detail="Missing active organization.") + if current_user.role == "super_admin": + return requested + membership = ( + db.query(OrganizationMembership) + .filter( + OrganizationMembership.organization_id == requested, + OrganizationMembership.user_id == current_user.id, + OrganizationMembership.status == MembershipStatus.ACTIVE, + OrganizationMembership.role == OrganizationRole.ORG_ADMIN, + ) + .first() + ) + if membership is None: + raise HTTPException(status_code=404, detail="Audit events not found.") + return requested + + +def _formula_safe(value: object) -> str: + text = "" if value is None else str(value) + return f"'{text}" if text.lstrip().startswith(("=", "+", "-", "@")) else text + + +@router.get("/audit-events", response_model=AuditEventPage) +def list_platform_audit_events( + action: str | None = None, + category: str | None = None, + outcome: str | None = None, + actor_id: UUID | None = None, + target_type: str | None = Query(default=None, min_length=1, max_length=60), + target_id: str | None = Query(default=None, min_length=1, max_length=100), + since: datetime | None = None, + until: datetime | None = None, + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=50, ge=1, le=MAX_PAGE_SIZE), + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "super_admin")), +): + query = _filters( + db.query(AuditEvent), + action=action, + category=category, + outcome=outcome, + actor_id=actor_id, + target_type=target_type, + target_id=target_id, + since=since, + until=until, + ) + return _page(db, query, limit=limit, cursor=cursor) + + +@router.get("/orgs/{org_id}/audit-events", response_model=AuditEventPage) +def list_organization_audit_events( + org_id: UUID, + action: str | None = None, + category: str | None = None, + outcome: str | None = None, + actor_id: UUID | None = None, + target_type: str | None = Query(default=None, min_length=1, max_length=60), + target_id: str | None = Query(default=None, min_length=1, max_length=100), + since: datetime | None = None, + until: datetime | None = None, + cursor: str | None = Query(default=None, max_length=512), + limit: int = Query(default=50, ge=1, le=MAX_PAGE_SIZE), + active_org_id: UUID | None = Depends(get_active_org_id), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if active_org_id != org_id: + raise HTTPException(status_code=404, detail="Audit events not found.") + scope = _organization_scope(db, current_user, active_org_id) + query = _filters( + db.query(AuditEvent).filter(AuditEvent.organization_id == scope), + action=action, + category=category, + outcome=outcome, + actor_id=actor_id, + target_type=target_type, + target_id=target_id, + since=since, + until=until, + ) + return _page(db, query, limit=limit, cursor=cursor) + + +@router.get("/audit-events/export.csv") +def export_platform_audit_events( + request: Request, + action: str | None = None, + category: str | None = None, + outcome: str | None = None, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "super_admin")), +): + enforce_rate_limit(request, "audit_export", actor_id=current_user.id) + query = _filters( + db.query(AuditEvent), + action=action, + category=category, + outcome=outcome, + actor_id=None, + target_type=None, + target_id=None, + since=None, + until=None, + ) + rows = query.order_by(AuditEvent.created_at.desc(), AuditEvent.id.desc()).limit(MAX_EXPORT_ROWS).all() + _verify_query_scopes(db, rows) + output = StringIO(newline="") + writer = csv.writer(output, lineterminator="\n") + writer.writerow( + [ + "event_id", + "created_at", + "action", + "category", + "outcome", + "actor_id", + "actor_role", + "organization_id", + "target_type", + "target_id", + "reason_code", + "request_id", + "correlation_id", + "before_state", + "after_state", + ] + ) + for event in rows: + writer.writerow( + [ + _formula_safe(event.id), + event.created_at.isoformat(), + event.action, + event.category, + event.outcome, + _formula_safe(event.actor_id), + event.actor_role, + _formula_safe(event.organization_id), + event.target_type, + _formula_safe(event.target_id), + event.reason_code or "", + _formula_safe(event.request_id), + _formula_safe(event.correlation_id), + json.dumps(event.before_state or {}, sort_keys=True, separators=(",", ":")), + json.dumps(event.after_state or {}, sort_keys=True, separators=(",", ":")), + ] + ) + append_audit_event( + db, + action="audit.exported", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="audit_event_set", + target_id="platform", + after={"row_count": len(rows)}, + ) + db.commit() + metrics.increment("echoed_audit_operations_total", operation="export", result="success") + return Response( + content=output.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="echoed-audit-events.csv"'}, + ) + + +@router.get("/audit-events/{event_id}", response_model=AuditEventResponse) +def get_platform_audit_event( + event_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(require_roles("admin", "super_admin")), +): + event = db.query(AuditEvent).filter(AuditEvent.id == event_id).first() + if event is None: + raise HTTPException(status_code=404, detail="Audit event not found.") + _verify_query_scopes(db, [event]) + return _serialize(event) diff --git a/backend/app/api/routes/courses.py b/backend/app/api/routes/courses.py index e212cbe..baf63e5 100644 --- a/backend/app/api/routes/courses.py +++ b/backend/app/api/routes/courses.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session, joinedload, selectinload from app.database import get_db +from app.audit import append_audit_event from app.deps import get_active_org_id, get_current_user, require_roles, require_org_roles from app.enum import MembershipStatus from app.enum import CourseVersionStatus @@ -861,6 +862,17 @@ def review_course_authoring_draft( for lesson in unit.lessons: lesson.review_status = "approved" lesson.reviewed_by = current_user.id + append_audit_event( + db, + action="course.review.changed", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="course", + target_id=course.id, + organization_id=course.organization_id, + before={"review_state": "submitted"}, + after={"review_state": payload.decision}, + ) db.commit() _course_studio_event("review_transition", "success", actor=current_user, course_id=course.id) return CourseLifecycleResponse(course_id=course.id, lifecycle_state=payload.decision, revision_number=course.revision_number, feedback=payload.feedback, changed_at=changed_at) @@ -1125,6 +1137,17 @@ def publish_course_version( for lesson in unit.lessons: lesson.revision_status = "current" lesson.published_at = changed_at + append_audit_event( + db, + action="course.version.published", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="course_version", + target_id=version.id, + organization_id=course.organization_id, + before={"version_status": CourseVersionStatus.DRAFT.value}, + after={"version_status": CourseVersionStatus.PUBLISHED.value}, + ) db.commit() db.refresh(version) _course_studio_event("publish", "success", actor=current_user, course_id=course.id) diff --git a/backend/app/api/routes/invites.py b/backend/app/api/routes/invites.py index c721814..aba69b8 100644 --- a/backend/app/api/routes/invites.py +++ b/backend/app/api/routes/invites.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session from app.database import get_db +from app.audit import append_audit_event from app.deps import get_current_user, require_org_roles from app.enum import MembershipStatus, OrganizationRole from app.models import OrganizationInvite, OrganizationMembership, Organization @@ -48,6 +49,7 @@ def create_invite( ) expires_at = payload.expires_at or (datetime.utcnow() + timedelta(days=7)) invite = OrganizationInvite( + id=uuid.uuid4(), organization_id=org_uuid, email=payload.email, role=OrganizationRole(payload.role), @@ -56,6 +58,17 @@ def create_invite( invited_by_user_id=current_user.id, ) db.add(invite) + append_audit_event( + db, + action="organization.invite.created", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="organization_invite", + target_id=invite.id, + organization_id=org_uuid, + after={"role": payload.role, "status": "pending"}, + request_id=getattr(request.state, "request_id", None), + ) db.commit() db.refresh(invite) security_event( @@ -117,12 +130,15 @@ def accept_invite( ) .first() ) + previous_role = existing_membership.role.value if existing_membership else None + previous_status = existing_membership.status.value if existing_membership else None if existing_membership: existing_membership.role = invite.role existing_membership.status = MembershipStatus.ACTIVE membership = existing_membership else: membership = OrganizationMembership( + id=uuid.uuid4(), organization_id=invite.organization_id, user_id=current_user.id, role=invite.role, @@ -132,5 +148,17 @@ def accept_invite( invite.accepted_at = datetime.utcnow() if not existing_membership: db.add(membership) + append_audit_event( + db, + action="organization.invite.accepted", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="organization_membership", + target_id=membership.id, + organization_id=invite.organization_id, + before={"role": previous_role, "status": previous_status}, + after={"role": invite.role.value, "status": MembershipStatus.ACTIVE.value}, + request_id=getattr(request.state, "request_id", None), + ) db.commit() return {"message": "Invite accepted"} diff --git a/backend/app/api/routes/posts.py b/backend/app/api/routes/posts.py index 47f96ec..0fd6b7e 100644 --- a/backend/app/api/routes/posts.py +++ b/backend/app/api/routes/posts.py @@ -3,6 +3,7 @@ from uuid import UUID from app.database import get_db +from app.audit import append_audit_event from app.deps import get_current_user from app.models import Post, Thread, User from app.rate_limit import enforce_rate_limit @@ -83,6 +84,16 @@ def delete_post( actor_id=current_user.id, actor_role=current_user.role, owner_id=db_post.user_id ) if current_user.id != db_post.user_id: + append_audit_event( + db, + action="forum.post.moderated", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="post", + target_id=db_post.id, + after={"moderator_override": True}, + request_id=getattr(request.state, "request_id", None), + ) security_event( action="forum_post_delete", result="allowed", diff --git a/backend/app/api/routes/threads.py b/backend/app/api/routes/threads.py index 9b68cf9..b4a9e79 100644 --- a/backend/app/api/routes/threads.py +++ b/backend/app/api/routes/threads.py @@ -3,6 +3,7 @@ from uuid import UUID from app.database import get_db +from app.audit import append_audit_event from app.deps import get_current_user from app.models import Thread, User from app.rate_limit import enforce_rate_limit @@ -80,6 +81,16 @@ def delete_thread( actor_id=current_user.id, actor_role=current_user.role, owner_id=db_thread.user_id ) if current_user.id != db_thread.user_id: + append_audit_event( + db, + action="forum.thread.moderated", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="thread", + target_id=db_thread.id, + after={"moderator_override": True}, + request_id=getattr(request.state, "request_id", None), + ) security_event( action="forum_thread_delete", result="allowed", diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index a810d31..576f5b7 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.database import get_db +from app.audit import append_audit_event from app.deps import require_roles from app.models import User, Post, Thread, StudentBadge, user_units from app.rate_limit import enforce_rate_limit @@ -117,6 +118,17 @@ def update_user( raise previous_role = db_user.role db_user.role = requested_role + append_audit_event( + db, + action="platform.role.changed", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="user", + target_id=db_user.id, + before={"role": previous_role}, + after={"role": requested_role}, + request_id=_request_id(request), + ) db.commit() security_event( action="platform_role_change", @@ -172,7 +184,19 @@ def delete_user( db.execute(user_units.delete().where(user_units.c.user_id == uid)) + deleted_role = db_user.role db.delete(db_user) + append_audit_event( + db, + action="platform.user.deleted", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="user", + target_id=uid, + before={"role": deleted_role}, + after={}, + request_id=_request_id(request), + ) db.commit() security_event( action="platform_user_delete", diff --git a/backend/app/api/routes/v2_platform.py b/backend/app/api/routes/v2_platform.py index 9a30f92..241dd87 100644 --- a/backend/app/api/routes/v2_platform.py +++ b/backend/app/api/routes/v2_platform.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session, joinedload from app.database import get_db +from app.audit import append_audit_event from app.deps import get_current_user from app.enum import MembershipStatus from app.lesson_governance import evaluate_course_publish_readiness @@ -519,8 +520,21 @@ def update_artifact_review_status( artifact = _get_visible_artifact(db, current_user, artifact_id) _require_manage_platform_record(db, current_user, artifact.workspace_id) + previous_state = artifact.review_state artifact.status = payload.status artifact.review_state = payload.status + workspace = db.query(Workspace).filter(Workspace.id == artifact.workspace_id).first() + append_audit_event( + db, + action="product.review.changed", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="artifact", + target_id=artifact.id, + organization_id=workspace.organization_id if workspace else None, + before={"review_state": previous_state}, + after={"review_state": payload.status}, + ) db.commit() db.refresh(artifact) return artifact @@ -539,8 +553,21 @@ def update_product_review_status( product = _get_visible_product(db, current_user, product_id) _require_manage_platform_record(db, current_user, product.workspace_id) + previous_state = product.review_state product.status = payload.status product.review_state = payload.status + workspace = db.query(Workspace).filter(Workspace.id == product.workspace_id).first() + append_audit_event( + db, + action="product.review.changed", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="product", + target_id=product.id, + organization_id=workspace.organization_id if workspace else None, + before={"review_state": previous_state}, + after={"review_state": payload.status}, + ) db.commit() db.refresh(product) return product @@ -947,6 +974,8 @@ def publish_product_wrapper( product = _get_visible_product(db, current_user, product_id) _require_manage_platform_record(db, current_user, product.workspace_id) + previous_status = product.status + previous_visibility = product.visibility product.status = "published" product.review_state = "approved" if product.review_state in {"not_reviewed", "draft", "in_review"} else product.review_state product.visibility = payload.visibility @@ -955,6 +984,18 @@ def publish_product_wrapper( product.published_at = product.published_at or now product.last_updated = now product.updated_at = now + workspace = db.query(Workspace).filter(Workspace.id == product.workspace_id).first() + append_audit_event( + db, + action="product.published", + actor_id=current_user.id, + actor_role=current_user.role, + target_type="product", + target_id=product.id, + organization_id=workspace.organization_id if workspace else None, + before={"status": previous_status, "visibility": previous_visibility}, + after={"status": "published", "visibility": payload.visibility}, + ) db.commit() db.refresh(product) return product diff --git a/backend/app/audit.py b/backend/app/audit.py new file mode 100644 index 0000000..4cf002f --- /dev/null +++ b/backend/app/audit.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import hashlib +import json +import re +from typing import Mapping +from uuid import UUID, uuid4 + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.models import AuditEvent +from app.observability import correlation_id_context, emit_event, metrics, request_id_context + + +AUDIT_SCHEMA_VERSION = 1 +GENESIS_HASH = "0" * 64 +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,99}$") +_SENSITIVE_PARTS = ( + "password", + "secret", + "token", + "authorization", + "cookie", + "email", + "name", + "content", + "answer", + "filename", + "body", +) + + +@dataclass(frozen=True) +class AuditAction: + category: str + state_keys: frozenset[str] + + +AUDIT_ACTIONS: dict[str, AuditAction] = { + "platform.role.changed": AuditAction("access", frozenset({"role"})), + "platform.user.deleted": AuditAction("identity", frozenset({"role"})), + "organization.invite.created": AuditAction("membership", frozenset({"role", "status"})), + "organization.invite.accepted": AuditAction("membership", frozenset({"role", "status"})), + "forum.post.moderated": AuditAction("moderation", frozenset({"moderator_override"})), + "forum.thread.moderated": AuditAction("moderation", frozenset({"moderator_override"})), + "course.review.changed": AuditAction("content_governance", frozenset({"review_state"})), + "course.version.published": AuditAction("content_governance", frozenset({"version_status"})), + "product.review.changed": AuditAction("content_governance", frozenset({"review_state"})), + "product.published": AuditAction("content_governance", frozenset({"status", "visibility"})), + "audit.exported": AuditAction("audit", frozenset({"row_count"})), + "audit.retention.performed": AuditAction("audit", frozenset({"deleted_count", "cutoff"})), +} + + +class AuditPayloadError(ValueError): + pass + + +def _safe_state(action: str, state: Mapping[str, object] | None) -> dict[str, str | int | float | bool | None]: + values = dict(state or {}) + definition = AUDIT_ACTIONS[action] + if len(values) > 16 or set(values) - definition.state_keys: + raise AuditPayloadError("Audit state contains unsupported fields") + result: dict[str, str | int | float | bool | None] = {} + for key, value in values.items(): + normalized = key.lower().replace("-", "_") + if any(part in normalized for part in _SENSITIVE_PARTS): + raise AuditPayloadError("Audit state contains a sensitive field") + if value is not None and not isinstance(value, (str, int, float, bool)): + raise AuditPayloadError("Audit state values must be primitive") + if isinstance(value, str) and len(value) > 160: + raise AuditPayloadError("Audit state value is too long") + result[key] = value + return result + + +def _canonical_payload(event: AuditEvent) -> bytes: + payload = { + "action": event.action, + "actor_id": str(event.actor_id) if event.actor_id else None, + "actor_role": event.actor_role, + "after": event.after_state or {}, + "before": event.before_state or {}, + "category": event.category, + "correlation_id": event.correlation_id, + "created_at": event.created_at.isoformat(timespec="microseconds"), + "id": str(event.id), + "organization_id": str(event.organization_id) if event.organization_id else None, + "outcome": event.outcome, + "previous_hash": event.previous_hash, + "reason_code": event.reason_code, + "request_id": event.request_id, + "schema_version": event.schema_version, + "scope_key": event.scope_key, + "scope_sequence": event.scope_sequence, + "target_id": event.target_id, + "target_type": event.target_type, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + + +def compute_event_hash(event: AuditEvent) -> str: + return hashlib.sha256(_canonical_payload(event)).hexdigest() + + +def _lock_scope_for_append(db: Session, scope_key: str) -> None: + if db.get_bind().dialect.name == "postgresql": + db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:scope_key))"), {"scope_key": scope_key}) + + +def append_audit_event( + db: Session, + *, + action: str, + actor_id: UUID | None, + actor_role: str, + target_type: str, + target_id: UUID | str, + organization_id: UUID | None = None, + before: Mapping[str, object] | None = None, + after: Mapping[str, object] | None = None, + outcome: str = "succeeded", + reason_code: str | None = None, + request_id: str | None = None, + correlation_id: str | None = None, +) -> AuditEvent: + if action not in AUDIT_ACTIONS: + raise AuditPayloadError("Unknown audit action") + if outcome not in {"succeeded", "failed"}: + raise AuditPayloadError("Unsupported audit outcome") + if not _SAFE_CODE.fullmatch(target_type) or not _SAFE_CODE.fullmatch(actor_role): + raise AuditPayloadError("Invalid audit attribution") + if reason_code is not None and not _SAFE_CODE.fullmatch(reason_code): + raise AuditPayloadError("Invalid audit reason code") + target_text = str(target_id) + if not target_text or len(target_text) > 100: + raise AuditPayloadError("Invalid audit target") + + scope_key = f"organization:{organization_id}" if organization_id else "platform" + # A row lock cannot serialize two concurrent first events because no row + # exists yet. PostgreSQL's transaction-scoped advisory lock closes that gap. + _lock_scope_for_append(db, scope_key) + latest = ( + db.query(AuditEvent) + .filter(AuditEvent.scope_key == scope_key) + .order_by(AuditEvent.scope_sequence.desc()) + .with_for_update() + .first() + ) + event = AuditEvent( + id=uuid4(), + created_at=datetime.utcnow(), + schema_version=AUDIT_SCHEMA_VERSION, + scope_key=scope_key, + scope_sequence=(latest.scope_sequence + 1) if latest else 1, + organization_id=organization_id, + actor_id=actor_id, + actor_role=actor_role, + action=action, + category=AUDIT_ACTIONS[action].category, + outcome=outcome, + target_type=target_type, + target_id=target_text, + request_id=(request_id or request_id_context.get()), + correlation_id=(correlation_id or correlation_id_context.get()), + reason_code=reason_code, + before_state=_safe_state(action, before), + after_state=_safe_state(action, after), + previous_hash=latest.event_hash if latest else GENESIS_HASH, + event_hash="", + ) + event.event_hash = compute_event_hash(event) + try: + db.add(event) + db.flush() + except Exception as exc: + metrics.increment("echoed_audit_operations_total", operation="capture", result="failure") + emit_event( + "audit.capture.failed", + level=40, + component="audit", + category=AUDIT_ACTIONS[action].category, + result="failure", + exc_info=exc, + ) + raise + metrics.increment("echoed_audit_operations_total", operation="capture", result="success") + emit_event( + "audit.capture.succeeded", + component="audit", + category=event.category, + result="success", + ) + return event + + +@dataclass(frozen=True) +class IntegrityResult: + valid: bool + checked: int + scope_key: str + first_event_id: str | None = None + last_event_id: str | None = None + error_event_id: str | None = None + + +def verify_audit_chain(db: Session, *, organization_id: UUID | None = None) -> IntegrityResult: + scope_key = f"organization:{organization_id}" if organization_id else "platform" + events = ( + db.query(AuditEvent) + .filter(AuditEvent.scope_key == scope_key) + .order_by(AuditEvent.scope_sequence.asc()) + .all() + ) + previous = events[0].previous_hash if events else GENESIS_HASH + for event in events: + if event.previous_hash != previous or event.event_hash != compute_event_hash(event): + metrics.increment("echoed_audit_operations_total", operation="verify", result="failure") + return IntegrityResult( + False, + len(events), + scope_key, + str(events[0].id), + str(events[-1].id), + str(event.id), + ) + previous = event.event_hash + metrics.increment("echoed_audit_operations_total", operation="verify", result="success") + return IntegrityResult( + True, + len(events), + scope_key, + str(events[0].id) if events else None, + str(events[-1].id) if events else None, + ) diff --git a/backend/app/main.py b/backend/app/main.py index f3c78e3..2320b60 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ from app.api.routes import ( activities, + audit_events, analytics, assessments, assignments, @@ -261,6 +262,7 @@ async def add_operational_context(request: Request, call_next): app.include_router(analytics.router, prefix="/api", tags=["Analytics"]) app.include_router(auth.router, prefix="/api", tags=["Auth"]) app.include_router(users.router, prefix="/api", tags=["Users"]) +app.include_router(audit_events.router, prefix="/api", tags=["Audit Events"]) app.include_router(courses.router, prefix="/api", tags=["Courses"]) app.include_router(orgs.router, prefix="/api", tags=["Organizations"]) app.include_router(invites.router, prefix="/api", tags=["Invites"]) diff --git a/backend/app/models.py b/backend/app/models.py index f327b44..012023c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -9,7 +9,9 @@ Text, Float, JSON, + Index, UniqueConstraint, + event, ) from sqlalchemy.orm import relationship, declarative_base, validates from sqlalchemy.dialects.postgresql import UUID @@ -194,6 +196,54 @@ class OrganizationInvite(Base): invited_by = relationship("User") +class AuditEvent(Base): + """Durable, append-only record of an approved high-impact action. + + Actor and target identifiers deliberately are not foreign keys: account or + resource deletion must not erase historical attribution. + """ + + __tablename__ = "audit_events" + __table_args__ = ( + UniqueConstraint("scope_key", "scope_sequence", name="uq_audit_events_scope_sequence"), + Index("ix_audit_events_scope_created", "scope_key", "created_at", "id"), + Index("ix_audit_events_action_created", "action", "created_at"), + Index("ix_audit_events_actor_created", "actor_id", "created_at"), + Index("ix_audit_events_target_created", "target_type", "target_id", "created_at"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) + schema_version = Column(Integer, nullable=False, default=1) + scope_key = Column(String(80), nullable=False) + scope_sequence = Column(Integer, nullable=False) + organization_id = Column(UUID(as_uuid=True), nullable=True) + actor_id = Column(UUID(as_uuid=True), nullable=True) + actor_role = Column(String(40), nullable=False) + action = Column(String(100), nullable=False) + category = Column(String(40), nullable=False) + outcome = Column(String(24), nullable=False) + target_type = Column(String(60), nullable=False) + target_id = Column(String(100), nullable=False) + request_id = Column(String(128), nullable=True) + correlation_id = Column(String(64), nullable=True) + reason_code = Column(String(80), nullable=True) + before_state = Column(JSON, nullable=False, default=dict) + after_state = Column(JSON, nullable=False, default=dict) + previous_hash = Column(String(64), nullable=False) + event_hash = Column(String(64), nullable=False, unique=True) + + +@event.listens_for(AuditEvent, "before_update") +def _reject_audit_event_update(_mapper, _connection, _target): + raise RuntimeError("Audit events are append-only") + + +@event.listens_for(AuditEvent, "before_delete") +def _reject_audit_event_delete(_mapper, _connection, _target): + raise RuntimeError("Audit events are append-only") + + class UserPreferences(Base): __tablename__ = "user_preferences" user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True) diff --git a/backend/app/operational_backup.py b/backend/app/operational_backup.py index 99d7b61..7f98065 100644 --- a/backend/app/operational_backup.py +++ b/backend/app/operational_backup.py @@ -149,5 +149,37 @@ def restore_test_backup( integrity = restored.execute("PRAGMA integrity_check").fetchone() if not integrity or integrity[0] != "ok": raise BackupSafetyError("Restored SQLite database failed integrity verification") + table_exists = restored.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'audit_events'" + ).fetchone() + if table_exists: + _verify_restored_audit_chains(database_target) files = manifest["files"] return BackupResult(bundle, len(files), sum(int(item["bytes"]) for item in files)) + + +def _verify_restored_audit_chains(database_path: Path) -> None: + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + from app.audit import verify_audit_chain + from app.models import AuditEvent + + engine = create_engine(f"sqlite:///{database_path.as_posix()}") + restored_session = sessionmaker(bind=engine)() + try: + organization_ids = [ + row[0] + for row in restored_session.query(AuditEvent.organization_id) + .filter(AuditEvent.organization_id.isnot(None)) + .distinct() + .all() + ] + scopes = [None, *organization_ids] + for organization_id in scopes: + result = verify_audit_chain(restored_session, organization_id=organization_id) + if not result.valid: + raise BackupSafetyError("Restored audit-event integrity verification failed") + finally: + restored_session.close() + engine.dispose() diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py index db3e5e1..5a90973 100644 --- a/backend/app/rate_limit.py +++ b/backend/app/rate_limit.py @@ -26,6 +26,7 @@ class RateLimitPolicy: "upload": RateLimitPolicy(20, 60), "forum_mutation": RateLimitPolicy(30, 60), "user_management": RateLimitPolicy(20, 60), + "audit_export": RateLimitPolicy(5, 300), } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a5d87cc..bac190c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -55,6 +55,33 @@ class PlatformUserRoleUpdate(BaseModel): model_config = ConfigDict(extra="forbid") + +class AuditEventResponse(BaseModel): + id: UUID + created_at: datetime + schema_version: int + actor_id: Optional[UUID] = None + actor_role: str + action: str + category: str + outcome: str + target_type: str + target_id: str + organization_id: Optional[UUID] = None + request_id: Optional[str] = None + correlation_id: Optional[str] = None + reason_code: Optional[str] = None + before_state: dict[str, str | int | float | bool | None] + after_state: dict[str, str | int | float | bool | None] + integrity_verified: bool = True + + model_config = ConfigDict(from_attributes=True) + + +class AuditEventPage(BaseModel): + items: list[AuditEventResponse] + next_cursor: Optional[str] = None + class AuthOrganizationResponse(BaseModel): id: UUID role: str diff --git a/backend/scripts/manage_audit_events.py b/backend/scripts/manage_audit_events.py new file mode 100644 index 0000000..497693c --- /dev/null +++ b/backend/scripts/manage_audit_events.py @@ -0,0 +1,91 @@ +"""Verify or expire durable audit events without exposing event contents.""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import os +from uuid import UUID + +from app.audit import append_audit_event, verify_audit_chain +from app.database import SessionLocal, operational_settings +from app.models import AuditEvent + + +def _cutoff(value: str) -> datetime: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise argparse.ArgumentTypeError("cutoff must be an ISO-8601 timestamp") from exc + + +def _organization(value: str | None) -> UUID | None: + return UUID(value) if value else None + + +def verify(organization_id: UUID | None) -> int: + with SessionLocal() as db: + result = verify_audit_chain(db, organization_id=organization_id) + print( + f"audit integrity: {'valid' if result.valid else 'invalid'}; " + f"scope={result.scope_key}; checked={result.checked}" + ) + return 0 if result.valid else 2 + + +def retain(args: argparse.Namespace) -> int: + production = operational_settings.environment == "production" + if os.getenv("AUDIT_PRESERVATION_HOLD", "false").strip().lower() in {"1", "true", "yes", "on"}: + raise SystemExit("Audit retention refused: a preservation hold is active.") + if production and args.apply: + if not args.ack_production or not args.backup_reference: + raise SystemExit( + "Production audit retention requires --ack-production and a verified --backup-reference." + ) + organization_id = _organization(args.organization_id) + scope_key = f"organization:{organization_id}" if organization_id else "platform" + with SessionLocal() as db: + query = db.query(AuditEvent).filter( + AuditEvent.scope_key == scope_key, + AuditEvent.created_at < args.before, + ) + count = query.count() + if not args.apply: + print(f"audit retention dry-run: scope={scope_key}; eligible={count}") + return 0 + query.delete(synchronize_session=False) + append_audit_event( + db, + action="audit.retention.performed", + actor_id=None, + actor_role="operator", + target_type="audit_event_set", + target_id=scope_key, + organization_id=organization_id, + after={"deleted_count": count, "cutoff": args.before.isoformat()}, + reason_code="retention_policy", + ) + db.commit() + print(f"audit retention applied: scope={scope_key}; deleted={count}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + subcommands = parser.add_subparsers(dest="command", required=True) + verify_parser = subcommands.add_parser("verify") + verify_parser.add_argument("--organization-id") + retention_parser = subcommands.add_parser("retain") + retention_parser.add_argument("--before", required=True, type=_cutoff) + retention_parser.add_argument("--organization-id") + retention_parser.add_argument("--apply", action="store_true") + retention_parser.add_argument("--ack-production", action="store_true") + retention_parser.add_argument("--backup-reference") + args = parser.parse_args() + if args.command == "verify": + return verify(_organization(args.organization_id)) + return retain(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_audit_events.py b/backend/tests/test_audit_events.py new file mode 100644 index 0000000..a8814b9 --- /dev/null +++ b/backend/tests/test_audit_events.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from types import SimpleNamespace +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import update + +from app.audit import AuditPayloadError, _lock_scope_for_append, append_audit_event, verify_audit_chain +from app.auth import get_current_user +from app.database import get_db +from app.main import app +from app.models import AuditEvent, Organization, OrganizationMembership, User +from app.enum import MembershipStatus, OrganizationRole, OrganizationType +from app.rate_limit import limiter +from app.operational_backup import create_test_backup, restore_test_backup + + +def _user(db, role: str) -> User: + user = User( + id=uuid.uuid4(), + firstname="Audit", + lastname="Reviewer", + username=f"audit-{uuid.uuid4()}", + email=f"audit-{uuid.uuid4()}@example.test", + hashed_password="unused", + role=role, + ) + db.add(user) + db.commit() + return user + + +def _override(db, user): + app.dependency_overrides[get_db] = lambda: (yield db) + app.dependency_overrides[get_current_user] = lambda: user + limiter.clear() + return TestClient(app) + + +def _clear_overrides(): + app.dependency_overrides.clear() + limiter.clear() + + +def test_append_is_minimized_chained_and_transaction_bound(db_session): + actor = _user(db_session, "admin") + first = append_audit_event( + db_session, + action="platform.role.changed", + actor_id=actor.id, + actor_role=actor.role, + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + after={"role": "teacher"}, + ) + second = append_audit_event( + db_session, + action="platform.user.deleted", + actor_id=actor.id, + actor_role=actor.role, + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + ) + assert second.previous_hash == first.event_hash + assert (first.scope_sequence, second.scope_sequence) == (1, 2) + assert db_session.query(AuditEvent).count() == 2 + db_session.rollback() + assert db_session.query(AuditEvent).count() == 0 + + +@pytest.mark.parametrize("unsafe", [{"password": "private"}, {"role": {"nested": "value"}}, {"role": "x" * 161}]) +def test_sensitive_or_unbounded_state_fails_closed(db_session, unsafe): + with pytest.raises(AuditPayloadError): + append_audit_event( + db_session, + action="platform.role.changed", + actor_id=uuid.uuid4(), + actor_role="admin", + target_type="user", + target_id=uuid.uuid4(), + after=unsafe, + ) + assert db_session.query(AuditEvent).count() == 0 + + +def test_chain_verification_detects_tampering(db_session): + actor = _user(db_session, "admin") + append_audit_event( + db_session, + action="platform.role.changed", + actor_id=actor.id, + actor_role=actor.role, + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + after={"role": "teacher"}, + ) + db_session.commit() + assert verify_audit_chain(db_session).valid + event = db_session.query(AuditEvent).one() + # Simulate modification below the protected application ORM boundary. + db_session.execute( + update(AuditEvent) + .where(AuditEvent.id == event.id) + .values(after_state={"role": "super_admin"}) + ) + db_session.commit() + assert not verify_audit_chain(db_session).valid + + +def test_ordinary_orm_delete_is_rejected(db_session): + event = append_audit_event( + db_session, + action="platform.user.deleted", + actor_id=uuid.uuid4(), + actor_role="admin", + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + ) + db_session.commit() + db_session.delete(event) + with pytest.raises(RuntimeError, match="append-only"): + db_session.flush() + db_session.rollback() + + +def test_ordinary_orm_update_is_rejected(db_session): + event = append_audit_event( + db_session, + action="platform.user.deleted", + actor_id=uuid.uuid4(), + actor_role="admin", + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + ) + db_session.commit() + event.after_state = {"role": "teacher"} + with pytest.raises(RuntimeError, match="append-only"): + db_session.flush() + db_session.rollback() + + +def test_postgresql_scope_lock_serializes_first_and_later_appends(): + captured = {} + + class FakeSession: + def get_bind(self): + return SimpleNamespace(dialect=SimpleNamespace(name="postgresql")) + + def execute(self, statement, parameters): + captured["sql"] = str(statement) + captured["parameters"] = parameters + + _lock_scope_for_append(FakeSession(), "organization:scope") + assert "pg_advisory_xact_lock" in captured["sql"] + assert captured["parameters"] == {"scope_key": "organization:scope"} + + +def test_platform_feed_is_explicit_paginated_and_role_protected(db_session): + admin = _user(db_session, "admin") + for role in ("student", "teacher"): + append_audit_event( + db_session, + action="platform.role.changed", + actor_id=admin.id, + actor_role=admin.role, + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + after={"role": role}, + ) + db_session.commit() + client = _override(db_session, admin) + try: + first = client.get("/api/audit-events?limit=1") + assert first.status_code == 200 + payload = first.json() + assert len(payload["items"]) == 1 + assert payload["next_cursor"] + assert "event_hash" not in payload["items"][0] + assert "scope_key" not in payload["items"][0] + second = client.get(f"/api/audit-events?limit=1&cursor={payload['next_cursor']}") + assert second.status_code == 200 + assert second.json()["items"][0]["id"] != payload["items"][0]["id"] + + learner = _user(db_session, "student") + app.dependency_overrides[get_current_user] = lambda: learner + assert client.get("/api/audit-events").status_code == 403 + finally: + _clear_overrides() + + +def test_organization_feed_conceals_cross_org_events(db_session): + org_admin = _user(db_session, "org_admin") + own = Organization(id=uuid.uuid4(), name="Own", type=OrganizationType.SCHOOL) + other = Organization(id=uuid.uuid4(), name="Other", type=OrganizationType.SCHOOL) + db_session.add_all([own, other]) + db_session.flush() + db_session.add( + OrganizationMembership( + id=uuid.uuid4(), + organization_id=own.id, + user_id=org_admin.id, + role=OrganizationRole.ORG_ADMIN, + status=MembershipStatus.ACTIVE, + ) + ) + append_audit_event( + db_session, + action="organization.invite.created", + actor_id=org_admin.id, + actor_role=org_admin.role, + target_type="organization_invite", + target_id=uuid.uuid4(), + organization_id=own.id, + after={"role": "teacher", "status": "pending"}, + ) + append_audit_event( + db_session, + action="organization.invite.created", + actor_id=uuid.uuid4(), + actor_role="org_admin", + target_type="organization_invite", + target_id=uuid.uuid4(), + organization_id=other.id, + after={"role": "teacher", "status": "pending"}, + ) + db_session.commit() + client = _override(db_session, org_admin) + try: + allowed = client.get(f"/api/orgs/{own.id}/audit-events", headers={"X-Org-Id": str(own.id)}) + assert allowed.status_code == 200 + assert {row["organization_id"] for row in allowed.json()["items"]} == {str(own.id)} + denied = client.get(f"/api/orgs/{other.id}/audit-events", headers={"X-Org-Id": str(other.id)}) + assert denied.status_code == 404 + finally: + _clear_overrides() + + +def test_export_is_formula_safe_capped_schema_and_audited(db_session): + admin = _user(db_session, "admin") + append_audit_event( + db_session, + action="platform.user.deleted", + actor_id=admin.id, + actor_role=admin.role, + target_type="user", + target_id="=formula", + before={"role": "student"}, + ) + db_session.commit() + client = _override(db_session, admin) + try: + response = client.get("/api/audit-events/export.csv") + assert response.status_code == 200 + assert "attachment" in response.headers["content-disposition"] + assert "'=formula" in response.text + assert "event_hash" not in response.text.splitlines()[0] + assert db_session.query(AuditEvent).filter(AuditEvent.action == "audit.exported").count() == 1 + finally: + _clear_overrides() + + +def test_role_change_creates_one_durable_event_and_denial_creates_none(db_session, monkeypatch): + from app.api.routes import users as user_routes + + admin = _user(db_session, "admin") + target = _user(db_session, "student") + monkeypatch.setattr(user_routes, "enforce_rate_limit", lambda *args, **kwargs: None) + request = SimpleNamespace(state=SimpleNamespace(request_id="audit-request")) + user_routes.update_user( + target.id, + SimpleNamespace(role="teacher"), + request, + db_session, + admin, + ) + event = db_session.query(AuditEvent).one() + assert event.action == "platform.role.changed" + assert event.before_state == {"role": "student"} + assert event.after_state == {"role": "teacher"} + + with pytest.raises(Exception): + user_routes.update_user( + admin.id, + SimpleNamespace(role="super_admin"), + request, + db_session, + admin, + ) + assert db_session.query(AuditEvent).count() == 1 + + +def test_retention_candidates_are_time_bounded(db_session): + actor = _user(db_session, "admin") + old = append_audit_event( + db_session, + action="platform.user.deleted", + actor_id=actor.id, + actor_role=actor.role, + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + ) + old_id = old.id + db_session.commit() + db_session.execute( + update(AuditEvent) + .where(AuditEvent.id == old_id) + .values(created_at=datetime.utcnow() - timedelta(days=400)) + ) + db_session.commit() + cutoff = datetime.utcnow() - timedelta(days=365) + assert db_session.query(AuditEvent).filter(AuditEvent.created_at < cutoff).count() == 1 + + +def test_backup_restore_acceptance_verifies_audit_chain(tmp_path): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + from app.models import Base + + source_path = tmp_path / "audit-source.sqlite3" + source_engine = create_engine(f"sqlite:///{source_path.as_posix()}") + Base.metadata.create_all(source_engine) + source_session = sessionmaker(bind=source_engine)() + try: + append_audit_event( + source_session, + action="platform.user.deleted", + actor_id=uuid.uuid4(), + actor_role="admin", + target_type="user", + target_id=uuid.uuid4(), + before={"role": "student"}, + ) + source_session.commit() + finally: + source_session.close() + source_engine.dispose() + + bundle = tmp_path / "audit-backup" + create_test_backup( + database_path=source_path, + storage_roots=[], + output_dir=bundle, + environment="test", + acknowledged_test_data=True, + ) + restored_path = tmp_path / "restored" / "audit.sqlite3" + restore_test_backup( + bundle=bundle, + database_target=restored_path, + storage_target=tmp_path / "restored-storage", + environment="test", + acknowledged_test_data=True, + ) + + restored_engine = create_engine(f"sqlite:///{restored_path.as_posix()}") + restored_session = sessionmaker(bind=restored_engine)() + try: + assert verify_audit_chain(restored_session).valid + assert restored_session.query(AuditEvent).count() == 1 + finally: + restored_session.close() + restored_engine.dispose() diff --git a/docs/audit/audit-event-policy.md b/docs/audit/audit-event-policy.md new file mode 100644 index 0000000..9ca1a8f --- /dev/null +++ b/docs/audit/audit-event-policy.md @@ -0,0 +1,48 @@ +# Durable Audit Event Policy + +## Store boundary + +Durable audit events are append-only application evidence for approved high-impact mutations. Structured security logs remain ephemeral diagnostics for monitoring and incident response. A successful durable mutation event is transactionally coupled to business state; an operational log is not. + +The database row contains UUID/timestamp, stable action/category/outcome, actor UUID and role snapshot, target type/ID, optional organization UUID, request/correlation references, version, allowlisted primitive before/after summaries, reason code, and integrity-chain hashes. It contains no joined names, emails, content, credentials, tokens, request bodies, or files. + +## Action catalog + +| Stable action | Scope | State keys | +| --- | --- | --- | +| `platform.role.changed` | Platform | role | +| `platform.user.deleted` | Platform | role | +| `organization.invite.created` | Organization | role, status | +| `organization.invite.accepted` | Organization | role, status | +| `forum.post.moderated` | Platform | moderator override | +| `forum.thread.moderated` | Platform | moderator override | +| `course.review.changed` | Owning organization when present | review state | +| `course.version.published` | Owning organization when present | version status | +| `product.review.changed` | Workspace organization when present | review state | +| `product.published` | Workspace organization when present | status, visibility | +| `audit.exported` | Platform | row count | +| `audit.retention.performed` | Selected scope | deleted count, cutoff | + +Denied or rolled-back mutations remain in privacy-safe operational security logs and do not become misleading durable success events. + +## Access matrix + +| Actor | Platform feed/detail/export | Organization feed | +| --- | --- | --- | +| `admin` | Allowed | Only through platform feed unless active organization policy grants org-admin authority | +| `super_admin` | Allowed | Allowed for explicitly selected active scope | +| active `org_admin` | Denied | Own active organization only | +| other authenticated roles | Denied | Denied | +| anonymous | 401 | 401 | + +Backend authorization is authoritative. Cross-organization organization feeds are concealed with 404. Reads use explicit schemas, bounded cursor pagination, and allowlisted filters. CSV applies the same platform scope, caps output at 5,000 rows, neutralizes spreadsheet formulas, and records the export itself. + +## Integrity boundary + +Events form a SHA-256 chain per platform or organization scope over canonical, versioned event content and a unique sequence. PostgreSQL transaction advisory locking serializes even concurrent first writes within a scope. Verification detects ordinary modification and reordering within retained history, while ORM guards reject ordinary application updates/deletes and no mutation API exists. Because guarded retention may delete an old prefix, only an external anchor could prove deletion before the retained boundary. + +This is tamper-evident application data, not proof against a fully privileged database operator who can rewrite rows and hashes. Independent anchoring, WORM storage, database credential separation, and external replication remain infrastructure work. + +## Retention and preservation + +The initial policy target is 365 days, subject to jurisdiction and operator policy. Security/operations owns execution; privacy/legal owners approve policy and preservation holds. The operator command defaults to dry-run. Production application requires explicit acknowledgement and a verified backup reference; `AUDIT_PRESERVATION_HOLD=true` blocks deletion. There is no public retention API. diff --git a/docs/audit/phase-12-baseline.md b/docs/audit/phase-12-baseline.md new file mode 100644 index 0000000..74b7722 --- /dev/null +++ b/docs/audit/phase-12-baseline.md @@ -0,0 +1,15 @@ +# Phase 12 Platform Audit Events Baseline + +Recorded 2026-08-13 before audit implementation. + +- Branch/commit: `aqw-echoed-dev` / `e67300b3c5a0d2f720eca8fa1c968eb02a610308`. +- Working tree: clean before Phase 11 archival; Phase 11 archive/spec-sync changes became the intentional initial dirty state for this phase. +- OpenSpec: `establish-operational-readiness` was complete and strictly valid, then synced and archived at `openspec/changes/archive/2026-08-13-establish-operational-readiness`. Phase 8 and Phase 10 remain completed active changes. +- Verified prior baselines: 299 backend, 308 Angular, and 23 Playwright tests; production Angular build and production npm audit passed. +- Existing event architecture: `security_event()` emits recursively redacted structured diagnostic logs and low-cardinality metrics. Events are ephemeral, non-transactional, and not an audit ledger. +- Existing event coverage: authentication failure, rate limiting, role changes, user deletion, final-admin denial, invitation creation, upload rejection, moderator deletion, authorization denial, and Course Studio operational outcomes. +- Database/migrations: SQLAlchemy ORM, Alembic single head `9a7b6c5d4e3f`, PostgreSQL production intent, SQLite test fixtures. Normal production startup does not migrate automatically. +- Privacy boundary: no passwords, hashes, bearer/invitation/reset tokens, cookies, authorization headers, private learner/course content, assessment answers, uploaded bytes, filenames, emails, or names belong in durable events. +- Persistent-state baseline: database and upload paths are operator-owned; repository backup drills accept acknowledged non-production SQLite/filesystem data only. + +This baseline does not claim external tamper resistance, WORM storage, legal retention compliance, or production audit readiness. diff --git a/docs/audit/phase-12-verification.md b/docs/audit/phase-12-verification.md new file mode 100644 index 0000000..55e7330 --- /dev/null +++ b/docs/audit/phase-12-verification.md @@ -0,0 +1,57 @@ +# Phase 12 Platform Audit Events Verification + +Recorded 2026-08-13 for `implement-platform-audit-events`. + +## Starting state + +- Branch/commit: `aqw-echoed-dev` at `e67300b3c5a0d2f720eca8fa1c968eb02a610308`. +- The worktree was clean before the Phase 11 spec sync/archive; those archive changes were preserved as the intentional starting delta for Phase 12. +- `establish-operational-readiness` was complete and strictly valid, was synced to `openspec/specs/platform-operational-readiness/spec.md`, and is archived at `openspec/changes/archive/2026-08-13-establish-operational-readiness`. +- Previous verified baselines were 299 backend, 308 Angular, and 23 Playwright tests. + +## Implemented contract + +- Added Alembic revision `b8f4c2d6e1a0` and the explicit `audit_events` model. Events retain actor/role and target identifiers without destructive foreign-key cascades. +- Added a centralized action catalog, primitive state allowlists, sensitive-key rejection, canonical SHA-256 hashing, per-scope sequence uniqueness, PostgreSQL transaction advisory locking, ORM update/delete rejection, and chain verification. +- Audit appends flush but never independently commit, so covered events share the business transaction and roll back with it. +- Covered supported role change, account deletion, organization invitation/acceptance membership, forum moderator deletion, course review/publish, artifact/product review, product publish, export, and retention operations. Unsupported restore/moderation workflows are not fabricated. +- Added platform and active-organization-admin scoped read APIs, concealment for cross-organization requests, bounded cursor pagination, allowlisted filters, explicit minimized schemas, capped formula-safe CSV, and export rate limiting. +- Added privacy-safe capture/read/export/verification/retention metrics and diagnostics without identifier labels or state payloads. +- Added the guarded `scripts/manage_audit_events.py` verification/retention command. Retention defaults to dry-run; production application requires acknowledgement, a safe backup reference, and no preservation hold. +- Added the guarded Angular Platform Admin route, navigation, list/detail/filter/pagination/export states, accessible error status, and stale protected-data clearing. +- Updated SQLite backup acceptance to verify every restored platform/organization chain when the audit table exists. + +## Verification evidence + +| Verification | Result | +| --- | --- | +| Focused audit and operational tests | 30 passed before the sequence/immutability hardening | +| Final focused audit tests | 14 passed | +| Complete backend suite | 313 passed, 4,225 warnings, 132.14 seconds; baseline increased by 14 | +| Python compile check | `python -m compileall -q app scripts` passed | +| PostgreSQL migration drill | Fresh PostgreSQL 15 database upgraded through the entire history to `b8f4c2d6e1a0 (head)` | +| SQLite new-revision compatibility | Prior head stamped and upgraded to `b8f4c2d6e1a0`; full legacy SQLite history remains unsupported by an older pre-existing `ALTER COLUMN` migration | +| Backup/restore drill | Test SQLite backup, manifest verification, isolated restore, row usability, and restored audit-chain verification passed in backend tests | +| Angular application typecheck | `tsc --noEmit -p tsconfig.app.json` passed | +| Angular spec typecheck | `tsc --noEmit -p tsconfig.spec.json` passed; five new audit specs compile | +| Angular browser tests | Not completed locally: Angular bundle setup exhausted the Windows process memory limit before assertions; an isolated Docker retry ended with Docker transport exit 255 during `npm ci` | +| Production Angular build | Not completed locally: build exhausted the same constrained Node process heap; application typecheck passed | +| Playwright collection | 23 tests in 9 files compile and enumerate, including authorized audit review and denied direct-route assertions | +| Playwright execution | Not rerun because the full seeded frontend/backend demo stack was not active; prior verified baseline remains 23 | +| Production dependency audit | `npm audit --omit=dev`: 0 vulnerabilities | +| Strict OpenSpec | `openspec validate implement-platform-audit-events --strict`: valid | +| Diff whitespace | `git diff --check`: passed before this evidence update and rerun at final handoff | + +The temporary PostgreSQL container and local migration/test databases were removed after verification. No dependency was added or removed. The single additive database migration is intentionally retained on application rollback so accumulated audit history is not destroyed. + +## Privacy, integrity, and access evidence + +Tests prove sensitive or nested state fails closed; explicit responses omit hashes and internal scope keys; CSV neutralizes formula prefixes; learner reads fail; organization administrators see only an active matching organization; and denied/rolled-back mutations do not create successful records. Direct application ORM updates/deletes fail. A low-level modification causes chain verification to fail. + +The integrity chain is not externally anchored. A fully privileged database operator can rewrite rows and hashes, and deliberate guarded retention can remove an old prefix that cannot subsequently be proven without an independent anchor. Production database permissions, encrypted backup storage, legal retention decisions, and external/WORM anchoring remain operator/infrastructure responsibilities. + +## Remaining gate + +Implementation, backend verification, migration/restore drills, documentation, strict validation, and static frontend checks are complete. The phase remains partially complete until the unchanged CI-class Angular browser suite, production build, and seeded Playwright execution pass in a runner with adequate memory and the demo stack. No result is represented as passed when its runner did not execute. + +Recommended follow-up after those verification gates pass: archive `implement-platform-audit-events`, then reassess the roadmap for `implement-curriculum-authoring` versus a narrowly scoped beta-release readiness change. External audit anchoring should remain an infrastructure/security follow-up, not be folded into product authoring. diff --git a/docs/operations/audit-events-runbook.md b/docs/operations/audit-events-runbook.md new file mode 100644 index 0000000..902d590 --- /dev/null +++ b/docs/operations/audit-events-runbook.md @@ -0,0 +1,39 @@ +# Audit Events Runbook + +## Review and correlation + +- Platform administrators use `/admin/audit-events` or `GET /api/audit-events`. +- Active organization administrators use `GET /api/orgs/{org_id}/audit-events` with the matching `X-Org-Id` context. +- Correlate an event's safe request ID with structured logs. Never request tokens, cookies, passwords, private content, or full database rows from users. +- Export is limited, authorization-scoped, formula-safe CSV and creates its own durable event. + +## Integrity verification + +From `backend/`: + +```powershell +venv\Scripts\python.exe scripts\manage_audit_events.py verify +venv\Scripts\python.exe scripts\manage_audit_events.py verify --organization-id ORGANIZATION_UUID +``` + +Success reports only scope and checked count. Failure is an incident signal: stop retention, preserve database/backups/log correlation, restrict administrative writes if necessary, and escalate to the security owner. Do not repair hashes in place. + +## Retention + +Dry-run first: + +```powershell +venv\Scripts\python.exe scripts\manage_audit_events.py retain --before 2025-08-13T00:00:00 +``` + +Production application additionally requires `--apply --ack-production --backup-reference SAFE_REFERENCE`; a preservation hold always refuses deletion. Backup references must identify an operator-controlled encrypted backup without embedding a credential or URL secret. + +## Backup and restore + +The audit table and integrity fields are part of the primary database backup. Restore acceptance requires database-native integrity checks followed by audit-chain verification for the platform and known organization scopes. Never export audit CSV as a substitute for database backup. Preserve audit backups separately according to incident and retention policy. + +## Known limitations + +- Hash chains are not externally anchored and cannot defeat a database superuser. +- SQLite test concurrency does not establish PostgreSQL production contention behavior. +- No external audit archive, legal-hold service, or WORM target is configured. diff --git a/docs/operations/backup-and-restore.md b/docs/operations/backup-and-restore.md index bd659b6..7d0d620 100644 --- a/docs/operations/backup-and-restore.md +++ b/docs/operations/backup-and-restore.md @@ -2,7 +2,7 @@ ## Production policy -- PostgreSQL: daily encrypted logical or provider-native backup, plus provider-supported continuous recovery if selected. Retain 7 daily and 4 weekly recovery points; store in a separate failure domain/account with access logs and least privilege. +- PostgreSQL: daily encrypted logical or provider-native backup of application tables, audit events and integrity fields, and migration metadata, plus provider-supported continuous recovery if selected. Retain 7 daily and 4 weekly recovery points; store in a separate failure domain/account with access logs and least privilege. - Uploads: daily versioned backup of `STORYBOOK_PATH`, `COLORINGS_PATH`, and `BADGES_PATH`, coordinated closely enough with the database to preserve ownership references. Apply the same 7-daily/4-weekly retention. - Configuration: version non-secret templates and immutable release metadata. Back up secrets only within the approved secret manager; never in repository bundles. - Angular/static application assets: rebuild from the immutable release; they are not mutable backup state. @@ -20,4 +20,4 @@ python -m scripts.operational_backup verify --bundle .pytest_tmp/backup python -m scripts.operational_backup restore --bundle .pytest_tmp/backup --database-target .pytest_tmp/restored.db --storage-target .pytest_tmp/restored-uploads --acknowledge-test-data ``` -For PostgreSQL, use the selected provider's consistent snapshot or `pg_dump`/`pg_restore` with credentials supplied out of band. Restore into isolation, verify schema heads, database consistency, upload checksums/ownership, readiness, and representative role workflows before cutover. Never overwrite a live database as a rehearsal. +For PostgreSQL, use the selected provider's consistent snapshot or `pg_dump`/`pg_restore` with credentials supplied out of band. Restore into isolation, verify schema heads, database consistency, audit-event chains, upload checksums/ownership, readiness, and representative role workflows before cutover. Never overwrite a live database as a rehearsal. The local SQLite restore helper performs the audit-chain check automatically when the audit table exists. diff --git a/docs/operations/incident-observability-guide.md b/docs/operations/incident-observability-guide.md index 644093a..388a10c 100644 --- a/docs/operations/incident-observability-guide.md +++ b/docs/operations/incident-observability-guide.md @@ -14,6 +14,6 @@ This is focused incident-readiness guidance, not a complete incident-response pr | Publishing failures | Publish attempt/blocked/success and HTTP failures | Keep current learner availability unchanged; resolve validation/dependency cause | State appears partially published or learners see unapproved content | | Worker failures | Not applicable: no executing worker exists | Do not infer queue health from generation-run metadata | A worker is introduced without lifecycle instrumentation | -For suspected data exposure or privilege compromise, prioritize containment and preservation over diagnostic verbosity. Use the security escalation process; operational logs are not a tamper-resistant audit ledger. +For suspected data exposure, privilege compromise, or audit-chain verification failure, prioritize containment and preservation over diagnostic verbosity. Pause retention, preserve database backups and correlated logs, and follow the [audit-events runbook](audit-events-runbook.md). Operational logs remain diagnostics; the database audit chain is application-level evidence, not externally anchored tamper-proof storage. Phase 11 release containment, application/configuration/database rollback boundaries, recovery ownership, and alert thresholds are defined in the [deployment runbook](deployment-runbook.md), [migration and rollback policy](migration-and-rollback-policy.md), [alerting policy](alerting-and-escalation.md), and [backup/restore procedure](backup-and-restore.md). Do not attempt an application-only rollback when schema compatibility is unknown. diff --git a/docs/platform-maturity/future-openspec-roadmap.md b/docs/platform-maturity/future-openspec-roadmap.md index 24fdf71..3b7be2e 100644 --- a/docs/platform-maturity/future-openspec-roadmap.md +++ b/docs/platform-maturity/future-openspec-roadmap.md @@ -11,7 +11,7 @@ Priority weighs release criticality, security/privacy risk, architectural depend Completed foundation changes: `harden-platform-security` (Phase 8), `establish-platform-observability` (Phase 10), and repository-scoped `establish-operational-readiness` (Phase 11). 1. `establish-operational-readiness` -2. `implement-platform-audit-events` +2. `implement-platform-audit-events` (implemented; archive after verification) 3. `implement-curriculum-authoring` 4. `implement-activity-and-assessment-authoring` 5. `implement-content-review-workflow` @@ -61,6 +61,8 @@ Phase 11 adds fail-closed runtime configuration, explicit host/proxy trust, non- ### `implement-platform-audit-events` +The active OpenSpec change implements transaction-bound, privacy-minimized records for the supported high-impact action catalog, per-scope integrity chains, scoped administrative review/export, retention tooling, and backup/restore verification. External anchoring, WORM storage, legal-retention guarantees, and general business activity analytics remain out of scope. + - **Problem/users/value:** Administrators, organization stewards, reviewers, and security responders need attributable high-impact action history. - **Current limitation/support:** Request logs and assessment attempt events exist, but no durable actor/action/resource audit model covers role, access, publish, invite, moderation, or destructive actions. - **Required work:** Append-only event model, service interface, minimized read API/UI, retention/export policy, and instrumentation of approved actions. diff --git a/docs/security/audit-event-privacy-and-access.md b/docs/security/audit-event-privacy-and-access.md new file mode 100644 index 0000000..48fe6e5 --- /dev/null +++ b/docs/security/audit-event-privacy-and-access.md @@ -0,0 +1,7 @@ +# Audit Event Privacy and Access + +Durable events use identifiers only where attribution and correlation require them. Names, emails, IP addresses, organization names, course/lesson text, assessment responses, invitation tokens, credentials, uploaded content, and raw request payloads are prohibited. Central action-specific allowlists accept only primitive bounded state values and fail the associated transaction if unsafe data is supplied. + +Platform audit reads require explicit `admin` or `super_admin`. Organization reads require an active matching `org_admin` membership, except the deliberate `super_admin` scope path. The frontend is a convenience surface; it is not an authorization boundary. Audit export follows the same backend policy and must be handled as sensitive administrative data. + +Database operators can alter database state and therefore remain outside the application tamper-evidence boundary. Restrict production database credentials, preserve encrypted backups, verify chains after restore, and investigate any verification failure without rewriting evidence. diff --git a/docs/security/security-event-logging.md b/docs/security/security-event-logging.md index 6df2d52..5b659b7 100644 --- a/docs/security/security-event-logging.md +++ b/docs/security/security-event-logging.md @@ -6,4 +6,4 @@ Implemented events include authentication failure, limiter triggers, platform ro Never log passwords, bearer/invitation/reset tokens, uploaded bytes, filenames supplied by users, learner content, decoded JWT payloads, or unnecessary email/profile data. IDs are operational identifiers, not permission to expose associated records. -These logs are diagnostic, potentially ephemeral, and intended for monitoring and incident response. They are not a durable/tamper-evident audit ledger; no retention guarantee, restricted search/export API, before/after state model, or administrative review UI exists. `implement-platform-audit-events` must separately define append-only persistence, atomic event/action behavior, actor/action/target/organization/correlation, minimized before/after state, retention, access control, privacy, export, and tamper resistance. +These logs are diagnostic, potentially ephemeral, and intended for monitoring and incident response. Supported high-impact mutations also write transaction-bound durable audit records through `app.audit`; those records have minimized before/after state, scoped review/export, retention tooling, and integrity verification. Operational logs remain distinct and may describe denied or failed attempts that correctly do not create successful durable records. See [the audit-event policy](../audit/audit-event-policy.md). diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts index e0b9ea7..080a3ac 100644 --- a/frontend/src/app/app.routes.spec.ts +++ b/frontend/src/app/app.routes.spec.ts @@ -125,6 +125,7 @@ describe('app routes', () => { 'admin/courses/:courseId', 'admin/badges', 'admin/reports', + 'admin/audit-events', 'home/admin/users', 'home/admin/courses', 'home/admin/badges', @@ -133,9 +134,11 @@ describe('app routes', () => { await expectLazyComponent(findRoute(routes, 'admin/'), 'AdminOverviewComponent'); await expectLazyComponent(findRoute(routes, 'admin/users'), 'AdminUsersComponent'); await expectLazyComponent(findRoute(routes, 'admin/organizations'), 'AdminOrganizationsComponent'); + await expectLazyComponent(findRoute(routes, 'admin/audit-events'), 'AdminAuditEventsComponent'); expect(findRoute(routes, 'admin/users')?.canActivate).toContain(RoleGuard); expect(findRoute(routes, 'admin/users')?.data?.['roles']).toEqual(['admin']); expect(findRoute(routes, 'admin/organizations')?.data?.['roles']).toEqual(['admin', 'super_admin']); + expect(findRoute(routes, 'admin/audit-events')?.data?.['roles']).toEqual(['admin', 'super_admin']); }); it('adds guarded canonical Studio routes while preserving workspace deep links', async () => { diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index bb85ec5..08004fd 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -7,6 +7,7 @@ import { courseStudioExitGuard } from './guards/course-studio-exit.guard'; const AccessDeniedComponent = () => import('./pages/access-denied/access-denied.component').then((m) => m.AccessDeniedComponent); const AccessGrantsComponent = () => import('./pages/access-grants/access-grants.component').then((m) => m.AccessGrantsComponent); const AdminBadgesComponent = () => import('./pages/admin-badges/admin-badges.component').then((m) => m.AdminBadgesComponent); +const AdminAuditEventsComponent = () => import('./pages/admin-audit-events/admin-audit-events.component').then((m) => m.AdminAuditEventsComponent); const AdminCoursesComponent = () => import('./pages/admin-courses/admin-courses.component').then((m) => m.AdminCoursesComponent); const AdminOrganizationsComponent = () => import('./pages/admin-organizations/admin-organizations.component').then((m) => m.AdminOrganizationsComponent); const AdminOverviewComponent = () => import('./pages/admin-overview/admin-overview.component').then((m) => m.AdminOverviewComponent); @@ -109,6 +110,7 @@ export const routes: Routes = [ { path: 'courses/:courseId', loadComponent: AdminCoursesComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, { path: 'badges', loadComponent: AdminBadgesComponent, canActivate: [RoleGuard], data: { roles: ['admin', 'super_admin'] } }, { path: 'reports', loadComponent: AdminReportsComponent, canActivate: [RoleGuard], data: { roles: ['admin'] } }, + { path: 'audit-events', loadComponent: AdminAuditEventsComponent, canActivate: [RoleGuard], data: { roles: ['admin', 'super_admin'] } }, ], }, { diff --git a/frontend/src/app/models/audit-event.ts b/frontend/src/app/models/audit-event.ts new file mode 100644 index 0000000..fef7a56 --- /dev/null +++ b/frontend/src/app/models/audit-event.ts @@ -0,0 +1,32 @@ +export interface AuditEvent { + id: string; + created_at: string; + schema_version: number; + actor_id?: string | null; + actor_role: string; + action: string; + category: string; + outcome: string; + target_type: string; + target_id: string; + organization_id?: string | null; + request_id?: string | null; + correlation_id?: string | null; + reason_code?: string | null; + before_state: Record; + after_state: Record; + integrity_verified: boolean; +} + +export interface AuditEventPage { + items: AuditEvent[]; + next_cursor?: string | null; +} + +export interface AuditEventFilters { + action?: string; + category?: string; + outcome?: string; + cursor?: string; + limit?: number; +} diff --git a/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.html b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.html new file mode 100644 index 0000000..739b449 --- /dev/null +++ b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.html @@ -0,0 +1,29 @@ +
+

Admin / Audit events

Audit events

Review durable, privacy-minimized evidence of high-impact platform actions.

+ +
+
+
+
+
+
+
+

{{ exportStatus }}

+
+ + + + + +
+

Event history

{{ events.length }} events loaded

+
Durable platform audit events
WhenActionActor roleTargetOutcomeDetails
{{ event.created_at | date:'medium' }}{{ label(event.action) }}{{ label(event.actor_role) }}{{ label(event.target_type) }}{{ label(event.outcome) }}
+ +
+ +
+

Event detail

{{ selected.id }}

+
Action
{{ label(selected.action) }}
Outcome
{{ label(selected.outcome) }}
Actor role
{{ label(selected.actor_role) }}
Target
{{ label(selected.target_type) }} · {{ selected.target_id }}
Organization scope
{{ selected.organization_id || 'Platform' }}
Request reference
{{ selected.request_id || 'Not available' }}
+

State change

Before

{{ label(item[0]) }}
{{ item[1] ?? 'None' }}

After

{{ label(item[0]) }}
{{ item[1] ?? 'None' }}
+
+
diff --git a/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.scss b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.scss new file mode 100644 index 0000000..aa6fddb --- /dev/null +++ b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.scss @@ -0,0 +1,5 @@ +@use '../../../styles/admin-production'; + +.audit-state { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; } +.audit-state > div { padding: 1rem; border: 1px solid var(--echo-border, #d8dee8); border-radius: .75rem; } +.audit-state dl div { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: .75rem; } diff --git a/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.spec.ts b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.spec.ts new file mode 100644 index 0000000..f811f5e --- /dev/null +++ b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; + +import { AdminAuditEventsComponent } from './admin-audit-events.component'; +import { AuditEventsService } from '../../services/audit-events.service'; + +describe('AdminAuditEventsComponent', () => { + let fixture: ComponentFixture; + const service = jasmine.createSpyObj('AuditEventsService', ['list', 'export']); + const event = { + id: 'event-1', created_at: '2026-08-13T12:00:00Z', schema_version: 1, + actor_id: 'actor-1', actor_role: 'admin', action: 'platform.role.changed', + category: 'access', outcome: 'succeeded', target_type: 'user', target_id: 'user-1', + organization_id: null, request_id: 'request-1', correlation_id: null, reason_code: null, + before_state: { role: 'student' }, after_state: { role: 'teacher' }, integrity_verified: true, + }; + + beforeEach(async () => { + service.list.and.returnValue(of({ items: [event], next_cursor: null })); + service.export.and.returnValue(of(new Blob(['csv']))); + await TestBed.configureTestingModule({ + imports: [AdminAuditEventsComponent], + providers: [{ provide: AuditEventsService, useValue: service }], + }).compileComponents(); + fixture = TestBed.createComponent(AdminAuditEventsComponent); + }); + + it('renders only the minimized event and accessible detail', () => { + fixture.detectChanges(); + fixture.componentInstance.selected = event; + fixture.detectChanges(); + const text = fixture.nativeElement.textContent; + expect(text).toContain('platform role changed'); + expect(text).toContain('State change'); + expect(text).not.toContain('event_hash'); + }); + + it('clears stale protected data and announces a load failure', () => { + fixture.detectChanges(); + service.list.and.returnValue(throwError(() => ({ status: 500, headers: { get: () => 'safe-reference' } }))); + fixture.componentInstance.load(); + fixture.detectChanges(); + expect(fixture.componentInstance.events).toEqual([]); + expect(fixture.nativeElement.textContent).toContain('Reference: safe-reference'); + }); + + it('passes filter state and exposes pagination', () => { + service.list.and.returnValue(of({ items: [event], next_cursor: 'next' })); + fixture.componentInstance.action = 'platform.role.changed'; + fixture.componentInstance.load(); + fixture.detectChanges(); + expect(service.list).toHaveBeenCalledWith(jasmine.objectContaining({ action: 'platform.role.changed' })); + expect(fixture.nativeElement.textContent).toContain('Load more'); + }); +}); diff --git a/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.ts b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.ts new file mode 100644 index 0000000..e4b622f --- /dev/null +++ b/frontend/src/app/pages/admin-audit-events/admin-audit-events.component.ts @@ -0,0 +1,98 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Subscription } from 'rxjs'; + +import { EchoLoadingStateComponent } from '../../components/echo-loading-state/echo-loading-state.component'; +import { EchoStatePanelComponent } from '../../components/echo-state-panel/echo-state-panel.component'; +import { AuditEvent } from '../../models/audit-event'; +import { AuditEventsService } from '../../services/audit-events.service'; +import { securityErrorMessage } from '../../services/security-error'; + +@Component({ + selector: 'admin-audit-events-page', + standalone: true, + imports: [CommonModule, FormsModule, EchoLoadingStateComponent, EchoStatePanelComponent], + templateUrl: './admin-audit-events.component.html', + styleUrl: './admin-audit-events.component.scss', +}) +export class AdminAuditEventsComponent implements OnInit, OnDestroy { + events: AuditEvent[] = []; + selected?: AuditEvent; + action = ''; + category = ''; + outcome = ''; + nextCursor?: string | null; + loading = true; + loadingMore = false; + exporting = false; + error = ''; + exportStatus = ''; + private readonly subscriptions = new Subscription(); + + constructor(private readonly auditEvents: AuditEventsService) {} + + ngOnInit(): void { this.load(); } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } + + load(cursor?: string): void { + const append = !!cursor; + this.error = ''; + this.selected = append ? this.selected : undefined; + if (append) this.loadingMore = true; else { this.loading = true; this.events = []; } + this.subscriptions.add(this.auditEvents.list({ + action: this.action || undefined, + category: this.category || undefined, + outcome: this.outcome || undefined, + cursor, + limit: 50, + }).subscribe({ + next: page => { + this.events = append ? [...this.events, ...page.items] : page.items; + this.nextCursor = page.next_cursor; + this.loading = false; + this.loadingMore = false; + }, + error: error => { + this.events = []; + this.selected = undefined; + this.nextCursor = undefined; + this.loading = false; + this.loadingMore = false; + this.error = securityErrorMessage(error, 'Audit events could not be loaded.'); + }, + })); + } + + clearFilters(): void { this.action = ''; this.category = ''; this.outcome = ''; this.load(); } + label(value: string): string { return value.replace(/[._-]/g, ' '); } + stateEntries(state: AuditEvent['before_state']): [string, string | number | boolean | null][] { + return Object.entries(state); + } + + export(): void { + if (this.exporting) return; + this.exporting = true; + this.exportStatus = ''; + this.subscriptions.add(this.auditEvents.export({ + action: this.action || undefined, + category: this.category || undefined, + outcome: this.outcome || undefined, + }).subscribe({ + next: blob => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'echoed-audit-events.csv'; + anchor.click(); + URL.revokeObjectURL(url); + this.exporting = false; + this.exportStatus = 'Audit export downloaded.'; + }, + error: error => { + this.exporting = false; + this.exportStatus = securityErrorMessage(error, 'Audit export could not be created.'); + }, + })); + } +} diff --git a/frontend/src/app/services/audit-events.service.spec.ts b/frontend/src/app/services/audit-events.service.spec.ts new file mode 100644 index 0000000..9a39c39 --- /dev/null +++ b/frontend/src/app/services/audit-events.service.spec.ts @@ -0,0 +1,36 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { environment } from '../../environments/environment'; +import { AuditEventsService } from './audit-events.service'; + +describe('AuditEventsService', () => { + let service: AuditEventsService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + service = TestBed.inject(AuditEventsService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + it('sends only bounded review filters', () => { + service.list({ action: 'platform.role.changed', outcome: 'succeeded', limit: 50 }).subscribe(); + const request = http.expectOne(req => req.url === `${environment.apiUrl}/api/audit-events`); + expect(request.request.params.get('action')).toBe('platform.role.changed'); + expect(request.request.params.get('outcome')).toBe('succeeded'); + expect(request.request.params.get('limit')).toBe('50'); + request.flush({ items: [], next_cursor: null }); + }); + + it('requests a blob export without adding response fields', () => { + service.export({ category: 'access' }).subscribe(); + const request = http.expectOne(req => req.url.endsWith('/api/audit-events/export.csv')); + expect(request.request.responseType).toBe('blob'); + expect(request.request.params.get('category')).toBe('access'); + request.flush(new Blob(['event_id\n'])); + }); +}); diff --git a/frontend/src/app/services/audit-events.service.ts b/frontend/src/app/services/audit-events.service.ts new file mode 100644 index 0000000..55a8936 --- /dev/null +++ b/frontend/src/app/services/audit-events.service.ts @@ -0,0 +1,29 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { environment } from '../../environments/environment'; +import { AuditEventFilters, AuditEventPage } from '../models/audit-event'; + +@Injectable({ providedIn: 'root' }) +export class AuditEventsService { + private readonly apiUrl = `${environment.apiUrl}/api/audit-events`; + + constructor(private readonly http: HttpClient) {} + + list(filters: AuditEventFilters = {}): Observable { + let params = new HttpParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== '') params = params.set(key, String(value)); + }); + return this.http.get(this.apiUrl, { params }); + } + + export(filters: Pick = {}): Observable { + let params = new HttpParams(); + Object.entries(filters).forEach(([key, value]) => { + if (value) params = params.set(key, value); + }); + return this.http.get(`${this.apiUrl}/export.csv`, { params, responseType: 'blob' }); + } +} diff --git a/frontend/src/app/services/shell-navigation.service.ts b/frontend/src/app/services/shell-navigation.service.ts index 33a149d..50e2b90 100644 --- a/frontend/src/app/services/shell-navigation.service.ts +++ b/frontend/src/app/services/shell-navigation.service.ts @@ -102,6 +102,7 @@ export class ShellNavigationService { { label: 'Courses', route: '/admin/courses', icon: 'BookOpen', permission: 'nav:admin-courses', roles: ['admin'] }, { label: 'Badges', route: '/admin/badges', icon: 'Award', roles: ['admin', 'super_admin'] }, { label: 'Reports', route: '/admin/reports', icon: 'SlidersHorizontal', permission: 'nav:admin-reports', roles: ['admin'] }, + { label: 'Audit events', route: '/admin/audit-events', icon: 'ClipboardList', roles: ['admin', 'super_admin'] }, ], }, ]; diff --git a/frontend/tests/demo/admin-platform-smoke.spec.ts b/frontend/tests/demo/admin-platform-smoke.spec.ts index 125d813..9f1a6eb 100644 --- a/frontend/tests/demo/admin-platform-smoke.spec.ts +++ b/frontend/tests/demo/admin-platform-smoke.spec.ts @@ -42,6 +42,9 @@ test.describe('seeded platform administrator smoke', () => { await page.getByRole('link', { name: 'Badges', exact: true }).first().click(); await expect(page.getByRole('heading', { name: 'Badge administration' })).toBeVisible(); + await page.getByRole('link', { name: 'Audit events', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Audit events' })).toBeVisible(); + await page.setViewportSize({ width: 390, height: 844 }); await expect(page.getByRole('heading', { name: 'Badge administration' })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); @@ -53,5 +56,8 @@ test.describe('seeded platform administrator smoke', () => { await page.goto('/admin/users'); await expect(page).toHaveURL(/\/access-denied$/); await expect(page.getByText(/permission|access/i).first()).toBeVisible(); + + await page.goto('/admin/audit-events'); + await expect(page).toHaveURL(/\/access-denied$/); }); }); diff --git a/openspec/changes/establish-operational-readiness/.openspec.yaml b/openspec/changes/archive/2026-08-13-establish-operational-readiness/.openspec.yaml similarity index 100% rename from openspec/changes/establish-operational-readiness/.openspec.yaml rename to openspec/changes/archive/2026-08-13-establish-operational-readiness/.openspec.yaml diff --git a/openspec/changes/establish-operational-readiness/design.md b/openspec/changes/archive/2026-08-13-establish-operational-readiness/design.md similarity index 100% rename from openspec/changes/establish-operational-readiness/design.md rename to openspec/changes/archive/2026-08-13-establish-operational-readiness/design.md diff --git a/openspec/changes/establish-operational-readiness/proposal.md b/openspec/changes/archive/2026-08-13-establish-operational-readiness/proposal.md similarity index 100% rename from openspec/changes/establish-operational-readiness/proposal.md rename to openspec/changes/archive/2026-08-13-establish-operational-readiness/proposal.md diff --git a/openspec/changes/establish-operational-readiness/specs/platform-operational-readiness/spec.md b/openspec/changes/archive/2026-08-13-establish-operational-readiness/specs/platform-operational-readiness/spec.md similarity index 100% rename from openspec/changes/establish-operational-readiness/specs/platform-operational-readiness/spec.md rename to openspec/changes/archive/2026-08-13-establish-operational-readiness/specs/platform-operational-readiness/spec.md diff --git a/openspec/changes/establish-operational-readiness/tasks.md b/openspec/changes/archive/2026-08-13-establish-operational-readiness/tasks.md similarity index 100% rename from openspec/changes/establish-operational-readiness/tasks.md rename to openspec/changes/archive/2026-08-13-establish-operational-readiness/tasks.md diff --git a/openspec/changes/implement-platform-audit-events/.openspec.yaml b/openspec/changes/implement-platform-audit-events/.openspec.yaml new file mode 100644 index 0000000..b6b2d1f --- /dev/null +++ b/openspec/changes/implement-platform-audit-events/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-13 diff --git a/openspec/changes/implement-platform-audit-events/design.md b/openspec/changes/implement-platform-audit-events/design.md new file mode 100644 index 0000000..5ab5fe9 --- /dev/null +++ b/openspec/changes/implement-platform-audit-events/design.md @@ -0,0 +1,82 @@ +## Context + +Phase 8 emits privacy-safe security events and Phase 10 routes them through structured, redacted logs with request/correlation IDs. Those records are operational diagnostics: they are process-local or deployment-log dependent, have no transactional relationship to database mutations, and provide no retention, scoped review, or integrity contract. Phase 11 established backup/restore and operational ownership but explicitly deferred durable audit events. + +EchoEd is a FastAPI/SQLAlchemy application with Alembic migrations, PostgreSQL production intent, SQLite test support, explicit platform and organization authorization helpers, an Angular administrative shell, and no background worker. The solution must work in that architecture without introducing a queue, commercial system, or external ledger. + +## Goals / Non-Goals + +**Goals:** + +- Persist a minimized, attributable event for approved high-impact mutations in the same transaction as the mutation. +- Make persisted events append-only through the application service and database permissions/guardrails available to the repository. +- Provide explicit platform and organization read authorization, stable filtering, bounded pagination, and safe CSV export. +- Preserve request/correlation context while excluding credentials, content bodies, learner answers, filenames, and unnecessary personal data. +- Detect missing/reordered/modified records through a per-scope hash chain and expose verification to operators. +- Define retention, legal/incident hold boundaries, backup/restore ownership, and safe operational verification. + +**Non-Goals:** + +- A cryptographically external or independently witnessed ledger. +- A commercial SIEM, centralized log service, blockchain, event bus, queue, or new hosting system. +- Authentication/authorization redesign, distributed rate limiting, or product analytics. +- Capturing ordinary reads, every CRUD operation, raw request bodies, course content, assessment answers, or user-provided files. +- Replacing operational/security logging; successful audit persistence may emit a diagnostic event, but the stores retain distinct purposes. + +## Decisions + +### Persist immutable, minimized rows + +`audit_events` stores a UUID, UTC creation time, stable action/category/outcome, actor ID/role snapshot, target type/ID, optional organization ID, request/correlation IDs, JSON before/after summaries, reason code, schema version, previous hash, and event hash. There is no update endpoint or ORM mutation service. Values are allowlisted and recursively privacy-validated before persistence. + +Alternative considered: persist the existing log payload verbatim. Rejected because log fields are not a durable schema and can include diagnostic details inappropriate for administrative review. + +### Transactional capture through one service + +High-impact routes call `append_audit_event(db, ...)` before the route's existing commit. The service adds and flushes the row but never commits independently. A flush failure aborts the business mutation. Failed/denied attempts remain operational security logs unless a deliberate durable failure event can be written without creating misleading transactional evidence. + +Alternative considered: capture from middleware after responses. Rejected because it cannot guarantee atomicity, reconstruct safe before/after state, or distinguish rolled-back work. + +### Start with approved high-impact actions + +Initial coverage includes platform-role changes, account deletion, organization invitation creation/acceptance and membership changes supported by current routes, forum moderation deletion, and Course Studio publish/review/restore transitions that currently exist. Coverage is maintained as an explicit catalog and regression-tested. Unsupported product actions are not invented. + +### Per-scope chained integrity evidence + +Each event hashes canonical versioned content plus the previous hash and sequence for its scope (`platform` or organization UUID). A PostgreSQL transaction advisory lock serializes even concurrent first writes, and a unique scope/sequence constraint prevents forks. An operator verifier recomputes retained chains and fails on modification or reordering. Intentional retention can remove a prefix, so deletion before the retained boundary cannot be proven without an external anchor. This is tamper-evident application data, not tamper-proof evidence against a database superuser; external anchoring remains deferred infrastructure. + +### Scoped, minimized reads + +`admin` and `super_admin` can read the platform feed. Active organization administrators can read only events whose organization matches their membership and cannot select another scope by ID. Responses expose display-safe IDs and state summaries, never joined user/profile/course bodies. Missing and concealed cross-organization resources use the existing security error policy. Exports apply the same query policy, filters, limits, and schema. + +### Bounded retention with explicit preservation + +Repository code exposes a dry-run-first operator retention command. Production deletion requires an explicit cutoff, environment acknowledgement, prior backup, and no active preservation hold. Retention deletion is performed only by operator tooling, emits a retained tombstone/summary event in the surviving chain, and is never available through the public API. Default policy documentation retains security administration events for 365 days, subject to operator/legal policy. + +### Angular review surface remains operationally narrow + +The existing Admin area gains an audit-events list/detail surface for allowed platform administrators. Organization-scoped review remains API-ready and can be linked from Organization navigation only where the existing shell can prove the active role. Filters are action/category/outcome/time; raw IDs are optional support fields. CSV export is a deliberate user action with accessible success/failure status. + +## Risks / Trade-offs + +- **Database administrators can still alter both rows and hashes** → Document the threat boundary; restrict database credentials, back up separately, verify chains, and defer external anchoring/WORM storage. +- **Concurrent events could fork a scope chain** → Lock the latest scope row in PostgreSQL and test sequential integrity; SQLite remains a single-process test limitation. +- **Instrumentation gaps can create false confidence** → Maintain a canonical action catalog and tests that successful sensitive mutations create exactly one event while rollback creates none. +- **Before/after state can leak data** → Permit only primitive allowlisted keys per action and reject sensitive key names/nested payloads centrally. +- **Audit storage grows indefinitely** → Index query dimensions, paginate reads, document capacity monitoring, and provide guarded retention tooling. +- **Account deletion removes actor/target joins** → Store UUID and role snapshots without foreign-key cascade dependencies; do not denormalize names or emails. +- **Exports increase disclosure risk** → Require the same authorization as reads, cap rows, omit private fields, use attachment-safe CSV encoding, and durably record export actions. + +## Migration Plan + +1. Deploy the additive `audit_events` table and indexes before application code that writes events. +2. Deploy backend capture/read/export/integrity behavior; verify readiness and a disposable mutation/event transaction. +3. Deploy the Angular review surface after the read API is available. +4. Add the audit table to production backup classification and execute isolated backup/restore plus chain verification. +5. Rollback application code only while leaving the additive table in place. Do not drop accumulated audit history during routine rollback. + +## Open Questions + +- Production retention may need jurisdiction-specific adjustment; the repository defines a safe default and operator mechanism, not legal advice. +- External anchoring, WORM storage, and independent audit replication depend on future infrastructure selection. +- Organization audit UI expansion depends on whether organization administrators require self-service review in the first operational rollout; backend scope remains mandatory either way. diff --git a/openspec/changes/implement-platform-audit-events/proposal.md b/openspec/changes/implement-platform-audit-events/proposal.md new file mode 100644 index 0000000..5a86360 --- /dev/null +++ b/openspec/changes/implement-platform-audit-events/proposal.md @@ -0,0 +1,30 @@ +## Why + +EchoEd's structured security logs are intentionally ephemeral and cannot provide administrators or incident responders with durable, attributable evidence of high-impact actions. With security, observability, and operational-readiness foundations complete, the platform now needs a privacy-minimized audit record whose persistence and access rules are enforced independently from diagnostic logging. + +## What Changes + +- Add an append-only audit-event persistence model with actor, action, target, organization, outcome, request/correlation context, minimized before/after state, timestamps, and integrity-chain metadata. +- Record approved high-impact platform and organization mutations in the same database transaction as the affected state, so a successful mutation cannot silently omit its audit event. +- Add explicit platform-global and organization-scoped audit read APIs with bounded filtering, cursor pagination, concealment, retention metadata, and safe export. +- Prevent application-level update/delete operations on audit records and provide integrity verification and retention tooling that preserves deletion evidence without presenting the store as cryptographically tamper-proof infrastructure. +- Add an accessible administrative review surface using minimized schemas and existing role/navigation patterns. +- Add privacy, retention, access-control, incident, backup, and operational documentation plus regression evidence. +- Preserve structured operational/security logs as a separate diagnostic signal; they are not replaced by the durable audit store. + +## Capabilities + +### New Capabilities + +- `platform-audit-events`: Durable append-only administrative event capture, transactional guarantees, scoped review/export, integrity verification, retention, privacy, and operational ownership. + +### Modified Capabilities + +- `platform-operational-readiness`: Extend the persistent-state, backup/restore, and operational-drill contract to include the audit-event store and integrity verification. + +## Impact + +- Backend: SQLAlchemy model, Alembic migration, audit service, authorization dependencies, APIs/schemas, high-impact mutation instrumentation, integrity/retention operator commands, metrics/log integration, and tests. +- Frontend: API models/service, guarded Platform Admin audit review route, accessible filtering/detail/export behavior, and tests. +- Operations/security: backup classification, retention policy, incident preservation, privacy rules, and verification evidence. +- No commercial dependency, external ledger, authentication redesign, distributed state, hosting change, or general application feature work is introduced. diff --git a/openspec/changes/implement-platform-audit-events/specs/platform-audit-events/spec.md b/openspec/changes/implement-platform-audit-events/specs/platform-audit-events/spec.md new file mode 100644 index 0000000..bd3e962 --- /dev/null +++ b/openspec/changes/implement-platform-audit-events/specs/platform-audit-events/spec.md @@ -0,0 +1,112 @@ +## ADDED Requirements + +### Requirement: Durable audit-event schema +The system MUST persist approved high-impact events using an explicit versioned schema containing event ID, timestamp, actor identity and role snapshot, action, category, outcome, target type and identifier, optional organization scope, request and correlation identifiers, minimized before/after state, safe reason code, and integrity metadata. The schema MUST NOT store credentials, tokens, authorization headers, cookies, private content, assessment answers, uploaded bytes, filenames, or unnecessary personal data. + +#### Scenario: High-impact action captured +- **WHEN** an approved administrative mutation succeeds +- **THEN** exactly one durable event records the allowlisted attribution and state-transition fields without protected content + +#### Scenario: Unsafe audit payload rejected +- **WHEN** code attempts to persist a sensitive key or unsupported nested value +- **THEN** audit persistence fails closed without writing the unsafe event or committing the associated mutation + +### Requirement: Atomic mutation and event persistence +The system MUST write successful high-impact mutation events in the same database transaction as their business-state changes. A mutation MUST roll back when its required audit event cannot be persisted, and a rolled-back mutation MUST NOT leave a success event. + +#### Scenario: Audit persistence failure +- **WHEN** required audit-event persistence fails during a role change +- **THEN** neither the role change nor a success audit event is committed + +#### Scenario: Business mutation rollback +- **WHEN** a high-impact mutation is rolled back after an audit row is staged +- **THEN** the audit row is rolled back in the same transaction + +### Requirement: Approved action coverage +The system SHALL maintain an explicit action catalog and MUST capture currently supported platform-role changes, account deletion, organization invitation and membership changes, forum moderation, and Course Studio publish/review/restore transitions. Unsupported actions MUST NOT be fabricated as successful audit events. + +#### Scenario: Covered mutation catalog +- **WHEN** a supported covered mutation succeeds +- **THEN** its stable catalog action is present in the durable audit store + +#### Scenario: Denied mutation +- **WHEN** authorization or safety controls deny a requested mutation +- **THEN** no successful durable mutation event is recorded and existing operational security diagnostics remain available + +### Requirement: Append-only and integrity verification +Application APIs MUST NOT update or delete audit events. Each event MUST include canonical integrity-chain metadata scoped to platform or organization, and repository tooling MUST verify ordering and content integrity. The documentation MUST state that this is tamper-evident application data rather than protection from a fully privileged database operator. + +#### Scenario: API mutation attempt +- **WHEN** a client attempts to update or delete an audit event +- **THEN** no supported route permits the operation + +#### Scenario: Integrity verification +- **WHEN** an operator verifies an unchanged audit chain +- **THEN** verification succeeds, while modified or reordered canonical event data causes verification to fail + +### Requirement: Scoped audit review +The backend MUST enforce explicit audit-read allowlists. Platform administrators SHALL read the minimized platform feed, and active organization administrators SHALL read only events scoped to their organization. Cross-organization IDs MUST NOT bypass scope checks or disclose event contents. + +#### Scenario: Platform audit review +- **WHEN** an authorized platform administrator requests the platform audit feed +- **THEN** a bounded minimized result is returned + +#### Scenario: Organization audit review +- **WHEN** an active organization administrator requests their organization audit feed +- **THEN** only events whose organization scope matches the active membership are returned + +#### Scenario: Cross-organization audit request +- **WHEN** an organization administrator supplies another organization's identifier +- **THEN** access is denied or concealed according to the security error policy without returning event metadata + +### Requirement: Bounded filtering and pagination +Audit reads MUST use stable bounded pagination and allowlisted low-cardinality filters for time, action, category, outcome, actor ID, and target type/ID. Invalid filters and limits MUST fail validation, and responses MUST NOT serialize ORM models directly. + +#### Scenario: Filtered page +- **WHEN** an authorized reviewer supplies valid filters and a bounded page cursor +- **THEN** the API returns only matching explicit response records plus a continuation cursor + +#### Scenario: Excessive page size +- **WHEN** a client requests more than the configured maximum page size +- **THEN** request validation rejects or caps the request according to the documented API contract + +### Requirement: Safe audit export +Authorized reviewers SHALL export only the same scoped, filtered, minimized fields available through review APIs. Export size MUST be capped, spreadsheet-formula injection MUST be neutralized, and each successful export MUST itself create a durable audit event. + +#### Scenario: Authorized CSV export +- **WHEN** an authorized platform administrator exports a bounded filtered audit set +- **THEN** the response is a safe CSV attachment and an `audit.exported` event records the operation without embedding exported contents + +#### Scenario: Unauthorized export +- **WHEN** an actor without audit-read permission requests an export +- **THEN** the request fails without disclosing whether matching events exist + +### Requirement: Retention and preservation controls +The system SHALL document retention ownership and provide dry-run-first operator tooling for bounded expiration. Production deletion MUST require explicit environment acknowledgement, a verified backup reference, and confirmation that no preservation hold applies. Retention MUST NOT be exposed as a public API. + +#### Scenario: Retention dry run +- **WHEN** an operator supplies a cutoff without destructive confirmation +- **THEN** tooling reports only aggregate eligible counts and changes no data + +#### Scenario: Preservation hold +- **WHEN** a preservation hold is active for the requested scope or period +- **THEN** retention deletion fails closed + +### Requirement: Accessible administrative review +The Angular application SHALL provide authorized platform administrators an accessible audit review experience with loading, empty, error, filtered, detail, pagination, and export states. Frontend controls MUST mirror but MUST NOT replace backend authorization. + +#### Scenario: Administrator reviews an event +- **WHEN** an authorized administrator opens the audit review route and selects an event +- **THEN** minimized attribution, action, outcome, scope, timestamp, and safe state changes are presented with accessible labels + +#### Scenario: Audit API failure +- **WHEN** audit loading or export fails +- **THEN** the page presents an accessible safe error with request-reference context where available and does not retain stale protected results + +### Requirement: Audit-store observability and privacy +Audit capture, read, export, verification, and retention operations MUST emit privacy-safe operational metrics/logs without duplicating before/after contents or using actor, target, organization, or event IDs as metric labels. Durable audit events and operational diagnostics MUST remain conceptually distinct. + +#### Scenario: Audit persistence failure signal +- **WHEN** required audit persistence fails +- **THEN** a bounded operational failure signal includes request correlation and category but excludes the attempted state payload + diff --git a/openspec/changes/implement-platform-audit-events/specs/platform-operational-readiness/spec.md b/openspec/changes/implement-platform-audit-events/specs/platform-operational-readiness/spec.md new file mode 100644 index 0000000..51eea9b --- /dev/null +++ b/openspec/changes/implement-platform-audit-events/specs/platform-operational-readiness/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Persistent-state backup and verified restore +The operational contract MUST identify all persistent state, including durable audit events and their integrity metadata, and define backup scope, cadence, retention, encryption, separation, integrity verification, and restore testing. A backup SHALL NOT be considered valid until an isolated restore proves database, audit-chain, and supported uploaded-asset usability. Audit retention MUST NOT remove the only recoverable copy required by incident or preservation policy. + +#### Scenario: Safe recovery drill +- **WHEN** an operator backs up disposable database and upload data, verifies the manifest, restores to isolated targets, verifies the restored audit chain, and runs usability checks +- **THEN** the restored records, audit integrity metadata, and asset bytes match the originals without exposing sensitive data + +#### Scenario: Corrupted backup +- **WHEN** a backup file no longer matches its integrity manifest +- **THEN** restore fails closed before replacing the target state + +#### Scenario: Audit-event recovery +- **WHEN** an operator restores a database containing durable audit events +- **THEN** audit-chain verification succeeds before the restored service is accepted for operational use diff --git a/openspec/changes/implement-platform-audit-events/tasks.md b/openspec/changes/implement-platform-audit-events/tasks.md new file mode 100644 index 0000000..feab7b5 --- /dev/null +++ b/openspec/changes/implement-platform-audit-events/tasks.md @@ -0,0 +1,42 @@ +## 1. Baseline and contracts + +- [x] 1.1 Record the starting branch, commit, dirty-tree/archive states, actual test baselines, existing security-event coverage, database/migration architecture, and audit privacy boundary +- [x] 1.2 Publish the action catalog, access/scope matrix, minimized field policy, retention ownership, integrity threat boundary, and operational audit-vs-log distinction +- [x] 1.3 Strictly validate the complete proposal, design, new capability spec, operational-readiness delta, and task plan + +## 2. Persistence and integrity foundation + +- [x] 2.1 Add the explicit append-only audit-event SQLAlchemy model, indexes, schema version, and Alembic migration with PostgreSQL and SQLite compatibility +- [x] 2.2 Implement centralized action definitions, payload allowlists/redaction rejection, canonical serialization, per-scope hash chaining, and integrity verification +- [x] 2.3 Implement transaction-bound append semantics that flush without independently committing and emit bounded operational metrics/logs +- [x] 2.4 Add guarded dry-run-first retention and integrity-verification operator tooling with production acknowledgement, backup reference, and preservation-hold controls + +## 3. High-impact mutation coverage + +- [x] 3.1 Capture successful platform-role changes and account deletion with minimized before/after state in the mutation transaction +- [x] 3.2 Capture supported organization invitation and membership mutations with organization scope in the mutation transaction +- [x] 3.3 Capture supported forum moderation mutations with author/moderator scope in the mutation transaction +- [x] 3.4 Capture supported Course Studio publish, review, and restore transitions with content identifiers but no course graph/content +- [x] 3.5 Verify denied, failed, and rolled-back mutations do not produce misleading successful durable events + +## 4. Scoped review and export APIs + +- [x] 4.1 Add explicit minimized audit summary/detail/filter/export schemas without ORM serialization +- [x] 4.2 Add platform-admin and active-organization-admin authorization dependencies with cross-organization concealment +- [x] 4.3 Add bounded cursor-paginated list/detail endpoints with allowlisted validation and stable ordering +- [x] 4.4 Add capped formula-safe CSV export using identical scope/filter rules and durable export-event capture +- [x] 4.5 Add capture/read/export/integrity/retention metrics and redacted structured diagnostics without high-cardinality labels + +## 5. Administrative review experience + +- [x] 5.1 Add frontend audit-event models/service with minimized response compatibility and request-reference error handling +- [x] 5.2 Add a role-guarded Platform Admin audit route, navigation entry, accessible filters, loading/empty/error/list/detail/pagination states, and bounded export action +- [x] 5.3 Add Angular tests for authorization-aligned visibility, minimized rendering, filter/pagination/export behavior, accessible failures, and stale-data clearing + +## 6. Verification and operations + +- [x] 6.1 Add backend tests for schema privacy, atomicity, rollback, action coverage, chain integrity/tampering/concurrency, append-only behavior, scoped reads, cross-organization denial, pagination, export safety, retention, and configuration +- [x] 6.2 Update backup/restore tooling and drills to preserve audit rows/integrity metadata and verify the restored chain +- [x] 6.3 Add stable Playwright coverage for authorized review and denied direct-route access where practical without exposing internal event contents +- [x] 6.4 Publish audit architecture, privacy/access, retention/export, integrity, incident/backup, operator runbook, baseline, and exact verification evidence; update canonical security/operations/roadmap documents +- [ ] 6.5 Run complete backend, Angular, Playwright, production build, dependency audit, configured lint/format/static checks, strict OpenSpec validation, secret/artifact checks, and `git diff --check` without baseline regression diff --git a/openspec/specs/platform-operational-readiness/spec.md b/openspec/specs/platform-operational-readiness/spec.md new file mode 100644 index 0000000..639299d --- /dev/null +++ b/openspec/specs/platform-operational-readiness/spec.md @@ -0,0 +1,119 @@ +# Platform Operational Readiness Specification + +## Purpose + +Define EchoEd's evidence-backed contract for safe production configuration, deployment, migration, health checking, shutdown, monitoring, backup, restore, rollback, and operational recovery without expanding into hosting infrastructure or durable audit events. + +## Requirements + +### Requirement: Fail-closed production configuration +The system MUST validate security-sensitive and operational configuration before serving production traffic, MUST reject missing, malformed, contradictory, unsafe, or development-only values, and MUST report actionable setting categories without exposing secret values. Development and test environments SHALL retain usable local configuration. + +#### Scenario: Valid production configuration +- **WHEN** an operator supplies every required production setting with mutually consistent safe values +- **THEN** validation succeeds before application initialization and exposes no setting values + +#### Scenario: Unsafe production configuration +- **WHEN** a production setting is absent, malformed, uses a known development default, or contradicts another setting +- **THEN** startup fails before traffic is served and identifies only the affected setting or category + +### Requirement: Trusted host and proxy boundary +The backend MUST enforce an explicit host allowlist and MUST treat forwarding metadata as authoritative only when proxy trust is enabled and the direct peer is in an explicit IP or CIDR allowlist. Untrusted forwarding metadata MUST NOT alter the authoritative client address, protocol, or host. + +#### Scenario: Allowed and rejected hosts +- **WHEN** requests use an allowed host and then a host outside the configured allowlist +- **THEN** the allowed request is processed and the unexpected host is rejected + +#### Scenario: Spoofed forwarding metadata +- **WHEN** an untrusted direct client supplies forwarding headers +- **THEN** the backend uses direct connection metadata rather than the supplied forwarding values + +#### Scenario: Trusted forwarding metadata +- **WHEN** a configured trusted proxy supplies valid bounded forwarding headers +- **THEN** the backend resolves the forwarded client, protocol, and host according to the documented topology + +### Requirement: Deterministic deployment and migration lifecycle +The repository SHALL provide deterministic pre-deployment validation, explicit database migration, application startup, liveness/readiness, smoke, monitoring, and rollback decision procedures. Normal production application startup MUST NOT automatically execute migrations. + +#### Scenario: Successful release lifecycle +- **WHEN** an operator validates configuration, executes required migrations, starts the immutable release, and runs post-deployment checks +- **THEN** each gate produces a clear pass/fail result before the release proceeds + +#### Scenario: Migration failure +- **WHEN** a migration fails or the database schema does not reach the repository heads +- **THEN** application rollout is stopped and no automatic downgrade is claimed + +### Requirement: Explicit rollback boundaries +Operational documentation MUST distinguish application, configuration, and database rollback and MUST identify schema compatibility conditions under which application-only rollback is unsafe. + +#### Scenario: Compatible application rollback +- **WHEN** a failed release has no incompatible schema or configuration transition +- **THEN** the operator can redeploy the previous immutable artifact and verify readiness and smoke checks + +#### Scenario: Incompatible schema transition +- **WHEN** the previous application cannot operate safely against the migrated schema +- **THEN** the procedure requires a verified downgrade or backup restore rather than application-only rollback + +### Requirement: Operational health and graceful shutdown +The lifecycle MUST use process-only liveness, dependency-aware readiness, and bounded graceful shutdown. Database unavailability MUST fail readiness without failing liveness, health output MUST remain non-disclosing, and shutdown MUST release application/database resources and emit bounded operational events. + +#### Scenario: Database unavailable +- **WHEN** the database dependency is unavailable +- **THEN** liveness remains healthy while readiness returns an unavailable status without connection details + +#### Scenario: Graceful termination +- **WHEN** the server receives a controlled termination request +- **THEN** it stops accepting work according to server semantics, allows bounded in-flight completion, executes shutdown hooks, and releases database resources + +### Requirement: Service objectives and alert ownership +The system documentation SHALL define measurable initial availability, successful-request, error-rate, latency, and readiness indicators with targets, windows, known limitations, alert conditions, severity, response, owner role, escalation path, and runbook. It MUST distinguish exposed signals from external aggregation and notification infrastructure that is not configured. + +#### Scenario: Operator evaluates a service objective +- **WHEN** an operator reviews the documented Phase 10 health, metric, and log signals +- **THEN** the indicator formula, target, window, data limitations, owner, and related response are unambiguous + +### Requirement: Persistent-state backup and verified restore +The operational contract MUST identify all persistent state and define backup scope, cadence, retention, encryption, separation, integrity verification, and restore testing. A backup SHALL NOT be considered valid until an isolated restore proves database and supported uploaded-asset usability. + +#### Scenario: Safe recovery drill +- **WHEN** an operator backs up disposable database and upload data, verifies the manifest, restores to isolated targets, and runs usability checks +- **THEN** the restored records and asset bytes match the originals without exposing sensitive data + +#### Scenario: Corrupted backup +- **WHEN** a backup file no longer matches its integrity manifest +- **THEN** restore fails closed before replacing the target state + +### Requirement: Storage ownership and recovery targets +Documentation MUST identify the source of truth, persistence boundary, backup owner, restore owner, deployment behavior, and loss consequences for database, uploaded assets, generated static assets, configuration, and secrets. It SHALL define defensible initial RPO and RTO targets and their prerequisites and limitations. + +#### Scenario: Ephemeral storage configuration +- **WHEN** production upload paths do not have an explicit persistent-storage decision +- **THEN** production configuration validation fails rather than silently accepting ephemeral ownership + +### Requirement: Secret rotation and environment separation +Production MUST NOT silently load development secrets or insecure defaults. The operational contract SHALL define preparation, sequencing, verification, invalidation where supported, and emergency rollback for rotating application secrets and credentials while keeping secret values out of repository artifacts and evidence. + +#### Scenario: Rotation simulation +- **WHEN** an operator validates old and replacement production configurations in an isolated simulation +- **THEN** both configurations pass only with independently supplied safe secrets and no secret value appears in output + +### Requirement: Evidence-driven operational drills +The repository SHALL provide repeatable safe drills for invalid production configuration, unavailable database/readiness, startup, shutdown, health, failed post-deploy verification, backup, restore, rollback, configuration rotation, and storage recovery. Each drill MUST define prerequisites, procedure, expected and observed behavior, and pass/fail criteria and MUST refuse production or uncontrolled data where automation could be destructive. + +#### Scenario: Complete local drill execution +- **WHEN** an engineer runs the operational drill suite against isolated test resources +- **THEN** every supported drill records a deterministic pass/fail result and temporary resources are removed + +### Requirement: Security and observability preservation +Operational readiness MUST preserve Phase 8 authorization and privacy controls and Phase 10 logging, correlation, metrics, and health behavior. Logs, metrics, health output, errors, documentation, and drill evidence MUST NOT expose credentials, tokens, authorization data, private content, uploaded bytes, SQL values, or private user information. + +#### Scenario: Operational failure evidence +- **WHEN** validation, readiness, backup integrity, or deployment verification fails +- **THEN** diagnostics identify the operational category and safe reference context without exposing protected values + +### Requirement: Explicit deferred capability boundary +The change MUST NOT implement durable platform audit events, distributed rate limiting/state, identity redesign, new hosting infrastructure, distributed observability expansion, or application feature work. + +#### Scenario: Future audit-event need +- **WHEN** an operational workflow needs durable tamper-resistant administrative history +- **THEN** the need is recorded for `implement-platform-audit-events` rather than implemented in this capability From 28f009ff675d5f4c4c8b5f7644e573506c795d21 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Thu, 13 Aug 2026 20:14:19 -0500 Subject: [PATCH 2/2] Update shell-navigation.service.spec.ts --- frontend/src/app/services/shell-navigation.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/services/shell-navigation.service.spec.ts b/frontend/src/app/services/shell-navigation.service.spec.ts index 0e0f555..4d0adca 100644 --- a/frontend/src/app/services/shell-navigation.service.spec.ts +++ b/frontend/src/app/services/shell-navigation.service.spec.ts @@ -119,7 +119,7 @@ describe('ShellNavigationService', () => { expect(adminLabels).toContain('Badges'); expect(adminLabels).toContain('Reports'); expect(adminLabels).not.toContain('Community'); - expect(superAdminLabels).toEqual(['Admin Overview', 'Organizations', 'Badges']); + expect(superAdminLabels).toEqual(['Admin Overview', 'Organizations', 'Badges', 'Audit events']); }); it('does not expose navigation for unsupported parent or viewer roles', () => {