feat(v2): initialize allowlisted repositories
This commit is contained in:
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user