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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ jobs:
with:
context: ./backend
push: true
tags: ghcr.io/${{ github.repository_owner }}/backend:latest
tags: |
ghcr.io/${{ github.repository_owner }}/backend:${{ github.sha }}
ghcr.io/${{ github.repository_owner }}/backend:latest
8 changes: 7 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ Important paths:
- `backend/alembic/versions/` contains database migrations.
- `backend/tests/` contains the backend test suite.

The application exposes `/health/live` and database-backed `/health/ready`. HTTP responses include a privacy-safe request ID and baseline security headers; backend request logs record method, path, status, duration, and correlation ID without logging bearer tokens. These are foundations rather than a complete metrics, tracing, or deployment platform; see [docs/platform-maturity](docs/platform-maturity/observability-baseline.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, 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).

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).

## Existing Domain Boundaries

Expand Down Expand Up @@ -140,3 +142,7 @@ cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke
## Contribution Guidance

Changes should be small, scoped, and aligned with existing boundaries. Documentation-only changes do not need backend migrations or frontend tests, but they should still pass OpenSpec validation when tied to an OpenSpec change.

## Operational lifecycle

`app.operational_config` validates environment, database, secret, host/proxy, origin, release, storage, migration, and observability decisions before route/database initialization. `TrustedHostMiddleware` and peer-CIDR forwarding resolution define the application network boundary; Uvicorn implicit proxy processing is disabled. Schema migration is a one-shot pre-start operation, health separates process liveness from database readiness, and the FastAPI lifespan disposes the SQLAlchemy engine during bounded shutdown. See [deployment architecture and limits](docs/operations/deployment-runbook.md).
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ 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 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_<GROUP>_LIMIT` and `RATE_LIMIT_<GROUP>_WINDOW_SECONDS` variables; the current store is process-local and forwarded client-IP headers are intentionally ignored.

## Local Development

### Backend
Expand Down Expand Up @@ -151,6 +155,10 @@ cd frontend
cmd /c .\node_modules\.bin\playwright.cmd test tests/demo/student-flagship-smoke.spec.ts
```

## Operations

Production startup is governed by the [production configuration contract](docs/operations/production-configuration.md). Migrations are an explicit release step; normal backend startup does not change schema. Start with the [deployment runbook](docs/operations/deployment-runbook.md), [backup/restore procedure](docs/operations/backup-and-restore.md), and [operational drills](docs/operations/operational-drills.md). Passing local readiness drills is not by itself a production-readiness claim.

## Contributing

EchoEd welcomes bounded, respectful contributions. Because this project is early and has no paid budget, contribution requests should be specific and transparent.
Expand Down
6 changes: 6 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ These are not Phase 1 requirements:

Phase 7 preserves the completed role-based experience while reducing initial frontend loading cost and establishing evidence-based security, observability, operational, dependency, and backend-capability baselines. Larger product capabilities remain independent future OpenSpec changes. See the [platform-maturity roadmap](docs/platform-maturity/future-openspec-roadmap.md) for priorities and dependencies; passing this foundation does not make EchoEd 1.0 production-ready.

Phase 8 (`harden-platform-security`) hardens the evidenced forum, privileged-user, role, rate-limit, upload, response-minimization, and object/organization authorization boundaries. Its verification evidence lives under [docs/security](docs/security/phase-8-security-verification.md). Remaining platform-maturity work continues as independent OpenSpec changes.

Phase 10 (`establish-platform-observability`) establishes privacy-conscious structured logs, request references, operational metrics, dependency readiness, Course Studio diagnostics, and operator/incident guidance without selecting a monitoring vendor. Durable, access-controlled, tamper-resistant administrative history remains the separate `implement-platform-audit-events` change. Operational readiness is the recommended next platform phase; observability alone is not a production-readiness claim.

Phase 11 (`establish-operational-readiness`) builds directly on Phase 10 with fail-closed production configuration, trusted host/proxy boundaries, explicit migrations, deployment/rollback gates, graceful shutdown, initial SLO/alert contracts, backup/restore tooling, storage ownership, secret rotation, and evidence-driven drills. Hosting selection, external alert delivery, distributed state/telemetry, and durable audit events remain separate work.

## Roadmap Principles

- Trust before scale.
Expand Down
10 changes: 8 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ Security reports may cover:

## Current Baseline

