feat(v2): initialize allowlisted repositories

This commit is contained in:
2026-07-27 20:19:51 +02:00
parent e529ad60fa
commit df62536773
2 changed files with 137 additions and 1 deletions
+77 -1
View File
@@ -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)],
+60
View File
@@ -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)