fix(authz): prefer project over workspace in _resolve_flow_domain

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.
This commit is contained in:
Eric Hare
2026-05-20 16:02:38 -07:00
parent 0f7df66d76
commit ac3e8bcf0f
2 changed files with 30 additions and 13 deletions

View File

@ -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 "*"

View File

@ -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) == "*"