The focused [Phase 7 security baseline](docs/platform-maturity/security-baseline.md) records repository evidence, severity, narrow remediations, and deferred security work. Phase 7 adds privacy-safe authentication logging, authenticated diagnostic access, active organization-membership enforcement, bounded image-upload validation, request correlation, baseline response headers, and patched Angular runtime packages. It is not a penetration test.
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.

The unauthenticated forum mutation boundary, administrative response minimization, rate limiting, comprehensive audit events, and production security policy remain explicit future work. Do not use the current demo with real learner or production data.
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.

## Reporting a Vulnerability

Expand Down Expand Up @@ -61,6 +61,12 @@ This is a no-budget early project, so response time may vary. The intended respo

Please do not publicly disclose a suspected vulnerability until there has been a reasonable opportunity to investigate and mitigate it.

## 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).

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).

## Out of Scope

The following are out of scope unless they demonstrate a concrete security impact:
Expand Down
3 changes: 2 additions & 1 deletion backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from alembic import context
from app.models import Base

load_dotenv()
if os.getenv("APP_ENV", "development").strip().lower() != "production":
load_dotenv()

db_url = os.getenv("DATABASE_URL")

Expand Down
15 changes: 10 additions & 5 deletions backend/app/api/routes/activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from app.database import get_db
from app.deps import require_roles
from app.models import Activity
from app.content_scope import course_for_activity, course_for_lesson, require_course_edit
from app.schemas import ActivityResponse
from pydantic import BaseModel

