Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
db4f6ac
feat(db): add migrations 004-006 for pin rotation, soft delete, and a…
GabGP Aug 24, 2026
43d1141
feat(auth): implement Bearer session dependency, role authorization, …
GabGP Aug 24, 2026
669704f
feat(auth): add POST /logout endpoint and must_change_pin in login re…
GabGP Aug 24, 2026
564b88c
refactor(tests): split test_auth.py into test_auth_login.py, test_aut…
GabGP Aug 24, 2026
fdc57ea
feat(validation): centralize validation constants and add multi-role …
GabGP Aug 25, 2026
b05015d
feat(users): implement student self-signup and profile retrieval endp…
GabGP Aug 25, 2026
b4269d3
feat(rate-limit): add sliding-window signup rate limiter and wire int…
GabGP Aug 25, 2026
0a0f54f
feat(users): implement username and PIN credential change endpoints
GabGP Aug 26, 2026
3c5f0cc
refactor(users): modularize users API package and split test suite
GabGP Aug 26, 2026
8eefa7e
feat(staff): implement roster listing and staff account creation endp…
GabGP Aug 27, 2026
63709e7
refactor(staff): modularize staff API package and split test suite
GabGP Aug 27, 2026
e1a87bc
feat(staff): implement temporary-PIN reset endpoint and security proofs
GabGP Aug 27, 2026
d1eb7aa
refactor(api): modularize auth into package with login and logout mod…
GabGP Aug 27, 2026
64449a7
refactor(security): modularize rate_limit into lockout and sliding_wi…
GabGP Aug 27, 2026
31da192
refactor(security): introduce reusable Pydantic field types and strea…
GabGP Aug 27, 2026
a627322
feat(backend): implement staff account soft-deletion and recovery
GabGP Aug 27, 2026
4cb2502
refactor(tests): reorganize test suite into modular packages and spli…
GabGP Aug 27, 2026
b4e076e
feat(audit): implement audit trail helper and admin read endpoint
GabGP Aug 27, 2026
1bf0df2
test(security): expand security proofs across authentication and user…
GabGP Aug 27, 2026
e944bac
docs(backend): update README with complete API and modular architecture
GabGP Aug 28, 2026
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
123 changes: 107 additions & 16 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@ FastAPI application designed to run on the NVIDIA Jetson Orin Nano, with local d

## Components

- **FastAPI REST API**: Health-check and username/PIN login endpoints.
- **Authentication**: Bcrypt-hashed PINs and active sessions.
- **Database**: SQLite with idempotent SQL migrations.
- **FastAPI REST API**:
- Health checks (`/health`)
- Authentication (`/login`, `/logout`)
- Student self-service lifecycle (`/signup`, `/users/me`, `/users/me/pin`, `/users/me/username`)
- Staff management (`/users`, `/users/{id}/reset-pin`, `/users/{id}`, `/users/{id}/recover`)
- Privileged system audit trail (`/audit-logs`)
- **Security & Access Control**:
- Role-based access control (RBAC) with `student`, `teacher`, and `admin` roles.
- Forced PIN rotation enforcement and policy validation (4–8 numeric digits).
- In-memory rate limiting: credential lockout limiter (consecutive failure backoff) and global sliding window limiters (signup / rate-throttling).
- Bearer session token lifecycle with active session deactivation.
- Zero-credential logging guards and anti-oracle check ordering.
- **Database & Migrations**:
- SQLite database with foreign keys and index optimization.
- Numbered, idempotent schema migrations (`001` through `006`).
- Append-only `audit_logs` table tracking privileged user and account mutations.

The following items are placeholders for planned work and are not fully implemented in the current backend:

Expand All @@ -17,6 +30,28 @@ The following items are placeholders for planned work and are not fully implemen
- **Math Validation**: Deterministic SymPy engine and containment guardrail.
- **ASR Service**: Meta Omnilingual ASR 300M CTC int8 (`sherpa-onnx`) integration for K'iche' speech-to-text.

---

## API Summary

