Skip to content

Enforce tenant scoping across all API query paths - #124

Merged
peaktwilight merged 1 commit into
mainfrom
feat/tenant-enforcement-query-coverage
Apr 21, 2026
Merged

Enforce tenant scoping across all API query paths#124
peaktwilight merged 1 commit into
mainfrom
feat/tenant-enforcement-query-coverage

Conversation

@peaktwilight

@peaktwilight peaktwilight commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Routes every multi-tenant-scoped read/mutation through the apply_tenant_access_query / enforce_tenant_access plugin hooks so optional packages can filter and block cross-tenant access.
  • Closes gaps in bulk alert update, the AI endpoints (summarize, triage, recommend, deduplicate), alert-activity listings, and incident-activity listings where data was loaded or handed to an LLM before tenant validators ran.
  • Adds tests/test_tenant_isolation.py (17 tests) that install a partner-field validator via _swap_validators and assert every list, detail, mutation and AI endpoint either filters or returns 403 on cross-tenant access. LLM and embedding clients are stubbed so any bypass is a hard failure.

Closes #113.

Test plan

  • pytest tests/test_tenant_isolation.py -v — 17/17 pass
  • pytest tests/ -v — full suite green (590 passed)
  • ruff check src/ tests/ — clean

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced access control validation for alert actions and AI-powered operations (summarize, triage, recommend, and deduplication).
    • Fixed potential cross-tenant data leakage in activities, incident tracking, and dashboard statistics.
    • Strengthened tenant boundary enforcement in bulk alert operations and observable queries.
  • Tests

    • Added comprehensive test suite verifying tenant isolation across all API endpoints.

Route every multi-tenant-scoped read and mutation through the
``apply_tenant_access_query`` / ``enforce_tenant_access`` plugin hooks so
optional packages can filter cross-tenant results and block cross-tenant
writes. Previously several endpoints loaded data before consulting the
tenant validators, which let cross-tenant payloads leak (or be mutated)
through the bulk-update, AI, activity, and incident-activity paths.

- alerts.py: bulk update now runs the alert query through the tenant
  filter and re-validates each record before mutating; list_alert_runs
  scopes its query to visible runs.
- ai.py: summarize, triage and recommend all load the alert and
  enforce tenant access before handing data to the LLM client.
- ai_dedup.py: deduplicate enforces tenant access before computing
  embeddings so cross-tenant alerts never leave the DB.
- incidents.py: list_incident_activities filters activities by visible
  alert ids via the tenant query hook.
- dashboard.py, actions.py: carry the same hook wiring so plugin
  validators can scope stats and action listings.

Adds tests/test_tenant_isolation.py with 17 tests that install a
partner-field-based validator via _swap_validators and verify list,
detail, mutation, AI and activity endpoints either filter or 403 on
cross-tenant access — with the AI and embedding clients stubbed out so
any bypass is a hard failure.

Closes #113.
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This pull request implements systematic tenant isolation enforcement across the API layer and database query operations. API endpoints now validate tenant access before executing operations, and database queries are filtered to return only tenant-visible data. A comprehensive test suite validates tenant isolation across multiple endpoints and workflows.

Changes

Cohort / File(s) Summary
API Endpoints - Tenant Access Enforcement
src/opensoar/api/actions.py, src/opensoar/api/ai.py, src/opensoar/api/ai_dedup.py
Added Request parameter intake and tenant access validation via enforce_tenant_access() before executing alert-linked operations. AI endpoints apply per-alert enforcement within processing loops and scope observable/alert queries using apply_tenant_access_query(); renamed dependency parameter from _analyst to analyst.
API Endpoints - Query Scoping
src/opensoar/api/alerts.py, src/opensoar/api/dashboard.py, src/opensoar/api/incidents.py
Applied apply_tenant_access_query() to constrain PlaybookRun, Observable, and Activity queries to tenant-visible data. In bulk_update_alerts, added per-alert enforcement during processing loop. In list_incident_activities, filtered linked activities based on visible alert IDs.
Comprehensive Test Suite
tests/test_tenant_isolation.py
Added new 640-line test module with cross-tenant isolation validation covering alert operations, AI endpoints, observables, activities, dashboard stats, and incident activities. Includes test utilities for validator injection and partner-scoped access control verification.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Through queries and endpoints, a fence we now weave,
Each tenant their own data—no secrets to thieve,
Tests guard the borders with partner-wise care,
Tenant enforcement, from SQL to the air! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main objective of the changeset: enforcing tenant scoping across all API query paths.
Linked Issues check ✅ Passed All major coding requirements from issue #113 are met: tenant filtering is applied across query paths via enforce_tenant_access and apply_tenant_access_query hooks, all relevant endpoints now filter by tenant, and comprehensive cross-tenant isolation tests are added.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #113 objectives: tenant scoping enforcement, endpoint filtering, and isolation testing. No unrelated modifications detected.

✏️ 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 feat/tenant-enforcement-query-coverage

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

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (3)
tests/test_tenant_isolation.py (2)

110-116: Activity query filter may be too restrictive.

The activity query filter (Lines 110-116) only includes activities where Activity.alert_id is in the allowed alerts:

if rtype == "activity":
    allowed_alert_ids = select(Alert.id).where(
        Alert.partner == allowed_partner
    )
    return query.where(
        Activity.alert_id.in_(allowed_alert_ids)
    )