Expand All @@ -24,8 +25,9 @@ class ActivityUpdate(ActivityCreate):
def create_activity(
activity: ActivityCreate,
db: Session = Depends(get_db),
current_user=Depends(require_roles("admin", "teacher")),
current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")),
):
require_course_edit(db, current_user, course_for_lesson(db, activity.lesson_id))
new_activity = Activity(
lesson_id=activity.lesson_id,
type=activity.type,
Expand All @@ -41,15 +43,15 @@ def create_activity(
@router.get('/activities', response_model=list[ActivityResponse])
def list_activities(
db: Session = Depends(get_db),
current_user=Depends(require_roles("admin", "teacher")),
current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")),
):
return db.query(Activity).all()

@router.get('/activities/{activity_id}', response_model=ActivityResponse)
def get_activity(
activity_id: UUID,
db: Session = Depends(get_db),
current_user=Depends(require_roles("admin", "teacher", "student")),
current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin", "student")),
):
activity = db.query(Activity).filter_by(id=activity_id).first()
if not activity:
Expand All @@ -61,11 +63,13 @@ def update_activity(
activity_id: UUID,
activity: ActivityUpdate,
db: Session = Depends(get_db),
current_user=Depends(require_roles("admin", "teacher")),
current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")),
):
db_activity = db.query(Activity).filter_by(id=activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail='Activity not found')
require_course_edit(db, current_user, course_for_activity(db, db_activity.id))
require_course_edit(db, current_user, course_for_lesson(db, activity.lesson_id))
db_activity.lesson_id = activity.lesson_id
db_activity.type = activity.type
db_activity.title = activity.title
Expand All @@ -79,11 +83,12 @@ def update_activity(
def delete_activity(
activity_id: UUID,
db: Session = Depends(get_db),
current_user=Depends(require_roles("admin", "teacher")),
current_user=Depends(require_roles("admin", "super_admin", "teacher", "instructor", "content_admin", "org_admin")),
):
db_activity = db.query(Activity).filter_by(id=activity_id).first()
if not db_activity:
raise HTTPException(status_code=404, detail='Activity not found')
require_course_edit(db, current_user, course_for_activity(db, db_activity.id))
db.delete(db_activity)
db.commit()
return {'message': 'Activity deleted'}
30 changes: 25 additions & 5 deletions backend/app/api/routes/assignments.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.database import get_db
from app.deps import get_current_user, require_org_roles
from app.models import Assignment, AssignmentSubmission
from app.section_scope import require_scoped_section
from app.enum import AssignmentTargetType, AssignmentSubmissionStatus
from app.models import Assignment, AssignmentSubmission, Enrollment
from app.section_scope import require_scoped_section, require_section_lesson, require_section_unit
from app.enum import AssignmentTargetType, AssignmentSubmissionStatus, EnrollmentStatus
from app.schemas import (
AssignmentCreateRequest,
AssignmentResponse,
Expand All @@ -26,9 +27,14 @@ def create_assignment(
membership=Depends(require_org_roles("teacher", "org_admin", "instructor")),
):
section = require_scoped_section(db, membership, section_id)
target_type = AssignmentTargetType(payload.target_type)
if target_type == AssignmentTargetType.UNIT:
require_section_unit(db, section, payload.target_id)
else:
require_section_lesson(db, section, payload.target_id)
assignment = Assignment(
section_id=section.id,
target_type=AssignmentTargetType(payload.target_type),
target_type=target_type,
target_id=payload.target_id,
due_at=payload.due_at,
instructions=payload.instructions,
Expand All @@ -52,11 +58,25 @@ def list_assignments(

@router.post("/assignments/{assignment_id}/submit", response_model=AssignmentSubmissionResponse)
def submit_assignment(
assignment_id: str,
assignment_id: UUID,
payload: AssignmentSubmissionRequest,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
assignment = db.query(Assignment).filter(Assignment.id == assignment_id).first()
if assignment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
enrollment = (
db.query(Enrollment)
.filter(
Enrollment.section_id == assignment.section_id,
Enrollment.user_id == current_user.id,
Enrollment.status == EnrollmentStatus.ACTIVE,
)
.first()
)
if enrollment is None:
raise HTTPException(status_code=404, detail="Assignment not found")
submission = (
db.query(AssignmentSubmission)
.filter(
Expand Down
46 changes: 42 additions & 4 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from datetime import timedelta

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

Expand All @@ -20,12 +20,17 @@
)
from app.schemas import AuthTokenResponse, UserDto
from app.enum import OrganizationType, OrganizationRole
from app.rate_limit import enforce_rate_limit
from app.security import PUBLIC_REGISTRATION_ROLES, security_event
from app.observability import emit_event, metrics

router = APIRouter()


@router.post("/auth/register")
def register_user(user: UserDto, db: Session = Depends(get_db)):
def register_user(user: UserDto, request: Request, db: Session = Depends(get_db)):
metrics.increment("echoed_authentication_total", operation="registration", result="attempt")
enforce_rate_limit(request, "auth_register", account_identifier=user.username)
existing_user = db.query(User).filter(User.username == user.username).first()
if existing_user:
raise HTTPException(status_code=400, detail="Username already registered")
Expand All @@ -35,7 +40,9 @@ def register_user(user: UserDto, db: Session = Depends(get_db)):
firstname=user.firstname,
lastname=user.lastname,
email=user.email,
role=user.role.lower(),
role=(user.role or "student").lower()
if (user.role or "student").lower() in PUBLIC_REGISTRATION_ROLES
else "student",
hashed_password=hash_password(user.password),
)
db.add(new_user)
Expand All @@ -59,17 +66,38 @@ def register_user(user: UserDto, db: Session = Depends(get_db)):
db.add(UserPreferences(user_id=new_user.id))
db.commit()

metrics.increment("echoed_authentication_total", operation="registration", result="success")
emit_event(
"auth.registration.succeeded",
component="authentication",
actor_id=new_user.id,
actor_role=new_user.role,
organization_context=True,
result="success",
)

return {"message": "User registered successfully", "organization_id": personal_org.id}


@router.post("/auth/token", response_model=AuthTokenResponse)
def login(
request: Request,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
metrics.increment("echoed_authentication_total", operation="login", result="attempt")
enforce_rate_limit(request, "auth_login", account_identifier=form_data.username)
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect username or password")
metrics.increment("echoed_authentication_total", operation="login", result="failure")
security_event(
action="authentication",
result="denied",
target_type="account",
reason="invalid_credentials",
request_id=getattr(request.state, "request_id", None),
)
raise HTTPException(status_code=401, detail="Incorrect username or password")

memberships = (
db.query(OrganizationMembership)
Expand All @@ -91,6 +119,16 @@ def login(
expires_delta=timedelta(minutes=120),
)

metrics.increment("echoed_authentication_total", operation="login", result="success")
emit_event(
"auth.login.succeeded",
component="authentication",
actor_id=user.id,
actor_role=user.role,
organization_context=bool(active_org_id),
result="success",
)

return {
"access_token": access_token,
"token_type": "bearer",
Expand Down
Loading
Loading