feat: integrate MinIO-backed user filesystem

This commit is contained in:
Codex
2026-06-03 12:06:34 +08:00
parent a27560102b
commit ffa1249403
56 changed files with 4810 additions and 116 deletions

View File

@ -7,7 +7,15 @@ from pathlib import Path
from threading import Lock
from typing import Any
from app.models import ChannelSettings, BackendCredential, BackendRecord, OutlookSettings, UserRecord, utcnow_iso
from app.models import (
ChannelSettings,
BackendCredential,
BackendRecord,
MinIOSettings,
OutlookSettings,
UserRecord,
utcnow_iso,
)
class JsonStore:
@ -152,6 +160,19 @@ class JsonStore:
return None
return OutlookSettings.model_validate(outlook)
def get_minio_settings(self, backend_id: str) -> MinIOSettings | None:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return None
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
return None
minio = backend_settings.get("minio")
if not isinstance(minio, dict):
return None
return MinIOSettings.model_validate(minio)
def list_channel_settings(self, backend_id: str) -> dict[str, ChannelSettings]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
@ -185,6 +206,19 @@ class JsonStore:
self._write_json(self.settings_path, {"settings": root})
return backend_settings
def save_minio_settings(self, backend_id: str, settings: MinIOSettings) -> dict[str, Any]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
root = {}
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
backend_settings = {}
backend_settings["minio"] = settings.model_dump(mode="json")
root[backend_id] = backend_settings
self._write_json(self.settings_path, {"settings": root})
return backend_settings
def save_channel_settings(self, backend_id: str, channel_id: str, settings: ChannelSettings) -> dict[str, Any]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
@ -218,6 +252,22 @@ class JsonStore:
self._write_json(self.settings_path, {"settings": root})
return True
def delete_minio_settings(self, backend_id: str) -> bool:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return False
backend_settings = root.get(backend_id)
if not isinstance(backend_settings, dict) or "minio" not in backend_settings:
return False
backend_settings.pop("minio", None)
if backend_settings:
root[backend_id] = backend_settings
else:
root.pop(backend_id, None)
self._write_json(self.settings_path, {"settings": root})
return True
def delete_channel_settings(self, backend_id: str, channel_id: str) -> bool:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})

View File

