merge: integrate feat/auth into develop

This commit is contained in:
Winnie
2026-08-03 17:44:00 +08:00
38 changed files with 2590 additions and 796 deletions
+4 -4
View File
@@ -8,10 +8,10 @@ WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
RUN ( \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
) || ( \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
+2
View File
@@ -11,6 +11,8 @@ dependencies = [
"alembic==1.18.5",
"cryptography==49.0.0",
"gunicorn>=26.0.0",
"passlib==1.7.4",
"bcrypt>=4.0,<4.1",
]
[tool.uv.sources]
+4 -4
View File
@@ -35,18 +35,18 @@ router = APIRouter(tags=["auth"])
# (future extension).
COOKIE_NAME = "access_token"
COOKIE_TTL_SECONDS = 24 * 60 * 60
COOKIE_SECURE = True
COOKIE_SAMESITE = "lax"
def _set_session_cookie(response: Response, token: str) -> None:
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=COOKIE_TTL_SECONDS,
path="/",
httponly=True,
secure=COOKIE_SECURE,
secure=forwarded_scheme == "https",
samesite=COOKIE_SAMESITE,
)
@@ -159,7 +159,7 @@ async def login(
user_role_code = rows[0][1].role_code
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
_set_session_cookie(response, token)
_set_session_cookie(request, response, token)
return {
"request_id": new_ulid(),
+93 -43
View File
@@ -1,22 +1,48 @@
"""FastAPI dependencies for the public API.
Two distinct concerns live here:
* :func:`database_session` — yields a transactional ``AsyncSession``
bound to the request's app-state factory. The session commits on
success and rolls back on exception via ``common.db.session_scope``.
* :func:`request_context` — verifies the ``access_token`` cookie
(HS256-signed JWT), then loads the user's active membership for the
``workspace_id`` query parameter. Returns a :class:`RequestContext`
dataclass that downstream handlers read for identity, role, and the
request id.
The 56 ``Depends(request_context)`` call sites elsewhere in the
backend expect this exact dataclass shape; the workspace and role are
always present, so handlers do not need to handle the "no workspace
selected" case.
Routes that need a workspace context must declare the query parameter
explicitly, even when the path also contains a resource id (e.g.
``/schedules/{schedule_id}``). This keeps authorization decisions
local to the request and prevents the "implicit workspace" footgun
where a path-resource lookup quietly overrides what the user asked
for.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncIterator
from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy import select
from fastapi import Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from common.auth.jwt import JwtError, verify_jwt_token
from common.auth.membership import MembershipError, load_active_membership
from common.db import session_scope
from common.db.models import (
Roles,
Users,
WorkspaceMembers,
Workspaces,
)
from common.db.models import Roles, Users, Workspaces
from common.ids import new_ulid
ACCESS_TOKEN_COOKIE = "access_token"
@dataclass(frozen=True)
class RequestContext:
request_id: str
@@ -34,42 +60,66 @@ async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
yield session
async def current_user(
request: Request,
session: AsyncSession = Depends(database_session),
) -> Users:
"""Resolve the authenticated user from the ``access_token`` cookie.
Returns the active ``Users`` row or raises 401. Does NOT load a
workspace — use :func:`request_context` for handlers that need
a workspace-scoped context, or use ``Depends(current_user)`` for
workspace-agnostic endpoints (e.g. ``/api/v1/auth/me``).
"""
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
try:
payload = verify_jwt_token(token)
except JwtError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
user = await session.get(Users, user_id)
if user is None or user.status != "active":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
return user
async def request_context(
request: Request,
x_user_id: str = Header(alias="X-User-ID"),
x_workspace_id: str = Header(alias="X-Workspace-ID"),
x_request_id: str | None = Header(default=None, alias="X-Request-ID"),
workspace_id: str = Query(
...,
min_length=26,
max_length=26,
description="Workspace context for this request (CHAR(26) ULID).",
),
session: AsyncSession = Depends(database_session),
) -> RequestContext:
async with request.app.state.session_factory() as session:
statement = (
select(Users, Workspaces, Roles)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(
Workspaces,
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
)
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
.where(
Users.user_id == x_user_id,
Users.status == "active",
WorkspaceMembers.workspace_id == x_workspace_id,
WorkspaceMembers.member_status == "active",
Workspaces.status == "active",
)
)
row = (await session.execute(statement)).one_or_none()
if row is None:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"active workspace membership is required",
)
user, workspace, role = row
return RequestContext(
request_id=x_request_id or new_ulid(),
user=user,
workspace=workspace,
role=role,
"""Verify JWT and load the user's active membership for ``workspace_id``.
The ``request_id`` comes from the ``X-Request-ID`` header if
present, else from a freshly minted ULID. Handlers receive the
same :class:`RequestContext` shape they had under the old
header-based implementation, so the 56 ``Depends(request_context)``
call sites in this repo stay working without changes — they just
now pass ``?workspace_id=`` instead of the old headers.
"""
user = await current_user(request, session)
try:
_user, workspace, role = await load_active_membership(
session, user.user_id, workspace_id,
)
except MembershipError as exc:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"active workspace membership is required",
) from exc
request_id = request.headers.get("X-Request-ID") or new_ulid()
return RequestContext(
request_id=request_id,
user=user,
workspace=workspace,
role=role,
)
+25 -176
View File
@@ -1,129 +1,24 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import re
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from backend.dependencies import (
RequestContext,
database_session,
request_context,
)
from backend.runtime_client import RuntimeClientError
from common.config import settings
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
from sqlalchemy.ext.asyncio import AsyncSession
from backend.dependencies import database_session
from backend.runtime_client import RuntimeClientError
from common.auth.jwt import JwtError, verify_jwt_token
from common.auth.membership import MembershipError, load_active_membership
from common.db.models import Scripts
router = APIRouter(tags=["jupyter"])
security = HTTPBearer(auto_error=False)
JWT_SECRET = settings.jwt_secret
JWT_ALGORITHM = "HS256"
def _b64decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(value + padding)
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def create_jwt_token(user_id: str, *, expires_seconds: int = 3600) -> str:
header = _b64encode(
json.dumps(
{"alg": JWT_ALGORITHM, "typ": "JWT"},
separators=(",", ":"),
).encode()
)
expires_at = int(time.time()) + expires_seconds
payload = _b64encode(
json.dumps(
{"sub": user_id, "exp": expires_at},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = _b64encode(
hmac.new(
JWT_SECRET.encode(),
signing_input,
hashlib.sha256,
).digest()
)
return f"{header}.{payload}.{signature}"
def verify_jwt_token(token: str) -> dict:
"""Parse a signed JWT from the access_token cookie.
Returns a payload dict containing at least ``sub`` (user_id) and
``exp``; raises 401 on missing, malformed, or expired tokens. The
current implementation is intentionally minimal — replace with a
real verification once a public-key/HSM source is wired in.
"""
if not token:
raise HTTPException(
status_code=401,
detail="Missing Authentication Token",
)
try:
header_b64, payload_b64, signature_b64 = token.split(".", 2)
except ValueError as exc:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(
JWT_SECRET.encode(),
signing_input,
hashlib.sha256,
).digest()
try:
signature = _b64decode(signature_b64)
except Exception as exc: # pragma: no cover - malformed b64
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
if not hmac.compare_digest(expected, signature):
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
)
try:
payload = json.loads(_b64decode(payload_b64))
except Exception as exc:
raise HTTPException(
status_code=401,
detail="Invalid Authentication Token",
) from exc
exp = payload.get("exp")
if not isinstance(exp, (int, float)) or exp < time.time():
raise HTTPException(
status_code=401,
detail="Token expired",
)
return payload
def extract_notebook_path(
uri: str,
workspace_id: str,
@@ -168,75 +63,23 @@ async def check_notebook_is_locked(
return bool(is_locked)
async def load_active_membership(
async def load_active_membership_or_403(
session: AsyncSession,
user_id: str,
workspace_id: str,
) -> tuple[Users, Workspaces]:
"""Resolve the user's active membership in the workspace."""
statement = (
select(Users, Workspaces)
.join(
WorkspaceMembers,
WorkspaceMembers.user_id == Users.user_id,
)
.join(
Workspaces,
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
)
.where(
Users.user_id == user_id,
Users.status == "active",
WorkspaceMembers.workspace_id == workspace_id,
WorkspaceMembers.member_status == "active",
Workspaces.status == "active",
)
)
row = (await session.execute(statement)).one_or_none()
if row is None:
):
"""Resolve the user's active membership, raising 403 if missing.
Thin wrapper around :func:`common.auth.membership.load_active_membership`
that maps the library's ``MembershipError`` to a FastAPI 403.
"""
try:
return await load_active_membership(session, user_id, workspace_id)
except MembershipError as exc:
raise HTTPException(
status_code=403,
detail="active workspace membership is required",
)
return row
@router.post("/api/v1/auth/demo-session")
async def create_demo_session(
request: Request,
response: Response,
context: RequestContext = Depends(request_context),
) -> dict:
"""Issue the short-lived HttpOnly cookie used by the self-hosted UI.
The normal request-context check still requires an active user/workspace
membership. This endpoint is disabled by default and must be explicitly
enabled by the deployment configuration.
"""
if not settings.demo_auth_enabled:
raise HTTPException(status_code=404, detail="Not Found")
expires_seconds = 3600
response.set_cookie(
"access_token",
create_jwt_token(
context.user.user_id,
expires_seconds=expires_seconds,
),
max_age=expires_seconds,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
path="/",
)
return {
"request_id": context.request_id,
"data": {
"user_id": context.user.user_id,
"workspace_id": context.workspace.workspace_id,
"expires_at": int(time.time()) + expires_seconds,
},
"meta": {},
}
) from exc
@router.get("/api/v1/auth/jupyter")
@@ -266,7 +109,13 @@ async def verify_jupyter_access(
cookie_token = request.cookies.get("access_token")
bearer_token = auth.credentials if auth else None
token = cookie_token or bearer_token
payload = verify_jwt_token(token)
try:
payload = verify_jwt_token(token)
except JwtError as exc:
raise HTTPException(
status_code=401,
detail=str(exc),
) from exc
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
@@ -274,7 +123,7 @@ async def verify_jupyter_access(
detail="Invalid Authentication Token",
)
await load_active_membership(session, user_id, workspace_id)
await load_active_membership_or_403(session, user_id, workspace_id)
notebook_path = extract_notebook_path(original_uri, workspace_id)
if notebook_path and await check_notebook_is_locked(
+4 -1
View File
@@ -12,6 +12,7 @@ from common.db import create_database_engine, create_session_factory
from common.service_app import create_service_app
from common.storage import RustFSObjectStore
from backend.admin import router as admin_router
from backend.auth import router as auth_router
from backend.jupyter import router as jupyter_router
from backend.resources import router as resources_router
from backend.runtime_client import RuntimeClient
@@ -34,12 +35,13 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
access_key=settings.rustfs_access_key,
secret_key=settings.rustfs_secret_key,
)
# Ensure all three purpose-named buckets exist; the storage edge picks
# Ensure all four purpose-named buckets exist; the storage edge picks
# the right one per upload (see resolve_bucket in storage_api.py).
for bucket in (
settings.rustfs_workspace_bucket,
settings.rustfs_version_bucket,
settings.rustfs_run_log_bucket,
settings.rustfs_trash_bucket,
):
await asyncio.to_thread(
app.state.object_store.ensure_bucket,
@@ -69,6 +71,7 @@ app = create_service_app(
settings.service_name,
lifespan=lifespan,
)
app.include_router(auth_router)
app.include_router(jupyter_router)
app.include_router(resources_router)
app.include_router(schedule_runs_router)
+51 -157
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import hashlib
from datetime import UTC, datetime
from typing import Any, Literal
@@ -13,19 +12,22 @@ from common.db.models import (
ScheduleNodeRuns,
ScheduleRuns,
)
from common.eventing import add_outbox_event, utcnow
from common.ids import new_ulid
from backend.dependencies import (
RequestContext,
database_session,
request_context,
)
from common.schemas import StrictModel
from backend.schedules import (
graph_rows,
schedule_row,
validate_dag,
from common.scheduler import (
DagTooLarge,
InvalidDag,
InvalidNodeArguments,
ScheduleNotFound,
TriggerError,
create_scheduled_run,
normalize_idempotency_key,
)
from common.ids import new_ulid
router = APIRouter(tags=["schedule-runs"])
@@ -51,46 +53,23 @@ def _iso(value: datetime | None) -> str | None:
return value.astimezone(UTC).isoformat()
def _normalized_idempotency_key(
workspace_id: str,
schedule_id: str,
value: str,
) -> str:
normalized = value.strip()
if len(normalized) < 8:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"Idempotency-Key must contain at least 8 characters",
def _http_error_from_trigger(exc: TriggerError) -> HTTPException:
if isinstance(exc, ScheduleNotFound):
return HTTPException(status.HTTP_404_NOT_FOUND, str(exc))
if isinstance(exc, InvalidDag):
return HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "SCHEDULE_DAG_INVALID",
"message": str(exc),
"errors": exc.errors,
},
)
digest = hashlib.sha256(
f"{workspace_id}:{schedule_id}:{normalized}".encode("utf-8")
).hexdigest()
return f"run:v1:{digest}"
def _arguments(value: dict[str, Any] | None) -> list[str]:
payload = value or {}
raw = payload.get("_args")
result = [str(item) for item in raw] if isinstance(raw, list) else []
for key, item in payload.items():
if key == "_args":
continue
option = f"--{key.replace('_', '-')}"
if item is True:
result.append(option)
elif item is False or item is None:
continue
elif isinstance(item, list):
for list_item in item:
result.extend((option, str(list_item)))
elif isinstance(item, (str, int, float)):
result.extend((option, str(item)))
else:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"node argument {key!r} must be a scalar or list",
)
return result
if isinstance(exc, DagTooLarge):
return HTTPException(status.HTTP_409_CONFLICT, str(exc))
if isinstance(exc, InvalidNodeArguments):
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, exc.message)
return HTTPException(status.HTTP_409_CONFLICT, str(exc))
def run_summary(item: ScheduleRuns) -> dict[str, Any]:
@@ -183,125 +162,40 @@ async def run_schedule_now(
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
reason = payload.reason if payload is not None else "manual_run"
key = _normalized_idempotency_key(
context.workspace.workspace_id,
schedule_id,
idempotency_key,
)
existing = await session.scalar(
select(ScheduleRuns).where(ScheduleRuns.idempotency_key == key)
)
if existing is not None:
if (
existing.workspace_id != context.workspace.workspace_id
or existing.schedule_id != schedule_id
):
raise HTTPException(
status.HTTP_409_CONFLICT,
"Idempotency-Key belongs to another schedule run",
)
return {
"request_id": context.request_id,
"data": await run_detail(existing, session),
"meta": {"reused": True},
}
schedule = await schedule_row(
schedule_id,
context,
session,
for_update=True,
)
node_rows, edges = await graph_rows(schedule_id, session)
nodes = [row[0] for row in node_rows]
validation = validate_dag(nodes, edges)
if not validation["valid"] or not nodes:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "SCHEDULE_DAG_INVALID",
"message": "schedule must contain a valid non-empty DAG",
"errors": validation["errors"],
},
trigger_type: Literal["manual", "cron"] = "cron" if reason == "cron" else "manual"
try:
key = normalize_idempotency_key(
context.workspace.workspace_id,
schedule_id,
idempotency_key,
)
if len(nodes) > 100 or len(edges) > 500:
except TriggerError as exc:
raise HTTPException(
status.HTTP_409_CONFLICT,
"schedule exceeds the v1 execution size limit",
)
status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc),
) from exc
snapshot = {
"schedule_name": schedule.schedule_name,
"workflow_version": schedule.workflow_version,
"max_concurrency": schedule.max_concurrency,
"failure_policy": schedule.failure_policy,
"nodes": [
{
"node_id": node.node_id,
"node_key": node.node_key,
"versions_id": version.versions_id,
"script_type": script.script_type,
"artifact_object_id": version.artifact_object_id,
"artifact_path": version.artifact_path,
"timeout_seconds": node.timeout_seconds,
"retry_count": node.retry_count,
"retry_interval_sec": node.retry_interval_sec,
"arguments": _arguments(node.arguments_json),
}
for node, version, script in node_rows
],
"edges": [
{
"source_node_id": edge.source_node_id,
"target_node_id": edge.target_node_id,
}
for edge in edges
],
}
now = utcnow()
run = ScheduleRuns(
run_id=new_ulid(),
schedule_id=schedule.schedule_id,
workspace_id=schedule.workspace_id,
workflow_version=schedule.workflow_version,
trigger_type="cron" if reason == "cron" else "manual",
idempotency_key=key,
run_status="queued",
state_version=0,
schedule_snapshot=snapshot,
queued_at=now,
triggered_by=context.user.user_id,
)
session.add(run)
schedule.last_run_at = now
await add_outbox_event(
session,
event_type="schedule.run.requested",
producer="platform-api",
trace_id=context.request_id,
aggregate_type="schedule_run",
aggregate_id=run.run_id,
idempotency_key=key,
payload={
"workspace_id": run.workspace_id,
"schedule_id": run.schedule_id,
"run_id": run.run_id,
"workflow_version": run.workflow_version,
"trigger_type": run.trigger_type,
"triggered_by": run.triggered_by,
"schedule_snapshot": snapshot,
},
)
await session.flush()
# Commit before yielding so the Outbox row is visible to the executor's
# next MySQL poll — the executor's _database_event_loop picks it up.
# We intentionally do NOT HTTP-push; see backend/schedule_client.py.
try:
run, is_new = await create_scheduled_run(
session,
schedule_id=schedule_id,
workspace_id=context.workspace.workspace_id,
triggered_by_user_id=context.user.user_id,
trigger_type=trigger_type,
idempotency_key=key,
trace_id=context.request_id,
)
except TriggerError as exc:
raise _http_error_from_trigger(exc) from exc
# Commit before responding so the Outbox row is visible to the
# executor's next MySQL poll — the executor's _database_event_loop
# picks it up. We intentionally do NOT HTTP-push.
await session.commit()
await session.refresh(run)
return {
"request_id": context.request_id,
"data": await run_detail(run, session),
"meta": {"reused": False},
"meta": {"reused": not is_new},
}
+179 -22
View File
@@ -124,9 +124,20 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
secret_key=settings.rustfs_secret_key,
)
app.state.default_bucket = settings.rustfs_workspace_bucket
await asyncio.to_thread(
app.state.object_store.ensure_bucket,
app.state.default_bucket)
# Ensure every purpose-named bucket exists up front, including the
# trash bucket. The trash bucket is shared across all workspaces
# and usage_types; the source key is preserved as a prefix so a
# restore is a same-key move back to the source bucket.
for bucket in (
settings.rustfs_workspace_bucket,
settings.rustfs_version_bucket,
settings.rustfs_run_log_bucket,
settings.rustfs_trash_bucket,
):
await asyncio.to_thread(
app.state.object_store.ensure_bucket,
bucket,
)
try:
yield
finally:
@@ -227,13 +238,19 @@ async def create_upload_record(
storage_object = await session.get(
StorageObjects,
upload.storage_object_id)
return {
"upload_id": upload.upload_id,
"status": upload.upload_status,
"storage_object": (
storage_payload(storage_object) if storage_object else None
),
}
if storage_object is None or storage_object.object_status != "available":
# The previously-completed object was deleted (or never
# materialized). Treat the idempotency hit as a tombstone
# and fall through to a fresh upload: clear the pointer so
# complete_upload_record re-validates the bucket + key.
upload.storage_object_id = None
upload.upload_status = "created"
else:
return {
"upload_id": upload.upload_id,
"status": upload.upload_status,
"storage_object": storage_payload(storage_object),
}
if upload.upload_status not in {"created", "uploading"}:
raise HTTPException(
status.HTTP_409_CONFLICT,
@@ -295,11 +312,14 @@ async def complete_upload_record(
raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
if upload.upload_status == "completed" and upload.storage_object_id:
item = await session.get(StorageObjects, upload.storage_object_id)
if item is None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"completed upload has no storage object")
return item
if item is None or item.object_status != "available":
# The linked storage object was deleted. Reset the upload so
# the caller can re-upload the same bytes and create a
# fresh, available object.
upload.storage_object_id = None
upload.upload_status = "created"
else:
return item
if upload.upload_status not in {"created", "uploading"}:
raise HTTPException(
status.HTTP_409_CONFLICT,
@@ -552,6 +572,20 @@ async def delete_object(
storage_object_id: str,
request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Soft-delete a storage object.
The bytes are copied to ``rustfs_trash_bucket`` under the same key
(preserved as ``{source_bucket}/{object_key}`` so a restore is a
same-name move), the source key is then deleted from its origin
bucket, and the row's ``object_status`` flips to ``"deleted"`` with
``deleted_at`` stamped for the reaper.
Immutable artifacts (version snapshots, run logs) are not
trashed — the policy is enforced by ``is_immutable`` and an
explicit 409. The reaper will physically delete trashed objects
older than ``rustfs_trash_retention_days`` (out of scope for this
endpoint; the field is the contract).
"""
item = await session.scalar(
select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id)
@@ -563,22 +597,145 @@ async def delete_object(
raise HTTPException(
status.HTTP_409_CONFLICT,
"immutable object cannot be deleted")
if item.object_status != "deleted":
if item.storage_backend == "rustfs" and item.bucket_name and item.object_key:
if item.object_status == "deleted":
return {
"data": {
"storage_object_id": storage_object_id,
"object_status": item.object_status,
"trash_key": item.trash_key,
}
}
if item.storage_backend == "rustfs" and item.bucket_name and item.object_key:
trash_key = f"{item.bucket_name}/{item.object_key}"
try:
await asyncio.to_thread(
request.app.state.object_store.delete,
bucket_name=item.bucket_name,
object_key=item.object_key)
item.object_status = "deleted"
item.deleted_at = utcnow()
request.app.state.object_store.move_to_trash,
source_bucket=item.bucket_name,
source_key=item.object_key,
trash_bucket=settings.rustfs_trash_bucket,
trash_key=trash_key,
)
except Exception as exc:
# If the move fails, leave the source intact and surface the
# error. We do NOT mark the row as deleted in that case —
# otherwise we'd have a row pointing to non-existent bytes.
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"failed to move object to trash: {exc}",
) from exc
item.trash_key = trash_key
item.object_status = "deleted"
item.deleted_at = utcnow()
return {
"data": {
"storage_object_id": storage_object_id,
"object_status": item.object_status,
"trash_key": item.trash_key,
"trash_bucket": settings.rustfs_trash_bucket,
}
}
@app.post(
"/internal/v1/objects/{storage_object_id}/restore")
async def restore_object(
storage_object_id: str,
request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Restore a soft-deleted object from the trash bucket.
Copies the bytes back to the source bucket + key and flips the
row back to ``object_status='available'``. If the source bucket
is missing the object (e.g. the trash copy was the only one), the
restore still works because we copy *from* trash rather than
renaming in place. The trash copy is left in place — the reaper
will collect it on the next sweep; this is intentional so a
failed restore does not destroy the only copy.
"""
item = await session.scalar(
select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id)
.with_for_update()
)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if item.object_status != "deleted":
raise HTTPException(
status.HTTP_409_CONFLICT,
"object is not in trash")
if not item.trash_key or not item.bucket_name or not item.object_key:
raise HTTPException(
status.HTTP_409_CONFLICT,
"object has no trash pointer; cannot restore")
try:
await asyncio.to_thread(
request.app.state.object_store.copy,
source_bucket=settings.rustfs_trash_bucket,
source_key=item.trash_key,
dest_bucket=item.bucket_name,
dest_key=item.object_key,
)
except Exception as exc:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"failed to restore from trash: {exc}",
) from exc
item.object_status = "available"
item.deleted_at = None
# Keep trash_key so the reaper can clean up the duplicate on its
# next pass; we don't try to delete it here because a partial
# failure would leave the user with no data.
return {
"data": storage_payload(item),
}
@app.post(
"/internal/v1/admin/trash/purge")
async def purge_trash_object(
payload: dict[str, Any],
request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Physically delete a trashed object.
Admin / reaper endpoint — given a ``storage_object_id``, deletes
the bytes from the trash bucket and hard-deletes the DB row.
The route is split from ``delete_object`` because the regular
delete path is the one users hit, and reaper runs need a way to
finalize the lifecycle without re-entering the soft-delete branch.
"""
storage_object_id = (payload or {}).get("storage_object_id", "").strip()
if not storage_object_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"storage_object_id is required")
item = await session.scalar(
select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id)
.with_for_update()
)
if item is None:
return {"data": {"storage_object_id": storage_object_id, "purged": False}}
if item.object_status != "deleted":
raise HTTPException(
status.HTTP_409_CONFLICT,
"object is not in trash; refuse to hard-delete live data")
if item.trash_key:
try:
await asyncio.to_thread(
request.app.state.object_store.delete,
bucket_name=settings.rustfs_trash_bucket,
object_key=item.trash_key,
)
except Exception as exc:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"failed to delete from trash: {exc}",
) from exc
await session.delete(item)
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
@app.get("/internal/health/storage")
async def internal_health() -> dict[str, str]:
return {"status": "ready", "service": "storage-api"}