-
Notifications
You must be signed in to change notification settings - Fork 508
feat: enable saml for scaleup plan #7295
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
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
8492d36
feat: display-saml-tab-for-scale-up-plans
Zaimwa9 f32e798
feat: backend-verifying-plan-permissions-for-saml-endpoints
Zaimwa9 33e1326
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] f5cfb17
feat: linted tests and reduced redundancy
Zaimwa9 e21eb58
Merge branch 'feat/enable-saml-for-scaleup-plan' of github.com:Flagsm…
Zaimwa9 573c09f
feat: lint test names
Zaimwa9 082580f
fix: removed unused future
Zaimwa9 e68e172
fix: removed unused minimum plan decorator
Zaimwa9 58b77a6
fix: removed redundant fixtures
Zaimwa9 046bbb8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 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 was deleted.
Oops, something went wrong.
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,60 @@ | ||
| from common.core.utils import is_saas | ||
| from rest_framework.permissions import BasePermission | ||
| from rest_framework.request import Request | ||
| from rest_framework.views import APIView | ||
|
|
||
| from organisations.models import Organisation | ||
| from organisations.subscriptions.constants import SubscriptionPlanFamily | ||
|
|
||
| _PLAN_RANK = { | ||
| SubscriptionPlanFamily.FREE: 0, | ||
| SubscriptionPlanFamily.START_UP: 1, | ||
| SubscriptionPlanFamily.SCALE_UP: 2, | ||
| SubscriptionPlanFamily.ENTERPRISE: 3, | ||
| } | ||
|
|
||
|
|
||
| def require_minimum_plan(minimum: SubscriptionPlanFamily) -> type[BasePermission]: | ||
| """ | ||
| Return a DRF permission class that requires the organisation associated | ||
| with the request to be on `minimum` plan family or higher. | ||
|
|
||
| On non-SaaS deployments (self-hosted OSS / Enterprise), plan gating is | ||
| always bypassed. These deployments don't use Chargebee subscriptions — | ||
| entitlements are handled via the enterprise licence file instead, so | ||
| `Subscription.plan` is typically `"free"` and not meaningful. | ||
|
|
||
| On SaaS, the organisation is read from: | ||
| - `obj.organisation` for detail actions (via `has_object_permission`) | ||
| - `request.data["organisation"]` or `?organisation=` for list/create | ||
| """ | ||
| min_rank = _PLAN_RANK[minimum] | ||
|
|
||
| def _meets(org: Organisation) -> bool: | ||
| return _PLAN_RANK.get(org.subscription.subscription_plan_family, -1) >= min_rank | ||
|
|
||
| class _MinimumPlanPermission(BasePermission): | ||
| message = f"This resource requires a {minimum.value} plan or above." | ||
|
|
||
| def has_permission(self, request: Request, view: APIView) -> bool: | ||
| if not is_saas(): | ||
| return True | ||
| org_id = request.data.get("organisation") or request.query_params.get( | ||
| "organisation" | ||
| ) | ||
| if not org_id: | ||
| # defer to has_object_permission for detail actions; | ||
| # list/create without an org will be caught by the view's validation | ||
| return True | ||
| org = Organisation.objects.filter(id=org_id).first() | ||
| return org is not None and _meets(org) | ||
|
|
||
| def has_object_permission( | ||
| self, request: Request, view: APIView, obj: object | ||
| ) -> bool: | ||
| if not is_saas(): | ||
| return True | ||
| org = getattr(obj, "organisation", None) | ||
| return isinstance(org, Organisation) and _meets(org) | ||
|
|
||
| return _MinimumPlanPermission | ||
42 changes: 0 additions & 42 deletions
42
api/tests/unit/organisations/subscriptions/test_unit_subscriptions_decorators.py
This file was deleted.
Oops, something went wrong.
177 changes: 177 additions & 0 deletions
177
api/tests/unit/organisations/subscriptions/test_unit_subscriptions_permissions.py
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,177 @@ | ||
| from unittest.mock import MagicMock | ||
|
|
||
| import pytest | ||
| from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] | ||
| from rest_framework.request import Request | ||
|
|
||
| from organisations.models import Organisation, Subscription | ||
| from organisations.subscriptions.constants import SubscriptionPlanFamily | ||
| from organisations.subscriptions.permissions import require_minimum_plan | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| @pytest.mark.parametrize( | ||
| "subscription_fixture, expected", | ||
| [ | ||
| (lazy_fixture("free_subscription"), False), | ||
| (lazy_fixture("startup_subscription"), False), | ||
| (lazy_fixture("scale_up_subscription"), True), | ||
| (lazy_fixture("enterprise_subscription"), True), | ||
| ], | ||
| ) | ||
| def test_require_minimum_plan__has_permission__plan_matrix( | ||
| organisation: Organisation, | ||
| subscription_fixture: Subscription, | ||
| expected: bool, | ||
| ) -> None: | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
| request = MagicMock(spec=Request) | ||
| request.data = {"organisation": organisation.id} | ||
| request.query_params = {} | ||
|
|
||
| # When / Then | ||
| assert permission.has_permission(request, MagicMock()) is expected | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| @pytest.mark.parametrize( | ||
| "subscription_fixture, expected", | ||
| [ | ||
| (lazy_fixture("free_subscription"), False), | ||
| (lazy_fixture("startup_subscription"), False), | ||
| (lazy_fixture("scale_up_subscription"), True), | ||
| (lazy_fixture("enterprise_subscription"), True), | ||
| ], | ||
| ) | ||
| def test_require_minimum_plan__has_object_permission__plan_matrix( | ||
| organisation: Organisation, | ||
| subscription_fixture: Subscription, | ||
| expected: bool, | ||
| ) -> None: | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
| obj = MagicMock() | ||
| obj.organisation = organisation | ||
|
|
||
| # When / Then | ||
| assert ( | ||
| permission.has_object_permission(MagicMock(spec=Request), MagicMock(), obj) | ||
| is expected | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| @pytest.mark.parametrize("source", ["data", "query_params"]) | ||
| def test_require_minimum_plan__organisation_lookup__reads_from_data_and_query_params( | ||
| organisation: Organisation, | ||
| free_subscription: Subscription, | ||
| source: str, | ||
| ) -> None: | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
| request = MagicMock(spec=Request) | ||
| request.data = {} | ||
| request.query_params = {} | ||
| setattr(request, source, {"organisation": organisation.id}) | ||
|
|
||
| # When / Then | ||
| assert permission.has_permission(request, MagicMock()) is False | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| def test_require_minimum_plan__no_organisation_in_request__defers_to_object_level() -> ( | ||
| None | ||
| ): | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
| request = MagicMock(spec=Request) | ||
| request.data = {} | ||
| request.query_params = {} | ||
|
|
||
| # When / Then | ||
| assert permission.has_permission(request, MagicMock()) is True | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| def test_require_minimum_plan__unknown_organisation_id__returns_false( | ||
| db: None, | ||
| ) -> None: | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
| request = MagicMock(spec=Request) | ||
| request.data = {"organisation": 999999} | ||
| request.query_params = {} | ||
|
|
||
| # When / Then | ||
| assert permission.has_permission(request, MagicMock()) is False | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| def test_require_minimum_plan_has_object_permission__obj_without_organisation__returns_false() -> ( | ||
| None | ||
| ): | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
|
|
||
| # When / Then | ||
| assert ( | ||
| permission.has_object_permission(MagicMock(spec=Request), MagicMock(), object()) | ||
| is False | ||
| ) | ||
|
|
||
|
|
||
| @pytest.mark.saas_mode | ||
| @pytest.mark.parametrize( | ||
| "minimum, subscription_fixture, allowed", | ||
| [ | ||
| (SubscriptionPlanFamily.START_UP, lazy_fixture("free_subscription"), False), | ||
| (SubscriptionPlanFamily.START_UP, lazy_fixture("startup_subscription"), True), | ||
| (SubscriptionPlanFamily.SCALE_UP, lazy_fixture("startup_subscription"), False), | ||
| (SubscriptionPlanFamily.SCALE_UP, lazy_fixture("scale_up_subscription"), True), | ||
| ( | ||
| SubscriptionPlanFamily.ENTERPRISE, | ||
| lazy_fixture("scale_up_subscription"), | ||
| False, | ||
| ), | ||
| ( | ||
| SubscriptionPlanFamily.ENTERPRISE, | ||
| lazy_fixture("enterprise_subscription"), | ||
| True, | ||
| ), | ||
| ], | ||
| ) | ||
| def test_require_minimum_plan__plan_hierarchy__honours_ordering( | ||
| organisation: Organisation, | ||
| minimum: SubscriptionPlanFamily, | ||
| subscription_fixture: Subscription, | ||
| allowed: bool, | ||
| ) -> None: | ||
| # Given | ||
| permission = require_minimum_plan(minimum)() | ||
| request = MagicMock(spec=Request) | ||
| request.data = {"organisation": organisation.id} | ||
| request.query_params = {} | ||
|
|
||
| # When / Then | ||
| assert permission.has_permission(request, MagicMock()) is allowed | ||
|
|
||
|
|
||
| def test_require_minimum_plan__self_hosted__bypasses_check( | ||
| organisation: Organisation, | ||
| free_subscription: Subscription, | ||
| ) -> None: | ||
| # Given | ||
| permission = require_minimum_plan(SubscriptionPlanFamily.SCALE_UP)() | ||
| request = MagicMock(spec=Request) | ||
| request.data = {"organisation": organisation.id} | ||
| request.query_params = {} | ||
| obj = MagicMock() | ||
| obj.organisation = organisation | ||
|
|
||
| # When / Then | ||
| assert permission.has_permission(request, MagicMock()) is True | ||
| assert ( | ||
| permission.has_object_permission(MagicMock(spec=Request), MagicMock(), obj) | ||
| is True | ||
| ) |
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
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.