Skip to content

test(authz): RBAC enforcement + share-lifecycle integration tests via a test-double enforcer#13549

Open
erichare wants to merge 1 commit into
release-1.10.0from
fix/rbac-enforcement-integration-tests
Open

test(authz): RBAC enforcement + share-lifecycle integration tests via a test-double enforcer#13549
erichare wants to merge 1 commit into
release-1.10.0from
fix/rbac-enforcement-integration-tests

Conversation

@erichare

@erichare erichare commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

No automated test exercised end-to-end authorization enforcement. The OSS authorization service is a pass-through — enforce() always returns True and supports_cross_user_fetch() is False — so allow/deny semantics could not be asserted against it. Only guard-wiring unit tests existed; nothing proved that the route guards, domain resolution, share-aware fetch, and deny_to_404 masking actually deny the right requests. (Closes gap D of the OSS RBAC rollout on release-1.10.0.)

Approach — an OSS-only test-double enforcer

The blocker is that you need a real allow/deny signal without the EE Casbin package. This PR adds a lightweight in-test enforcer that reads the same policy tables a real plugin would:

_policy_double.py

  • PolicyTestAuthorizationService(BaseAuthorizationService) — derives allow/deny from seeded authz_role / authz_role_assignment / authz_share rows, honors AUTHZ_SUPERUSER_BYPASS, and sets SUPPORTS_CROSS_USER_FETCH=True.
  • install_policy_authz(settings) — a context manager that swaps the service registered on the service manager (str-enum keys make ServiceType.AUTHORIZATION_SERVICE interchangeable across the lfx/langflow enums) so every get_authorization_service() call site (guards, fetch, listing, helpers) resolves to the double, flips AUTHZ_ENABLED=true / AUTHZ_SUPERUSER_BYPASS=false, and restores all of it on exit.
  • Declarative seed helpers: seed_system_roles (get-or-create, so it works whether or not the test DB ran the migration seed), assign_role, create_user_share.

Installing via the service manager (rather than per-module monkeypatching) means the tests run through the real HTTP stack and exercise the production wiring end to end.

Tests — test_rbac_enforcement_integration.py

Role matrix (Phase 1.11) on real flow routes. Flows are owned by a separate user so the guards' owner-override doesn't mask the role decision — these assert the role, not ownership:

role GET (read) build (execute) PATCH (write) POST (create) DELETE
viewer 200 200 404 403 404
developer 200 200 201 404
admin 200 200 201 200

Share lifecycle (Phase 3.13): Alice shares a flow with Bob → Bob GET/PATCH/build all succeed; without the share every fetch route returns 404 (UUID-privacy mask, not 403). A separate test gives Bob a read-level share and asserts GET 200 but PATCH 404 — proving permission_level is enforced, not mere share presence.

Domain resolution: a workspace-scoped grant authorizes a flow in that workspace (200) but 404s a flow in a different workspace — this trips if _resolve_authz_domain regresses (the route would resolve the wrong domain and the scoped grant would stop matching).

Why these assertions are meaningful (acceptance criteria)

  • Fails if a guard is removed. In every test a cross-user GET → 200 is the linchpin: it's only reachable with enforcement on and share-aware fetch widening the query. The paired PATCH/DELETE → 404 is therefore a genuine deny, not an owner-scoped fetch miss. Remove the write/delete guard and that 404 flips to 200 → test fails.
  • Cross-user deny → 404 not 403 on fetch routes — asserted directly in the viewer and share tests (== 404 fails if it were 403).
  • Fails if domain resolution regresses — the dedicated workspace-scoped test.
  • Runs without the EE package — the double only imports the lfx base class + langflow models.

Testing

142 passed   # 6 new integration tests + 136 existing authorization tests

Backend ruff clean; all pre-commit hooks (incl. detect-secrets) pass. No production code changed — additive test-only.

Triage note

This implements the full version (test-double + role matrix + share lifecycle + domain resolution). Per the ticket's triage note, if the team prefers the deny-path matrix to live in the EE repo, _policy_double.py stands alone as "the test-double enforcer fixture so OSS can self-test enforcement semantics" and the matrix tests can move without touching it.

