Skip to content

fix(rbac): enforce tenant/team membership on object routes (OH1/OH2/OH3/H1)#58

Open
PenguinzTech wants to merge 2 commits into
fix/oidc-hardeningfrom
fix/tenant-team-isolation
Open

fix(rbac): enforce tenant/team membership on object routes (OH1/OH2/OH3/H1)#58
PenguinzTech wants to merge 2 commits into
fix/oidc-hardeningfrom
fix/tenant-team-isolation

Conversation

@PenguinzTech

@PenguinzTech PenguinzTech commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Security fixes (Track A3) — tenant/team isolation (IDOR)

Stacked on #57 (base = fix/oidc-hardening); merge #56#57 → this. Ultimately targets release/v0.1.x.

Closes an IDOR where any self-registered global viewer (holds global tenants:read/teams:read) could read any org's tenants/teams and member emails across tenants, because the scope gate passed globally and routes then loaded objects by id with no membership check.

  • Membership helpers in rbac.py; every object-scoped tenant/team route now requires the caller to be a global admin OR a member of that specific tenant/team. Admin ops (update/delete/member changes) require tenant/team admin scope, resolved WITH the object id so tenant/team-scoped role assignments are actually consulted (also fixes the previously over-restrictive path).
  • create_team validates the body tenant_id against caller membership → no cross-tenant writes.

Tests

Regression tests added: non-member viewer → 403, member → 200, global admin → 200, foreign-tenant create → 403. Full suite: 413 passed, 90.58% coverage.

🤖 Generated with Claude Code

Summary by Sourcery

Enforce tenant and team membership, with global admin exceptions, on object-scoped tenant and team routes to close cross-tenant/team IDORs.

New Features:

  • Add RBAC helpers to check tenant/team membership and global admin scopes for object-scoped access control.

Bug Fixes:

  • Require tenant or team membership (or global admin) to read tenants, teams, and team member lists, preventing global viewers from accessing foreign tenants/teams.
  • Validate create_team requests so teams can only be created in tenants the caller belongs to, blocking cross-tenant writes.
  • Ensure tenant update operations are limited to tenant members with appropriate scopes or global admins, preventing unauthorized modifications.

Tests:

  • Extend tenant and team tests with membership-based access regressions for viewers, admins, and foreign-tenant team creation to cover IDOR scenarios.

…H3/H1)

Closes IDOR: a global viewer holds global tenants:read/teams:read, so the
scope gate passed for everyone and routes then loaded any object by id with
no membership check — any user could read any org's tenants/teams + member
emails across tenants.

- Adds membership helpers in rbac.py; each object-scoped tenant/team route now
  requires the caller to be a global admin OR a member of that specific
  tenant/team (admin ops require tenant/team admin scope, resolved with the id
  so tenant/team-scoped roles are actually consulted).
- create_team validates the body tenant_id against caller membership (no more
  cross-tenant writes).
- Regression tests: non-member viewer 403, member 200, global admin 200,
  foreign-tenant create 403.

