Files
backup-tool/backend/src/backup_tool/api/app.py
T

623 lines
24 KiB
Python

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