@ -1,12 +1,13 @@
from __future__ import annotations
import asyncio
import os
import re
from pathlib import Path
from typing import Any
import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from app.json_store import JsonStore
@ -16,6 +17,7 @@ from app.models import (
BackendRecord,
IntrospectRequest,
IntrospectResponse,
MinIOSettings,
OAuthTokenRequest,
OAuthTokenResponse,
OutlookSettings,
@ -30,6 +32,12 @@ from app.models import (
UserRecord,
utcnow_iso,
)
from app.minio_provisioning import (
MinIODeprovisioningError,
MinIOProvisioningError,
deprovision_user_file_minio_resources,
provision_user_file_minio_settings,
)
from app.security import JwtSigner, generate_client_secret, hash_secret, verify_secret
DATA_DIR = Path(os.getenv("AUTHZ_DATA_DIR", Path(__file__).resolve().parents[1] / "data"))
@ -360,6 +368,42 @@ def _upsert_user(
return user
async def _ensure_user_file_storage_settings(backend_id: str) -> MinIOSettings | None:
try:
settings = await asyncio.to_thread(
provision_user_file_minio_settings,
backend_id=backend_id,
existing=store.get_minio_settings(backend_id),
)
except MinIOProvisioningError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
if settings is not None:
store.save_minio_settings(backend_id, settings)
store.touch_backend(backend_id)
return settings
async def _deprovision_user_file_storage(backend_id: str, *, best_effort: bool = False) -> dict[str, Any]:
existing = store.get_minio_settings(backend_id)
try:
result = await asyncio.to_thread(
deprovision_user_file_minio_resources,
backend_id=backend_id,
existing=existing,
best_effort=best_effort,
)
except MinIODeprovisioningError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
settings_removed = False
if result.get("ok") is True:
settings_removed = store.delete_minio_settings(backend_id)
if settings_removed:
store.touch_backend(backend_id)
result["settings"] = {"status": "removed" if settings_removed else "absent"}
return result
def _resolve_register_backend_payload(req: RegisterBackendRequest) -> tuple[str, str, str, str | None]:
backend_name = req.name.strip() or "backend"
backend_id = _clean_optional(req.backend_id)
@ -475,6 +519,7 @@ async def register_backend(req: RegisterBackendRequest) -> RegisterBackendRespon
base_url=base_url,
frontend_base_url=frontend_base_url,
)
await _ensure_user_file_storage_settings(backend.backend_id)
return RegisterBackendResponse(
backend_id=backend.backend_id,
client_id=backend.backend_id,
@ -589,6 +634,39 @@ async def delete_outlook_settings(backend_id: str) -> dict[str, Any]:
return {"ok": removed}
@app.get("/backends/{backend_id}/settings/minio")
async def get_minio_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_minio_settings(backend_id)
if settings is None:
return {"configured": False}
return settings.masked_dict()
@app.post("/backends/{backend_id}/settings/minio")
async def save_minio_settings(backend_id: str, payload: MinIOSettings) -> dict[str, Any]:
_ensure_backend(backend_id)
existing = store.get_minio_settings(backend_id)
if not payload.secret_key:
if existing is None or not existing.secret_key:
raise HTTPException(status_code=400, detail="Secret key is required for initial MinIO setup")
payload = payload.model_copy(update={"secret_key": existing.secret_key, "updated_at": utcnow_iso()})
else:
payload = payload.model_copy(update={"updated_at": utcnow_iso()})
store.save_minio_settings(backend_id, payload)
store.touch_backend(backend_id)
return payload.masked_dict()
@app.delete("/backends/{backend_id}/settings/minio")
async def delete_minio_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
removed = store.delete_minio_settings(backend_id)
if removed:
store.touch_backend(backend_id)
return {"ok": removed}
@app.get("/backends/{backend_id}/settings/channels")
async def list_channel_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
@ -662,6 +740,7 @@ async def oauth_register(req: RegisterUserRequest) -> RegisterUserResponse:
email=_clean_optional(req.email),
default_backend_id=backend.backend_id,
)
await _ensure_user_file_storage_settings(backend.backend_id)
return RegisterUserResponse(
user=user,
backend=RegisterUserBackendResult(
@ -686,6 +765,23 @@ async def get_internal_outlook_settings(backend_id: str) -> dict[str, Any]:
return settings.model_dump(mode="json")
@app.get("/internal/backends/{backend_id}/settings/minio", dependencies=[Depends(_require_internal)])
async def get_internal_minio_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_minio_settings(backend_id)
if settings is None:
raise HTTPException(status_code=404, detail="MinIO settings not configured")
return settings.model_dump(mode="json")
@app.delete("/internal/backends/{backend_id}/user-files", dependencies=[Depends(_require_internal)])
async def delete_internal_user_files(
backend_id: str,
best_effort: bool = Query(default=False),
) -> dict[str, Any]:
return await _deprovision_user_file_storage(backend_id, best_effort=best_effort)
@app.get("/internal/backends/{backend_id}/settings/channels", dependencies=[Depends(_require_internal)])
async def list_internal_channel_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)

View File

@ -0,0 +1,317 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import os
import re
import secrets
from typing import Any
from .models import MinIOSettings
@dataclass(slots=True)
class MinIOProvisioningConfig:
enabled: bool
endpoint: str
public_endpoint: str
admin_access_key: str
admin_secret_key: str
bucket: str
secure: bool
region: str | None
class MinIOProvisioningError(RuntimeError):
pass
class MinIODeprovisioningError(RuntimeError):
pass
def minio_provisioning_config_from_env() -> MinIOProvisioningConfig:
endpoint = _env("USER_FILES_MINIO_ENDPOINT") or _env("MINIO_ENDPOINT")
public_endpoint = _env("USER_FILES_MINIO_PUBLIC_ENDPOINT") or endpoint
return MinIOProvisioningConfig(
enabled=_truthy(_env("USER_FILES_MINIO_PROVISIONING_ENABLED")),
endpoint=endpoint,
public_endpoint=public_endpoint,
admin_access_key=_env("USER_FILES_MINIO_ADMIN_ACCESS_KEY") or _env("MINIO_ROOT_USER"),
admin_secret_key=_env("USER_FILES_MINIO_ADMIN_SECRET_KEY") or _env("MINIO_ROOT_PASSWORD"),
bucket=_env("USER_FILES_MINIO_BUCKET") or "beaver-user-files",
secure=_truthy(_env("USER_FILES_MINIO_SECURE")),
region=_env("USER_FILES_MINIO_REGION") or None,
)
def provision_user_file_minio_settings(
*,
backend_id: str,
existing: MinIOSettings | None,
config: MinIOProvisioningConfig | None = None,
) -> MinIOSettings | None:
cfg = config or minio_provisioning_config_from_env()
if not cfg.enabled:
return existing
if existing is not None and existing.configured and existing.secret_key:
return existing
if not cfg.endpoint or not cfg.admin_access_key or not cfg.admin_secret_key:
raise MinIOProvisioningError("MinIO provisioning requires endpoint and admin credentials")
namespace = default_namespace(backend_id)
access_key = _access_key_for_backend(backend_id)
secret_key = secrets.token_urlsafe(32)
policy_name = _policy_name_for_backend(backend_id)
try:
from minio import Minio
from minio import MinioAdmin
from minio.credentials import StaticProvider
client = Minio(
endpoint=cfg.endpoint,
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
secure=cfg.secure,
region=cfg.region,
)
if not client.bucket_exists(cfg.bucket):
client.make_bucket(cfg.bucket, location=cfg.region)
admin = MinioAdmin(
endpoint=cfg.endpoint,
credentials=StaticProvider(
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
),
secure=cfg.secure,
)
try:
admin.user_add(access_key, secret_key)
except Exception:
# Treat an existing user as idempotent; keep the newly generated
# secret only when user creation succeeded.
if existing is not None and existing.secret_key:
secret_key = existing.secret_key
else:
raise
policy = _namespace_policy(bucket=cfg.bucket, namespace=namespace)
admin.policy_add(policy_name, policy=policy)
admin.attach_policy(policies=[policy_name], user=access_key)
except Exception as exc:
raise MinIOProvisioningError(f"MinIO user file provisioning failed: {exc}") from exc
return MinIOSettings(
endpoint=cfg.public_endpoint,
access_key=access_key,
secret_key=secret_key,
bucket=cfg.bucket,
namespace=namespace,
secure=cfg.secure,
region=cfg.region,
)
def deprovision_user_file_minio_resources(
*,
backend_id: str,
existing: MinIOSettings | None,
best_effort: bool = False,
config: MinIOProvisioningConfig | None = None,
) -> dict[str, Any]:
cfg = config or minio_provisioning_config_from_env()
settings_found = existing is not None
result: dict[str, Any] = {
"ok": True,
"backend_id": backend_id,
"settings_found": settings_found,
"best_effort": best_effort,
"bucket": "skipped",
"objects": {"status": "absent", "deleted": 0},
"user": {"status": "absent"},
"policy": {"status": "absent"},
}
if existing is None and not best_effort:
return result
bucket = (existing.bucket if existing else None) or cfg.bucket
namespace = (existing.namespace if existing else None) or default_namespace(backend_id)
access_key = (existing.access_key if existing else None) or _access_key_for_backend(backend_id)
policy_name = _policy_name_for_backend(backend_id)
if not bucket or not namespace.strip("/"):
raise MinIODeprovisioningError("MinIO deprovisioning requires bucket and namespace")
if not cfg.endpoint or not cfg.admin_access_key or not cfg.admin_secret_key:
raise MinIODeprovisioningError("MinIO deprovisioning requires endpoint and admin credentials")
try:
from minio import Minio
from minio import MinioAdmin
from minio.credentials import StaticProvider
from minio.deleteobjects import DeleteObject
client = Minio(
endpoint=cfg.endpoint,
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
secure=cfg.secure,
region=cfg.region,
)
admin = MinioAdmin(
endpoint=cfg.endpoint,
credentials=StaticProvider(
access_key=cfg.admin_access_key,
secret_key=cfg.admin_secret_key,
),
secure=cfg.secure,
)
prefix = namespace.strip("/")
if client.bucket_exists(bucket):
result["bucket"] = "present"
objects = [
DeleteObject(item.object_name)
for item in client.list_objects(bucket, prefix=f"{prefix}/", recursive=True)
if getattr(item, "object_name", None)
]
errors = list(client.remove_objects(bucket, objects)) if objects else []
if errors:
result["objects"] = {
"status": "failed",
"deleted": 0,
"errors": [_safe_error_text(error) for error in errors],
}
result["ok"] = False
else:
result["objects"] = {
"status": "removed" if objects else "absent",
"deleted": len(objects),
}
else:
result["bucket"] = "absent"
result["objects"] = {"status": "absent", "deleted": 0}
result["policy_detach"] = _admin_step(
lambda: _call_admin_method(
admin,
["detach_policy"],
policies=[policy_name],
user=access_key,
)
)
result["user"] = _admin_step(lambda: _call_admin_method(admin, ["user_remove", "remove_user"], access_key))
result["policy"] = _admin_step(
lambda: _call_admin_method(admin, ["policy_remove", "remove_policy"], policy_name)
)
except Exception as exc:
raise MinIODeprovisioningError(f"MinIO user file deprovisioning failed: {_safe_error_text(exc)}") from exc
result["ok"] = bool(result["ok"]) and all(
item.get("status") != "failed"
for item in (
result["objects"],
result["policy_detach"],
result["user"],
result["policy"],
)
if isinstance(item, dict)
)
return result
def default_namespace(backend_id: str) -> str:
return f"users/{backend_id.strip().strip('/')}"
def _namespace_policy(*, bucket: str, namespace: str) -> dict[str, object]:
prefix = namespace.strip("/")
return {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetBucketLocation"],
"Resource": [f"arn:aws:s3:::{bucket}"],
},
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": [f"arn:aws:s3:::{bucket}"],
"Condition": {"StringLike": {"s3:prefix": [f"{prefix}/*", prefix]}},
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
],
"Resource": [f"arn:aws:s3:::{bucket}/{prefix}/*"],
},
],
}
def policy_json_for_backend(*, backend_id: str, bucket: str = "beaver-user-files") -> str:
return json.dumps(_namespace_policy(bucket=bucket, namespace=default_namespace(backend_id)), indent=2)
def _access_key_for_backend(backend_id: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", backend_id.strip()).strip("-_")
if not slug:
slug = secrets.token_hex(6)
return f"beaver-{slug}"[:64]
def _policy_name_for_backend(backend_id: str) -> str:
slug = re.sub(r"[^A-Za-z0-9_-]+", "-", backend_id.strip()).strip("-_")
return f"beaver-user-files-{slug or secrets.token_hex(6)}"[:128]
def _call_admin_method(admin: object, names: list[str], *args: Any, **kwargs: Any) -> Any:
for name in names:
method = getattr(admin, name, None)
if callable(method):
return method(*args, **kwargs)
raise AttributeError(f"MinIO admin client does not support any of: {', '.join(names)}")
def _admin_step(action: Any) -> dict[str, Any]:
try:
action()
except Exception as exc:
if _is_absent_error(exc):
return {"status": "absent"}
return {"status": "failed", "error": _safe_error_text(exc)}
return {"status": "removed"}
def _is_absent_error(exc: Exception) -> bool:
text = _safe_error_text(exc).lower()
absent_markers = (
"not found",
"notfound",
"no such",
"does not exist",
"doesn't exist",
"specified user does not exist",
"specified policy does not exist",
"the specified bucket does not exist",
)
return any(marker in text for marker in absent_markers)
def _safe_error_text(exc: object) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__
def _env(name: str) -> str:
return os.getenv(name, "").strip()
def _truthy(value: str) -> bool:
return value.lower() in {"1", "true", "yes", "on"}

View File

@ -77,6 +77,24 @@ class OutlookSettings(BaseModel):
return data
class MinIOSettings(BaseModel):
configured: bool = True
endpoint: str
access_key: str
secret_key: str
bucket: str | None = None
namespace: str | None = None
secure: bool = False
region: str | None = None
updated_at: str = Field(default_factory=utcnow_iso)
def masked_dict(self) -> dict[str, Any]:
data = self.model_dump()
data.pop("secret_key", None)
data["secret_key_masked"] = True
return data
class BackendRoutingPayload(BaseModel):
name: str | None = None
backend_id: str | None = None

View File

@ -9,6 +9,7 @@ dependencies = [
"httpx>=0.28.0,<1.0.0",
"pydantic>=2.12.0,<3.0.0",
"cryptography>=45.0.0,<46.0.0",
"minio>=7.2.0,<8.0.0",
"PyJWT>=2.10.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0",
]

View File

@ -0,0 +1,212 @@
from __future__ import annotations
import importlib
import sys
from types import ModuleType
from typing import Any
from fastapi.testclient import TestClient
from app.minio_provisioning import (
MinIOProvisioningConfig,
deprovision_user_file_minio_resources,
)
from app.models import MinIOSettings
class _FakeObject:
def __init__(self, object_name: str) -> None:
self.object_name = object_name
class _FakeMinio:
bucket_exists_value = True
objects: list[str] = []
removed_objects: list[str] = []
def __init__(self, **_kwargs: Any) -> None:
pass
def bucket_exists(self, bucket: str) -> bool:
return self.bucket_exists_value
def list_objects(self, bucket: str, *, prefix: str, recursive: bool) -> list[_FakeObject]:
return [_FakeObject(name) for name in self.objects if name.startswith(prefix)]
def remove_objects(self, bucket: str, objects: list[Any]) -> list[Any]:
self.removed_objects.extend(item.object_name for item in objects)
return []
class _FakeAdmin:
calls: list[tuple[str, Any]] = []
missing = False
def __init__(self, **_kwargs: Any) -> None:
pass
def detach_policy(self, **kwargs: Any) -> None:
self.calls.append(("detach_policy", kwargs))
if self.missing:
raise RuntimeError("policy not found")
def user_remove(self, access_key: str) -> None:
self.calls.append(("user_remove", access_key))
if self.missing:
raise RuntimeError("user not found")
def policy_remove(self, policy_name: str) -> None:
self.calls.append(("policy_remove", policy_name))
if self.missing:
raise RuntimeError("policy not found")
class _FakeStaticProvider:
def __init__(self, **_kwargs: Any) -> None:
pass
class _FakeDeleteObject:
def __init__(self, object_name: str) -> None:
self.object_name = object_name
def _install_fake_minio(monkeypatch) -> None:
minio_module = ModuleType("minio")
minio_module.Minio = _FakeMinio
minio_module.MinioAdmin = _FakeAdmin
credentials_module = ModuleType("minio.credentials")
credentials_module.StaticProvider = _FakeStaticProvider
deleteobjects_module = ModuleType("minio.deleteobjects")
deleteobjects_module.DeleteObject = _FakeDeleteObject
monkeypatch.setitem(sys.modules, "minio", minio_module)
monkeypatch.setitem(sys.modules, "minio.credentials", credentials_module)
monkeypatch.setitem(sys.modules, "minio.deleteobjects", deleteobjects_module)
_FakeMinio.bucket_exists_value = True
_FakeMinio.objects = []
_FakeMinio.removed_objects = []
_FakeAdmin.calls = []
_FakeAdmin.missing = False
def _config() -> MinIOProvisioningConfig:
return MinIOProvisioningConfig(
enabled=True,
endpoint="minio.local:9000",
public_endpoint="minio.local:9000",
admin_access_key="admin",
admin_secret_key="admin-secret",
bucket="beaver-user-files",
secure=False,
region=None,
)
def _settings() -> MinIOSettings:
return MinIOSettings(
endpoint="minio.local:9000",
access_key="beaver-alice",
secret_key="alice-secret",
bucket="beaver-user-files",
namespace="users/alice",
)
def _client(tmp_path, monkeypatch) -> TestClient:
monkeypatch.setenv("AUTHZ_DATA_DIR", str(tmp_path))
monkeypatch.setenv("AUTHZ_PRIVATE_KEY_PATH", str(tmp_path / "signing_key.pem"))
monkeypatch.setenv("AUTHZ_INTERNAL_TOKEN", "test-internal-token")
monkeypatch.setenv("USER_FILES_MINIO_ENDPOINT", "minio.local:9000")
monkeypatch.setenv("USER_FILES_MINIO_ADMIN_ACCESS_KEY", "admin")
monkeypatch.setenv("USER_FILES_MINIO_ADMIN_SECRET_KEY", "admin-secret")
monkeypatch.setenv("USER_FILES_MINIO_BUCKET", "beaver-user-files")
import app.main as main
main = importlib.reload(main)
return TestClient(main.app)
def _register_backend(client: TestClient) -> None:
response = client.post(
"/backends/register",
json={"backend_id": "alice", "name": "Alice", "base_url": "http://alice.local"},
)
assert response.status_code == 200
def test_deprovision_removes_namespace_resources_without_secrets(monkeypatch) -> None:
_install_fake_minio(monkeypatch)
_FakeMinio.objects = [
"users/alice/uploads/a.txt",
"users/alice/outputs/b.txt",
"users/bob/uploads/c.txt",
]
result = deprovision_user_file_minio_resources(
backend_id="alice",
existing=_settings(),
config=_config(),
)
assert result["ok"] is True
assert result["objects"] == {"status": "removed", "deleted": 2}
assert _FakeMinio.removed_objects == ["users/alice/uploads/a.txt", "users/alice/outputs/b.txt"]
assert ("user_remove", "beaver-alice") in _FakeAdmin.calls
assert ("policy_remove", "beaver-user-files-alice") in _FakeAdmin.calls
assert "secret" not in str(result).lower()
def test_deprovision_is_idempotent_when_resources_are_absent(monkeypatch) -> None:
_install_fake_minio(monkeypatch)
_FakeMinio.bucket_exists_value = False
_FakeAdmin.missing = True
result = deprovision_user_file_minio_resources(
backend_id="alice",
existing=_settings(),
config=_config(),
)
assert result["ok"] is True
assert result["bucket"] == "absent"
assert result["objects"] == {"status": "absent", "deleted": 0}
assert result["user"] == {"status": "absent"}
assert result["policy"] == {"status": "absent"}
def test_internal_user_file_deprovision_requires_internal_token(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
unauthorized = client.delete("/internal/backends/alice/user-files")
assert unauthorized.status_code == 401
def test_internal_user_file_deprovision_deletes_settings_without_returning_secret(tmp_path, monkeypatch) -> None:
_install_fake_minio(monkeypatch)
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "beaver-alice",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
},
)
deleted = client.delete(
"/internal/backends/alice/user-files",
headers={"Authorization": "Bearer test-internal-token"},
)
after_delete = client.get("/backends/alice/settings/minio")
assert deleted.status_code == 200
payload = deleted.json()
assert payload["ok"] is True
assert payload["settings"] == {"status": "removed"}
assert "secret" not in str(payload).lower()
assert after_delete.json() == {"configured": False}