Also gitignore /worktrees/ (repo-local worktree convention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@PenguinzTech PenguinzTech added this to the v0.1.x milestone Jul 14, 2026
@PenguinzTech PenguinzTech added the type:security Security fix label Jul 14, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds tenant/team membership enforcement for object-scoped tenant and team routes, introduces RBAC helpers for membership/global-admin checks, wires them into teams and tenants handlers (including create_team), and adds regression tests to prevent cross-tenant/team IDOR reads and writes while preserving global admin behavior.

Sequence diagram for create_team with tenant membership enforcement

sequenceDiagram
    actor User
    participant TeamsAPI as create_team
    participant RBAC as rbac_helpers
    participant DB

    User->>TeamsAPI: POST /teams (name, tenant_id)
    TeamsAPI->>DB: get_db
    TeamsAPI->>User: g.current_user["id"]
    TeamsAPI->>RBAC: _has_global_admin_scope(DB, user_id, tenants:admin)
    RBAC->>DB: get_user_scopes(user_id)
    RBAC-->>TeamsAPI: is_global_admin
    alt tenant_id provided
        TeamsAPI->>RBAC: _is_tenant_member(DB, user_id, tenant_id)
        RBAC->>DB: select from tenant_members
        RBAC-->>TeamsAPI: is_tenant_member
        alt not is_global_admin and not is_tenant_member
            TeamsAPI-->>User: 403 Forbidden
        else allowed
            TeamsAPI->>DB: teams.insert(name, description, tenant_id, created_by)
            DB-->>TeamsAPI: team_id
            TeamsAPI-->>User: 201 Created
        end
    else no tenant_id
        TeamsAPI->>DB: teams.insert(name, description, None, created_by)
        DB-->>TeamsAPI: team_id
        TeamsAPI-->>User: 201 Created
    end
Loading

Sequence diagram for get_team/get_tenant membership checks

sequenceDiagram
    actor User
    participant TeamsAPI as get_team
    participant TenantsAPI as get_tenant
    participant RBAC as rbac_helpers
    participant DB

    User->>TeamsAPI: GET /teams/:team_id
    TeamsAPI->>DB: get_db
    TeamsAPI->>User: g.current_user["id"]
    TeamsAPI->>DB: select from teams where id == team_id
    alt team not found
        TeamsAPI-->>User: 404 NotFound
    else team exists
        TeamsAPI->>RBAC: _has_global_admin_scope(DB, user_id, teams:admin)
        RBAC->>DB: get_user_scopes(user_id)
        RBAC-->>TeamsAPI: is_admin
        TeamsAPI->>RBAC: _is_team_member(DB, user_id, team_id)
        RBAC->>DB: select from team_members
        RBAC-->>TeamsAPI: is_member
        alt not is_admin and not is_member
            TeamsAPI-->>User: 403 Forbidden
        else allowed
            TeamsAPI->>DB: select team_members
            DB-->>TeamsAPI: members
            TeamsAPI-->>User: 200 OK (team + members)
        end
    end

    User->>TenantsAPI: GET /tenants/:tenant_id
    TenantsAPI->>DB: get_db
    TenantsAPI->>User: g.current_user["id"]
    TenantsAPI->>DB: select from tenants where id == tenant_id
    alt tenant not found
        TenantsAPI-->>User: 404 NotFound
    else tenant exists
        TenantsAPI->>RBAC: _has_global_admin_scope(DB, user_id, tenants:admin)
        RBAC->>DB: get_user_scopes(user_id)
        RBAC-->>TenantsAPI: is_admin
        TenantsAPI->>RBAC: _is_tenant_member(DB, user_id, tenant_id)
        RBAC->>DB: select from tenant_members
        RBAC-->>TenantsAPI: is_member
        alt not is_admin and not is_member
            TenantsAPI-->>User: 403 Forbidden
        else allowed
            TenantsAPI->>DB: select tenant_members
            DB-->>TenantsAPI: members
            TenantsAPI-->>User: 200 OK (tenant + members)
        end
    end
Loading

File-Level Changes

Change Details Files
Introduce RBAC helpers to check tenant/team membership and global admin scopes for object-scoped authorization decisions.
  • Add _is_tenant_member helper to check tenant membership via tenant_members table.
  • Add _is_team_member helper to check team membership via team_members table.
  • Add _has_global_admin_scope helper to evaluate global admin scopes including system:admin.
  • Prepare these helpers for use by route handlers enforcing object-level access control.
services/flask-backend/app/rbac.py
Enforce tenant membership and admin semantics on tenant object routes, while keeping certain admin operations globally scoped.
  • Update get_tenant to require tenants:read plus membership in the tenant unless the caller has a global tenants:admin/system:admin scope, returning 403 for non-members.
  • Update update_tenant to require tenants:write/tenants:admin plus membership in the tenant unless caller has global tenants:admin/system:admin.
  • Clarify delete_tenant, add_tenant_member, and remove_tenant_member semantics by relying on global scopes via decorators and documenting that no extra membership check is needed for those admin/member-management actions.
services/flask-backend/app/tenants.py
Enforce team membership and tenant membership on team-related routes, including creation and member listing, while relying on scopes for admin operations.
  • Update create_team to optionally accept tenant_id but require the caller to either have global tenants:admin/system:admin or be a member of that tenant before creating the team.
  • Change get_team to check that the caller is a team member or has global teams:admin/system:admin after passing teams:read, returning 403 for non-members.
  • Change get_team_members to also require membership in the team or global admin scope, returning 403 otherwise.
  • Simplify require_scope usage on team routes by removing team_id_param so that membership is enforced explicitly with the new helpers for object-level checks.
  • Leave write/admin team routes (update/delete/member management) still gated by teams:write/teams:admin scopes, now with object-level checks handled in-code where needed.
services/flask-backend/app/teams.py
Add regression tests to validate tenant/team isolation semantics for viewers, members, and global admins, and update existing tests to reflect the new membership requirements.
  • Modify existing viewer read tests for tenants and teams to first add the viewer as a member before asserting successful reads.
  • Add tests ensuring a non-member viewer gets 403 when reading a foreign team or its members, and that a member viewer or global admin receives 200.
  • Add tests ensuring a non-member viewer gets 403 when reading or updating a foreign tenant, while member viewers and global admins can read tenants.
  • Add a test ensuring viewers cannot create a team in a tenant where they are not a member (foreign-tenant create → 403).
services/flask-backend/tests/test_teams.py
services/flask-backend/tests/test_tenants.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The new _has_global_admin_scope helper takes a db parameter but doesn’t use it, and several handlers in tenants.py import _has_global_admin_scope without ever calling it; consider either wiring it into the logic or removing the unused parameter/imports to keep the API and call-sites clean.
  • The membership checks for tenants/teams (login to get viewer ID, then POST to members) are duplicated across multiple tests; extracting a small helper or fixture to add a user as a tenant/team member would make the tests shorter and easier to maintain.
  • The repeated inline membership checks in teams.py/tenants.py (admin-or-member checks) could be refactored into a shared decorator or helper to avoid drifting behavior between endpoints and to make the access rules easier to audit in one place.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `_has_global_admin_scope` helper takes a `db` parameter but doesn’t use it, and several handlers in `tenants.py` import `_has_global_admin_scope` without ever calling it; consider either wiring it into the logic or removing the unused parameter/imports to keep the API and call-sites clean.
- The membership checks for tenants/teams (login to get viewer ID, then POST to members) are duplicated across multiple tests; extracting a small helper or fixture to add a user as a tenant/team member would make the tests shorter and easier to maintain.
- The repeated inline membership checks in `teams.py`/`tenants.py` (admin-or-member checks) could be refactored into a shared decorator or helper to avoid drifting behavior between endpoints and to make the access rules easier to audit in one place.

## Individual Comments

### Comment 1
<location path="services/flask-backend/app/rbac.py" line_range="710-719" />
<code_context>
+    return member is not None
+
+
+def _has_global_admin_scope(db, user_id: int, admin_scope: str) -> bool:
+    """
+    Check if a user has a global admin scope (e.g., 'tenants:admin' or 'system:admin').
+
+    Args:
+        db: Database connection
+        user_id: User ID
+        admin_scope: Admin scope to check (e.g., 'tenants:admin')
+
+    Returns:
+        True if user has the global admin scope
+    """
+    global_scopes = get_user_scopes(user_id)
+    return admin_scope in global_scopes or "system:admin" in global_scopes
+
</code_context>
<issue_to_address>
**suggestion:** The `db` parameter is unused in `_has_global_admin_scope`, which can be misleading.

Since the implementation ignores `db` and calls `get_user_scopes(user_id)` directly, it’s unclear which connection is intended. Either remove `db` from the signature or actually use it in the scope lookup to keep the API consistent and avoid ambiguity about the database context.

Suggested implementation:

```python
def _has_global_admin_scope(db, user_id: int, admin_scope: str) -> bool:
    """
    Check if a user has a global admin scope (e.g., 'tenants:admin' or 'system:admin').

    Args:
        db: Database connection
        user_id: User ID
        admin_scope: Admin scope to check (e.g., 'tenants:admin')

    Returns:
        True if user has the global admin scope
    """
    global_scopes = get_user_scopes(db, user_id)
    return admin_scope in global_scopes or "system:admin" in global_scopes

```

If `get_user_scopes` currently has the signature `get_user_scopes(user_id: int)` without `db`, you should update it (and its call sites) to accept `db` as the first parameter, e.g. `get_user_scopes(db, user_id)`, so that all scope lookups consistently use an explicit database connection.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +710 to +719
def _has_global_admin_scope(db, user_id: int, admin_scope: str) -> bool:
"""
Check if a user has a global admin scope (e.g., 'tenants:admin' or 'system:admin').

Args:
db: Database connection
user_id: User ID
admin_scope: Admin scope to check (e.g., 'tenants:admin')

Returns:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The db parameter is unused in _has_global_admin_scope, which can be misleading.

Since the implementation ignores db and calls get_user_scopes(user_id) directly, it’s unclear which connection is intended. Either remove db from the signature or actually use it in the scope lookup to keep the API consistent and avoid ambiguity about the database context.

Suggested implementation:

def _has_global_admin_scope(db, user_id: int, admin_scope: str) -> bool:
    """
    Check if a user has a global admin scope (e.g., 'tenants:admin' or 'system:admin').

    Args:
        db: Database connection
        user_id: User ID
        admin_scope: Admin scope to check (e.g., 'tenants:admin')

    Returns:
        True if user has the global admin scope
    """
    global_scopes = get_user_scopes(db, user_id)
    return admin_scope in global_scopes or "system:admin" in global_scopes

If get_user_scopes currently has the signature get_user_scopes(user_id: int) without db, you should update it (and its call sites) to accept db as the first parameter, e.g. get_user_scopes(db, user_id), so that all scope lookups consistently use an explicit database connection.

…follow-up)

The initial isolation fix guarded only read routes; write/admin routes still had
just a global scope gate, so a user with global teams:write/tenants:write (e.g. a
global maintainer) could modify/delete any team/tenant and grant roles across orgs
— privilege escalation. (Also restores object-scoped authz that had regressed when
team_id_param was dropped from the decorators.)

- Adds _is_team_admin / _is_tenant_admin helpers (global admin scope OR team/tenant-
  scoped admin role for THAT object).
- update/delete/add-member/remove-member on both teams.py and tenants.py now require
  the caller to be an admin of that specific team/tenant (or global admin).
- add_team_member/add_tenant_member especially: they grant roles incl. *_admin, so
  non-admins of the object are now blocked.
- Regression tests: global maintainer (no admin role) → 403 on every write route;
  team/tenant admin → 200/201.

Full suite: 423 passed, 90.10% coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:security Security fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant