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

@ -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