feat: integrate MinIO-backed user filesystem
This commit is contained in:
@ -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", {})
|
||||
|
||||
@ -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)
|
||||
|
||||
317
authz-service/src/app/minio_provisioning.py
Normal file
317
authz-service/src/app/minio_provisioning.py
Normal 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"}
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
212
authz-service/src/tests/test_minio_deprovisioning.py
Normal file
212
authz-service/src/tests/test_minio_deprovisioning.py
Normal 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}
|
||||
139
authz-service/src/tests/test_minio_settings.py
Normal file
139
authz-service/src/tests/test_minio_settings.py
Normal 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
|
||||
114
authz-service/src/uv.lock
generated
114
authz-service/src/uv.lock
generated
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user