mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor: explicit DB session propagation across backend paths (#38559)
Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
1 parent
7d5835fbc0
commit
ab3e4daa95
397 files changed
+14205
-9967
No files matched your search
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from models import App
|
||||
from models.model import AppMode
|
||||
from models.model import AppMode, load_annotation_reply_config
|
||||
|
||||
JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
@@ -89,13 +91,13 @@ def _form_to_jsonschema(form: list[dict[str, Any]]) -> tuple[dict[str, Any], lis
|
||||
return properties, required
|
||||
|
||||
|
||||
def resolve_app_config(app: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
def resolve_app_config(app: App, *, session: Session) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Resolve `(features_dict, user_input_form)` for parameters / schema derivation.
|
||||
|
||||
Raises `AppUnavailableError` on misconfigured apps.
|
||||
"""
|
||||
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app.workflow
|
||||
workflow = app.workflow_with_session(session=session)
|
||||
if workflow is None:
|
||||
raise AppUnavailableError()
|
||||
return (
|
||||
@@ -103,21 +105,22 @@ def resolve_app_config(app: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
cast(list[dict[str, Any]], workflow.user_input_form(to_old_structure=True)),
|
||||
)
|
||||
|
||||
app_model_config = app.app_model_config
|
||||
app_model_config = app.app_model_config_with_session(session=session)
|
||||
if app_model_config is None:
|
||||
raise AppUnavailableError()
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict())
|
||||
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
|
||||
features_dict = cast(dict[str, Any], app_model_config.to_dict(annotation_reply=annotation_reply))
|
||||
return features_dict, cast(list[dict[str, Any]], features_dict.get("user_input_form", []))
|
||||
|
||||
|
||||
def build_input_schema(app: App) -> dict[str, Any]:
|
||||
def build_input_schema(app: App, *, session: Session) -> dict[str, Any]:
|
||||
"""Derive Draft 2020-12 JSON Schema from `user_input_form` + app mode.
|
||||
|
||||
chat / agent-chat / advanced-chat: top-level `query` (required, minLength=1) + `inputs` object.
|
||||
completion / workflow: `inputs` object only.
|
||||
Raises `AppUnavailableError` on misconfigured apps.
|
||||
"""
|
||||
_, user_input_form = resolve_app_config(app)
|
||||
_, user_input_form = resolve_app_config(app, session=session)
|
||||
inputs_props, inputs_required = _form_to_jsonschema(user_input_form)
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.session import with_session
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import (
|
||||
@@ -18,7 +20,6 @@ from controllers.openapi._models import (
|
||||
)
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.oauth_bearer import (
|
||||
Scope,
|
||||
@@ -41,14 +42,13 @@ from services.oauth_device_flow import (
|
||||
class AccountApi(Resource):
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, AccountResponse, description="Account info")
|
||||
def get(self, *, auth_data: AuthData):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, *, auth_data: AuthData):
|
||||
enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{auth_data.account_id}")
|
||||
|
||||
account_id_str = str(auth_data.account_id) if auth_data.account_id else None
|
||||
account = AccountService.get_account_by_id(account_id_str, session=db.session()) if account_id_str else None
|
||||
memberships = (
|
||||
TenantService.get_account_memberships(account_id_str, session=db.session()) if account_id_str else []
|
||||
)
|
||||
account = AccountService.get_account_by_id(account_id_str, session=session) if account_id_str else None
|
||||
memberships = TenantService.get_account_memberships(account_id_str, session=session) if account_id_str else []
|
||||
default_ws_id = _pick_default_workspace(memberships)
|
||||
|
||||
return AccountResponse(
|
||||
@@ -64,8 +64,9 @@ class AccountApi(Resource):
|
||||
class AccountSessionsSelfApi(Resource):
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, RevokeResponse, description="Session revoked")
|
||||
def delete(self, *, auth_data: AuthData):
|
||||
revoke_oauth_token(redis_client, str(auth_data.token_id), session=db.session())
|
||||
@with_session
|
||||
def delete(self, session: Session, *, auth_data: AuthData):
|
||||
revoke_oauth_token(redis_client, str(auth_data.token_id), session=session)
|
||||
return RevokeResponse(status="revoked")
|
||||
|
||||
|
||||
@@ -74,7 +75,8 @@ class AccountSessionsApi(Resource):
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, SessionListResponse, description="Session list")
|
||||
@accepts(query=SessionListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: SessionListQuery):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, *, auth_data: AuthData, query: SessionListQuery):
|
||||
# SessionListQuery enforces the advertised bounds (extra='forbid', page>=1,
|
||||
# 1<=limit<=MAX_PAGE_LIMIT) so the server rejects out-of-range paging rather
|
||||
# than silently coercing (e.g. page=0 -> empty slice).
|
||||
@@ -83,7 +85,7 @@ class AccountSessionsApi(Resource):
|
||||
page = query.page
|
||||
limit = query.limit
|
||||
|
||||
all_rows = list_active_sessions(ctx, now, session=db.session())
|
||||
all_rows = list_active_sessions(ctx, now, session=session)
|
||||
|
||||
total = len(all_rows)
|
||||
sliced = all_rows[(page - 1) * limit : page * limit]
|
||||
@@ -114,15 +116,16 @@ class AccountSessionsApi(Resource):
|
||||
class AccountSessionByIdApi(Resource):
|
||||
@auth_router.guard(scope=Scope.FULL, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, RevokeResponse, description="Session revoked")
|
||||
def delete(self, session_id: str, *, auth_data: AuthData):
|
||||
@with_session
|
||||
def delete(self, session: Session, session_id: str, *, auth_data: AuthData):
|
||||
ctx = get_auth_ctx()
|
||||
|
||||
# 404 (not 403) on cross-subject so the endpoint doesn't leak
|
||||
# token IDs that belong to other subjects.
|
||||
if not token_belongs_to_subject(session_id, ctx, session=db.session()):
|
||||
if not token_belongs_to_subject(session_id, ctx, session=session):
|
||||
raise NotFound("session not found")
|
||||
|
||||
revoke_oauth_token(redis_client, session_id, session=db.session())
|
||||
revoke_oauth_token(redis_client, session_id, session=session)
|
||||
return RevokeResponse(status="revoked")
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import uuid as _uuid
|
||||
from typing import Any, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.app_access import AppAccessFilter, resolve_app_access_filter
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.session import with_session
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
@@ -28,7 +30,6 @@ from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
@@ -56,7 +57,7 @@ _EMPTY_PARAMETERS: dict[str, Any] = {
|
||||
class AppReadResource(Resource):
|
||||
"""Base for per-app read endpoints; subclasses call `_load()` for membership/exists checks."""
|
||||
|
||||
def _load(self, app_id: str, workspace_id: str | None = None) -> App:
|
||||
def _load(self, session: Session, app_id: str, workspace_id: str | None = None) -> App:
|
||||
try:
|
||||
parsed_uuid = _uuid.UUID(app_id)
|
||||
is_uuid = True
|
||||
@@ -66,13 +67,13 @@ class AppReadResource(Resource):
|
||||
|
||||
if is_uuid:
|
||||
# ``str(parsed_uuid)`` normalises to the canonical dashed form.
|
||||
app = AppService.get_visible_app_by_id(str(parsed_uuid), session=db.session())
|
||||
app = AppService.get_visible_app_by_id(str(parsed_uuid), session)
|
||||
if app is None:
|
||||
raise NotFound("app not found")
|
||||
else:
|
||||
if not workspace_id:
|
||||
raise UnprocessableEntity("workspace_id is required for name-based lookup")
|
||||
matches = AppService.find_visible_apps_by_name(name=app_id, tenant_id=workspace_id, session=db.session())
|
||||
matches = AppService.find_visible_apps_by_name(session, name=app_id, tenant_id=workspace_id)
|
||||
if len(matches) == 0:
|
||||
raise NotFound("app not found")
|
||||
if len(matches) > 1:
|
||||
@@ -86,14 +87,14 @@ class AppReadResource(Resource):
|
||||
return app
|
||||
|
||||
|
||||
def parameters_payload(app: App) -> dict:
|
||||
def parameters_payload(app: App, *, session: Session) -> dict:
|
||||
"""Mirrors service_api/app/app.py::AppParameterApi response body."""
|
||||
features_dict, user_input_form = resolve_app_config(app)
|
||||
features_dict, user_input_form = resolve_app_config(app, session=session)
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
return Parameters.model_validate(parameters).model_dump(mode="json")
|
||||
|
||||
|
||||
def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescribeResponse:
|
||||
def build_app_describe_response(app: App, fields: set[str] | None, *, session: Session) -> AppDescribeResponse:
|
||||
"""Public projection of an app (name / params / input schema) — never internal config."""
|
||||
want_info = fields is None or "info" in fields
|
||||
want_params = fields is None or "parameters" in fields
|
||||
@@ -117,12 +118,12 @@ def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescrib
|
||||
input_schema: dict[str, Any] | None = None
|
||||
if want_params:
|
||||
try:
|
||||
parameters = parameters_payload(app)
|
||||
parameters = parameters_payload(app, session=session)
|
||||
except AppUnavailableError:
|
||||
parameters = dict(_EMPTY_PARAMETERS)
|
||||
if want_schema:
|
||||
try:
|
||||
input_schema = build_input_schema(app)
|
||||
input_schema = build_input_schema(app, session=session)
|
||||
except AppUnavailableError:
|
||||
input_schema = dict(EMPTY_INPUT_SCHEMA)
|
||||
|
||||
@@ -138,10 +139,11 @@ class AppDescribeApi(AppReadResource):
|
||||
)
|
||||
@returns(200, AppDescribeResponse, description="App description")
|
||||
@accepts(query=AppDescribeQuery)
|
||||
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
|
||||
# describe is UUID-only (workspace_id query param dropped in #37212).
|
||||
app = self._load(app_id)
|
||||
return build_app_describe_response(app, query.fields)
|
||||
app = self._load(session, app_id)
|
||||
return build_app_describe_response(app, query.fields, session=session)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps")
|
||||
@@ -149,7 +151,8 @@ class AppListApi(Resource):
|
||||
@auth_router.guard_workspace(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, AppListResponse, description="App list")
|
||||
@accepts(query=AppListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: AppListQuery):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, *, auth_data: AuthData, query: AppListQuery):
|
||||
workspace_id = query.workspace_id
|
||||
|
||||
empty = AppListResponse(page=query.page, limit=query.limit, total=0, has_more=False, data=[])
|
||||
@@ -173,11 +176,15 @@ class AppListApi(Resource):
|
||||
)
|
||||
access_filter = AppAccessFilter.unrestricted()
|
||||
if apply_rbac_filter:
|
||||
access_filter = resolve_app_access_filter(workspace_id, str(auth_data.account_id))
|
||||
access_filter = resolve_app_access_filter(
|
||||
workspace_id,
|
||||
str(auth_data.account_id),
|
||||
session=session,
|
||||
)
|
||||
|
||||
tenant_name: str | None = None
|
||||
if parsed_uuid is not None:
|
||||
app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), session=db.session())
|
||||
app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), session)
|
||||
if app is None or str(app.tenant_id) != workspace_id:
|
||||
return empty
|
||||
if not _is_listable(app):
|
||||
@@ -188,7 +195,7 @@ class AppListApi(Resource):
|
||||
str(app.id), str(app.maintainer) if app.maintainer else None, str(auth_data.account_id)
|
||||
):
|
||||
return empty
|
||||
tenant_name = TenantService.get_tenant_name(workspace_id, session=db.session())
|
||||
tenant_name = TenantService.get_tenant_name(workspace_id, session=session)
|
||||
item = AppListRow(
|
||||
id=str(app.id),
|
||||
name=app.name,
|
||||
@@ -215,13 +222,13 @@ class AppListApi(Resource):
|
||||
if apply_rbac_filter:
|
||||
access_filter.apply_to_params(params)
|
||||
|
||||
pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, db.session())
|
||||
pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, session)
|
||||
if pagination is None:
|
||||
return empty
|
||||
|
||||
tenant_name = None
|
||||
if pagination.items:
|
||||
tenant_name = TenantService.get_tenant_name(workspace_id, session=db.session())
|
||||
tenant_name = TenantService.get_tenant_name(workspace_id, session=session)
|
||||
|
||||
items = [
|
||||
AppListRow(
|
||||
|
||||
@@ -8,8 +8,10 @@ EE blueprint chain so this module is unreachable there.
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.session import with_session
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import (
|
||||
@@ -22,7 +24,6 @@ from controllers.openapi._models import (
|
||||
from controllers.openapi.apps import build_app_describe_response
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData, Edition
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import App
|
||||
from models.enums import AppStatus
|
||||
@@ -40,7 +41,8 @@ class PermittedExternalAppsListApi(Resource):
|
||||
)
|
||||
@returns(200, PermittedExternalAppsListResponse, description="Permitted external apps list")
|
||||
@accepts(query=PermittedExternalAppsListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: PermittedExternalAppsListQuery):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, *, auth_data: AuthData, query: PermittedExternalAppsListQuery):
|
||||
page_result = list_permitted_apps(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
@@ -55,10 +57,10 @@ class PermittedExternalAppsListApi(Resource):
|
||||
return env
|
||||
|
||||
apps_by_id: dict[str, App] = {
|
||||
str(a.id): a for a in AppService.find_visible_apps_by_ids(page_result.app_ids, session=db.session())
|
||||
str(a.id): a for a in AppService.find_visible_apps_by_ids(page_result.app_ids, session)
|
||||
}
|
||||
tenant_ids = list({str(a.tenant_id) for a in apps_by_id.values()})
|
||||
tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(tenant_ids, session=db.session())}
|
||||
tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(tenant_ids, session=session)}
|
||||
|
||||
items: list[AppListRow] = []
|
||||
for app_id in page_result.app_ids:
|
||||
@@ -96,9 +98,10 @@ class PermittedExternalAppDescribeApi(Resource):
|
||||
)
|
||||
@returns(200, AppDescribeResponse, description="Permitted external app description")
|
||||
@accepts(query=AppDescribeQuery)
|
||||
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
|
||||
# App already loaded and ACL-checked by the external_sso pipeline; project it.
|
||||
app = auth_data.app
|
||||
if app is None:
|
||||
raise NotFound("app not found")
|
||||
return build_app_describe_response(app, query.fields)
|
||||
return build_app_describe_response(app, query.fields, session=session)
|
||||
@@ -6,7 +6,7 @@ from flask import request
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.data import AuthData, CallerKind
|
||||
from extensions.ext_database import db
|
||||
from core.db.session_factory import session_factory
|
||||
from models.account import AccountStatus, TenantStatus
|
||||
from models.enums import AppStatus, EndUserType
|
||||
from services.account_service import AccountService, TenantService
|
||||
@@ -23,7 +23,8 @@ def load_app(data: AuthData) -> None:
|
||||
uuid.UUID(app_id)
|
||||
except ValueError:
|
||||
raise NotFound("app not found")
|
||||
app = AppService.get_app_by_id(app_id, session=db.session())
|
||||
with session_factory.create_session() as session:
|
||||
app = AppService.get_app_by_id(app_id, session)
|
||||
if not app or app.status != AppStatus.NORMAL:
|
||||
raise NotFound("app not found")
|
||||
data.app = app
|
||||
@@ -34,7 +35,8 @@ def load_tenant(data: AuthData) -> None:
|
||||
return
|
||||
if data.app is None:
|
||||
raise InternalServerError("pipeline_invariant_violated: app not loaded before load_tenant")
|
||||
tenant = TenantService.get_tenant_by_id(str(data.app.tenant_id), session=db.session())
|
||||
with session_factory.create_session() as session:
|
||||
tenant = TenantService.get_tenant_by_id(str(data.app.tenant_id), session=session)
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise Forbidden("workspace unavailable")
|
||||
data.tenant = tenant
|
||||
@@ -50,7 +52,8 @@ def load_tenant_from_request(data: AuthData) -> None:
|
||||
uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise NotFound("workspace not found")
|
||||
tenant = TenantService.get_tenant_by_id(workspace_id, session=db.session())
|
||||
with session_factory.create_session() as session:
|
||||
tenant = TenantService.get_tenant_by_id(workspace_id, session=session)
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise NotFound("workspace not found")
|
||||
data.tenant = tenant
|
||||
@@ -59,11 +62,12 @@ def load_tenant_from_request(data: AuthData) -> None:
|
||||
def load_account(data: AuthData) -> None:
|
||||
if data.caller is not None:
|
||||
return
|
||||
account = AccountService.get_account_by_id(str(data.account_id), session=db.session())
|
||||
if account is None:
|
||||
raise Unauthorized("account not found")
|
||||
if data.tenant:
|
||||
account.current_tenant = data.tenant
|
||||
with session_factory.create_session() as session:
|
||||
account = AccountService.get_account_by_id(str(data.account_id), session=session)
|
||||
if account is None:
|
||||
raise Unauthorized("account not found")
|
||||
if data.tenant:
|
||||
account.set_current_tenant_with_session(data.tenant, session=session)
|
||||
data.caller = account
|
||||
data.caller_kind = CallerKind.ACCOUNT
|
||||
|
||||
@@ -75,7 +79,8 @@ def load_workspace_role(data: AuthData) -> None:
|
||||
return
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE:
|
||||
return
|
||||
role = TenantService.get_account_role_in_tenant(str(data.account_id), str(data.tenant.id), session=db.session())
|
||||
with session_factory.create_session() as session:
|
||||
role = TenantService.get_account_role_in_tenant(str(data.account_id), str(data.tenant.id), session=session)
|
||||
if role is None:
|
||||
return
|
||||
data.tenant_role = role
|
||||
|
||||
@@ -15,9 +15,11 @@ from itertools import starmap
|
||||
from urllib import parse
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.session import with_session
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
|
||||
@@ -35,7 +37,6 @@ from controllers.openapi._models import (
|
||||
)
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
from models.account import TenantAccountRole, TenantStatus
|
||||
@@ -64,15 +65,15 @@ def _member_response(account: Account) -> MemberResponse:
|
||||
)
|
||||
|
||||
|
||||
def _load_tenant(workspace_id: str) -> Tenant:
|
||||
tenant = TenantService.get_tenant_by_id(workspace_id, session=db.session())
|
||||
def _load_tenant(session: Session, workspace_id: str) -> Tenant:
|
||||
tenant = TenantService.get_tenant_by_id(workspace_id, session=session)
|
||||
if tenant is None or tenant.status != TenantStatus.NORMAL:
|
||||
raise NotFound("workspace not found")
|
||||
return tenant
|
||||
|
||||
|
||||
def _load_account(account_id: object) -> Account:
|
||||
account = AccountService.get_account_by_id(str(account_id), session=db.session()) if account_id else None
|
||||
def _load_account(session: Session, account_id: object) -> Account:
|
||||
account = AccountService.get_account_by_id(str(account_id), session=session) if account_id else None
|
||||
if account is None:
|
||||
raise RuntimeError("authenticated account_id has no Account row")
|
||||
return account
|
||||
@@ -94,8 +95,9 @@ def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
class WorkspacesApi(Resource):
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, WorkspaceListResponse, description="Workspace list")
|
||||
def get(self, *, auth_data: AuthData):
|
||||
rows = TenantService.get_workspaces_for_account(str(auth_data.account_id), session=db.session())
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, *, auth_data: AuthData):
|
||||
rows = TenantService.get_workspaces_for_account(str(auth_data.account_id), session=session)
|
||||
|
||||
return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows)))
|
||||
|
||||
@@ -104,8 +106,9 @@ class WorkspacesApi(Resource):
|
||||
class WorkspaceByIdApi(Resource):
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, WorkspaceDetailResponse, description="Workspace detail")
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData):
|
||||
row = TenantService.find_workspace_for_account(str(auth_data.account_id), workspace_id, session=db.session())
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, workspace_id: str, *, auth_data: AuthData):
|
||||
row = TenantService.find_workspace_for_account(str(auth_data.account_id), workspace_id, session=session)
|
||||
# 404 (not 403) on non-member so workspace IDs don't leak across tenants.
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
@@ -125,15 +128,16 @@ class WorkspaceSwitchApi(Resource):
|
||||
|
||||
@auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, WorkspaceDetailResponse, description="Workspace detail")
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData):
|
||||
account = _load_account(auth_data.account_id)
|
||||
@with_session
|
||||
def post(self, session: Session, workspace_id: str, *, auth_data: AuthData):
|
||||
account = _load_account(session, auth_data.account_id)
|
||||
|
||||
try:
|
||||
TenantService.switch_tenant(account, workspace_id, session=db.session())
|
||||
TenantService.switch_tenant(account, workspace_id, session=session)
|
||||
except AccountNotLinkTenantError:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
row = TenantService.find_workspace_for_account(str(auth_data.account_id), workspace_id, session=db.session())
|
||||
row = TenantService.find_workspace_for_account(str(auth_data.account_id), workspace_id, session=session)
|
||||
if row is None:
|
||||
raise NotFound("workspace not found")
|
||||
tenant, membership = row
|
||||
@@ -151,9 +155,10 @@ class WorkspaceMembersApi(Resource):
|
||||
@auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@returns(200, MemberListResponse, description="Member list")
|
||||
@accepts(query=MemberListQuery)
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData, query: MemberListQuery):
|
||||
tenant = _load_tenant(workspace_id)
|
||||
members = TenantService.get_tenant_members(tenant, session=db.session())
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, workspace_id: str, *, auth_data: AuthData, query: MemberListQuery):
|
||||
tenant = _load_tenant(session, workspace_id)
|
||||
members = TenantService.get_tenant_members(tenant, session=session)
|
||||
total = len(members)
|
||||
start = (query.page - 1) * query.limit
|
||||
page_items = members[start : start + query.limit]
|
||||
@@ -172,9 +177,10 @@ class WorkspaceMembersApi(Resource):
|
||||
)
|
||||
@returns(201, MemberInviteResponse, description="Member invited")
|
||||
@accepts(body=MemberInvitePayload)
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData, body: MemberInvitePayload):
|
||||
inviter = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
@with_session
|
||||
def post(self, session: Session, workspace_id: str, *, auth_data: AuthData, body: MemberInvitePayload):
|
||||
inviter = _load_account(session, auth_data.account_id)
|
||||
tenant = _load_tenant(session, workspace_id)
|
||||
|
||||
_check_member_invite_quota(str(tenant.id))
|
||||
|
||||
@@ -185,7 +191,7 @@ class WorkspaceMembersApi(Resource):
|
||||
language=None,
|
||||
role=body.role,
|
||||
inviter=inviter,
|
||||
session=db.session(),
|
||||
session=session,
|
||||
)
|
||||
except AccountAlreadyInTenantError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
@@ -197,7 +203,7 @@ class WorkspaceMembersApi(Resource):
|
||||
raise BadRequest(str(exc))
|
||||
|
||||
normalized_email = body.email.lower()
|
||||
member = AccountService.get_account_by_email_with_case_fallback(normalized_email, session=db.session())
|
||||
member = AccountService.get_account_by_email_with_case_fallback(normalized_email, session=session)
|
||||
if member is None:
|
||||
# invite_new_member just created or fetched this account.
|
||||
raise RuntimeError("invited member missing from DB after invite")
|
||||
@@ -229,15 +235,16 @@ class WorkspaceMemberApi(Resource):
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
@returns(200, MemberActionResponse, description="Member removed")
|
||||
def delete(self, workspace_id: str, member_id: str, *, auth_data: AuthData):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(member_id, session=db.session())
|
||||
@with_session
|
||||
def delete(self, session: Session, workspace_id: str, member_id: str, *, auth_data: AuthData):
|
||||
operator = _load_account(session, auth_data.account_id)
|
||||
tenant = _load_tenant(session, workspace_id)
|
||||
member = AccountService.get_account_by_id(member_id, session=session)
|
||||
if member is None:
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.remove_member_from_tenant(tenant, member, operator, session=db.session())
|
||||
TenantService.remove_member_from_tenant(tenant, member, operator, session=session)
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
@@ -254,15 +261,24 @@ class WorkspaceMemberApi(Resource):
|
||||
)
|
||||
@returns(200, MemberActionResponse, description="Role updated")
|
||||
@accepts(body=MemberRoleUpdatePayload)
|
||||
def patch(self, workspace_id: str, member_id: str, *, auth_data: AuthData, body: MemberRoleUpdatePayload):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
member = AccountService.get_account_by_id(member_id, session=db.session())
|
||||
@with_session
|
||||
def patch(
|
||||
self,
|
||||
session: Session,
|
||||
workspace_id: str,
|
||||
member_id: str,
|
||||
*,
|
||||
auth_data: AuthData,
|
||||
body: MemberRoleUpdatePayload,
|
||||
):
|
||||
operator = _load_account(session, auth_data.account_id)
|
||||
tenant = _load_tenant(session, workspace_id)
|
||||
member = AccountService.get_account_by_id(member_id, session=session)
|
||||
if member is None:
|
||||
raise NotFound("member not found")
|
||||
|
||||
try:
|
||||
TenantService.update_member_role(tenant, member, body.role, operator, session=db.session())
|
||||
TenantService.update_member_role(tenant, member, body.role, operator, session=session)
|
||||
except CannotOperateSelfError as exc:
|
||||
raise BadRequest(str(exc))
|
||||
except NoPermissionError as exc:
|
||||
|
||||
Reference in new issue
Block a user