This will exclude incident-only activities where Activity.alert_id IS NULL. In production, list_incident_activities handles this by using an OR condition with Activity.alert_id.is_(None). However, if there are other activity listing endpoints that use this validator, they may incorrectly filter out null-alert activities.

This is acceptable for test purposes since the test focuses on validating that cross-tenant alert activities are blocked, but worth noting if this validator pattern is intended as a reference implementation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_tenant_isolation.py` around lines 110 - 116, The current activity
filter in the validator (the block checking if rtype == "activity" that builds
allowed_alert_ids via select(Alert.id).where(Alert.partner == allowed_partner)
and returns query.where(Activity.alert_id.in_(allowed_alert_ids))) wrongly
excludes activities with no alert; update the predicate to allow
Activity.alert_id IS NULL as well (i.e., combine the existing
Activity.alert_id.in_(allowed_alert_ids) check with an OR
Activity.alert_id.is_(None)), following the same approach used in
list_incident_activities so null-alert activities are not filtered out.

291-296: Bulk update test assertion could be more precise.

The assertion at Lines 294-296 is lenient:

assert body["updated"] <= 1
assert body["failed"] >= 1 or body["updated"] == 1

This passes if either the foreign alert fails or if only the owned alert is updated, but doesn't distinguish between:

  1. Foreign alert filtered out at query level (not returned, reported as "not found")
  2. Foreign alert blocked at per-record enforcement (HTTPException caught)

Consider asserting more specifically that mine was updated and foreign was not:

assert body["updated"] == 1  # Only mine should succeed
assert any("not found" in e or "denied" in e.lower() for e in body["errors"])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_tenant_isolation.py` around lines 291 - 296, The current lenient
assertions should be tightened: replace the two assertions with one asserting
body["updated"] == 1 to ensure only the owned alert (mine) was updated, and then
assert that body["errors"] contains evidence the foreign alert was not allowed
by checking that at least one error string in body["errors"] includes "not
found" or "denied" (case-insensitive); locate these checks around the existing
assertions using resp and body in the test function to implement the stricter
assertions.
src/opensoar/api/ai.py (1)

267-280: Silent skip pattern for batch AI endpoints is intentional but differs from standard error propagation.

In auto_resolve (Lines 267-280) and correlate_alerts (Lines 355-368), cross-tenant alerts are silently skipped rather than causing a 403 response. This is a deliberate design choice documented in the comments ("Skip alerts the caller can't access across tenants").

This differs from standard endpoints (per Context snippets 3-4 in playbooks.py and observables.py) where HTTPException propagates. The batch semantics here are similar to bulk_update_alerts but without error reporting.

Consider whether the response should indicate how many alerts were skipped for tenant access reasons, similar to how bulk_update_alerts reports failed counts.

Also applies to: 355-368

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/opensoar/api/ai.py` around lines 267 - 280, The batch AI endpoints
auto_resolve and correlate_alerts currently silently skip alerts that fail
enforce_tenant_access (catching HTTPException and continue); update these
functions to track a skipped_due_to_tenant_access counter (or add to existing
failed counts) when enforce_tenant_access raises HTTPException and include that
count in the endpoint response payload (mirroring bulk_update_alerts' failed
reporting), so callers get explicit visibility into how many items were skipped
for tenant-access reasons while preserving batch processing semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/opensoar/api/ai.py`:
- Around line 267-280: The batch AI endpoints auto_resolve and correlate_alerts
currently silently skip alerts that fail enforce_tenant_access (catching
HTTPException and continue); update these functions to track a
skipped_due_to_tenant_access counter (or add to existing failed counts) when
enforce_tenant_access raises HTTPException and include that count in the
endpoint response payload (mirroring bulk_update_alerts' failed reporting), so
callers get explicit visibility into how many items were skipped for
tenant-access reasons while preserving batch processing semantics.

In `@tests/test_tenant_isolation.py`:
- Around line 110-116: The current activity filter in the validator (the block
checking if rtype == "activity" that builds allowed_alert_ids via
select(Alert.id).where(Alert.partner == allowed_partner) and returns
query.where(Activity.alert_id.in_(allowed_alert_ids))) wrongly excludes
activities with no alert; update the predicate to allow Activity.alert_id IS
NULL as well (i.e., combine the existing
Activity.alert_id.in_(allowed_alert_ids) check with an OR
Activity.alert_id.is_(None)), following the same approach used in
list_incident_activities so null-alert activities are not filtered out.
- Around line 291-296: The current lenient assertions should be tightened:
replace the two assertions with one asserting body["updated"] == 1 to ensure
only the owned alert (mine) was updated, and then assert that body["errors"]
contains evidence the foreign alert was not allowed by checking that at least
one error string in body["errors"] includes "not found" or "denied"
(case-insensitive); locate these checks around the existing assertions using
resp and body in the test function to implement the stricter assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6275f9bb-c72d-4523-a5c8-79f1a8714836

📥 Commits

Reviewing files that changed from the base of the PR and between 33c4812 and 7b8b8fc.

📒 Files selected for processing (7)
  • src/opensoar/api/actions.py
  • src/opensoar/api/ai.py
  • src/opensoar/api/ai_dedup.py
  • src/opensoar/api/alerts.py
  • src/opensoar/api/dashboard.py
  • src/opensoar/api/incidents.py
  • tests/test_tenant_isolation.py

@peaktwilight
peaktwilight merged commit d94fb87 into main Apr 21, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-tenancy enforcement is incomplete

1 participant