Summary by CodeRabbit

  • Tests
    • Added comprehensive integration testing for role-based access control (RBAC) enforcement across different permission levels and scenarios
    • Added test utilities for authorization policy validation including role assignment and share management capabilities

… a test-double enforcer

No automated test exercised end-to-end authorization enforcement. The OSS
authorization service is a pass-through (enforce() always returns True,
supports_cross_user_fetch() is False), so allow/deny semantics could not be
asserted against it — only guard-wiring unit tests existed.

Add a lightweight, OSS-only test-double enforcer plus integration tests that
drive the real flow routes over HTTP under a genuine allow/deny signal — no EE
Casbin package required.

- _policy_double.py: PolicyTestAuthorizationService derives allow/deny from the
  seeded authz_role / authz_role_assignment / authz_share rows and sets
  SUPPORTS_CROSS_USER_FETCH=True. install_policy_authz() swaps it onto the
  service manager (str-enum keys make ServiceType interchangeable) and flips
  AUTHZ_ENABLED=true / AUTHZ_SUPERUSER_BYPASS=false, restoring both on exit, so
  every get_authorization_service() call site (guards, fetch, listing, helpers)
  sees it for the duration of a test. Ships seed helpers (roles/assignments/
  shares) so tests stay declarative.

- test_rbac_enforcement_integration.py:
  * Role matrix (Phase 1.11) on flow routes, with flows owned by a separate user
    so the guards' owner-override does not mask the role decision:
      - viewer: read + execute (build) allowed; write/delete -> 404; create -> 403
      - developer: write + create allowed; delete -> 404
      - admin: full, including delete
  * Share lifecycle (Phase 3.13): Alice shares a flow with Bob ->
    Bob GET/PATCH/build succeed; without the share every fetch route -> 404
    (UUID-privacy mask). A read-level share grants GET but 404s PATCH, proving
    permission_level (not mere share presence) is enforced.
  * Domain resolution: a workspace-scoped grant authorizes a flow in that
    workspace but 404s a flow in another — trips if _resolve_authz_domain
    regresses.

In each test a cross-user GET -> 200 (only possible with enforcement on +
share-aware fetch) is the linchpin proving the paired deny is real enforcement,
not an owner-scoped fetch miss; removing a guard flips a 404 to 200/200.

Closes the enforcement integration-test gap (D) for OSS RBAC on release-1.10.0.
Per the ticket's triage note, the reusable test-double also stands alone if the
team prefers the deny-path matrix to live in EE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds a test-mode authorization enforcer (PolicyTestAuthorizationService) that derives allow/deny decisions from seeded database role and share tables, supporting role-based and share-based permission grants with domain scoping. Includes test data setup helpers and a context manager to install the enforcer. Introduces comprehensive HTTP-driven integration tests validating RBAC enforcement across role matrices, share lifecycle, and workspace scoping.

Changes

RBAC test enforcement infrastructure

Layer / File(s) Summary
Authorization double - core service and enforcement entry points
src/backend/tests/unit/services/authorization/_policy_double.py
Defines PolicyTestAuthorizationService with module documentation, static permission mappings, class initialization, is_enabled() configuration check, enforce() per-call async enforcement with role checking, batch_enforce() delegation, and _role_allows() loading and permission matching using exact and wildcard patterns.
Authorization double - share and domain permission logic
src/backend/tests/unit/services/authorization/_policy_double.py
Implements _share_allows() to query AuthzShare rows and grant when action matches permission-level action set, includes _assignment_covers() domain matching for global vs. workspace-scoped role assignments, and _share_targets_user() filtering.
Test infrastructure - data seeding, role assignment, and enforcement installation
src/backend/tests/unit/services/authorization/_policy_double.py, test_rbac_enforcement_integration.py
Provides seed_system_roles() to create viewer/developer/admin roles with seeded permissions, assign_role() and create_user_share() to insert test data, install_policy_authz() context manager to temporarily swap the global authorization service, and test module setup including basic user/auth/resource helpers and role assignment fixtures.
Integration test scenarios - role matrix, shares, and domain scoping
src/backend/tests/unit/services/authorization/test_rbac_enforcement_integration.py
Implements end-to-end HTTP-driven test cases: role matrix validating viewer/developer/admin permissions with 404 masking for denials, admin and read-only share-based cross-user access tests, and workspace scoping tests verifying workspace-scoped role grants only apply within matching workspaces.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • langflow-ai/langflow#13444: Introduces in-test authorization enforcer and integration tests exercising authz_share-based RBAC decisions during HTTP route calls, aligning with share-aware and variable route behavior.

Suggested labels

lgtm

Suggested reviewers

  • HimavarshaVS
  • Cristhianzl
🚥 Pre-merge checks | ✅ 6 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Test Quality And Coverage ⚠️ Warning Four critical implementation gaps from prior review remain unaddressed in test and implementation code. Fix all 4 review comments: re-seed role permissions, validate domain_id separately, add ValueError in assign_role, and add build assertion to read-only share test.
Test File Naming And Structure ⚠️ Warning Naming and structure correct. Unresolved: missing build test in read_only_share, seed_system_roles doesn't update existing roles, _assignment_covers treats null domain_id as global. Add build test to read_only_share, update roles in seed_system_roles, fix _assignment_covers domain validation.
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding RBAC enforcement and share-lifecycle integration tests via a test-double enforcer, which matches the core additions of PolicyTestAuthorizationService and the new integration test module.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Test Coverage For New Implementations ✅ Passed PR adds two test files (_policy_double.py, test_rbac_enforcement_integration.py with 6 tests). No production code changes. Naming conventions met; all test helpers used; tests substantive.
Excessive Mock Usage Warning ✅ Passed No excessive mock usage found. PR implements a real test double (not mocks) with actual RBAC logic and database queries, and integration tests using the real HTTP stack.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/rbac-enforcement-integration-tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the test Changes to tests label Jun 8, 2026
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

✅ Test Coverage Advisor

No source changes detected without accompanying tests. Thanks for keeping coverage up! 🎉

Advisory check only — never blocks merge.

@github-actions github-actions Bot added test Changes to tests and removed test Changes to tests labels Jun 8, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/tests/unit/services/authorization/_policy_double.py`:
- Around line 226-239: seed_system_roles currently leaves preexisting AuthzRole
rows unchanged, which causes non-deterministic policies; update the function
(seed_system_roles) to, for each found existing AuthzRole (by AuthzRole.name),
overwrite its permissions, is_system flag, and description to match
SYSTEM_ROLE_PERMISSIONS and the test seed description (convert permissions to a
list), flush after changes, and then commit — ensuring returned ids map to the
updated/created role IDs so the test RBAC matrix is deterministic.
- Around line 200-209: The helper _assignment_covers currently treats
assignments with domain_id is None as global; change it so only assignments with
domain_type == "global" are treated as global (i.e., return False when domain_id
is None for non-global types) and add input validation in the role assignment
path (e.g., the assign_role function) to raise a ValueError when domain_type !=
"global" and domain_id is None so malformed scoped grants fail fast; update
logic around _assignment_covers and assign_role to enforce that only explicit
global domain_type grants global coverage.

In
`@src/backend/tests/unit/services/authorization/test_rbac_enforcement_integration.py`:
- Around line 206-229: The test_read_only_share_allows_get_but_denies_write test
only asserts PATCH is denied; add an additional request that exercises the
build/execute endpoint and assert it is also denied (404) to ensure
permission_level="read" doesn't grant build/execute. Inside the same test (after
the existing PATCH check) use the test client to call the build/execute API for
the flow_id (the same resource created above) with bob_headers and expect a 404
status; this complements the existing client.get and client.patch assertions and
uses the same setup (create_user_share with permission_level="read") so the
negative case for build/execute is covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c11f0885-228a-44af-8bb5-c0092939f819

📥 Commits

Reviewing files that changed from the base of the PR and between a1adb1a and aec5f79.

📒 Files selected for processing (2)
  • src/backend/tests/unit/services/authorization/_policy_double.py
  • src/backend/tests/unit/services/authorization/test_rbac_enforcement_integration.py

Comment on lines +200 to +209
def _assignment_covers(assignment: AuthzRoleAssignment, request_domain: str) -> bool:
"""A global assignment covers every domain; a scoped one must match exactly.

The scoped form is intentionally exact (``{domain_type}:{domain_id}`` ==
request domain) so a regression that changes the domain the route resolves
flips the decision and trips the test.
"""
if assignment.domain_type == "global" or assignment.domain_id is None:
return True
return f"{assignment.domain_type}:{assignment.domain_id}" == request_domain

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't widen malformed scoped grants into global access.

Line 207 treats any assignment with domain_id is None as covering every domain. A test that accidentally calls assign_role(..., domain_type="workspace") without a domain_id will therefore pass with global privileges and can hide domain-resolution regressions. Non-global assignments should fail fast, or at minimum return False here.

Suggested fix
 def _assignment_covers(assignment: AuthzRoleAssignment, request_domain: str) -> bool:
     """A global assignment covers every domain; a scoped one must match exactly.
@@
-    if assignment.domain_type == "global" or assignment.domain_id is None:
+    if assignment.domain_type == "global":
         return True
+    if assignment.domain_id is None:
+        return False
     return f"{assignment.domain_type}:{assignment.domain_id}" == request_domain
async def assign_role(...):
    if domain_type != "global" and domain_id is None:
        raise ValueError("domain_id is required for non-global assignments")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/authorization/_policy_double.py` around lines
