diff --git a/backend/src/backup_tool/api/app.py b/backend/src/backup_tool/api/app.py index f871e8a..738c437 100644 --- a/backend/src/backup_tool/api/app.py +++ b/backend/src/backup_tool/api/app.py @@ -16,7 +16,8 @@ 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, Secret, User +from backup_tool.db.models import ApiToken, AuditEvent, IdempotencyRecord, Repository, Secret, User +from backup_tool.repository import RepositoryError, initialize from backup_tool.security.auth import ( hash_password, hash_token, @@ -76,6 +77,18 @@ 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()}"' @@ -442,6 +455,69 @@ def create_app(settings: Settings) -> FastAPI: 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/{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.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)], diff --git a/backend/src/backup_tool/repository.py b/backend/src/backup_tool/repository.py new file mode 100644 index 0000000..1f722fd --- /dev/null +++ b/backend/src/backup_tool/repository.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +from dataclasses import dataclass +from pathlib import Path + +from backup_tool.config import Settings + + +class RepositoryError(ValueError): + pass + + +@dataclass(frozen=True) +class InitializedRepository: + root: Path + format_version: int = 1 + compression: str = "none" + encryption: str = "none" + + +def blob_digest(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _contained(root: Path, relative_path: str) -> Path: + candidate = (root / relative_path).resolve() + if not relative_path or Path(relative_path).is_absolute() or candidate == root: + raise RepositoryError("repository path must be a non-empty relative child") + if root not in candidate.parents: + raise RepositoryError("repository path escapes configured roots") + return candidate + + +def initialize( + settings: Settings, relative_path: str, compression: str, encryption: str +) -> InitializedRepository: + if compression not in {"none"} or encryption not in {"none"}: + raise RepositoryError("requested repository policy is unavailable") + root = _contained(settings.repository_roots[0], relative_path) + if root.exists(): + raise RepositoryError("repository path already exists") + staging = root.with_name(f".{root.name}.staging-{os.urandom(8).hex()}") + try: + staging.mkdir(parents=True) + (staging / "blobs" / "sha256").mkdir(parents=True) + (staging / "manifests").mkdir() + payload = {"format_version": 1, "compression": compression, "encryption": encryption} + file = staging / "repository.json" + file.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + with file.open("rb") as handle: + os.fsync(handle.fileno()) + os.replace(staging, root) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + return InitializedRepository(root=root, compression=compression, encryption=encryption)