mirror of
https://github.com/langgenius/dify.git
synced 2026-06-03 08:16:37 +08:00
fix(api): enforce workspace membership + role checks in auth pipeline (#36931)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -147,7 +147,7 @@ class AppDescribeApi(AppReadResource):
|
||||
class AppListApi(Resource):
|
||||
@openapi_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@openapi_ns.response(200, "App list", openapi_ns.models[AppListResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@auth_router.guard_workspace(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, *, auth_data: AuthData):
|
||||
try:
|
||||
query: AppListQuery = AppListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from controllers.openapi.auth.conditions import (
|
||||
EDITION_CE,
|
||||
EDITION_EE,
|
||||
HAS_ALLOWED_ROLES,
|
||||
LOADED_APP_IS_PRIVATE,
|
||||
PATH_HAS_APP_ID,
|
||||
WEBAPP_AUTH_ENABLED,
|
||||
WORKSPACE_MEMBERSHIP_REQUIRED,
|
||||
WORKSPACE_SCOPED,
|
||||
)
|
||||
from controllers.openapi.auth.data import Edition
|
||||
from controllers.openapi.auth.flow import When
|
||||
@@ -15,14 +17,18 @@ from controllers.openapi.auth.prepare import (
|
||||
load_app,
|
||||
load_app_access_mode,
|
||||
load_tenant,
|
||||
load_tenant_from_request,
|
||||
load_workspace_role,
|
||||
resolve_external_user,
|
||||
)
|
||||
from controllers.openapi.auth.verify import (
|
||||
check_acl,
|
||||
check_app_access,
|
||||
check_membership,
|
||||
check_app_api_enabled,
|
||||
check_private_app_permission,
|
||||
check_scope,
|
||||
check_workspace_member,
|
||||
check_workspace_mismatch,
|
||||
check_workspace_role,
|
||||
)
|
||||
from libs.oauth_bearer import TokenType
|
||||
|
||||
@@ -30,13 +36,17 @@ account_pipeline = AuthPipeline(
|
||||
prepare=[
|
||||
When(PATH_HAS_APP_ID, then=load_app),
|
||||
When(PATH_HAS_APP_ID, then=load_tenant),
|
||||
load_account, # all tokens here are account tokens
|
||||
When(WORKSPACE_MEMBERSHIP_REQUIRED, then=load_tenant_from_request),
|
||||
load_account,
|
||||
When(WORKSPACE_SCOPED, then=load_workspace_role),
|
||||
When(PATH_HAS_APP_ID & EDITION_EE, then=load_app_access_mode),
|
||||
],
|
||||
auth=[
|
||||
When(PATH_HAS_APP_ID, then=check_app_api_enabled),
|
||||
check_scope,
|
||||
When(EDITION_CE & PATH_HAS_APP_ID, then=check_membership),
|
||||
When(EDITION_EE & PATH_HAS_APP_ID & ~WEBAPP_AUTH_ENABLED, then=check_app_access),
|
||||
When(WORKSPACE_SCOPED, then=check_workspace_member),
|
||||
When(PATH_HAS_APP_ID, then=check_workspace_mismatch),
|
||||
When(HAS_ALLOWED_ROLES, then=check_workspace_role),
|
||||
When(PATH_HAS_APP_ID & EDITION_EE & WEBAPP_AUTH_ENABLED, then=check_acl),
|
||||
When(EDITION_EE & LOADED_APP_IS_PRIVATE, then=check_private_app_permission),
|
||||
],
|
||||
@@ -50,6 +60,7 @@ external_sso_pipeline = AuthPipeline(
|
||||
When(PATH_HAS_APP_ID, then=load_app_access_mode),
|
||||
],
|
||||
auth=[
|
||||
When(PATH_HAS_APP_ID, then=check_app_api_enabled),
|
||||
check_scope,
|
||||
When(PATH_HAS_APP_ID & WEBAPP_AUTH_ENABLED, then=check_acl),
|
||||
When(LOADED_APP_IS_PRIVATE, then=check_private_app_permission),
|
||||
|
||||
@@ -50,4 +50,11 @@ EDITION_SAAS = config_cond(lambda: current_edition() == Edition.SAAS)
|
||||
|
||||
WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled)
|
||||
|
||||
WORKSPACE_MEMBERSHIP_REQUIRED = request_cond(lambda ctx: ctx.workspace_membership)
|
||||
HAS_ALLOWED_ROLES = request_cond(lambda ctx: ctx.allowed_roles is not None)
|
||||
|
||||
# Caller must belong to the resolved tenant: either an app-scoped path (tenant
|
||||
# from the app) or an explicit workspace-membership path (tenant from request).
|
||||
WORKSPACE_SCOPED = PATH_HAS_APP_ID | WORKSPACE_MEMBERSHIP_REQUIRED
|
||||
|
||||
LOADED_APP_IS_PRIVATE = data_cond(lambda data: data.app_access_mode == WebAppAccessMode.PRIVATE)
|
||||
|
||||
@@ -9,7 +9,7 @@ from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from configs import dify_config
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models.account import Account, Tenant
|
||||
from models.account import Account, Tenant, TenantAccountRole
|
||||
from models.model import App, EndUser
|
||||
from services.enterprise.enterprise_service import WebAppAccessMode
|
||||
|
||||
@@ -41,6 +41,8 @@ class RequestContext(BaseModel):
|
||||
token_type: TokenType
|
||||
scope: Scope | None = None
|
||||
path_params: dict[str, str]
|
||||
workspace_membership: bool = False
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None
|
||||
|
||||
|
||||
class AuthData(BaseModel):
|
||||
@@ -56,10 +58,14 @@ class AuthData(BaseModel):
|
||||
external_identity: ExternalIdentity | None = None
|
||||
path_params: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None
|
||||
|
||||
app: App | None = None
|
||||
tenant: Tenant | None = None
|
||||
app_access_mode: WebAppAccessMode | None = None
|
||||
|
||||
tenant_role: TenantAccountRole | None = None
|
||||
|
||||
caller: Account | EndUser | None = None
|
||||
caller_kind: Literal["account", "end_user"] | None = None
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from libs.oauth_bearer import (
|
||||
reset_auth_ctx,
|
||||
set_auth_ctx,
|
||||
)
|
||||
from models.account import TenantAccountRole
|
||||
from services.feature_service import FeatureService, LicenseStatus
|
||||
|
||||
|
||||
@@ -56,11 +57,15 @@ class AuthPipeline:
|
||||
view: Callable,
|
||||
*,
|
||||
scope: Scope | None,
|
||||
workspace_membership: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
) -> Any:
|
||||
req_ctx = RequestContext(
|
||||
token_type=identity.token_type,
|
||||
scope=scope,
|
||||
path_params=dict(request.view_args or {}),
|
||||
workspace_membership=workspace_membership,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
|
||||
data = AuthData(
|
||||
@@ -71,6 +76,7 @@ class AuthPipeline:
|
||||
scopes=frozenset(identity.scopes),
|
||||
tenants=dict(identity.verified_tenants),
|
||||
required_scope=scope,
|
||||
allowed_roles=allowed_roles,
|
||||
path_params=dict(req_ctx.path_params),
|
||||
external_identity=(
|
||||
ExternalIdentity(email=identity.subject_email, issuer=identity.subject_issuer)
|
||||
@@ -121,6 +127,41 @@ class PipelineRouter:
|
||||
scope: Scope | None = None,
|
||||
allowed_token_types: frozenset[TokenType] | None = None,
|
||||
edition: frozenset[Edition] | None = None,
|
||||
workspace_membership: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
) -> Callable:
|
||||
return self._make_decorator(
|
||||
scope=scope,
|
||||
allowed_token_types=allowed_token_types,
|
||||
edition=edition,
|
||||
workspace_membership=workspace_membership,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
|
||||
def guard_workspace(
|
||||
self,
|
||||
*,
|
||||
scope: Scope | None = None,
|
||||
allowed_token_types: frozenset[TokenType] | None = None,
|
||||
edition: frozenset[Edition] | None = None,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
) -> Callable:
|
||||
return self._make_decorator(
|
||||
scope=scope,
|
||||
allowed_token_types=allowed_token_types,
|
||||
edition=edition,
|
||||
workspace_membership=True,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
|
||||
def _make_decorator(
|
||||
self,
|
||||
*,
|
||||
scope: Scope | None,
|
||||
allowed_token_types: frozenset[TokenType] | None,
|
||||
edition: frozenset[Edition] | None,
|
||||
workspace_membership: bool,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None,
|
||||
) -> Callable:
|
||||
def decorator(view: Callable) -> Callable:
|
||||
@wraps(view)
|
||||
@@ -132,6 +173,8 @@ class PipelineRouter:
|
||||
scope=scope,
|
||||
allowed_token_types=allowed_token_types,
|
||||
edition=edition,
|
||||
workspace_membership=workspace_membership,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
|
||||
return decorated
|
||||
@@ -147,6 +190,8 @@ class PipelineRouter:
|
||||
scope: Scope | None,
|
||||
allowed_token_types: frozenset[TokenType] | None,
|
||||
edition: frozenset[Edition] | None,
|
||||
workspace_membership: bool = False,
|
||||
allowed_roles: frozenset[TenantAccountRole] | None = None,
|
||||
) -> Any:
|
||||
# 404 not 403 — this edition doesn't expose the feature at all
|
||||
if edition is not None and current_edition() not in edition:
|
||||
@@ -182,7 +227,15 @@ class PipelineRouter:
|
||||
if not license_checked and Edition.EE in route.required_edition:
|
||||
_check_license()
|
||||
|
||||
return route.pipeline._run(identity, args, kwargs, view, scope=scope)
|
||||
return route.pipeline._run(
|
||||
identity,
|
||||
args,
|
||||
kwargs,
|
||||
view,
|
||||
scope=scope,
|
||||
workspace_membership=workspace_membership,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
|
||||
|
||||
def _should_run(step: Any, req_ctx: RequestContext, data: AuthData | None) -> bool:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from flask import request
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
@@ -13,16 +16,18 @@ from services.enterprise.enterprise_service import EnterpriseService, WebAppAcce
|
||||
|
||||
|
||||
def load_app(data: AuthData) -> None:
|
||||
if data.app is not None:
|
||||
return
|
||||
app_id = data.path_params["app_id"]
|
||||
app = AppService.get_app_by_id(db.session, app_id)
|
||||
if not app or app.status != "normal":
|
||||
raise NotFound("app not found")
|
||||
if not app.enable_api:
|
||||
raise Forbidden("service_api_disabled")
|
||||
data.app = app
|
||||
|
||||
|
||||
def load_tenant(data: AuthData) -> None:
|
||||
if data.tenant is not None:
|
||||
return
|
||||
if data.app is None:
|
||||
raise InternalServerError("pipeline_invariant_violated: app not loaded before load_tenant")
|
||||
tenant = TenantService.get_tenant_by_id(db.session, str(data.app.tenant_id))
|
||||
@@ -31,7 +36,25 @@ def load_tenant(data: AuthData) -> None:
|
||||
data.tenant = tenant
|
||||
|
||||
|
||||
def load_tenant_from_request(data: AuthData) -> None:
|
||||
if data.tenant is not None:
|
||||
return
|
||||
workspace_id = data.path_params.get("workspace_id") or request.args.get("workspace_id")
|
||||
if not workspace_id:
|
||||
raise NotFound("workspace not found")
|
||||
try:
|
||||
uuid.UUID(workspace_id)
|
||||
except ValueError:
|
||||
raise NotFound("workspace not found")
|
||||
tenant = TenantService.get_tenant_by_id(db.session, workspace_id)
|
||||
if tenant is None or tenant.status == TenantStatus.ARCHIVE:
|
||||
raise NotFound("workspace not found")
|
||||
data.tenant = tenant
|
||||
|
||||
|
||||
def load_account(data: AuthData) -> None:
|
||||
if data.caller is not None:
|
||||
return
|
||||
account = AccountService.get_account_by_id(db.session, str(data.account_id))
|
||||
if account is None:
|
||||
raise Unauthorized("account not found")
|
||||
@@ -41,6 +64,19 @@ def load_account(data: AuthData) -> None:
|
||||
data.caller_kind = "account"
|
||||
|
||||
|
||||
def load_workspace_role(data: AuthData) -> None:
|
||||
if data.tenant_role is not None:
|
||||
return
|
||||
if data.tenant is None or data.account_id is None:
|
||||
return
|
||||
if data.caller is not None and getattr(data.caller, "status", None) != "active":
|
||||
return
|
||||
role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id))
|
||||
if role is None:
|
||||
return
|
||||
data.tenant_role = role
|
||||
|
||||
|
||||
def resolve_external_user(data: AuthData) -> None:
|
||||
if data.tenant is None or data.app is None or data.external_identity is None:
|
||||
raise Unauthorized("missing context for external user resolution")
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Workspace role gate.
|
||||
|
||||
Layered on top of `validate_bearer` + `accept_subjects(SubjectType.ACCOUNT)`
|
||||
for routes whose access depends on the caller's `TenantAccountJoin.role`
|
||||
in the workspace named by the `workspace_id` path parameter.
|
||||
|
||||
Usage::
|
||||
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
|
||||
class Members(Resource):
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role() # any member
|
||||
def get(self, workspace_id: str): ...
|
||||
|
||||
@validate_bearer(accept=ACCEPT_USER_ANY)
|
||||
@accept_subjects(SubjectType.ACCOUNT)
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def post(self, workspace_id: str): ...
|
||||
|
||||
Non-member callers get 404 (matching `GET /openapi/v1/workspaces/<id>`)
|
||||
so workspace IDs do not leak across tenants. A member without one of the
|
||||
allowed roles gets 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TypeVar
|
||||
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import try_get_auth_ctx
|
||||
from models.account import TenantAccountRole
|
||||
from services.account_service import TenantService
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., object])
|
||||
|
||||
|
||||
def require_workspace_role(*allowed_roles: TenantAccountRole) -> Callable[[F], F]:
|
||||
"""Gate a route on the caller's role in ``workspace_id``.
|
||||
|
||||
Pass no roles to require only membership. Pass one or more roles to
|
||||
require the caller's role be in that set.
|
||||
"""
|
||||
|
||||
allowed = frozenset(allowed_roles)
|
||||
|
||||
def deco(fn: F) -> F:
|
||||
@wraps(fn)
|
||||
def wrapper(*args: object, **kwargs: object) -> object:
|
||||
ctx = try_get_auth_ctx()
|
||||
if ctx is None or ctx.account_id is None:
|
||||
raise RuntimeError(
|
||||
"require_workspace_role called without account-bearer context; "
|
||||
"stack validate_bearer + accept_subjects(SubjectType.ACCOUNT) above it"
|
||||
)
|
||||
|
||||
workspace_id = kwargs.get("workspace_id")
|
||||
if not workspace_id:
|
||||
raise RuntimeError("require_workspace_role expects a 'workspace_id' route parameter")
|
||||
|
||||
role = TenantService.get_account_role_in_tenant(db.session, str(ctx.account_id), str(workspace_id))
|
||||
|
||||
if role is None:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
if allowed and role not in allowed:
|
||||
raise Forbidden("insufficient workspace role")
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
return deco
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
from flask import request
|
||||
from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType, check_workspace_membership
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from services.account_service import AccountService, TenantService
|
||||
from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode
|
||||
|
||||
@@ -17,17 +18,39 @@ def check_scope(data: AuthData) -> None:
|
||||
raise Forbidden("insufficient_scope")
|
||||
|
||||
|
||||
def check_membership(data: AuthData) -> None:
|
||||
def check_workspace_member(data: AuthData) -> None:
|
||||
"""Assert the caller belongs to the resolved tenant.
|
||||
|
||||
`load_workspace_role` stashes the membership role (None when the caller is
|
||||
not a member or is inactive). A missing membership surfaces as 404, not
|
||||
403, so workspace IDs don't leak across tenants.
|
||||
"""
|
||||
if data.tenant_role is None:
|
||||
raise NotFound("workspace not found")
|
||||
|
||||
|
||||
def check_workspace_mismatch(data: AuthData) -> None:
|
||||
if data.tenant is None:
|
||||
raise Unauthorized("tenant unset")
|
||||
if data.account_id is None:
|
||||
raise Unauthorized("account_id unset")
|
||||
check_workspace_membership(
|
||||
account_id=data.account_id,
|
||||
tenant_id=data.tenant.id,
|
||||
token_hash=data.token_hash,
|
||||
membership_cache=data.tenants,
|
||||
)
|
||||
return
|
||||
request_workspace_id = data.path_params.get("workspace_id") or request.args.get("workspace_id")
|
||||
if request_workspace_id and request_workspace_id != str(data.tenant.id):
|
||||
raise UnprocessableEntity("workspace_id does not match app's workspace")
|
||||
|
||||
|
||||
def check_workspace_role(data: AuthData) -> None:
|
||||
if data.allowed_roles is None:
|
||||
return
|
||||
if data.tenant_role is None:
|
||||
raise NotFound("workspace not found")
|
||||
if data.tenant_role not in data.allowed_roles:
|
||||
raise Forbidden("insufficient workspace role")
|
||||
|
||||
|
||||
def check_app_api_enabled(data: AuthData) -> None:
|
||||
if data.app is None:
|
||||
return
|
||||
if not data.app.enable_api:
|
||||
raise Forbidden("service_api_disabled")
|
||||
|
||||
|
||||
def check_app_access(data: AuthData) -> None:
|
||||
|
||||
@@ -5,9 +5,8 @@ endpoints. Account bearers (dfoa_) see every tenant they're a member of.
|
||||
External SSO bearers (dfoe_) have no account_id and so see an empty list —
|
||||
that matches /openapi/v1/account.
|
||||
|
||||
Member-management endpoints are gated by both `accept_subjects` (SSO out)
|
||||
and `require_workspace_role` (membership / role lookup against the path's
|
||||
``workspace_id``).
|
||||
Member-management endpoints use ``guard_workspace`` which enforces
|
||||
workspace membership and optional role requirements via the auth pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,7 +36,6 @@ from controllers.openapi._models import (
|
||||
)
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.auth.role_gate import require_workspace_role
|
||||
from extensions.ext_database import db
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models import Account, Tenant, TenantAccountJoin
|
||||
@@ -152,8 +150,7 @@ class WorkspaceSwitchApi(Resource):
|
||||
"""
|
||||
|
||||
@openapi_ns.response(200, "Workspace detail", openapi_ns.models[WorkspaceDetailResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@require_workspace_role()
|
||||
@auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData):
|
||||
account = _load_account(auth_data.account_id)
|
||||
|
||||
@@ -179,8 +176,7 @@ class WorkspaceMembersApi(Resource):
|
||||
|
||||
@openapi_ns.doc(params=query_params_from_model(MemberListQuery))
|
||||
@openapi_ns.response(200, "Member list", openapi_ns.models[MemberListResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@require_workspace_role()
|
||||
@auth_router.guard_workspace(scope=Scope.WORKSPACE_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
def get(self, workspace_id: str, *, auth_data: AuthData):
|
||||
try:
|
||||
query = MemberListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@@ -202,8 +198,11 @@ class WorkspaceMembersApi(Resource):
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[MemberInvitePayload.__name__])
|
||||
@openapi_ns.response(201, "Member invited", openapi_ns.models[MemberInviteResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_WRITE, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
def post(self, workspace_id: str, *, auth_data: AuthData):
|
||||
payload = _validate_body(MemberInvitePayload)
|
||||
inviter = _load_account(auth_data.account_id)
|
||||
@@ -253,8 +252,11 @@ class WorkspaceMemberApi(Resource):
|
||||
"""
|
||||
|
||||
@openapi_ns.response(200, "Member removed", openapi_ns.models[MemberActionResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_WRITE, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
def delete(self, workspace_id: str, member_id: str, *, auth_data: AuthData):
|
||||
operator = _load_account(auth_data.account_id)
|
||||
tenant = _load_tenant(workspace_id)
|
||||
@@ -284,8 +286,11 @@ class WorkspaceMemberRoleApi(Resource):
|
||||
|
||||
@openapi_ns.expect(openapi_ns.models[MemberRoleUpdatePayload.__name__])
|
||||
@openapi_ns.response(200, "Role updated", openapi_ns.models[MemberActionResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.WORKSPACE_WRITE, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
@auth_router.guard_workspace(
|
||||
scope=Scope.WORKSPACE_WRITE,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
def put(self, workspace_id: str, member_id: str, *, auth_data: AuthData):
|
||||
payload = _validate_body(MemberRoleUpdatePayload)
|
||||
operator = _load_account(auth_data.account_id)
|
||||
|
||||
@@ -1329,9 +1329,9 @@ class TenantService:
|
||||
) -> TenantAccountRole | None:
|
||||
"""Return the caller's role in ``tenant_id``, or ``None`` if not a member.
|
||||
|
||||
Backs ``controllers.openapi.auth.role_gate.require_workspace_role``:
|
||||
the gate maps ``None`` to 404 (non-member — no cross-tenant ID leak)
|
||||
and an out-of-set role to 403, so it never touches the ORM itself.
|
||||
Backs the openapi auth pipeline's ``load_workspace_role`` prepare step:
|
||||
``None`` is treated as non-member (the pipeline maps it to 404 — no
|
||||
cross-tenant ID leak) and an out-of-set role to 403.
|
||||
|
||||
``None``/empty ``account_id`` short-circuits to ``None`` so SSO
|
||||
bearers (no account) collapse to the non-member path. Mirrors the
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import uuid
|
||||
|
||||
from controllers.openapi.auth.composition import account_pipeline, auth_router, external_sso_pipeline
|
||||
from controllers.openapi.auth.data import RequestContext
|
||||
from controllers.openapi.auth.flow import When
|
||||
from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter
|
||||
from controllers.openapi.auth.verify import (
|
||||
check_workspace_member,
|
||||
check_workspace_mismatch,
|
||||
check_workspace_role,
|
||||
)
|
||||
from libs.oauth_bearer import TokenType
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
|
||||
def test_account_pipeline_is_auth_pipeline():
|
||||
@@ -16,20 +25,20 @@ def test_auth_router_is_pipeline_router():
|
||||
assert isinstance(auth_router, PipelineRouter)
|
||||
|
||||
|
||||
def test_account_pipeline_prepare_has_four_entries():
|
||||
assert len(account_pipeline._prepare) == 4
|
||||
def test_account_pipeline_prepare_has_six_entries():
|
||||
assert len(account_pipeline._prepare) == 6
|
||||
|
||||
|
||||
def test_account_auth_list_has_five_entries():
|
||||
assert len(account_pipeline._auth) == 5
|
||||
def test_account_auth_list_has_seven_entries():
|
||||
assert len(account_pipeline._auth) == 7
|
||||
|
||||
|
||||
def test_external_sso_pipeline_prepare_has_four_entries():
|
||||
assert len(external_sso_pipeline._prepare) == 4
|
||||
|
||||
|
||||
def test_external_sso_auth_list_has_three_entries():
|
||||
assert len(external_sso_pipeline._auth) == 3
|
||||
def test_external_sso_auth_list_has_four_entries():
|
||||
assert len(external_sso_pipeline._auth) == 4
|
||||
|
||||
|
||||
def test_account_pipeline_has_unconditional_load_account():
|
||||
@@ -41,17 +50,14 @@ def test_external_sso_pipeline_all_prepare_entries_are_when():
|
||||
assert all(isinstance(s, When) for s in external_sso_pipeline._prepare)
|
||||
|
||||
|
||||
def test_first_auth_entry_is_check_scope_in_both_pipelines():
|
||||
assert not isinstance(account_pipeline._auth[0], When)
|
||||
assert not isinstance(external_sso_pipeline._auth[0], When)
|
||||
def test_account_pipeline_has_one_unconditional_auth_step():
|
||||
non_when = [s for s in account_pipeline._auth if not isinstance(s, When)]
|
||||
assert len(non_when) == 1
|
||||
|
||||
|
||||
def test_remaining_auth_entries_are_when_for_account():
|
||||
assert all(isinstance(s, When) for s in account_pipeline._auth[1:])
|
||||
|
||||
|
||||
def test_remaining_auth_entries_are_when_for_external_sso():
|
||||
assert all(isinstance(s, When) for s in external_sso_pipeline._auth[1:])
|
||||
def test_external_sso_pipeline_has_one_unconditional_auth_step():
|
||||
non_when = [s for s in external_sso_pipeline._auth if not isinstance(s, When)]
|
||||
assert len(non_when) == 1
|
||||
|
||||
|
||||
def test_router_routes_contain_both_token_types():
|
||||
@@ -71,3 +77,58 @@ def test_account_route_has_no_required_edition():
|
||||
route = auth_router._routes[TokenType.OAUTH_ACCOUNT]
|
||||
assert isinstance(route, PipelineRoute)
|
||||
assert route.required_edition is None
|
||||
|
||||
|
||||
def _selected_auth_steps(*, app_id: bool, workspace_membership: bool, allowed_roles):
|
||||
ctx = RequestContext(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
scope=None,
|
||||
path_params={"app_id": str(uuid.uuid4())} if app_id else {},
|
||||
workspace_membership=workspace_membership,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
selected = []
|
||||
for step in account_pipeline._auth:
|
||||
if isinstance(step, When):
|
||||
if step.applies(ctx, None):
|
||||
selected.append(step._step)
|
||||
else:
|
||||
selected.append(step)
|
||||
return selected
|
||||
|
||||
|
||||
_ALL_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.NORMAL})
|
||||
|
||||
|
||||
def test_workspace_path_selects_membership_check():
|
||||
steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=None)
|
||||
assert check_workspace_member in steps
|
||||
assert check_workspace_role not in steps
|
||||
|
||||
|
||||
def test_app_path_selects_membership_check():
|
||||
steps = _selected_auth_steps(app_id=True, workspace_membership=False, allowed_roles=None)
|
||||
assert check_workspace_member in steps
|
||||
assert check_workspace_role not in steps
|
||||
|
||||
|
||||
def test_roles_set_selects_both_membership_and_role_check():
|
||||
steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=_ALL_ROLES)
|
||||
assert check_workspace_member in steps
|
||||
assert check_workspace_role in steps
|
||||
|
||||
|
||||
def test_plain_path_selects_no_membership_or_role_step():
|
||||
steps = _selected_auth_steps(app_id=False, workspace_membership=False, allowed_roles=None)
|
||||
assert check_workspace_member not in steps
|
||||
assert check_workspace_role not in steps
|
||||
|
||||
|
||||
def test_app_path_selects_workspace_mismatch_check():
|
||||
steps = _selected_auth_steps(app_id=True, workspace_membership=False, allowed_roles=None)
|
||||
assert check_workspace_mismatch in steps
|
||||
|
||||
|
||||
def test_workspace_path_skips_workspace_mismatch_check():
|
||||
steps = _selected_auth_steps(app_id=False, workspace_membership=True, allowed_roles=None)
|
||||
assert check_workspace_mismatch not in steps
|
||||
|
||||
@@ -4,11 +4,13 @@ from controllers.openapi.auth.conditions import (
|
||||
EDITION_CE,
|
||||
EDITION_EE,
|
||||
EDITION_SAAS,
|
||||
HAS_ALLOWED_ROLES,
|
||||
LOADED_APP_IS_PRIVATE,
|
||||
PATH_HAS_APP_ID,
|
||||
TOKEN_IS_OAUTH_ACCOUNT,
|
||||
TOKEN_IS_OAUTH_EXTERNAL_SSO,
|
||||
WEBAPP_AUTH_ENABLED,
|
||||
WORKSPACE_MEMBERSHIP_REQUIRED,
|
||||
Cond,
|
||||
config_cond,
|
||||
data_cond,
|
||||
@@ -16,13 +18,15 @@ from controllers.openapi.auth.conditions import (
|
||||
)
|
||||
from controllers.openapi.auth.data import AuthData, Edition, RequestContext
|
||||
from libs.oauth_bearer import TokenType
|
||||
from models.account import TenantAccountRole
|
||||
from services.enterprise.enterprise_service import WebAppAccessMode
|
||||
|
||||
|
||||
def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None):
|
||||
def _ctx(token_type=TokenType.OAUTH_ACCOUNT, path_params=None, **kwargs):
|
||||
return RequestContext(
|
||||
token_type=token_type,
|
||||
path_params=path_params or {},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -141,3 +145,28 @@ def test_loaded_app_is_private():
|
||||
assert LOADED_APP_IS_PRIVATE(_ctx(), data_public) is False
|
||||
assert LOADED_APP_IS_PRIVATE(_ctx(), data_none) is False
|
||||
assert LOADED_APP_IS_PRIVATE(_ctx(), None) is False
|
||||
|
||||
|
||||
def test_workspace_membership_required_true():
|
||||
assert WORKSPACE_MEMBERSHIP_REQUIRED(_ctx(workspace_membership=True)) is True
|
||||
|
||||
|
||||
def test_workspace_membership_required_false():
|
||||
assert WORKSPACE_MEMBERSHIP_REQUIRED(_ctx(workspace_membership=False)) is False
|
||||
|
||||
|
||||
def test_workspace_membership_required_default():
|
||||
assert WORKSPACE_MEMBERSHIP_REQUIRED(_ctx()) is False
|
||||
|
||||
|
||||
def test_has_allowed_roles_true():
|
||||
ctx = _ctx(allowed_roles=frozenset({TenantAccountRole.OWNER}))
|
||||
assert HAS_ALLOWED_ROLES(ctx) is True
|
||||
|
||||
|
||||
def test_has_allowed_roles_false():
|
||||
assert HAS_ALLOWED_ROLES(_ctx(allowed_roles=None)) is False
|
||||
|
||||
|
||||
def test_has_allowed_roles_default():
|
||||
assert HAS_ALLOWED_ROLES(_ctx()) is False
|
||||
|
||||
@@ -115,3 +115,69 @@ def test_auth_data_token_id_optional():
|
||||
scopes=frozenset(),
|
||||
)
|
||||
assert data.token_id is None
|
||||
|
||||
|
||||
def test_request_context_workspace_membership_default_false():
|
||||
ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={})
|
||||
assert ctx.workspace_membership is False
|
||||
|
||||
|
||||
def test_request_context_workspace_membership_set():
|
||||
ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}, workspace_membership=True)
|
||||
assert ctx.workspace_membership is True
|
||||
|
||||
|
||||
def test_request_context_allowed_roles_default_none():
|
||||
ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={})
|
||||
assert ctx.allowed_roles is None
|
||||
|
||||
|
||||
def test_request_context_allowed_roles_set():
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
roles = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
||||
ctx = RequestContext(token_type=TokenType.OAUTH_ACCOUNT, path_params={}, allowed_roles=roles)
|
||||
assert ctx.allowed_roles == roles
|
||||
|
||||
|
||||
def test_auth_data_allowed_roles_default_none():
|
||||
data = AuthData(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
token_hash="abc",
|
||||
scopes=frozenset(),
|
||||
)
|
||||
assert data.allowed_roles is None
|
||||
|
||||
|
||||
def test_auth_data_allowed_roles_set():
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
roles = frozenset({TenantAccountRole.ADMIN})
|
||||
data = AuthData(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
token_hash="abc",
|
||||
scopes=frozenset(),
|
||||
allowed_roles=roles,
|
||||
)
|
||||
assert data.allowed_roles == roles
|
||||
|
||||
|
||||
def test_auth_data_tenant_role_default_none():
|
||||
data = AuthData(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
token_hash="abc",
|
||||
scopes=frozenset(),
|
||||
)
|
||||
assert data.tenant_role is None
|
||||
|
||||
|
||||
def test_auth_data_tenant_role_set():
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
data = AuthData(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
token_hash="abc",
|
||||
scopes=frozenset(),
|
||||
tenant_role=TenantAccountRole.ADMIN,
|
||||
)
|
||||
assert data.tenant_role == TenantAccountRole.ADMIN
|
||||
|
||||
@@ -247,6 +247,60 @@ def test_guard_populates_external_identity_from_subject_email(app):
|
||||
assert received["data"].external_identity.issuer == "https://idp.example.com"
|
||||
|
||||
|
||||
def test_guard_workspace_sets_membership_and_roles(app):
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
router = _make_router()
|
||||
received = {}
|
||||
|
||||
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
|
||||
with (
|
||||
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
|
||||
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
|
||||
patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()),
|
||||
patch("controllers.openapi.auth.pipeline.reset_auth_ctx"),
|
||||
):
|
||||
mock_auth.return_value.authenticate.return_value = _fake_identity()
|
||||
|
||||
roles = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
||||
|
||||
@router.guard_workspace(
|
||||
scope=Scope.FULL,
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=roles,
|
||||
)
|
||||
def view(*, auth_data):
|
||||
received["data"] = auth_data
|
||||
|
||||
view()
|
||||
|
||||
assert isinstance(received["data"], AuthData)
|
||||
assert received["data"].allowed_roles == roles
|
||||
|
||||
|
||||
def test_guard_workspace_without_roles(app):
|
||||
router = _make_router()
|
||||
received = {}
|
||||
|
||||
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
|
||||
with (
|
||||
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
|
||||
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
|
||||
patch("controllers.openapi.auth.pipeline.set_auth_ctx", return_value=MagicMock()),
|
||||
patch("controllers.openapi.auth.pipeline.reset_auth_ctx"),
|
||||
):
|
||||
mock_auth.return_value.authenticate.return_value = _fake_identity()
|
||||
|
||||
@router.guard_workspace(scope=Scope.FULL)
|
||||
def view(*, auth_data):
|
||||
received["data"] = auth_data
|
||||
|
||||
view()
|
||||
|
||||
assert isinstance(received["data"], AuthData)
|
||||
assert received["data"].allowed_roles is None
|
||||
|
||||
|
||||
def test_guard_no_external_identity_when_subject_email_absent(app):
|
||||
router = _make_router()
|
||||
received = {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from controllers.openapi.auth.data import AuthData, ExternalIdentity
|
||||
@@ -10,9 +11,12 @@ from controllers.openapi.auth.prepare import (
|
||||
load_app,
|
||||
load_app_access_mode,
|
||||
load_tenant,
|
||||
load_tenant_from_request,
|
||||
load_workspace_role,
|
||||
resolve_external_user,
|
||||
)
|
||||
from libs.oauth_bearer import TokenType
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
|
||||
def _make_auth_data(**kwargs) -> AuthData:
|
||||
@@ -54,14 +58,21 @@ def test_load_app_raises_not_found_when_not_normal():
|
||||
load_app(data)
|
||||
|
||||
|
||||
def test_load_app_raises_forbidden_when_api_disabled():
|
||||
def test_load_app_stashes_app_even_when_api_disabled():
|
||||
app = MagicMock()
|
||||
app.status = "normal"
|
||||
app.enable_api = False
|
||||
data = _make_auth_data(path_params={"app_id": "abc"})
|
||||
with patch("controllers.openapi.auth.prepare.AppService.get_app_by_id", return_value=app):
|
||||
with pytest.raises(Forbidden):
|
||||
load_app(data)
|
||||
assert data.app is app
|
||||
|
||||
|
||||
def test_load_app_skips_when_already_set():
|
||||
existing_app = MagicMock()
|
||||
data = _make_auth_data(app=existing_app, path_params={"app_id": "abc"})
|
||||
load_app(data)
|
||||
assert data.app is existing_app
|
||||
|
||||
|
||||
def test_load_tenant_writes_tenant():
|
||||
@@ -75,6 +86,13 @@ def test_load_tenant_writes_tenant():
|
||||
assert data.tenant is tenant
|
||||
|
||||
|
||||
def test_load_tenant_skips_when_already_set():
|
||||
existing_tenant = MagicMock()
|
||||
data = _make_auth_data(app=MagicMock(), tenant=existing_tenant)
|
||||
load_tenant(data)
|
||||
assert data.tenant is existing_tenant
|
||||
|
||||
|
||||
def test_load_tenant_raises_forbidden_when_archived():
|
||||
from models.account import TenantStatus
|
||||
|
||||
@@ -115,6 +133,13 @@ def test_load_account_writes_caller():
|
||||
assert data.caller_kind == "account"
|
||||
|
||||
|
||||
def test_load_account_skips_when_already_set():
|
||||
existing_caller = MagicMock()
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), caller=existing_caller)
|
||||
load_account(data)
|
||||
assert data.caller is existing_caller
|
||||
|
||||
|
||||
def test_load_account_sets_current_tenant_when_tenant_present():
|
||||
account = MagicMock()
|
||||
tenant = MagicMock()
|
||||
@@ -181,3 +206,143 @@ def test_load_app_access_mode_no_op_when_app_missing():
|
||||
data = _make_auth_data()
|
||||
load_app_access_mode(data)
|
||||
assert data.app_access_mode is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_from_path_params(flask_app):
|
||||
tenant = MagicMock()
|
||||
tenant.status = "normal"
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={"workspace_id": wid})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is tenant
|
||||
|
||||
|
||||
def test_load_tenant_from_request_from_query_param(flask_app):
|
||||
tenant = MagicMock()
|
||||
tenant.status = "normal"
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={})
|
||||
with flask_app.test_request_context(f"/test?workspace_id={wid}"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is tenant
|
||||
|
||||
|
||||
def test_load_tenant_from_request_skips_when_already_set(flask_app):
|
||||
existing_tenant = MagicMock()
|
||||
data = _make_auth_data(tenant=existing_tenant, path_params={})
|
||||
with flask_app.test_request_context("/test"):
|
||||
load_tenant_from_request(data)
|
||||
assert data.tenant is existing_tenant
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_no_id(flask_app):
|
||||
data = _make_auth_data(path_params={})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_missing(flask_app):
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={"workspace_id": wid})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=None):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_archived(flask_app):
|
||||
from models.account import TenantStatus
|
||||
|
||||
tenant = MagicMock()
|
||||
tenant.status = TenantStatus.ARCHIVE
|
||||
wid = str(uuid.uuid4())
|
||||
data = _make_auth_data(path_params={"workspace_id": wid})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with patch("controllers.openapi.auth.prepare.TenantService.get_tenant_by_id", return_value=tenant):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
def test_load_tenant_from_request_raises_not_found_when_invalid_uuid(flask_app):
|
||||
data = _make_auth_data(path_params={"workspace_id": "not-a-uuid"})
|
||||
with flask_app.test_request_context("/test"):
|
||||
with pytest.raises(NotFound):
|
||||
load_tenant_from_request(data)
|
||||
|
||||
|
||||
# --- load_workspace_role ---
|
||||
|
||||
|
||||
def test_load_workspace_role_stashes_role():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "active"
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant, caller=caller)
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.TenantService.get_account_role_in_tenant",
|
||||
return_value=TenantAccountRole.ADMIN,
|
||||
):
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role == TenantAccountRole.ADMIN
|
||||
|
||||
|
||||
def test_load_workspace_role_none_when_not_member():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "active"
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant, caller=caller)
|
||||
with patch(
|
||||
"controllers.openapi.auth.prepare.TenantService.get_account_role_in_tenant",
|
||||
return_value=None,
|
||||
):
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
|
||||
def test_load_workspace_role_none_when_account_inactive():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "banned"
|
||||
data = _make_auth_data(account_id=uuid.uuid4(), tenant=tenant, caller=caller)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
|
||||
def test_load_workspace_role_skips_when_already_set():
|
||||
tenant = MagicMock()
|
||||
tenant.id = uuid.uuid4()
|
||||
caller = MagicMock()
|
||||
caller.status = "active"
|
||||
data = _make_auth_data(
|
||||
account_id=uuid.uuid4(),
|
||||
tenant=tenant,
|
||||
caller=caller,
|
||||
tenant_role=TenantAccountRole.OWNER,
|
||||
)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role == TenantAccountRole.OWNER
|
||||
|
||||
|
||||
def test_load_workspace_role_skips_when_tenant_missing():
|
||||
data = _make_auth_data(account_id=uuid.uuid4())
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
|
||||
def test_load_workspace_role_skips_when_account_id_missing():
|
||||
tenant = MagicMock()
|
||||
data = _make_auth_data(tenant=tenant, account_id=None)
|
||||
load_workspace_role(data)
|
||||
assert data.tenant_role is None
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
"""Role-gate tests.
|
||||
|
||||
The decorator wraps `validate_bearer` + `accept_subjects` and must:
|
||||
- 404 when caller is not a member of ``workspace_id`` (parity with
|
||||
`GET /openapi/v1/workspaces/<id>`; prevents tenant-id existence leak)
|
||||
- 403 when caller IS a member but their role is not in the allowed set
|
||||
- pass through when role matches (or when no role restriction given)
|
||||
- raise RuntimeError on missing auth context / account_id / workspace_id —
|
||||
those are wiring bugs, not user-driven failures
|
||||
|
||||
Identity is read from the openapi auth ContextVar — the slot
|
||||
`validate_bearer` publishes — so these tests seed it via `_seed`
|
||||
(``set_auth_ctx``), NOT ``flask.g``. `test_seeding_only_flask_g_*`
|
||||
locks in that ``g`` is *not* a valid identity source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.openapi.auth.role_gate import require_workspace_role
|
||||
from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, reset_auth_ctx, set_auth_ctx
|
||||
from models.account import TenantAccountRole
|
||||
|
||||
# Tokens from `_seed`'s `set_auth_ctx` calls, drained after each test so a
|
||||
# published identity can't leak into the next (the ContextVar is module-global
|
||||
# and worker threads are reused). Seed via `_seed(...)`, never `flask.g`.
|
||||
_seed_tokens: list = []
|
||||
|
||||
|
||||
def _seed(ctx: AuthContext) -> None:
|
||||
_seed_tokens.append(set_auth_ctx(ctx))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_auth_ctx():
|
||||
yield
|
||||
while _seed_tokens:
|
||||
reset_auth_ctx(_seed_tokens.pop())
|
||||
|
||||
|
||||
def _account_ctx(account_id: uuid.UUID | None = None) -> AuthContext:
|
||||
return AuthContext(
|
||||
subject_type=SubjectType.ACCOUNT,
|
||||
subject_email="user@example.com",
|
||||
subject_issuer="dify:account",
|
||||
account_id=account_id or uuid.uuid4(),
|
||||
client_id="difyctl",
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
token_id=uuid.uuid4(),
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
expires_at=datetime.now(UTC),
|
||||
token_hash="h1",
|
||||
verified_tenants={},
|
||||
)
|
||||
|
||||
|
||||
def _sso_ctx() -> AuthContext:
|
||||
return AuthContext(
|
||||
subject_type=SubjectType.EXTERNAL_SSO,
|
||||
subject_email="sso@partner.com",
|
||||
subject_issuer="https://idp.partner.com",
|
||||
account_id=None,
|
||||
client_id="difyctl",
|
||||
scopes=frozenset({Scope.APPS_RUN}),
|
||||
token_id=uuid.uuid4(),
|
||||
token_type=TokenType.OAUTH_EXTERNAL_SSO,
|
||||
expires_at=datetime.now(UTC),
|
||||
token_hash="h2",
|
||||
verified_tenants={},
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _stub_role(role: TenantAccountRole | None):
|
||||
"""Stub the service-layer membership lookup the gate delegates to.
|
||||
|
||||
The gate no longer issues SQL itself — it calls
|
||||
``TenantService.get_account_role_in_tenant`` and acts purely on the
|
||||
returned role (``None`` → non-member). These tests pin that behaviour;
|
||||
the query itself is covered in ``TestTenantService``.
|
||||
"""
|
||||
with patch(
|
||||
"controllers.openapi.auth.role_gate.TenantService.get_account_role_in_tenant",
|
||||
return_value=role,
|
||||
) as mocked:
|
||||
yield mocked
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-member → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_member_gets_404():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(None):
|
||||
with pytest.raises(NotFound):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Member with insufficient role → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normal_member_blocked_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.NORMAL):
|
||||
with pytest.raises(Forbidden):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_editor_blocked_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.EDITOR):
|
||||
with pytest.raises(Forbidden):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Member with allowed role → pass
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_passes_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.ADMIN):
|
||||
assert view(workspace_id=workspace_id) == "ok"
|
||||
|
||||
|
||||
def test_owner_passes_when_admin_required():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(TenantAccountRole.OWNER):
|
||||
assert view(workspace_id=workspace_id) == "ok"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Membership-only (no role restriction)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_membership_only_passes_for_any_role():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
for role in (
|
||||
TenantAccountRole.OWNER,
|
||||
TenantAccountRole.ADMIN,
|
||||
TenantAccountRole.EDITOR,
|
||||
TenantAccountRole.NORMAL,
|
||||
TenantAccountRole.DATASET_OPERATOR,
|
||||
):
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(role):
|
||||
assert view(workspace_id=workspace_id) == "ok"
|
||||
|
||||
|
||||
def test_membership_only_still_404s_non_member():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx())
|
||||
with _stub_role(None):
|
||||
with pytest.raises(NotFound):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup is scoped to the caller's account_id and the URL workspace_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lookup_is_scoped_to_caller_and_workspace():
|
||||
"""The decorator must delegate the lookup keyed on
|
||||
`(caller's account_id, URL workspace_id)` — otherwise a member of
|
||||
workspace A could quietly hit endpoints for workspace B. Assert the
|
||||
exact arguments handed to the service; the SQL those arguments compile
|
||||
to is pinned in ``TestTenantService.test_get_account_role_in_tenant_*``.
|
||||
"""
|
||||
|
||||
app = Flask(__name__)
|
||||
account_id = uuid.uuid4()
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_account_ctx(account_id=account_id))
|
||||
with _stub_role(TenantAccountRole.NORMAL) as mocked:
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
_session, passed_account_id, passed_workspace_id = mocked.call_args.args
|
||||
assert passed_account_id == str(account_id)
|
||||
assert passed_workspace_id == workspace_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring bugs surface as RuntimeError (loud), not 403 (silent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_auth_ctx_is_runtime_error():
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
with pytest.raises(RuntimeError):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_seeding_only_flask_g_does_not_satisfy_gate():
|
||||
"""Regression — pins the identity source to the ContextVar, not ``flask.g``.
|
||||
|
||||
Production fills the ContextVar (``validate_bearer`` → ``set_auth_ctx``)
|
||||
and never touches ``g.auth_ctx``. An earlier revision of this gate read
|
||||
``g.auth_ctx``, so every real request raised RuntimeError → 500 while the
|
||||
suite stayed green (it seeded ``g`` directly). Here we seed ONLY ``g`` and
|
||||
leave the ContextVar empty: the gate must still raise, proving it does not
|
||||
accept ``g`` as an identity source. Reading ``g`` again would let the
|
||||
membership lookup run (stubbed to succeed) and this would fail.
|
||||
"""
|
||||
from flask import g
|
||||
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
g.auth_ctx = _account_ctx() # the wrong slot — must be ignored
|
||||
with _stub_role(TenantAccountRole.OWNER):
|
||||
with pytest.raises(RuntimeError):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_sso_caller_is_runtime_error():
|
||||
"""External SSO context has account_id=None — the caller stacked the
|
||||
role gate without `accept_subjects(SubjectType.ACCOUNT)`. That's a
|
||||
wiring bug, surface it as RuntimeError rather than 404 the SSO user."""
|
||||
|
||||
app = Flask(__name__)
|
||||
workspace_id = str(uuid.uuid4())
|
||||
|
||||
@require_workspace_role()
|
||||
def view(workspace_id: str) -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
|
||||
_seed(_sso_ctx())
|
||||
with pytest.raises(RuntimeError):
|
||||
view(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def test_missing_workspace_id_kwarg_is_runtime_error():
|
||||
app = Flask(__name__)
|
||||
|
||||
@require_workspace_role()
|
||||
def view() -> str:
|
||||
return "ok"
|
||||
|
||||
with app.test_request_context("/openapi/v1/foo"):
|
||||
_seed(_account_ctx())
|
||||
with pytest.raises(RuntimeError):
|
||||
view()
|
||||
@@ -2,18 +2,22 @@ import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from controllers.openapi.auth.verify import (
|
||||
check_acl,
|
||||
check_app_access,
|
||||
check_membership,
|
||||
check_app_api_enabled,
|
||||
check_private_app_permission,
|
||||
check_scope,
|
||||
check_workspace_member,
|
||||
check_workspace_mismatch,
|
||||
check_workspace_role,
|
||||
)
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models.account import Tenant
|
||||
from models.account import Tenant, TenantAccountRole
|
||||
from models.model import App
|
||||
from services.enterprise.enterprise_service import WebAppAccessMode
|
||||
|
||||
@@ -41,28 +45,13 @@ def test_check_scope_raises_forbidden_when_scope_missing():
|
||||
check_scope(_data(required_scope=Scope.APPS_RUN, scopes=frozenset({Scope.APPS_READ})))
|
||||
|
||||
|
||||
def test_check_membership_raises_unauthorized_when_tenant_none():
|
||||
with pytest.raises(Unauthorized):
|
||||
check_membership(_data(tenant=None))
|
||||
def test_check_workspace_member_raises_not_found_when_no_role():
|
||||
with pytest.raises(NotFound, match="workspace not found"):
|
||||
check_workspace_member(_data(tenant_role=None))
|
||||
|
||||
|
||||
def test_check_membership_calls_check_workspace_membership():
|
||||
tenant = MagicMock(spec=Tenant)
|
||||
tenant.id = "tenant-1"
|
||||
data = _data(
|
||||
account_id=uuid.uuid4(),
|
||||
token_hash="myhash",
|
||||
tenants={"tenant-1": True},
|
||||
tenant=tenant,
|
||||
)
|
||||
with patch("controllers.openapi.auth.verify.check_workspace_membership") as mock_cwm:
|
||||
check_membership(data)
|
||||
mock_cwm.assert_called_once_with(
|
||||
account_id=data.account_id,
|
||||
tenant_id="tenant-1",
|
||||
token_hash="myhash",
|
||||
membership_cache=data.tenants,
|
||||
)
|
||||
def test_check_workspace_member_passes_when_role_present():
|
||||
check_workspace_member(_data(tenant_role=TenantAccountRole.NORMAL))
|
||||
|
||||
|
||||
def test_check_app_access_passes_when_tenant_none():
|
||||
@@ -140,3 +129,92 @@ def test_check_private_app_permission_passes_when_allowed():
|
||||
target = "controllers.openapi.auth.verify.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp"
|
||||
with patch(target, return_value=True):
|
||||
check_private_app_permission(data)
|
||||
|
||||
|
||||
# --- check_workspace_mismatch ---
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flask_app():
|
||||
return Flask(__name__)
|
||||
|
||||
|
||||
def test_check_workspace_mismatch_passes_when_tenant_none(flask_app):
|
||||
with flask_app.test_request_context("/test"):
|
||||
check_workspace_mismatch(_data(tenant=None))
|
||||
|
||||
|
||||
def test_check_workspace_mismatch_passes_when_ids_match(flask_app):
|
||||
tenant = MagicMock(spec=Tenant)
|
||||
tid = uuid.uuid4()
|
||||
tenant.id = tid
|
||||
with flask_app.test_request_context(f"/test?workspace_id={tid}"):
|
||||
check_workspace_mismatch(_data(tenant=tenant, path_params={}))
|
||||
|
||||
|
||||
def test_check_workspace_mismatch_raises_422_on_mismatch(flask_app):
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
tenant = MagicMock(spec=Tenant)
|
||||
tenant.id = uuid.uuid4()
|
||||
other_id = uuid.uuid4()
|
||||
with flask_app.test_request_context(f"/test?workspace_id={other_id}"):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
check_workspace_mismatch(_data(tenant=tenant, path_params={}))
|
||||
|
||||
|
||||
def test_check_workspace_mismatch_passes_when_no_request_workspace_id(flask_app):
|
||||
tenant = MagicMock(spec=Tenant)
|
||||
tenant.id = uuid.uuid4()
|
||||
with flask_app.test_request_context("/test"):
|
||||
check_workspace_mismatch(_data(tenant=tenant, path_params={}))
|
||||
|
||||
|
||||
# --- check_workspace_role ---
|
||||
|
||||
|
||||
def test_check_workspace_role_passes_when_allowed_roles_none():
|
||||
check_workspace_role(_data(allowed_roles=None))
|
||||
|
||||
|
||||
def test_check_workspace_role_raises_not_found_when_not_member():
|
||||
data = _data(tenant_role=None, allowed_roles=frozenset({TenantAccountRole.ADMIN}))
|
||||
with pytest.raises(NotFound):
|
||||
check_workspace_role(data)
|
||||
|
||||
|
||||
def test_check_workspace_role_raises_forbidden_when_wrong_role():
|
||||
data = _data(
|
||||
tenant_role=TenantAccountRole.EDITOR,
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER}),
|
||||
)
|
||||
with pytest.raises(Forbidden, match="insufficient workspace role"):
|
||||
check_workspace_role(data)
|
||||
|
||||
|
||||
def test_check_workspace_role_passes_when_role_allowed():
|
||||
data = _data(
|
||||
tenant_role=TenantAccountRole.ADMIN,
|
||||
allowed_roles=frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN}),
|
||||
)
|
||||
check_workspace_role(data)
|
||||
|
||||
|
||||
# --- check_app_api_enabled ---
|
||||
|
||||
|
||||
def test_check_app_api_enabled_passes_when_enabled():
|
||||
app = MagicMock(spec=App)
|
||||
app.enable_api = True
|
||||
check_app_api_enabled(_data(app=app))
|
||||
|
||||
|
||||
def test_check_app_api_enabled_raises_forbidden_when_disabled():
|
||||
app = MagicMock(spec=App)
|
||||
app.enable_api = False
|
||||
with pytest.raises(Forbidden, match="service_api_disabled"):
|
||||
check_app_api_enabled(_data(app=app))
|
||||
|
||||
|
||||
def test_check_app_api_enabled_passes_when_app_none():
|
||||
check_app_api_enabled(_data(app=None))
|
||||
|
||||
@@ -9,7 +9,18 @@ from controllers.openapi.auth.pipeline import PipelineRouter
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
|
||||
|
||||
def _stub_execute(self, args, kwargs, view, *, scope=None, allowed_token_types=None, edition=None):
|
||||
def _stub_execute(
|
||||
self,
|
||||
args,
|
||||
kwargs,
|
||||
view,
|
||||
*,
|
||||
scope=None,
|
||||
allowed_token_types=None,
|
||||
edition=None,
|
||||
workspace_membership=False,
|
||||
allowed_roles=None,
|
||||
):
|
||||
"""Bypass all auth logic; inject minimal AuthData and call the view directly."""
|
||||
kwargs["auth_data"] = AuthData(
|
||||
token_type=TokenType.OAUTH_ACCOUNT,
|
||||
@@ -18,6 +29,7 @@ def _stub_execute(self, args, kwargs, view, *, scope=None, allowed_token_types=N
|
||||
token_id=uuid.uuid4(),
|
||||
scopes=frozenset({Scope.FULL}),
|
||||
required_scope=scope,
|
||||
allowed_roles=allowed_roles,
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ Coverage:
|
||||
|
||||
Auth-pipeline plumbing is bypassed via the `bypass_pipeline` fixture from
|
||||
conftest.py; the bearer identity is seeded into the openapi auth ContextVar
|
||||
via `_seed` (the slot `validate_bearer` publishes), and the role gate's DB
|
||||
lookup is mocked. Tests that exercise endpoint *bodies* skip the decorators
|
||||
via ``__wrapped__`` since those layers are covered in `auth/test_role_gate.py`.
|
||||
via `_seed` (the slot `validate_bearer` publishes). Tests that exercise
|
||||
endpoint *bodies* skip the single `guard_workspace` decorator via
|
||||
``__wrapped__`` — membership and role enforcement live in the auth pipeline
|
||||
and are covered in `auth/test_prepare.py` and `auth/test_verify.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -268,7 +269,7 @@ def test_switch_returns_workspace_detail_with_current_true(app, bypass_pipeline,
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 200
|
||||
assert body["id"] == ws_id
|
||||
@@ -296,7 +297,7 @@ def test_switch_404s_when_service_raises_account_not_link_tenant(app, bypass_pip
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -330,7 +331,7 @@ def test_members_list_returns_normalized_rows(app, bypass_pipeline, monkeypatch)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.get.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
body, status = api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 200
|
||||
assert body["page"] == 1
|
||||
@@ -372,7 +373,7 @@ def test_members_list_paginates_with_query_params(app, bypass_pipeline, monkeypa
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?page=2&limit=2"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.get.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
body, status = api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 200
|
||||
assert body["page"] == 2
|
||||
@@ -395,7 +396,7 @@ def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypa
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?pg=2"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
api.get.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,7 +434,7 @@ def test_invite_happy_path_returns_invite_url_and_member_id(app, bypass_pipeline
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 201
|
||||
assert body["result"] == "success"
|
||||
@@ -518,7 +519,7 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
body = exc_info.value.response.json
|
||||
assert body["code"] == "members.limit_exceeded"
|
||||
@@ -564,7 +565,7 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
body = exc_info.value.response.json
|
||||
assert body["code"] == "workspace_members.license_exceeded"
|
||||
@@ -603,7 +604,7 @@ def test_invite_ce_passes_when_both_caps_disabled(app, bypass_pipeline, monkeypa
|
||||
|
||||
with _invite_request(app, ws_id, acct_id):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
body, status = api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 201
|
||||
assert body["email"] == "new@example.com"
|
||||
@@ -632,7 +633,7 @@ def test_invite_400_when_already_in_tenant(app, bypass_pipeline, monkeypatch):
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -665,7 +666,7 @@ def test_delete_member_happy_path(app, bypass_pipeline, monkeypatch):
|
||||
method="DELETE",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.delete.__wrapped__.__wrapped__(
|
||||
body, status = api.delete.__wrapped__(
|
||||
api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)
|
||||
)
|
||||
|
||||
@@ -707,7 +708,7 @@ def test_delete_member_exception_mapping(app, bypass_pipeline, monkeypatch, exc,
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(expected):
|
||||
api.delete.__wrapped__.__wrapped__(
|
||||
api.delete.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
@@ -734,7 +735,7 @@ def test_delete_member_404_when_member_missing(app, bypass_pipeline, monkeypatch
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.delete.__wrapped__.__wrapped__(
|
||||
api.delete.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
@@ -774,9 +775,7 @@ def test_update_role_happy_path(app, bypass_pipeline, monkeypatch):
|
||||
content_type="application/json",
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
body, status = api.put.__wrapped__.__wrapped__(
|
||||
api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id)
|
||||
)
|
||||
body, status = api.put.__wrapped__(api, workspace_id=ws_id, member_id=member_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
assert status == 200
|
||||
assert body == {"result": "success"}
|
||||
@@ -820,7 +819,7 @@ def test_update_role_exception_mapping(app, bypass_pipeline, monkeypatch, exc, e
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(expected):
|
||||
api.put.__wrapped__.__wrapped__(
|
||||
api.put.__wrapped__(
|
||||
api,
|
||||
workspace_id=ws_id,
|
||||
member_id=member_id,
|
||||
@@ -828,44 +827,6 @@ def test_update_role_exception_mapping(app, bypass_pipeline, monkeypatch, exc, e
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role gate composition — non-member sees 404 even with valid bearer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_non_member_caller_gets_404_on_switch(app, bypass_pipeline, monkeypatch):
|
||||
"""End-to-end: caller has valid account bearer but no membership in
|
||||
the requested workspace. The role gate must short-circuit to 404
|
||||
before any TenantService method is touched."""
|
||||
ws_id = str(uuid.uuid4())
|
||||
acct_id = uuid.uuid4()
|
||||
api = WorkspaceSwitchApi()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
|
||||
switch_mock = Mock()
|
||||
monkeypatch.setattr(
|
||||
sys.modules["controllers.openapi.workspaces"],
|
||||
"TenantService",
|
||||
_tenant_service(switch_tenant=switch_mock),
|
||||
)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
|
||||
monkeypatch.setattr(sys.modules["controllers.openapi.auth.role_gate"], "db", mock_db)
|
||||
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
# Strip only the bearer + surface-gate wrappers; keep the role gate.
|
||||
# Decorator stack (innermost → outermost):
|
||||
# role_gate → accept_subjects → validate_bearer
|
||||
# `post.__wrapped__` is now the role-gate wrapper directly (auth_router.guard is the only outer wrapper).
|
||||
gated = api.post.__wrapped__
|
||||
with pytest.raises(NotFound):
|
||||
gated(api, workspace_id=ws_id)
|
||||
|
||||
switch_mock.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_tenant rejects archived tenant
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -891,7 +852,7 @@ def test_load_tenant_rejects_archived_workspace(app, bypass_pipeline, monkeypatc
|
||||
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(NotFound):
|
||||
api.get.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.get.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -925,4 +886,4 @@ def test_invite_400_when_register_error(app, bypass_pipeline, monkeypatch):
|
||||
):
|
||||
_seed(_auth_ctx(account_id=acct_id))
|
||||
with pytest.raises(BadRequest):
|
||||
api.post.__wrapped__.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
|
||||
|
||||
@@ -638,8 +638,8 @@ class TestTenantService:
|
||||
callable_func(*args, **kwargs)
|
||||
|
||||
# ==================== get_account_role_in_tenant Tests ====================
|
||||
# Backs `require_workspace_role`: None => non-member (gate maps to 404),
|
||||
# otherwise the caller's role (gate maps an out-of-set role to 403).
|
||||
# Backs the auth pipeline's `load_workspace_role`: None => non-member
|
||||
# (pipeline maps to 404), otherwise the caller's role (out-of-set role => 403).
|
||||
|
||||
def test_get_account_role_in_tenant_returns_role_for_member(self):
|
||||
"""A row in TenantAccountJoin yields the caller's role."""
|
||||
|
||||
Reference in New Issue
Block a user