200 - 209, The helper _assignment_covers currently treats assignments with
domain_id is None as global; change it so only assignments with domain_type ==
"global" are treated as global (i.e., return False when domain_id is None for
non-global types) and add input validation in the role assignment path (e.g.,
the assign_role function) to raise a ValueError when domain_type != "global" and
domain_id is None so malformed scoped grants fail fast; update logic around
_assignment_covers and assign_role to enforce that only explicit global
domain_type grants global coverage.

Comment on lines +226 to +239
async def seed_system_roles(session: AsyncSession) -> dict[str, UUID]:
"""Get-or-create viewer/developer/admin roles; return ``{name: role_id}``."""
ids: dict[str, UUID] = {}
for name, permissions in SYSTEM_ROLE_PERMISSIONS.items():
existing = (await session.exec(select(AuthzRole).where(AuthzRole.name == name))).first()
if existing is None:
existing = AuthzRole(
name=name, description=f"{name} (test seed)", is_system=True, permissions=list(permissions)
)
session.add(existing)
await session.flush()
ids[name] = existing.id
await session.commit()
return ids

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-seed existing roles to keep the policy matrix deterministic.

Line 230 only keys on AuthzRole.name and leaves preexisting permissions untouched. If the test DB already contains viewer / developer / admin rows from an older migration or another fixture, this helper silently inherits that policy instead of SYSTEM_ROLE_PERMISSIONS, so these RBAC assertions stop being self-contained.

Suggested fix
 async def seed_system_roles(session: AsyncSession) -> dict[str, UUID]:
     """Get-or-create viewer/developer/admin roles; return ``{name: role_id}``."""
     ids: dict[str, UUID] = {}
     for name, permissions in SYSTEM_ROLE_PERMISSIONS.items():
         existing = (await session.exec(select(AuthzRole).where(AuthzRole.name == name))).first()
         if existing is None:
             existing = AuthzRole(
                 name=name, description=f"{name} (test seed)", is_system=True, permissions=list(permissions)
             )
             session.add(existing)
             await session.flush()