| Method | Path | Auth / Role | Description |
| :--- | :--- | :--- | :--- |
| `GET` | `/health` | Public | System and SQLite health check |
| `POST` | `/signup` | Public (Rate-limited) | Student self-registration |
| `POST` | `/login` | Public (Rate-limited) | Authenticate with username and PIN; returns session token |
| `POST` | `/logout` | Bearer Token | Invalidate current caller session |
| `GET` | `/users/me` | Bearer Token | Return profile of authenticated user |
| `PATCH` | `/users/me/pin` | Bearer Token | Self-service PIN change (clears forced rotation flag) |
| `PATCH` | `/users/me/username` | Bearer Token | Self-service username change |
| `GET` | `/users` | Teacher, Admin | List active accounts, or deleted accounts with `?include_deleted=true` |
| `POST` | `/users` | Teacher, Admin | Staff user creation (Teachers: student/teacher; Admins: any role) |
| `POST` | `/users/{id}/reset-pin` | Teacher, Admin | Issue 6-digit temp PIN, invalidate sessions, and require PIN rotation |
| `DELETE` | `/users/{id}` | Teacher, Admin | Soft-delete user, anonymize username, preserve telemetry, last-admin guard |
| `POST` | `/users/{id}/recover` | Teacher, Admin | Restore soft-deleted account under new username with temporary PIN |
| `GET` | `/audit-logs` | Admin Only | View up to 500 append-only audit trail records |

---

## Environment Setup

Ensure you are using Python 3.10 or newer.
Expand Down Expand Up @@ -74,12 +109,18 @@ python -m uvicorn src.main:app --reload
The interactive API documentation is available at <http://127.0.0.1:8000/docs>.

#### Running Tests & Linters:
Run pytest with code coverage
Run pytest with full code coverage:
```bash
python -m pytest
```
Run Ruff linter and formatter checks manually

Run a specific test subpackage:
```bash
python -m pytest tests/api/staff/ -o addopts="--strict-markers"
```

Run Ruff linter and formatter checks manually:
```bash
ruff check .
ruff format --check .
```
Expand All @@ -102,40 +143,90 @@ python -m uvicorn src.main:app --host 0.0.0.0 --port 8000

The database `tutorbox.db` will be initialized and migrated automatically on startup. Use the `DATABASE_PATH` environment variable to configure a custom SQLite file location.

---

## Project Structure

Generated environments, caches, and build artifacts are omitted from this overview.

```text
.
backend/
├── migrations/
│ ├── 001_initial_schema.sql
│ ├── 002_add_user_role.sql
│ └── 003_add_lookup_indexes.sql
│ ├── 003_add_lookup_indexes.sql
│ ├── 004_add_must_change_pin.sql
│ ├── 005_add_users_deleted_at.sql
│ └── 006_add_audit_logs.sql
├── src/
│ ├── __init__.py
│ ├── main.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ └── health.py
│ │ ├── auth/
│ │ │ ├── __init__.py
│ │ │ ├── login.py
│ │ │ └── logout.py
│ │ ├── health.py
│ │ ├── staff/
│ │ │ ├── __init__.py
│ │ │ ├── audit.py
│ │ │ ├── lifecycle.py
│ │ │ ├── reset_pin.py
│ │ │ └── users.py
│ │ └── users/
│ │ ├── __init__.py
│ │ ├── credentials.py
│ │ ├── profile.py
│ │ └── signup.py
│ ├── db/
│ │ ├── __init__.py
│ │ ├── audit.py
│ │ ├── database.py
│ │ └── migrations.py
│ └── security/
│ ├── __init__.py
│ ├── auth.py
│ └── rate_limit.py
│ ├── rate_limit/
│ │ ├── __init__.py
│ │ ├── lockout.py
│ │ └── sliding_window.py
│ ├── session.py
│ └── validation.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_auth.py
│ ├── test_database.py
│ ├── test_health.py
│ ├── test_migrations.py
│ ├── test_rate_limit.py
│ └── test_security_pin.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── auth/
│ │ │ ├── __init__.py
│ │ │ ├── test_login.py
│ │ │ └── test_logout.py
│ │ ├── staff/
│ │ │ ├── __init__.py
│ │ │ ├── test_audit.py
│ │ │ ├── test_delete.py
│ │ │ ├── test_recover.py
│ │ │ ├── test_reset_pin.py
│ │ │ └── test_users.py
│ │ ├── users/
│ │ │ ├── __init__.py
│ │ │ ├── test_change_pin.py
│ │ │ ├── test_change_username.py
│ │ │ ├── test_profile.py
│ │ │ └── test_signup.py
│ │ └── test_health.py
│ ├── db/
│ │ ├── __init__.py
│ │ ├── test_audit.py
│ │ ├── test_database.py
│ │ └── test_migrations.py
│ └── security/
│ ├── __init__.py
│ ├── test_auth_pin.py
│ ├── test_rate_limit.py
│ ├── test_security_pin.py
│ └── test_session_auth.py
├── pyproject.toml
└── README.md
```
3 changes: 3 additions & 0 deletions backend/migrations/004_add_must_change_pin.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 004_add_must_change_pin.sql
-- Flags accounts that must rotate their PIN on next login (staff-initiated resets).
ALTER TABLE users ADD COLUMN must_change_pin INTEGER NOT NULL DEFAULT 0;
4 changes: 4 additions & 0 deletions backend/migrations/005_add_users_deleted_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- 005_add_users_deleted_at.sql
-- Soft-delete marker + original-username retention for account recovery.
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE users ADD COLUMN former_username TEXT;
12 changes: 12 additions & 0 deletions backend/migrations/006_add_audit_logs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- 006_add_audit_logs.sql
-- Append-only trail for privileged/account actions.
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER,
action TEXT NOT NULL,
target_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs (actor_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs (target_user_id);
4 changes: 2 additions & 2 deletions backend/src/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""TutorBox API package."""

from . import auth, health
from . import auth, health, staff, users

__all__ = ["auth", "health"]
__all__ = ["auth", "health", "staff", "users"]
26 changes: 26 additions & 0 deletions backend/src/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Auth API package."""

from fastapi import APIRouter

from .login import (
LoginRequest,
LoginResponse,
)
from .login import (
router as login_router,
)
from .logout import (
router as logout_router,
)

router = APIRouter()
router.include_router(login_router)
router.include_router(logout_router)

__all__ = [
"LoginRequest",
"LoginResponse",
"login_router",
"logout_router",
"router",
]
40 changes: 18 additions & 22 deletions backend/src/api/auth.py → backend/src/api/auth/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,32 @@
import uuid

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from pydantic import BaseModel

from db.database import get_db
from security.auth import verify_pin
from security.rate_limit import check_rate_limit, login_rate_limiter
from security import (
PinField,
UsernameField,
check_rate_limit,
login_rate_limiter,
verify_pin,
)

logger = logging.getLogger(__name__)

router = APIRouter()

USERNAME_PATTERN = r"^[A-Za-z0-9_.-]{3,32}$"
PIN_PATTERN = r"^\d{4,8}$"


class LoginRequest(BaseModel):
username: str = Field(
...,
min_length=3,
max_length=32,
pattern=USERNAME_PATTERN,
examples=["student1"],
)
pin: str = Field(
...,
min_length=4,
max_length=8,
pattern=PIN_PATTERN,
examples=["1234"],
)
username: UsernameField
pin: PinField


class LoginResponse(BaseModel):
session_id: str
username: str
status: str = "authenticated"
must_change_pin: bool = False


@router.post("/login", response_model=LoginResponse)
Expand All @@ -51,7 +42,8 @@ def login(request: LoginRequest):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id, username, hashed_pin FROM users WHERE username = ?",
"SELECT id, username, hashed_pin, must_change_pin "
"FROM users WHERE username = ? AND deleted_at IS NULL",
(request.username,),
)
user = cursor.fetchone()
Expand Down Expand Up @@ -89,4 +81,8 @@ def login(request: LoginRequest):
conn.commit()

