623 lines
24 KiB
Python
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
|