feat: guard openapi with rbac (#37752)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yunlu Wen
2026-06-22 09:35:33 +00:00
committed by GitHub
co-authored by autofix-ci[bot] Copilot Autofix powered by AI
parent 0d7ca17cd1
commit 82d08851be
18 changed files with 655 additions and 97 deletions
+6 -2
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from controllers.openapi.auth.conditions import (
EDITION_EE,
HAS_ALLOWED_ROLES,
HAS_RBAC,
LOADED_APP_IS_PRIVATE,
PATH_HAS_APP_ID,
WEBAPP_AUTH_ENABLED,
WEBAPP_RUN_SCOPED,
WORKSPACE_MEMBERSHIP_REQUIRED,
WORKSPACE_SCOPED,
)
@@ -25,6 +27,7 @@ from controllers.openapi.auth.verify import (
check_acl,
check_app_api_enabled,
check_private_app_permission,
check_rbac_permission,
check_scope,
check_workspace_member,
check_workspace_mismatch,
@@ -47,8 +50,9 @@ account_pipeline = AuthPipeline(
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),
When(HAS_RBAC, then=check_rbac_permission),
When(PATH_HAS_APP_ID & EDITION_EE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl),
When(EDITION_EE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission),
],
)
+4 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable
from controllers.openapi.auth.data import AuthData, Edition, RequestContext, current_edition
from libs.oauth_bearer import TokenType
from libs.oauth_bearer import Scope, TokenType
from services.enterprise.enterprise_service import WebAppAccessMode
from services.feature_service import FeatureService
@@ -50,8 +50,11 @@ EDITION_SAAS = config_cond(lambda: current_edition() == Edition.SAAS)
WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled)
WEBAPP_RUN_SCOPED = request_cond(lambda ctx: ctx.scope == Scope.APPS_RUN)
WORKSPACE_MEMBERSHIP_REQUIRED = request_cond(lambda ctx: ctx.workspace_membership)
HAS_ALLOWED_ROLES = request_cond(lambda ctx: ctx.allowed_roles is not None)
HAS_RBAC = request_cond(lambda ctx: ctx.rbac 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).
+11
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import InternalServerError
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from libs.oauth_bearer import Scope, TokenType
from models.account import Account, Tenant, TenantAccountRole
from models.model import App, EndUser
@@ -35,6 +36,14 @@ class ExternalIdentity(BaseModel):
issuer: str | None = None
class RBACRequirement(BaseModel):
model_config = ConfigDict(frozen=True)
resource_type: RBACResourceScope
scene: RBACPermission
resource_required: bool = True
class RequestContext(BaseModel):
model_config = ConfigDict(frozen=True)
@@ -43,6 +52,7 @@ class RequestContext(BaseModel):
path_params: dict[str, str]
workspace_membership: bool = False
allowed_roles: frozenset[TenantAccountRole] | None = None
rbac: RBACRequirement | None = None
class AuthData(BaseModel):
@@ -59,6 +69,7 @@ class AuthData(BaseModel):
path_params: dict[str, str] = Field(default_factory=dict)
allowed_roles: frozenset[TenantAccountRole] | None = None
rbac: RBACRequirement | None = None
app: App | None = None
tenant: Tenant | None = None
+12
View File
@@ -21,6 +21,7 @@ from controllers.openapi.auth.data import (
AuthData,
Edition,
ExternalIdentity,
RBACRequirement,
RequestContext,
current_edition,
)
@@ -59,6 +60,7 @@ class AuthPipeline:
scope: Scope | None,
workspace_membership: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | None = None,
) -> Any:
req_ctx = RequestContext(
token_type=identity.token_type,
@@ -66,6 +68,7 @@ class AuthPipeline:
path_params=dict(request.view_args or {}),
workspace_membership=workspace_membership,
allowed_roles=allowed_roles,
rbac=rbac,
)
data = AuthData(
@@ -77,6 +80,7 @@ class AuthPipeline:
tenants=dict(identity.verified_tenants),
required_scope=scope,
allowed_roles=allowed_roles,
rbac=rbac,
path_params=dict(req_ctx.path_params),
external_identity=(
ExternalIdentity(email=identity.subject_email, issuer=identity.subject_issuer)
@@ -129,6 +133,7 @@ class PipelineRouter:
edition: frozenset[Edition] | None = None,
workspace_membership: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | None = None,
) -> Callable:
return self._make_decorator(
scope=scope,
@@ -136,6 +141,7 @@ class PipelineRouter:
edition=edition,
workspace_membership=workspace_membership,
allowed_roles=allowed_roles,
rbac=rbac,
)
def guard_workspace(
@@ -145,6 +151,7 @@ class PipelineRouter:
allowed_token_types: frozenset[TokenType] | None = None,
edition: frozenset[Edition] | None = None,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | None = None,
) -> Callable:
return self._make_decorator(
scope=scope,
@@ -152,6 +159,7 @@ class PipelineRouter:
edition=edition,
workspace_membership=True,
allowed_roles=allowed_roles,
rbac=rbac,
)
def _make_decorator(
@@ -162,6 +170,7 @@ class PipelineRouter:
edition: frozenset[Edition] | None,
workspace_membership: bool,
allowed_roles: frozenset[TenantAccountRole] | None,
rbac: RBACRequirement | None,
) -> Callable:
def decorator(view: Callable) -> Callable:
@wraps(view)
@@ -175,6 +184,7 @@ class PipelineRouter:
edition=edition,
workspace_membership=workspace_membership,
allowed_roles=allowed_roles,
rbac=rbac,
)
return decorated
@@ -192,6 +202,7 @@ class PipelineRouter:
edition: frozenset[Edition] | None,
workspace_membership: bool = False,
allowed_roles: frozenset[TenantAccountRole] | None = None,
rbac: RBACRequirement | 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:
@@ -235,6 +246,7 @@ class PipelineRouter:
scope=scope,
workspace_membership=workspace_membership,
allowed_roles=allowed_roles,
rbac=rbac,
)
+26
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from flask import request
from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
@@ -38,6 +40,9 @@ def check_workspace_mismatch(data: AuthData) -> None:
def check_workspace_role(data: AuthData) -> None:
if dify_config.RBAC_ENABLED and data.rbac is not None:
# fine-grained permission check is performed by RBAC
return
if data.allowed_roles is None:
return
if data.tenant_role is None:
@@ -46,6 +51,27 @@ def check_workspace_role(data: AuthData) -> None:
raise Forbidden("insufficient workspace role")
def check_rbac_permission(data: AuthData) -> None:
req = data.rbac
if req is None:
return
if not dify_config.RBAC_ENABLED:
return
# Only account callers are subject to RBAC; end_user access is scope-controlled.
if data.caller_kind != "account":
return
if data.account_id is None or data.tenant is None:
raise Forbidden("rbac context missing")
enforce_rbac_access(
tenant_id=str(data.tenant.id),
account_id=str(data.account_id),
resource_type=req.resource_type,
scene=req.scene,
resource_required=req.resource_required,
path_args=dict(data.path_params),
)
def check_app_api_enabled(data: AuthData) -> None:
if data.app is None:
return