+        else:
+            existing.permissions = list(permissions)
+            existing.is_system = True
+            existing.description = f"{name} (test seed)"
         ids[name] = existing.id
     await session.commit()
     return ids
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def seed_system_roles(session: AsyncSession) -> dict[str, UUID]:
"""Get-or-create viewer/developer/admin roles; return ``{name: role_id}``."""
ids: dict[str, UUID] = {}
for name, permissions in SYSTEM_ROLE_PERMISSIONS.items():
existing = (await session.exec(select(AuthzRole).where(AuthzRole.name == name))).first()
if existing is None:
existing = AuthzRole(
name=name, description=f"{name} (test seed)", is_system=True, permissions=list(permissions)
)
session.add(existing)
await session.flush()
ids[name] = existing.id
await session.commit()
return ids
async def seed_system_roles(session: AsyncSession) -> dict[str, UUID]:
"""Get-or-create viewer/developer/admin roles; return ``{name: role_id}``."""
ids: dict[str, UUID] = {}
for name, permissions in SYSTEM_ROLE_PERMISSIONS.items():
existing = (await session.exec(select(AuthzRole).where(AuthzRole.name == name))).first()
if existing is None:
existing = AuthzRole(
name=name, description=f"{name} (test seed)", is_system=True, permissions=list(permissions)
)
session.add(existing)
await session.flush()
else:
existing.permissions = list(permissions)
existing.is_system = True
existing.description = f"{name} (test seed)"
ids[name] = existing.id
await session.commit()
return ids
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/tests/unit/services/authorization/_policy_double.py` around lines
226 - 239, seed_system_roles currently leaves preexisting AuthzRole rows
unchanged, which causes non-deterministic policies; update the function
(seed_system_roles) to, for each found existing AuthzRole (by AuthzRole.name),
overwrite its permissions, is_system flag, and description to match
SYSTEM_ROLE_PERMISSIONS and the test seed description (convert permissions to a
list), flush after changes, and then commit — ensuring returned ids map to the
updated/created role IDs so the test RBAC matrix is deterministic.

Comment on lines +206 to +229
async def test_read_only_share_allows_get_but_denies_write(client):
"""A read-level share grants GET but not PATCH — permission_level is enforced, not mere presence."""
settings = get_settings_service()
alice_id = await _make_user(f"alice_{uuid4().hex}")
bob_username = f"bob_{uuid4().hex}"
bob_id = await _make_user(bob_username)
flow_id = await _make_flow(alice_id, f"aliceflow_{uuid4().hex}")
bob_headers = await _login(client, bob_username)

async with session_scope() as session:
await create_user_share(
session,
resource_type="flow",
resource_id=flow_id,
target_user_id=bob_id,
permission_level="read",
created_by=alice_id,
)

with install_policy_authz(settings):
assert (await client.get(f"api/v1/flows/{flow_id}", headers=bob_headers)).status_code == 200
# write is not granted by a read-level share -> deny -> 404
patch = await client.patch(f"api/v1/flows/{flow_id}", headers=bob_headers, json={"name": "nope"})
assert patch.status_code == 404

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert that read-only shares also deny build/execute.

This case only proves that a read share blocks PATCH. Because execute is modeled independently from write in the new test double, a regression that accidentally grants build access to permission_level="read" would still pass here. Add a build request and expect the same 404 mask.

Suggested fix
     with install_policy_authz(settings):
         assert (await client.get(f"api/v1/flows/{flow_id}", headers=bob_headers)).status_code == 200
         # write is not granted by a read-level share -> deny -> 404
         patch = await client.patch(f"api/v1/flows/{flow_id}", headers=bob_headers, json={"name": "nope"})
         assert patch.status_code == 404
+        build = await client.post(f"api/v1/build/{flow_id}/flow", headers=bob_headers, json={})
+        assert build.status_code == 404

As per coding guidelines, "For API endpoints, verify both success and error response testing" and "Ensure tests cover both positive and negative scenarios where appropriate."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/backend/tests/unit/services/authorization/test_rbac_enforcement_integration.py`
around lines 206 - 229, The test_read_only_share_allows_get_but_denies_write
test only asserts PATCH is denied; add an additional request that exercises the
build/execute endpoint and assert it is also denied (404) to ensure
permission_level="read" doesn't grant build/execute. Inside the same test (after
the existing PATCH check) use the test client to call the build/execute API for
the flow_id (the same resource created above) with bob_headers and expect a 404
status; this complements the existing client.get and client.patch assertions and
uses the same setup (create_user_share with permission_level="read") so the
negative case for build/execute is covered.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test Changes to tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant