From ac3e8bcf0f7b777d391e574c19ca0e463747553f Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 20 May 2026 16:02:38 -0700 Subject: [PATCH] fix(authz): prefer project over workspace in _resolve_flow_domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Casbin g2 inheritance is directional: a child resource inherits from its parent, not the other way round. Declaring `g2, project:xyz, workspace:abc` lets a workspace-scoped grant flow down to checks made against project:xyz, but a project-scoped grant does NOT flow up to checks made against workspace:abc. The original precedence picked workspace whenever it was set, which made project-scoped grants invisible to the enforcer the moment a workspace was also known. Flip the precedence so the more specific domain (project) wins — project-scoped grants now match directly, and workspace-scoped grants still match via g2 inheritance. Both ids stay in the enforce context for ABAC. - _resolve_flow_domain returns project first, then workspace, then "*". - Docstrings on both _resolve_flow_domain and its callers updated. - test_ensure_flow_permission_workspace_beats_project renamed to test_ensure_flow_permission_project_beats_workspace with inverted assertion and an explanation of g2 directionality. - test_resolve_flow_domain_precedence updated accordingly. Deployment helper uses the same function but only one deployment test passes both ids; deployment behavior is unchanged in tests that pass project_id only. --- .../langflow/services/authorization/utils.py | 20 ++++++++++------ .../unit/services/authorization/test_utils.py | 23 ++++++++++++++----- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/backend/base/langflow/services/authorization/utils.py b/src/backend/base/langflow/services/authorization/utils.py index 419085c482..7af35ff6e8 100644 --- a/src/backend/base/langflow/services/authorization/utils.py +++ b/src/backend/base/langflow/services/authorization/utils.py @@ -150,19 +150,25 @@ async def ensure_permission( def _resolve_flow_domain(workspace_id: UUID | None, folder_id: UUID | None) -> str: """Pick the most specific Casbin domain for a flow check. - Precedence (outer → inner): - 1. ``workspace:{workspace_id}`` when a workspace is set, - 2. ``project:{folder_id}`` when a folder is set, + Precedence (inner → outer): + 1. ``project:{folder_id}`` when a folder is set, + 2. ``workspace:{workspace_id}`` when only a workspace is set, 3. ``"*"`` when neither is set. Enterprise Casbin policies link the two via ``g2`` (e.g. - ``g2, project:xyz, workspace:abc``) so a workspace-scoped role - grant automatically applies to every project inside it. + ``g2, project:xyz, workspace:abc``) so that when the enforcer checks + against the **project** domain, both project-scoped and workspace-scoped + role grants match (workspace grants flow down to projects via g2). + Passing the workspace domain when a project is known would make + project-scoped grants invisible because g2 is directional — children + inherit from parents, not the other way round. ``workspace_id`` is also + forwarded in the enforce context for plugins that prefer ABAC-style + matchers. """ - if workspace_id is not None: - return f"workspace:{workspace_id}" if folder_id is not None: return f"project:{folder_id}" + if workspace_id is not None: + return f"workspace:{workspace_id}" return "*" diff --git a/src/backend/tests/unit/services/authorization/test_utils.py b/src/backend/tests/unit/services/authorization/test_utils.py index 496a40f853..32e13394af 100644 --- a/src/backend/tests/unit/services/authorization/test_utils.py +++ b/src/backend/tests/unit/services/authorization/test_utils.py @@ -255,8 +255,14 @@ async def test_ensure_flow_permission_falls_back_to_project_domain(monkeypatch, @pytest.mark.anyio -async def test_ensure_flow_permission_workspace_beats_project(monkeypatch, fake_user): - """When both workspace_id and folder_id are set, workspace wins as the domain.""" +async def test_ensure_flow_permission_project_beats_workspace(monkeypatch, fake_user): + """When both workspace_id and folder_id are set, the project domain wins. + + Casbin g2 is directional (children inherit from parents). Passing the + project domain lets workspace-scoped grants flow down via g2 *and* keeps + project-scoped grants visible. Passing workspace would make project + grants invisible — that was the original (buggy) behavior. + """ _install_settings(monkeypatch, authz_enabled=True) service = _StubAuthorizationService(allow=True) _install_authz(monkeypatch, service) @@ -273,8 +279,8 @@ async def test_ensure_flow_permission_workspace_beats_project(monkeypatch, fake_ folder_id=folder_id, ) - assert service.calls[0]["domain"] == f"workspace:{workspace_id}" - # folder_id is still passed in context so the plugin can use it for g2 hierarchy. + assert service.calls[0]["domain"] == f"project:{folder_id}" + # workspace_id is still passed in context so the plugin can use it for ABAC matchers. assert service.calls[0]["context"]["folder_id"] == folder_id assert service.calls[0]["context"]["workspace_id"] == workspace_id @@ -292,9 +298,14 @@ async def test_ensure_flow_permission_wildcard_domain_when_neither_set(monkeypat def test_resolve_flow_domain_precedence(): - """Unit test the precedence rule directly (workspace > project > '*').""" + """Unit test the precedence rule directly (project > workspace > '*'). + + Project is more specific than workspace; passing it lets workspace grants + flow down via Casbin g2 inheritance while keeping project-scoped grants + visible to the enforcer. + """ ws, folder = uuid4(), uuid4() - assert authz_utils._resolve_flow_domain(workspace_id=ws, folder_id=folder) == f"workspace:{ws}" + assert authz_utils._resolve_flow_domain(workspace_id=ws, folder_id=folder) == f"project:{folder}" assert authz_utils._resolve_flow_domain(workspace_id=ws, folder_id=None) == f"workspace:{ws}" assert authz_utils._resolve_flow_domain(workspace_id=None, folder_id=folder) == f"project:{folder}" assert authz_utils._resolve_flow_domain(workspace_id=None, folder_id=None) == "*"