import asyncio import base64 import hashlib from collections.abc import AsyncIterator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Annotated, Any, cast from fastapi import Depends, FastAPI, Header, Request, Response from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import desc, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from backup_tool.cli import build_alembic_config from backup_tool.config import Settings from backup_tool.db.engine import SchemaNotCurrentError, assert_schema_current, create_engine from backup_tool.db.models import ApiToken, AuditEvent, IdempotencyRecord, Repository, Secret, User from backup_tool.repository import RepositoryError, initialize, inspect_repository from backup_tool.security.auth import ( hash_password, hash_token, new_csrf_token, new_token, sign_session, verify_password, verify_session, ) from backup_tool.security.redaction import redact from backup_tool.security.secrets import EnvelopeCipher class Problem(Exception): def __init__(self, status: int, code: str, detail: str): self.status = status self.code = code self.detail = detail def problem_response(request: Request, status: int, code: str, detail: str) -> JSONResponse: return JSONResponse( status_code=status, media_type="application/problem+json", content={ "type": f"https://backup-tool.invalid/problems/{code}", "title": code.replace("_", " ").title(), "status": status, "detail": detail, "instance": str(request.url.path), "code": code, }, ) class SetupInput(BaseModel): username: str = Field(min_length=1, max_length=255) password: str = Field(min_length=12, max_length=1024) bootstrap_secret: str | None = Field(default=None, max_length=1024) class LoginInput(BaseModel): username: str = Field(min_length=1, max_length=255) password: str = Field(min_length=1, max_length=1024) class TokenInput(BaseModel): scopes: list[str] = Field(min_length=1) expires_at: datetime | None = None class SecretInput(BaseModel): purpose: str = Field(min_length=1, max_length=64) value: str = Field(min_length=1, max_length=65536) class UserPatch(BaseModel): state: str class RepositoryInput(BaseModel): name: str = Field(min_length=1, max_length=255) relative_path: str = Field(min_length=1, max_length=1024) compression: str = "none" encryption: str = "none" class RepositoryPatch(BaseModel): compression: str | None = None encryption: str | None = None def _etag(user: User) -> str: return f'"{user.id}:{user.updated_at.isoformat()}"' def _cursor(item_id: str) -> str: return base64.urlsafe_b64encode(item_id.encode()).decode().rstrip("=") def _decode_cursor(value: str) -> str: try: return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)).decode() except (ValueError, UnicodeDecodeError) as error: raise Problem(400, "invalid_cursor", "Cursor is invalid.") from error def _digest_request(payload: TokenInput) -> str: value = payload.model_dump(mode="json") encoded = repr(sorted(value.items())).encode() return hashlib.sha256(encoded).hexdigest() class SessionDependency: def __init__(self, factory: async_sessionmaker[AsyncSession]): self._factory = factory async def __call__(self) -> AsyncIterator[AsyncSession]: async with self._factory() as db: yield db def create_app(settings: Settings) -> FastAPI: app = FastAPI(title="Backup Tool API", version="2.0.0", docs_url=None, redoc_url=None) app.state.settings = settings app.state.engine = create_engine(settings) app.state.sessions = async_sessionmaker(app.state.engine, expire_on_commit=False) app.state.cipher = EnvelopeCipher.from_file(settings.master_key_file) app.state.setup_lock = asyncio.Lock() app.state.revoked_sessions = set() @app.middleware("http") async def request_id_middleware(request: Request, call_next: Any) -> Response: from backup_tool.ids import new_uuid7 request.state.request_id = str(new_uuid7()) response = cast(Response, await call_next(request)) response.headers["X-Request-ID"] = request.state.request_id return response @app.exception_handler(Problem) async def handle_problem(request: Request, error: Problem) -> JSONResponse: return problem_response(request, error.status, error.code, error.detail) @app.exception_handler(RequestValidationError) async def handle_validation(request: Request, _error: RequestValidationError) -> JSONResponse: return problem_response(request, 422, "validation_failed", "Request validation failed.") session = SessionDependency(app.state.sessions) async def actor( request: Request, db: Annotated[AsyncSession, Depends(session)], authorization: Annotated[str | None, Header()] = None, ) -> tuple[User, set[str], bool]: if authorization and authorization.startswith("Bearer "): supplied = authorization.removeprefix("Bearer ") token = await db.scalar( select(ApiToken).where(ApiToken.token_hash == hash_token(supplied)) ) if token is None or token.revoked_at is not None: raise Problem(401, "authentication_required", "Authentication is required.") if token.expires_at is not None and token.expires_at <= datetime.now(UTC): raise Problem(401, "authentication_required", "Authentication is required.") user = await db.get(User, token.owner_id) if user is None or user.state != "active": raise Problem(401, "authentication_required", "Authentication is required.") return user, set(token.scopes), False encoded = request.cookies.get("backup_tool_session") data = verify_session(encoded, settings.master_key_file) if encoded else None if data is None or data["sid"] in app.state.revoked_sessions: raise Problem(401, "authentication_required", "Authentication is required.") user = await db.get(User, data["sub"]) if user is None or user.state != "active": raise Problem(401, "authentication_required", "Authentication is required.") return user, {"*"}, True async def require( request: Request, db: Annotated[AsyncSession, Depends(session)], authorization: Annotated[str | None, Header()] = None, csrf: Annotated[str | None, Header(alias="X-CSRF-Token")] = None, ) -> tuple[User, set[str], bool]: user, scopes, cookie_auth = await actor(request, db, authorization) if cookie_auth: data = verify_session( request.cookies.get("backup_tool_session", ""), settings.master_key_file ) if data is None or csrf is None or csrf != data.get("csrf"): raise Problem(403, "csrf_failed", "CSRF validation failed.") return user, scopes, cookie_auth async def audit( db: AsyncSession, request: Request, action: str, resource_type: str, resource_id: str | None, outcome: str, actor_id: str | None, details: dict[str, Any] | None = None, ) -> None: db.add( AuditEvent( actor_id=actor_id, action=action, resource_type=resource_type, resource_id=resource_id, outcome=outcome, request_id=request.state.request_id, details=redact(details or {}), ) ) def set_session(response: Response, user_id: str) -> None: csrf = new_csrf_token() ttl = settings.session_ttl_seconds response.set_cookie( "backup_tool_session", sign_session( user_id, csrf, settings.master_key_file, expires_at=datetime.now(UTC) + timedelta(seconds=ttl), ), httponly=True, secure=True, samesite="strict", path="/", max_age=ttl, ) response.set_cookie( "backup_tool_csrf", csrf, httponly=False, secure=True, samesite="strict", path="/", max_age=ttl, ) @app.get("/livez") async def livez() -> dict[str, str]: return {"status": "alive"} @app.get("/readyz") async def readyz(db: Annotated[AsyncSession, Depends(session)]) -> dict[str, str]: try: await assert_schema_current(app.state.engine, build_alembic_config(settings)) except SchemaNotCurrentError as error: raise Problem(503, "schema_not_current", "Metadata schema is not current.") from error if await db.scalar(select(User.id).limit(1)) is None: raise Problem(503, "setup_required", "Initial administrator setup is required.") return {"status": "ready"} @app.post("/api/v2/setup", status_code=201) async def setup( input_: SetupInput, request: Request, response: Response, db: Annotated[AsyncSession, Depends(session)], ) -> dict[str, str]: async with app.state.setup_lock: if ( settings.bootstrap_secret is not None and input_.bootstrap_secret != settings.bootstrap_secret ): raise Problem(403, "bootstrap_required", "Bootstrap credentials are required.") if await db.scalar(select(User.id).limit(1)) is not None: raise Problem(409, "setup_complete", "Initial administrator already exists.") user = User(username=input_.username, password_hash=hash_password(input_.password)) db.add(user) try: await db.flush() except IntegrityError as error: await db.rollback() raise Problem( 409, "setup_complete", "Initial administrator already exists." ) from error await audit(db, request, "setup", "user", user.id, "success", user.id) await db.commit() set_session(response, user.id) return {"id": user.id, "username": user.username} @app.post("/api/v2/auth/login") async def login( input_: LoginInput, request: Request, response: Response, db: Annotated[AsyncSession, Depends(session)], ) -> dict[str, str]: user = await db.scalar(select(User).where(User.username == input_.username)) if ( user is None or user.state != "active" or not verify_password(user.password_hash, input_.password) ): await audit(db, request, "login", "user", None, "denied", None) await db.commit() raise Problem(401, "authentication_failed", "Invalid credentials.") await audit(db, request, "login", "user", user.id, "success", user.id) await db.commit() set_session(response, user.id) return {"id": user.id, "username": user.username} @app.post("/api/v2/auth/logout", status_code=204) async def logout( request: Request, response: Response, _: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> None: encoded = request.cookies.get("backup_tool_session") data = verify_session(encoded, settings.master_key_file) if encoded else None if data is not None: app.state.revoked_sessions.add(data["sid"]) response.delete_cookie("backup_tool_session", path="/") response.delete_cookie("backup_tool_csrf", path="/") @app.get("/api/v2/auth/session") async def get_session( identity: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> dict[str, str]: user, _, _ = identity return {"id": user.id, "username": user.username, "state": user.state} @app.post("/api/v2/auth/tokens") async def create_token( input_: TokenInput, request: Request, db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(require)], idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, ) -> JSONResponse: user, scopes, _ = identity if "*" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") if not idempotency_key: raise Problem(400, "idempotency_key_required", "Idempotency-Key is required.") digest = _digest_request(input_) existing = await db.scalar( select(IdempotencyRecord).where( IdempotencyRecord.actor_id == user.id, IdempotencyRecord.key == idempotency_key, IdempotencyRecord.operation == "create_api_token", ) ) if existing is not None: if existing.request_digest != digest: raise Problem( 409, "idempotency_mismatch", "Idempotency-Key was used for another request." ) return JSONResponse( {"id": existing.response_resource_id, "token": None}, status_code=200 ) raw = new_token() token = ApiToken( owner_id=user.id, token_hash=hash_token(raw), scopes=input_.scopes, expires_at=input_.expires_at, ) db.add(token) await db.flush() db.add( IdempotencyRecord( actor_id=user.id, key=idempotency_key, operation="create_api_token", request_digest=digest, response_resource_type="api_token", response_resource_id=token.id, ) ) await audit(db, request, "create", "api_token", token.id, "success", user.id) await db.commit() return JSONResponse({"id": token.id, "token": raw}, status_code=201) @app.delete("/api/v2/auth/tokens/{token_id}", status_code=204) async def revoke_token( token_id: str, request: Request, db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> None: user, scopes, _ = identity if "*" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") token = await db.get(ApiToken, token_id) if token is None or token.owner_id != user.id: raise Problem(404, "resource_not_found", "API token was not found.") token.revoked_at = datetime.now(UTC) await audit(db, request, "revoke", "api_token", token.id, "success", user.id) await db.commit() @app.post("/api/v2/admin/secrets", status_code=201) async def create_secret( input_: SecretInput, request: Request, db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> dict[str, Any]: user, scopes, _ = identity if "*" not in scopes and "admin:write" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") ciphertext, key_id = app.state.cipher.encrypt( input_.value, purpose=input_.purpose, version=1 ) secret = Secret(ciphertext=ciphertext, key_id=key_id, purpose=input_.purpose) db.add(secret) await db.flush() await audit( db, request, "create", "secret", secret.id, "success", user.id, {"purpose": input_.purpose}, ) await db.commit() return { "id": secret.id, "purpose": secret.purpose, "key_id": secret.key_id, "version": secret.version, } @app.get("/api/v2/admin/secrets") async def list_secrets( db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> list[dict[str, Any]]: _, scopes, _ = identity if "*" not in scopes and "admin:read" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") result = await db.scalars(select(Secret).order_by(desc(Secret.created_at))) return [ {"id": item.id, "purpose": item.purpose, "key_id": item.key_id, "version": item.version} for item in result ] @app.get("/api/v2/admin/users/{user_id}") async def get_user( user_id: str, response: Response, db: Annotated[AsyncSession, Depends(session)], _: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> dict[str, str]: user = await db.get(User, user_id) if user is None: raise Problem(404, "resource_not_found", "User was not found.") response.headers["ETag"] = _etag(user) return {"id": user.id, "username": user.username, "state": user.state} @app.patch("/api/v2/admin/users/{user_id}") async def patch_user( user_id: str, input_: UserPatch, request: Request, response: Response, db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(require)], if_match: Annotated[str | None, Header(alias="If-Match")] = None, ) -> dict[str, str]: actor_user, scopes, _ = identity if "*" not in scopes and "admin:write" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") user = await db.get(User, user_id) if user is None: raise Problem(404, "resource_not_found", "User was not found.") if if_match != _etag(user): raise Problem(412, "etag_mismatch", "Resource was modified by another request.") if input_.state not in {"active", "disabled"}: raise Problem(422, "validation_failed", "State is invalid.") user.state = input_.state user.updated_at = datetime.now(UTC) await audit(db, request, "update", "user", user.id, "success", actor_user.id) await db.commit() await db.refresh(user) response.headers["ETag"] = _etag(user) return {"id": user.id, "username": user.username, "state": user.state} @app.post("/api/v2/repositories", status_code=201) async def create_repository( input_: RepositoryInput, request: Request, db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> dict[str, Any]: user, scopes, _ = identity if "*" not in scopes and "admin:write" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") try: initialized = initialize( settings, input_.relative_path, input_.compression, input_.encryption ) except RepositoryError as error: raise Problem(409, "repository_invalid", str(error)) from error repository = Repository( name=input_.name, root=str(initialized.root), format_version=initialized.format_version, compression=initialized.compression, encryption=initialized.encryption, ) db.add(repository) await db.flush() await audit(db, request, "create", "repository", repository.id, "success", user.id) await db.commit() return { "id": repository.id, "name": repository.name, "format_version": repository.format_version, "compression": repository.compression, "encryption": repository.encryption, } @app.get("/api/v2/repositories") async def list_repositories( db: Annotated[AsyncSession, Depends(session)], _: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> dict[str, Any]: items = list((await db.scalars(select(Repository).order_by(Repository.name))).all()) return { "items": [ { "id": item.id, "name": item.name, "format_version": item.format_version, "compression": item.compression, "encryption": item.encryption, "state": item.state, } for item in items ] } @app.get("/api/v2/repositories/{repository_id}") async def get_repository( repository_id: str, db: Annotated[AsyncSession, Depends(session)], _: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> dict[str, Any]: repository = await db.get(Repository, repository_id) if repository is None: raise Problem(404, "resource_not_found", "Repository was not found.") return { "id": repository.id, "name": repository.name, "format_version": repository.format_version, "compression": repository.compression, "encryption": repository.encryption, "state": repository.state, } @app.get("/api/v2/repositories/{repository_id}/inspection") async def inspect_repository_endpoint( repository_id: str, db: Annotated[AsyncSession, Depends(session)], _: Annotated[tuple[User, set[str], bool], Depends(actor)], ) -> dict[str, Any]: repository = await db.get(Repository, repository_id) if repository is None: raise Problem(404, "resource_not_found", "Repository was not found.") try: inspected = inspect_repository(Path(repository.root)) except RepositoryError as error: raise Problem(409, "repository_invalid", str(error)) from error return { "id": repository.id, "format_version": inspected.format_version, "compression": inspected.compression, "encryption": inspected.encryption, } @app.patch("/api/v2/repositories/{repository_id}") async def patch_repository( repository_id: str, input_: RepositoryPatch, _: Annotated[tuple[User, set[str], bool], Depends(require)], ) -> None: if input_.compression is not None or input_.encryption is not None: raise Problem(409, "repository_policy_immutable", "Repository policy is immutable.") raise Problem(422, "validation_failed", "No mutable fields supplied.") @app.get("/api/v2/audit") async def list_audit( db: Annotated[AsyncSession, Depends(session)], identity: Annotated[tuple[User, set[str], bool], Depends(actor)], limit: int = 50, cursor: str | None = None, ) -> dict[str, Any]: _, scopes, _ = identity if "*" not in scopes and "audit:read" not in scopes: raise Problem(403, "insufficient_scope", "Required scope is missing.") if not 1 <= limit <= 100: raise Problem(422, "validation_failed", "Limit must be between 1 and 100.") statement = select(AuditEvent).order_by(desc(AuditEvent.id)).limit(limit + 1) if cursor: statement = statement.where(AuditEvent.id < _decode_cursor(cursor)) items = list((await db.scalars(statement)).all()) page, remainder = items[:limit], items[limit:] return { "items": [ { "id": item.id, "action": item.action, "resource_type": item.resource_type, "resource_id": item.resource_id, "outcome": item.outcome, "request_id": item.request_id, "created_at": item.created_at.isoformat(), "details": redact(item.details), } for item in page ], "next_cursor": _cursor(page[-1].id) if page and remainder else None, } return app