View File

@ -0,0 +1,139 @@
from __future__ import annotations
import importlib
from fastapi.testclient import TestClient
from app.minio_provisioning import default_namespace, policy_json_for_backend
def _client(tmp_path, monkeypatch) -> TestClient:
monkeypatch.setenv("AUTHZ_DATA_DIR", str(tmp_path))
monkeypatch.setenv("AUTHZ_PRIVATE_KEY_PATH", str(tmp_path / "signing_key.pem"))
monkeypatch.setenv("AUTHZ_INTERNAL_TOKEN", "test-internal-token")
import app.main as main
main = importlib.reload(main)
return TestClient(main.app)
def _register_backend(client: TestClient) -> None:
response = client.post(
"/backends/register",
json={"backend_id": "alice", "name": "Alice", "base_url": "http://alice.local"},
)
assert response.status_code == 200
def test_minio_settings_round_trip_with_masked_public_read(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
saved = client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "alice-access",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
"secure": True,
"region": "us-east-1",
},
)
public = client.get("/backends/alice/settings/minio")
internal = client.get(
"/internal/backends/alice/settings/minio",
headers={"Authorization": "Bearer test-internal-token"},
)
assert saved.status_code == 200
assert saved.json()["configured"] is True
assert saved.json()["endpoint"] == "minio.local:9000"
assert saved.json()["secret_key_masked"] is True
assert "secret_key" not in saved.json()
assert public.status_code == 200
assert public.json()["access_key"] == "alice-access"
assert public.json()["bucket"] == "beaver-user-files"
assert public.json()["namespace"] == "users/alice"
assert public.json()["secret_key_masked"] is True
assert "secret_key" not in public.json()
assert internal.status_code == 200
assert internal.json()["secret_key"] == "alice-secret"
assert internal.json()["bucket"] == "beaver-user-files"
assert internal.json()["namespace"] == "users/alice"
def test_minio_settings_preserve_secret_on_masked_update(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
first = client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "alice-access",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
},
)
updated = client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio2.local:9000",
"access_key": "alice-access-2",
"secret_key": "",
"bucket": "beaver-user-files",
"namespace": "users/alice-v2",
},
)
internal = client.get(
"/internal/backends/alice/settings/minio",
headers={"Authorization": "Bearer test-internal-token"},
)
assert first.status_code == 200
assert updated.status_code == 200
assert updated.json()["endpoint"] == "minio2.local:9000"
assert updated.json()["namespace"] == "users/alice-v2"
assert internal.status_code == 200
assert internal.json()["secret_key"] == "alice-secret"
assert internal.json()["bucket"] == "beaver-user-files"
assert internal.json()["namespace"] == "users/alice-v2"
def test_minio_settings_delete_and_missing_behavior(tmp_path, monkeypatch) -> None:
with _client(tmp_path, monkeypatch) as client:
_register_backend(client)
missing_public = client.get("/backends/alice/settings/minio")
missing_internal = client.get(
"/internal/backends/alice/settings/minio",
headers={"Authorization": "Bearer test-internal-token"},
)
client.post(
"/backends/alice/settings/minio",
json={
"endpoint": "minio.local:9000",
"access_key": "alice-access",
"secret_key": "alice-secret",
"bucket": "beaver-user-files",
"namespace": "users/alice",
},
)
deleted = client.delete("/backends/alice/settings/minio")
after_delete = client.get("/backends/alice/settings/minio")
assert missing_public.status_code == 200
assert missing_public.json() == {"configured": False}
assert missing_internal.status_code == 404
assert deleted.status_code == 200
assert deleted.json() == {"ok": True}
assert after_delete.status_code == 200
assert after_delete.json() == {"configured": False}
def test_minio_namespace_policy_is_scoped_to_backend_prefix() -> None:
policy = policy_json_for_backend(backend_id="alice", bucket="beaver-user-files")
assert default_namespace("alice") == "users/alice"
assert "arn:aws:s3:::beaver-user-files/users/alice/*" in policy
assert "users/bob" not in policy

View File

@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
[[package]]
name = "annotated-doc"
@ -34,6 +38,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" },
{ url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" },
{ url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" },
{ url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" },
{ url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" },
]
[[package]]
name = "authz-service"
version = "0.1.0"
@ -42,6 +94,7 @@ dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "minio" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "python-multipart" },
@ -58,6 +111,7 @@ requires-dist = [
{ name = "cryptography", specifier = ">=45.0.0,<46.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
{ name = "minio", specifier = ">=7.2.0,<8.0.0" },
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
{ name = "pyjwt", specifier = ">=2.10.0,<3.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" },
@ -351,6 +405,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "minio"
version = "7.2.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi" },
{ name = "certifi" },
{ name = "pycryptodome" },
{ name = "typing-extensions" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -378,6 +448,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
@ -717,6 +822,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "uvicorn"
version = "0.41.0"