fix: bucket name error

This commit is contained in:
tao.chen
2026-08-05 16:01:28 +08:00
parent d851f98581
commit e997e6cf56
7 changed files with 153 additions and 54 deletions
+3 -22
View File
@@ -22,6 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import database_session from backend.dependencies import database_session
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
from common.auth.membership import resolve_is_system_admin
from common.auth.passwords import verify_password from common.auth.passwords import verify_password
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid from common.ids import new_ulid
@@ -72,26 +73,6 @@ def _user_payload(
} }
async def _resolve_is_system_admin(
session: AsyncSession,
user: Users,
) -> bool:
"""Return True iff the user holds a platform-scoped admin role.
The check is: ``Users.status == 'active'`` AND
``Users.platform_role_id`` points to a ``Roles`` row whose
``role_code == 'admin'``. Any other shape (no platform_role_id,
disabled user, wrong role code) returns False — the frontend reads
this to decide whether to show the system-admin entry point.
"""
if user.status != "active" or user.platform_role_id is None:
return False
platform_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return platform_role is not None and platform_role.role_code == "admin"
def _workspace_payload( def _workspace_payload(
workspace: Workspaces, workspace: Workspaces,
role: Roles, role: Roles,
@@ -187,7 +168,7 @@ async def login(
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS) token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(request, response, token) _set_session_cookie(request, response, token)
is_system_admin = await _resolve_is_system_admin(session, user) is_system_admin = await resolve_is_system_admin(session, user)
return { return {
"request_id": new_ulid(), "request_id": new_ulid(),
@@ -266,7 +247,7 @@ async def me(
if user_role_code is None and rows: if user_role_code is None and rows:
user_role_code = rows[0][1].role_code user_role_code = rows[0][1].role_code
is_system_admin = await _resolve_is_system_admin(session, user) is_system_admin = await resolve_is_system_admin(session, user)
return { return {
"request_id": new_ulid(), "request_id": new_ulid(),
+54 -3
View File
@@ -31,10 +31,15 @@ from dataclasses import dataclass
from typing import AsyncIterator from typing import AsyncIterator
from fastapi import Depends, HTTPException, Query, Request, status from fastapi import Depends, HTTPException, Query, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from common.auth.jwt import JwtError, verify_jwt_token from common.auth.jwt import JwtError, verify_jwt_token
from common.auth.membership import MembershipError, load_active_membership from common.auth.membership import (
MembershipError,
load_active_membership,
resolve_is_system_admin,
)
from common.db import session_scope from common.db import session_scope
from common.db.models import Roles, Users, Workspaces from common.db.models import Roles, Users, Workspaces
from common.ids import new_ulid from common.ids import new_ulid
@@ -49,10 +54,18 @@ class RequestContext:
user: Users user: Users
workspace: Workspaces workspace: Workspaces
role: Roles role: Roles
# True when the requester holds the platform-scoped admin role (via
# Users.platform_role_id). Same flag exposed on ``/api/v1/auth/me``
# so the frontend can render the platform-admin entry point. System
# admins have full control over every workspace — including disabled
# ones — so ``request_context`` lets them through and this flag is
# the single signal handlers use to gate platform-only operations.
is_system_admin: bool = False
@property @property
def is_admin(self) -> bool: def is_admin(self) -> bool:
return self.role.role_code == "admin" """Workspace admin OR system admin (the latter is strictly stronger)."""
return self.is_system_admin or self.role.role_code == "admin"
async def database_session(request: Request) -> AsyncIterator[AsyncSession]: async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
@@ -97,7 +110,7 @@ async def request_context(
), ),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> RequestContext: ) -> RequestContext:
"""Verify JWT and load the user's active membership for ``workspace_id``. """Verify JWT and load the user's membership for ``workspace_id``.
The ``request_id`` comes from the ``X-Request-ID`` header if The ``request_id`` comes from the ``X-Request-ID`` header if
present, else from a freshly minted ULID. Handlers receive the present, else from a freshly minted ULID. Handlers receive the
@@ -105,8 +118,37 @@ async def request_context(
header-based implementation, so the 56 ``Depends(request_context)`` header-based implementation, so the 56 ``Depends(request_context)``
call sites in this repo stay working without changes — they just call sites in this repo stay working without changes — they just
now pass ``?workspace_id=`` instead of the old headers. now pass ``?workspace_id=`` instead of the old headers.
System admins (Users.platform_role_id → admin role) bypass the
active-membership requirement: they can address disabled workspaces
because they own the platform. Non-admin users still need an
active ``WorkspaceMembers`` row in an active ``Workspaces`` row.
""" """
user = await current_user(request, session) user = await current_user(request, session)
is_system_admin = await resolve_is_system_admin(session, user)
if is_system_admin:
# System admin: any workspace (active or disabled) is fine.
# Still 404 if the workspace_id is genuinely unknown — the
# ``?workspace_id=`` query param is part of the URL contract.
workspace = await session.get(Workspaces, workspace_id)
if workspace is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
"workspace not found",
)
# Synthesize a role object so the rest of ``RequestContext``
# (and downstream ``is_admin`` checks) keep working without
# branching on whether a real WorkspaceMembers row exists.
role = await _load_admin_role(session)
if role is None:
# The seed migration creates this row, so this is a
# hard config error if it's missing.
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
"admin role not configured",
)
else:
try: try:
_user, workspace, role = await load_active_membership( _user, workspace, role = await load_active_membership(
session, user.user_id, workspace_id, session, user.user_id, workspace_id,
@@ -116,10 +158,19 @@ async def request_context(
status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN,
"active workspace membership is required", "active workspace membership is required",
) from exc ) from exc
request_id = request.headers.get("X-Request-ID") or new_ulid() request_id = request.headers.get("X-Request-ID") or new_ulid()
return RequestContext( return RequestContext(
request_id=request_id, request_id=request_id,
user=user, user=user,
workspace=workspace, workspace=workspace,
role=role, role=role,
is_system_admin=is_system_admin,
)
async def _load_admin_role(session: AsyncSession) -> Roles | None:
"""Return the singleton ``role_code='admin'`` row (None if absent)."""
return await session.scalar(
select(Roles).where(Roles.role_code == "admin")
) )
+13 -4
View File
@@ -10,7 +10,13 @@ from fastapi.routing import APIRoute
from common.config import settings from common.config import settings
from common.db import create_database_engine, create_session_factory from common.db import create_database_engine, create_session_factory
from common.service_app import create_service_app from common.service_app import create_service_app
from common.storage import AsyncStorageBackend, PURPOSE_BUCKETS, build_storage_config, create_storage from common.storage import (
AsyncStorageBackend,
PURPOSE_BUCKETS,
actual_bucket_name,
build_storage_config,
create_storage,
)
from backend.admin import router as admin_router from backend.admin import router as admin_router
from backend.auth import router as auth_router from backend.auth import router as auth_router
from backend.platform import router as platform_router from backend.platform import router as platform_router
@@ -31,10 +37,13 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
# Storage API is part of the backend process. Platform routers call # Storage API is part of the backend process. Platform routers call
# the helpers in ``backend.services.storage`` directly (in-process), # the helpers in ``backend.services.storage`` directly (in-process),
# so no HTTP client is needed. ``build_storage_config`` picks s3 vs # so no HTTP client is needed. The dict is keyed by the actual
# local based on ``settings.storage_backend``. # bucket name (e.g. "versions"), matching ``UploadSessions.bucket_name``
# and ``StorageObjects.bucket_name`` so call sites can do
# ``object_stores[upload.bucket_name].put(...)`` directly.
app.state.object_stores: dict[str, AsyncStorageBackend] = { app.state.object_stores: dict[str, AsyncStorageBackend] = {
name: create_storage(build_storage_config(name)) for name in PURPOSE_BUCKETS actual_bucket_name(purpose): create_storage(build_storage_config(purpose))
for purpose in PURPOSE_BUCKETS
} }
app.state.default_bucket = settings.s3_workspace_bucket app.state.default_bucket = settings.s3_workspace_bucket
runtime_http_client = httpx.AsyncClient( runtime_http_client = httpx.AsyncClient(
+27 -16
View File
@@ -23,6 +23,7 @@ from common.service_app import create_service_app
from common.storage import ( from common.storage import (
AsyncStorageBackend, AsyncStorageBackend,
PURPOSE_BUCKETS, PURPOSE_BUCKETS,
actual_bucket_name,
build_storage_config, build_storage_config,
create_storage, create_storage,
) )
@@ -62,20 +63,26 @@ def safe_file_name(value: str) -> str:
return name return name
# Map an upload's usage_type to the S3 bucket that should hold the # Map an upload's usage_type to its purpose (which then resolves to the
# resulting object. ``usage_type`` is the only signal available at the # actual bucket / directory via ``actual_bucket_name``). Keeping the
# storage edge (the request comes from either the public API or the # purpose as the intermediate value means s3 mode and local mode share
# internal schedule worker), so we make the routing decision in one place # the same routing logic — only the final ``actual_bucket_name`` differs.
# here and let every other layer — server-object create, multipart upload, USAGE_TYPE_TO_PURPOSE: dict[str, str] = {
# direct put — inherit the mapping. "working_copy": "workspace",
"public_script": "workspace",
"data_resource": "workspace",
"snapshot": "workspace",
"version_artifact": "version",
"run_log": "run_log",
"run_result": "run_log",
}
# Pre-resolved bucket map (for read-only callers like services/storage.py).
# Re-resolved at module load; re-resolve via resolve_bucket() if the
# workspace.artifact_bucket override matters.
BUCKET_FOR_USAGE: dict[str, str] = { BUCKET_FOR_USAGE: dict[str, str] = {
"working_copy": settings.s3_workspace_bucket, usage_type: actual_bucket_name(purpose)
"public_script": settings.s3_workspace_bucket, for usage_type, purpose in USAGE_TYPE_TO_PURPOSE.items()
"data_resource": settings.s3_workspace_bucket,
"snapshot": settings.s3_workspace_bucket,
"version_artifact": settings.s3_version_bucket,
"run_log": settings.s3_run_log_bucket,
"run_result": settings.s3_run_log_bucket,
} }
@@ -92,7 +99,8 @@ def resolve_bucket(
""" """
if workspace.artifact_bucket: if workspace.artifact_bucket:
return workspace.artifact_bucket return workspace.artifact_bucket
return BUCKET_FOR_USAGE.get(usage_type, settings.s3_workspace_bucket) purpose = USAGE_TYPE_TO_PURPOSE.get(usage_type, "workspace")
return actual_bucket_name(purpose)
def storage_payload(item: StorageObjects) -> dict[str, Any]: def storage_payload(item: StorageObjects) -> dict[str, Any]:
@@ -124,9 +132,12 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
app.state.session_factory = create_session_factory(engine) app.state.session_factory = create_session_factory(engine)
# Buckets are pre-provisioned by the deployment; the storage layer no # Buckets are pre-provisioned by the deployment; the storage layer no
# longer auto-creates them. ``build_storage_config`` picks s3 vs local # longer auto-creates them. ``build_storage_config`` picks s3 vs local
# based on ``settings.storage_backend``. # based on ``settings.storage_backend``. The dict is keyed by the
# actual bucket name so ``object_stores[upload.bucket_name]``
# works without a reverse mapping.
app.state.object_stores: dict[str, AsyncStorageBackend] = { app.state.object_stores: dict[str, AsyncStorageBackend] = {
name: create_storage(build_storage_config(name)) for name in PURPOSE_BUCKETS actual_bucket_name(purpose): create_storage(build_storage_config(purpose))
for purpose in PURPOSE_BUCKETS
} }
app.state.default_bucket = settings.s3_workspace_bucket app.state.default_bucket = settings.s3_workspace_bucket
try: try:
+29
View File
@@ -22,6 +22,34 @@ class MembershipError(Exception):
"""Raised when the user is not an active member of the workspace.""" """Raised when the user is not an active member of the workspace."""
async def resolve_is_system_admin(
session: AsyncSession,
user: Users,
) -> bool:
"""Return True iff the user holds a platform-scoped admin role.
The check is: ``Users.status == 'active'`` AND
``Users.platform_role_id`` points to a ``Roles`` row whose
``role_code == 'admin'``. Any other shape (no platform_role_id,
disabled user, wrong role code) returns False — the frontend reads
this to decide whether to show the system-admin entry point.
System admins own the platform: they can address disabled workspaces,
bypass per-workspace membership checks, etc. Anything that wants to
gate "platform-only" behavior (deleting a workspace, soft-deleting
a user globally, …) should consult this flag — it is the single
source of truth.
"""
from sqlalchemy import select
if user.status != "active" or user.platform_role_id is None:
return False
platform_role = await session.scalar(
select(Roles).where(Roles.role_id == user.platform_role_id)
)
return platform_role is not None and platform_role.role_code == "admin"
async def load_active_membership( async def load_active_membership(
session: AsyncSession, session: AsyncSession,
user_id: str, user_id: str,
@@ -66,4 +94,5 @@ async def load_active_membership(
__all__ = [ __all__ = [
"MembershipError", "MembershipError",
"load_active_membership", "load_active_membership",
"resolve_is_system_admin",
] ]
+2
View File
@@ -22,6 +22,7 @@ from .base import AsyncStorageBackend, ObjectMeta, StorageBackend
from .factory import ( from .factory import (
PURPOSE_BUCKETS, PURPOSE_BUCKETS,
RCLONE_REMOTE_NAME, RCLONE_REMOTE_NAME,
actual_bucket_name,
build_storage_config, build_storage_config,
create_storage, create_storage,
rclone_remote_spec, rclone_remote_spec,
@@ -32,6 +33,7 @@ from .registry import register_backend, registered_backends
__all__ = [ __all__ = [
"create_storage", "create_storage",
"build_storage_config", "build_storage_config",
"actual_bucket_name",
"workspaces_root", "workspaces_root",
"rclone_remote_spec", "rclone_remote_spec",
"RCLONE_REMOTE_NAME", "RCLONE_REMOTE_NAME",
+16
View File
@@ -62,6 +62,22 @@ def create_storage(config: Dict[str, Any]) -> AnyStorageBackend:
PURPOSE_BUCKETS: tuple[str, ...] = ("workspace", "version", "run_log", "trash") PURPOSE_BUCKETS: tuple[str, ...] = ("workspace", "version", "run_log", "trash")
def actual_bucket_name(purpose: str) -> str:
"""把 purpose 名称解析成实际桶路径 / 名(runtime 数据会存在这个字符串里)。
- s3 模式:`settings.s3_<purpose>_bucket`(e.g. ``"versions"``)
- local 模式:`${local_storage_base_dir}/<purpose>`(e.g. ``"/data/version"``)
这是 ``app.state.object_stores`` 的 dict key——``UploadSessions.bucket_name``
和 ``StorageObjects.bucket_name`` 都存这个值,所以 dict 必须用这个串
做 key 才能在 ``object_stores[upload.bucket_name]`` 那里直接命中。
"""
from common.config import settings # 延迟 import 避免循环
if settings.storage_backend == "local":
return str(Path(settings.local_storage_base_dir) / purpose)
return getattr(settings, f"s3_{purpose}_bucket")
def build_storage_config(bucket_name: str) -> Dict[str, Any]: def build_storage_config(bucket_name: str) -> Dict[str, Any]:
"""根据 ``settings.storage_backend`` 构造 ``create_storage()`` 的入参。 """根据 ``settings.storage_backend`` 构造 ``create_storage()`` 的入参。