logger.info("Login successful for user '%s'.", username)
return LoginResponse(session_id=session_id, username=username)
return LoginResponse(
session_id=session_id,
username=username,
must_change_pin=bool(user["must_change_pin"]),
)
28 changes: 28 additions & 0 deletions backend/src/api/auth/logout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import logging
from typing import Annotated

from fastapi import APIRouter, Depends

from db.database import get_db
from security import AuthContext, get_current_session

logger = logging.getLogger(__name__)

router = APIRouter()


@router.post("/logout")
def logout(ctx: Annotated[AuthContext, Depends(get_current_session)]):
"""
Deactivates the caller's current session.
"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute(
"UPDATE sessions SET is_active = 0 WHERE id = ? AND is_active = 1",
(ctx.session_id,),
)
conn.commit()

logger.info("User '%s' logged out.", ctx.username)
return {"detail": "Logged out."}
54 changes: 54 additions & 0 deletions backend/src/api/staff/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Staff API package."""

from fastapi import APIRouter

from .audit import (
AuditLogsResponse,
)
from .audit import (
router as audit_router,
)
from .lifecycle import (
DeleteUserResponse,
RecoverUserRequest,
RecoverUserResponse,
)
from .lifecycle import (
router as lifecycle_router,
)
from .reset_pin import (
ResetPinResponse,
)
from .reset_pin import (
router as reset_pin_router,
)
from .users import (
CreateUserRequest,
CreateUserResponse,
UserListResponse,
)
from .users import (
router as users_router,
)

router = APIRouter()
router.include_router(users_router)
router.include_router(reset_pin_router)
router.include_router(lifecycle_router)
router.include_router(audit_router)

__all__ = [
"AuditLogsResponse",
"CreateUserRequest",
"CreateUserResponse",
"DeleteUserResponse",
"RecoverUserRequest",
"RecoverUserResponse",
"ResetPinResponse",
"UserListResponse",
"audit_router",
"lifecycle_router",
"reset_pin_router",
"router",
"users_router",
]
Loading