merge: integrate feat/auth into develop
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user