-
Notifications
You must be signed in to change notification settings - Fork 626
[FIX] Scope user lookups to current env's OIDC connection via plugin-driven filter #1982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jaseemjaskp
wants to merge
6
commits into
main
Choose a base branch
from
fix/oidc-connection-scoping-multi-idp
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+317
−40
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
98c95bf
[FIX] Scope user lookups to current env's OIDC connection via plugin-…
jaseemjaskp 006f4a1
[FIX] Address PR review comments — PK bypass, multi-match raise, regi…
jaseemjaskp 0cf7397
[FIX] Use logger.exception in authorization callback handler
jaseemjaskp b1756de
[FIX] Strip PII from ambiguity logs; emit row PKs instead
jaseemjaskp bc2b3c7
[FIX] Address follow-up review: cap ambiguity query, unify lookups, n…
jaseemjaskp ecee6a4
Merge remote-tracking branch 'origin/main' into fix/oidc-connection-s…
jaseemjaskp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| """Regression tests for ``UserFilterRegistry``. | ||
|
|
||
| These tests guard the plumbing that identity-scoping plugins use to scope | ||
| User / OrganizationMember querysets. A future refactor that breaks the | ||
| registry contract (e.g., loses dedupe-on-register, swallows plugin | ||
| exceptions, mishandles unregister) would silently let cross-environment | ||
| identities leak — the very failure mode the registry exists to prevent. | ||
|
|
||
| No Django app registry / DB is required: the registry only depends on | ||
| ``django.db.models.QuerySet`` as a static type hint, and the tests use | ||
| ``unittest.mock`` to fake the queryset chain. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import unittest | ||
| from unittest.mock import MagicMock | ||
|
|
||
| from account_v2.user_filter_registry import UserFilterRegistry | ||
|
|
||
|
|
||
| class UserFilterRegistryTests(unittest.TestCase): | ||
| def setUp(self) -> None: | ||
| # Class-level state — clear before every test to avoid bleed. | ||
| UserFilterRegistry.clear() | ||
| self.addCleanup(UserFilterRegistry.clear) | ||
|
|
||
| def test_apply_with_no_filters_is_identity(self) -> None: | ||
| qs = MagicMock(name="queryset") | ||
| self.assertIs(UserFilterRegistry.apply(qs, "user"), qs) | ||
|
|
||
| def test_register_appends_filter(self) -> None: | ||
| def fn(qs, kind): # noqa: ANN001 - inline test helper | ||
| return qs | ||
|
|
||
| UserFilterRegistry.register(fn) | ||
| self.assertIn(fn, UserFilterRegistry._filters) | ||
|
|
||
| def test_register_dedupes(self) -> None: | ||
| def fn(qs, kind): # noqa: ANN001 | ||
| return qs | ||
|
|
||
| UserFilterRegistry.register(fn) | ||
| UserFilterRegistry.register(fn) | ||
| self.assertEqual(UserFilterRegistry._filters.count(fn), 1) | ||
|
|
||
| def test_unregister_removes_filter(self) -> None: | ||
| def fn(qs, kind): # noqa: ANN001 | ||
| return qs | ||
|
|
||
| UserFilterRegistry.register(fn) | ||
| UserFilterRegistry.unregister(fn) | ||
| self.assertNotIn(fn, UserFilterRegistry._filters) | ||
|
|
||
| def test_unregister_unknown_is_noop(self) -> None: | ||
| def fn(qs, kind): # noqa: ANN001 | ||
| return qs | ||
|
|
||
| # Must not raise. | ||
| UserFilterRegistry.unregister(fn) | ||
|
|
||
| def test_clear_empties_registry(self) -> None: | ||
| UserFilterRegistry.register(lambda qs, kind: qs) | ||
| UserFilterRegistry.register(lambda qs, kind: qs) | ||
| UserFilterRegistry.clear() | ||
| self.assertEqual(UserFilterRegistry._filters, []) | ||
|
|
||
| def test_apply_runs_filters_in_registration_order(self) -> None: | ||
| order: list[str] = [] | ||
|
|
||
| def first(qs, kind): # noqa: ANN001 | ||
| order.append("first") | ||
| return qs | ||
|
|
||
| def second(qs, kind): # noqa: ANN001 | ||
| order.append("second") | ||
| return qs | ||
|
|
||
| UserFilterRegistry.register(first) | ||
| UserFilterRegistry.register(second) | ||
| UserFilterRegistry.apply(MagicMock(), "user") | ||
| self.assertEqual(order, ["first", "second"]) | ||
|
|
||
| def test_apply_threads_filtered_queryset_through_chain(self) -> None: | ||
| qs0 = MagicMock(name="qs0") | ||
| qs1 = MagicMock(name="qs1") | ||
| qs2 = MagicMock(name="qs2") | ||
|
|
||
| def fn_a(qs, kind): # noqa: ANN001 | ||
| self.assertIs(qs, qs0) | ||
| return qs1 | ||
|
|
||
| def fn_b(qs, kind): # noqa: ANN001 | ||
| self.assertIs(qs, qs1) | ||
| return qs2 | ||
|
|
||
| UserFilterRegistry.register(fn_a) | ||
| UserFilterRegistry.register(fn_b) | ||
| self.assertIs(UserFilterRegistry.apply(qs0, "user"), qs2) | ||
|
|
||
| def test_apply_reraises_plugin_exceptions_with_attribution_log(self) -> None: | ||
| # Fail-closed semantics: a broken plugin must not silently let | ||
| # un-scoped users leak into a downstream query. The exception must | ||
| # propagate AND the offending fn must be identifiable in the log. | ||
| def broken(qs, kind): # noqa: ANN001 | ||
| raise RuntimeError("simulated plugin bug") | ||
|
|
||
| UserFilterRegistry.register(broken) | ||
| with self.assertLogs("account_v2.user_filter_registry", level="ERROR") as cm: | ||
| with self.assertRaises(RuntimeError): | ||
| UserFilterRegistry.apply(MagicMock(), "user") | ||
| joined = "\n".join(cm.output) | ||
| self.assertIn("user_filter plugin raised", joined) | ||
| # Plugin attribution: the failing fn's repr should appear in the log | ||
| # so an operator knows which plugin to investigate. | ||
| self.assertIn("broken", joined) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| logging.basicConfig(level=logging.DEBUG) | ||
| unittest.main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| """Pluggable filters for User / OrganizationMember queries. | ||
|
|
||
| Identity plugins can register a callable that scopes querysets to a | ||
| subset of users — for example, limiting visibility to users whose | ||
| external identity belongs to the current environment. The service | ||
| layers in `account_v2.user.UserService` and | ||
| `tenant_account_v2.organization_member_service.OrganizationMemberService` | ||
| call ``UserFilterRegistry.apply`` on each user lookup so registered | ||
| filters take effect uniformly without core having to know which plugin | ||
| is loaded. | ||
|
|
||
| When no filters are registered the registry is a no-op, so OSS and | ||
| development setups are unaffected. | ||
|
jaseemjaskp marked this conversation as resolved.
|
||
| """ | ||
|
|
||
| import logging | ||
| from collections.abc import Callable | ||
| from typing import ClassVar, Literal | ||
|
|
||
| from django.db.models import QuerySet | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # "user" filters operate on `account_v2.User` querysets and should | ||
| # reference `user_id`. "org_member" filters operate on | ||
| # `tenant_account_v2.OrganizationMember` querysets and should reference | ||
| # `user__user_id`. | ||
| FilterKind = Literal["user", "org_member"] | ||
|
jaseemjaskp marked this conversation as resolved.
|
||
|
|
||
| FilterFn = Callable[[QuerySet, FilterKind], QuerySet] | ||
|
|
||
|
|
||
| class UserFilterRegistry: | ||
| _filters: ClassVar[list[FilterFn]] = [] | ||
|
jaseemjaskp marked this conversation as resolved.
|
||
|
|
||
| @classmethod | ||
| def register(cls, fn: FilterFn) -> None: | ||
| if fn not in cls._filters: | ||
| cls._filters.append(fn) | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
|
|
||
| @classmethod | ||
| def unregister(cls, fn: FilterFn) -> None: | ||
| if fn in cls._filters: | ||
| cls._filters.remove(fn) | ||
|
|
||
| @classmethod | ||
| def clear(cls) -> None: | ||
| """Remove all registered filters. Intended for tests only.""" | ||
| cls._filters.clear() | ||
|
|
||
| @classmethod | ||
| def apply(cls, qs: QuerySet, kind: FilterKind) -> QuerySet: | ||
| for fn in cls._filters: | ||
| try: | ||
| qs = fn(qs, kind) | ||
| except Exception: | ||
| logger.exception( | ||
| "user_filter plugin raised; aborting lookup (fn=%r kind=%s)", | ||
| fn, | ||
| kind, | ||
| ) | ||
| raise | ||
| return qs | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.