From 920a608fbaf3a8abd8f901fd579feb819cb637c2 Mon Sep 17 00:00:00 2001 From: Eric Meehan Date: Thu, 29 Oct 2026 14:17:16 -0400 Subject: [PATCH] Tiered LDAP groups --- .env.example | 4 + .vscode/tasks.json | 2 + README.md | 72 ++++ membership/config/settings.py | 10 + membership/members/billing.py | 8 +- membership/members/directory.py | 12 +- membership/members/service_groups.py | 37 ++ .../members/tests/test_service_groups.py | 407 ++++++++++++++++++ membership/members/views.py | 5 +- membership/service-groups.eom.json | 16 + 10 files changed, 565 insertions(+), 8 deletions(-) create mode 100755 membership/members/service_groups.py create mode 100755 membership/members/tests/test_service_groups.py create mode 100755 membership/service-groups.eom.json diff --git a/.env.example b/.env.example index 7d8e4ef..d5881df 100755 --- a/.env.example +++ b/.env.example @@ -53,6 +53,10 @@ SHKEEPER_CA_CERT_FILE= # Set real recurring Stripe Price IDs and pre-created dedicated LDAP groups. # The display price is informational; Stripe Checkout is authoritative. MEMBERSHIP_TIERS_FILE=/app/tiers.eom.json +# Explicit effective level -> list of additional service group DNs, including 0. +# Empty lists grant nothing; no inheritance. Pre-create dedicated LDAP groups. +MEMBERSHIP_SERVICE_GROUPS_FILE=/app/service-groups.eom.json +# MEMBERSHIP_SERVICE_GROUPS_JSON overrides the file, e.g. {"0":["cn=community,ou=groups,dc=example,dc=org"],"1":["cn=community,ou=groups,dc=example,dc=org","cn=members-chat,ou=groups,dc=example,dc=org"]} # These are existing LIVE-mode prices; use separate test-mode prices in sandbox. # Set MEMBERSHIP_TIERS_JSON instead to override the file ({} disables paid tiers). # Each configured paid tier needs a unique positive level (level 0 is free). diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 269cbcd..8213237 100755 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,6 +11,7 @@ "members.tests.test_membership", "members.tests.test_oidc", "members.tests.test_profiles", + "members.tests.test_service_groups", "members.tests.test_postgres", "members.tests.test_shkeeper", "--settings=config.test_settings", @@ -30,6 +31,7 @@ "members.tests.test_membership", "members.tests.test_oidc", "members.tests.test_profiles", + "members.tests.test_service_groups", "members.tests.test_postgres", "members.tests.test_shkeeper", "--settings=config.test_settings", diff --git a/README.md b/README.md index 145ecd8..248b7c9 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,78 @@ Stripe outages do not extend locally recorded paid expiry. Already-issued Dex tokens and downstream sessions do not instantly lose their cached claims; set short token lifetimes and enforce reauthorization in each relying application. +## Service access by membership level + +Additional LDAP service benefits are configured separately from Stripe prices in +[membership/service-groups.eom.json](membership/service-groups.eom.json). Set +`MEMBERSHIP_SERVICE_GROUPS_FILE` to that file, or supply an equivalent object in +`MEMBERSHIP_SERVICE_GROUPS_JSON` (which takes precedence). The default is empty; +the shipped file contains empty lists for levels 0–5, granting nothing until +you supply real, reviewed group DNs. Existing paid tiers and their `group_dn` +continue working unchanged. + +The shape is a level-to-list mapping, for example: + +```json +{ + "0": ["cn=community-chat,ou=membership-services,dc=example,dc=org"], + "1": ["cn=community-chat,ou=membership-services,dc=example,dc=org"], + "2": [ + "cn=community-chat,ou=membership-services,dc=example,dc=org", + "cn=game-servers,ou=membership-services,dc=example,dc=org" + ] +} +``` + +Keys are canonical level strings: `"0"` or an existing paid tier's positive +level. Values are lists of full LDAP group DNs. Omitted levels/empty lists grant +no additional service access. **The highest currently paid level selects exactly +one list**. There is no implicit inheritance: in this example level 2 gets chat +and games, while levels 3–5 get neither unless explicitly listed. Level 0 means +free accounts only, not a baseline automatically inherited by paid accounts. +To offer a service to everyone, repeat its DN under every configured level. +An active Stripe and SHKeeper combination still selects the highest paid level; +it does not combine lower service lists. Normal per-tier group grants remain +additive for each valid paid entitlement, as before. + +The worker computes the level from paid subscription/invoice expiry on **every +pass**, never from cached Dex groups or the account's stored level. Expiry falls +back to level 0 and revokes paid-only service access without contacting payment +providers. Editing/removing a DN, removing a tier or changing levels reconciles +on the next sweep. Historical `ManagedGroup` records are retained so old grants +are revoked even after configuration removal; management intent commits before +LDAP changes so failed writes can be retried safely. Login/registration records +intent but does not grant groups directly. Level 0 grants cover local membership +accounts (including first-time Dex sign-ins), not every LDAP account that has +never used this application. + +**Provision and secure the groups before enabling a mapping:** + +- Targets must already be `groupOfUniqueNames` groups with the configured + sentinel entry. Allow the worker narrowly scoped read/write of `uniqueMember` + on these groups only. No LDAP schema, group or ACL is created automatically. +- Mapping a DN hands this application control of that group's membership for + **every local member**, including revocations of manual grants to noneligible + members. Use dedicated groups for membership-controlled service access. Do not + map administrator, staff, existing independently managed entitlement groups, + the sentinel DN or groups where such removals would be unsafe. Configure + services/Dex to authorize these dedicated groups and review any service-specific + user provisioning requirements separately. Only administrators edit mappings; + browser forms and Dex claims cannot choose entitlement groups. +- Keep removed groups and ACL access available until the worker has successfully + removed old grants. Missing groups/LDAP outages fail synchronization and delay + grants/revocations; they must not be hidden by deleting historical tracking. +- Group names/tokens already cached by Dex and relying services must refresh. + Revocation is not an instantaneous logout across services. + +For Kubernetes, use the role's `www_membership_service_groups` variable, not an +image-baked file: the role mounts its own mapping next to its tiers configuration. +Change the variable and rerun the role; its revision includes this mapping and +rolls web/worker so they reload it. Directly changing a ConfigMap does not reload +Django settings. Build/deploy the updated image and role once before using the +new setting. No database migration is needed for service mappings. Changes to +the benefit display text remain separate from actual authorization rules. + ## Existing Stripe product The live Stripe dashboard was inspected on 2026-10-23. This integration reuses diff --git a/membership/config/settings.py b/membership/config/settings.py index 7c2e577..453c94e 100755 --- a/membership/config/settings.py +++ b/membership/config/settings.py @@ -141,3 +141,13 @@ for key, tier in MEMBERSHIP_TIERS.items(): raise ValueError except (InvalidOperation, ValueError, TypeError): raise ImproperlyConfigured(f"Tier {key} usd_amount must be a positive USD amount with at most two decimals.") from None + +# Separate from Stripe tiers so free accounts can receive service benefits too. +from members.service_groups import validate_service_groups + +service_groups_file = os.environ.get("MEMBERSHIP_SERVICE_GROUPS_FILE", "") +try: + service_groups = json.loads(os.environ["MEMBERSHIP_SERVICE_GROUPS_JSON"]) if "MEMBERSHIP_SERVICE_GROUPS_JSON" in os.environ else json.loads(Path(service_groups_file).read_text(encoding="utf-8")) if service_groups_file else {} +except (ValueError, OSError): + raise ImproperlyConfigured("Cannot load membership service group configuration.") from None +MEMBERSHIP_SERVICE_GROUPS = validate_service_groups(service_groups, levels) diff --git a/membership/members/billing.py b/membership/members/billing.py index 0f87848..894051c 100755 --- a/membership/members/billing.py +++ b/membership/members/billing.py @@ -12,6 +12,7 @@ from django.utils import timezone from .directory import reconcile_groups from .models import Account, CryptoInvoice, ManagedGroup, Subscription, WebhookEvent from . import shkeeper +from .service_groups import configured_groups logger = logging.getLogger(__name__) @@ -101,6 +102,9 @@ def apply_groups(account): if tier: desired.add(tier["group_dn"]) membership_level = max(membership_level, tier.get("level", 0)) + # Compute from paid time on this pass, never the cached account level or + # Dex claims. Level 0 is selected only when no paid entitlement remains. + desired.update(settings.MEMBERSHIP_SERVICE_GROUPS.get(str(membership_level), [])) managed = set(ManagedGroup.objects.filter(account=account).values_list("dn", flat=True)) account.ldap_dn = reconcile_groups(account, desired, managed) if managed else account.ldap_dn account.last_ldap_sync = now @@ -122,8 +126,8 @@ def sync_account(account_id, refresh_stripe=True): # which old groups to revoke, even after the tier is removed from config. with transaction.atomic(): account = Account.objects.select_for_update().get(pk=account_id) - for tier in settings.MEMBERSHIP_TIERS.values(): - ManagedGroup.objects.get_or_create(account=account, dn=tier["group_dn"]) + for dn in configured_groups(settings.MEMBERSHIP_TIERS, settings.MEMBERSHIP_SERVICE_GROUPS): + ManagedGroup.objects.get_or_create(account=account, dn=dn) with transaction.atomic(): account = Account.objects.select_for_update().get(pk=account_id) # Never hold local expiry hostage to an invoice-history API read. diff --git a/membership/members/directory.py b/membership/members/directory.py index 9f672ef..6df5160 100755 --- a/membership/members/directory.py +++ b/membership/members/directory.py @@ -112,8 +112,12 @@ def reconcile_groups(account, desired, managed): raise ImproperlyConfigured("The LDAP sentinel must not be a login account.") if not conn.search(placeholder, "(objectClass=*)", search_scope=BASE, attributes=["objectClass"]): raise ImproperlyConfigured("LDAP sentinel does not exist.") - # Revoke obsolete tiers before granting new tiers on plan changes. - for group_dn in sorted(managed, key=lambda group: (group in desired, group)): + # A DN shared by multiple service levels is one target, even if its + # spelling changes case between mappings or historical records. + desired_keys = {group.casefold() for group in desired} + managed_targets = {group.casefold(): group for group in managed} + # Revoke obsolete grants before granting new ones on plan changes. + for group_dn in sorted(managed_targets.values(), key=lambda group: (group.casefold() in desired_keys, group)): if not conn.search(group_dn, "(objectClass=groupOfUniqueNames)", search_scope=BASE, attributes=["uniqueMember"]): raise ImproperlyConfigured("Managed group is missing or not groupOfUniqueNames.") members = {str(value).casefold() for value in conn.entries[0].uniqueMember.values} @@ -123,9 +127,9 @@ def reconcile_groups(account, desired, managed): # Remove a stale DN too, e.g. after a directory rename. candidates = {value.casefold(): value for value in (account.ldap_dn, dn)} for candidate in candidates.values(): - if candidate.casefold() in members and (group_dn not in desired or candidate.casefold() != dn.casefold()): + if candidate.casefold() in members and (group_dn.casefold() not in desired_keys or candidate.casefold() != dn.casefold()): changes.append((MODIFY_DELETE, [candidate])) - if group_dn in desired and dn.casefold() not in members: + if group_dn.casefold() in desired_keys and dn.casefold() not in members: changes.append((MODIFY_ADD, [dn])) if changes and not conn.modify(group_dn, {"uniqueMember": changes}): raise LDAPException("LDAP group synchronization failed.") diff --git a/membership/members/service_groups.py b/membership/members/service_groups.py new file mode 100755 index 0000000..6f01c61 --- /dev/null +++ b/membership/members/service_groups.py @@ -0,0 +1,37 @@ +"""Explicit, administrator-controlled service grants for each effective level.""" + +import re + +from django.core.exceptions import ImproperlyConfigured +from ldap3.core.exceptions import LDAPInvalidDnError +from ldap3.utils.dn import parse_dn + + +def validate_service_groups(value, paid_levels): + if not isinstance(value, dict): + raise ImproperlyConfigured("Membership service groups must be an object mapping levels to lists of LDAP group DNs.") + valid_levels = {0, *paid_levels} + for level, groups in value.items(): + if not isinstance(level, str) or not re.fullmatch(r"0|[1-9][0-9]*", level) or int(level) not in valid_levels: + raise ImproperlyConfigured("Service group levels must be 0 or a configured paid membership level.") + if not isinstance(groups, list): + raise ImproperlyConfigured("Each service group level must contain a list of LDAP group DNs.") + seen = set() + for dn in groups: + if not isinstance(dn, str) or not dn or dn != dn.strip() or len(dn) > 1024 or any(ord(char) < 32 for char in dn): + raise ImproperlyConfigured("Service group DNs must be nonempty valid LDAP DNs of at most 1024 characters.") + try: + parts = parse_dn(dn) + except LDAPInvalidDnError: + raise ImproperlyConfigured("Invalid LDAP service group DN.") from None + if not parts or dn.casefold() in seen: + raise ImproperlyConfigured("Service group DNs must be valid and unique within each level.") + seen.add(dn.casefold()) + return value + + +def configured_groups(tiers, service_groups): + """All managed targets, not only the groups this account currently earns.""" + return {tier["group_dn"] for tier in tiers.values()} | { + dn for groups in service_groups.values() for dn in groups + } diff --git a/membership/members/tests/test_service_groups.py b/membership/members/tests/test_service_groups.py new file mode 100755 index 0000000..9c8a1ba --- /dev/null +++ b/membership/members/tests/test_service_groups.py @@ -0,0 +1,407 @@ +"""Exact-level service grants and durable, provider-independent revocation.""" + +import json +import os +import runpy +from contextlib import contextmanager +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + +from django.core.exceptions import ImproperlyConfigured +from django.db import connection +from django.test import SimpleTestCase, TestCase, TransactionTestCase, override_settings +from django.utils import timezone + +from members import billing, views +from members.models import Account, CryptoInvoice, ManagedGroup, Subscription +from members.service_groups import configured_groups, validate_service_groups +from ldap3 import Connection, MOCK_SYNC, Server + +BASE = "ou=groups,dc=example,dc=org" +DN = "uid=alice,ou=people,dc=example,dc=org" +TIER_GROUP = "cn=supporter," + BASE +OTHER_TIER_GROUP = "cn=patron," + BASE +TOP_TIER_GROUP = "cn=premier," + BASE +FREE_GROUP = "cn=free-service," + BASE +PAID_GROUP = "cn=paid-service," + BASE +SECOND_PAID_GROUP = "cn=second-paid-service," + BASE +OTHER_SERVICE_GROUP = "cn=patron-service," + BASE +TOP_SERVICE_GROUP = "cn=premier-service," + BASE +HISTORICAL_GROUP = "cn=retired-service," + BASE +TIERS = { + "supporter": {"label": "Bronze", "level": 1, "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": TIER_GROUP}, + "patron": {"label": "Silver", "level": 2, "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_TIER_GROUP}, + "premier": {"label": "Diamond", "level": 10, "price_id": "price_premier", "price_label": "$50 / month", "group_dn": TOP_TIER_GROUP}, +} +SERVICE_GROUPS = { + "0": [FREE_GROUP], + "1": [PAID_GROUP, SECOND_PAID_GROUP], + "2": [OTHER_SERVICE_GROUP], + "10": [TOP_SERVICE_GROUP], +} +ALL_GROUPS = { + TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, FREE_GROUP, + PAID_GROUP, SECOND_PAID_GROUP, OTHER_SERVICE_GROUP, TOP_SERVICE_GROUP, +} + + +@override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}, LDAP_SEARCH_BASE_DN="ou=people,dc=example,dc=org", LDAP_GROUP_PLACEHOLDER_DN="cn=sentinel,dc=example,dc=org") +class ServiceGroupLDAPIntegrationTests(TestCase): + def test_free_grant_and_mapping_removal_modify_real_offline_entries(self): + member = Account.objects.create(uid="alice", email="alice@example.org", ldap_dn=DN, ldap_uuid="uuid-alice") + conn = Connection(Server("mock"), client_strategy=MOCK_SYNC) + conn.bind() + self.addCleanup(conn.unbind) + sentinel = "cn=sentinel,dc=example,dc=org" + conn.strategy.add_entry(DN, {"objectClass": ["inetOrgPerson"], "uid": "alice", "mail": "alice@example.org", "entryUUID": "uuid-alice"}) + conn.strategy.add_entry(sentinel, {"objectClass": ["organizationalRole"], "cn": "sentinel"}) + conn.strategy.add_entry(FREE_GROUP, {"objectClass": ["groupOfUniqueNames"], "cn": "free-service", "uniqueMember": [sentinel]}) + + @contextmanager + def offline(): + yield conn + + def members(): + conn.search(FREE_GROUP, "(objectClass=*)", attributes=["uniqueMember"]) + return set(conn.entries[0].uniqueMember.values) + + with patch("members.directory.connection", offline): + self.assertTrue(billing.sync_account(member.pk, refresh_stripe=False)) + self.assertEqual(members(), {sentinel, DN}) + # Case-only mapping changes must not revoke the shared target. + with override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP.upper()]}): + billing.sync_account(member.pk, refresh_stripe=False) + self.assertEqual(members(), {sentinel, DN}) + with override_settings(MEMBERSHIP_SERVICE_GROUPS={}): + billing.sync_account(member.pk, refresh_stripe=False) + self.assertEqual(members(), {sentinel}) + self.assertTrue(ManagedGroup.objects.filter(account=member, dn=FREE_GROUP).exists()) + + +class ServiceGroupValidationTests(SimpleTestCase): + def test_valid_mapping_is_returned_unchanged(self): + self.assertIs(validate_service_groups(SERVICE_GROUPS, {1, 2, 10}), SERVICE_GROUPS) + + def test_empty_mapping_and_empty_group_lists_are_valid(self): + for mapping in ({}, {"0": []}, {"1": []}): + with self.subTest(mapping=mapping): + self.assertEqual(validate_service_groups(mapping, {1}), mapping) + + def test_free_only_mapping_needs_no_paid_tiers(self): + mapping = {"0": [FREE_GROUP]} + self.assertEqual(validate_service_groups(mapping, set()), mapping) + + def test_shared_group_across_levels_is_valid(self): + mapping = {"0": [FREE_GROUP], "1": [FREE_GROUP, PAID_GROUP]} + self.assertEqual(validate_service_groups(mapping, {1}), mapping) + + def test_mapping_must_be_a_dict(self): + for value in (None, [], [FREE_GROUP], "{}", "0", 0, True): + with self.subTest(value=value), self.assertRaises(ImproperlyConfigured): + validate_service_groups(value, {1, 2, 10}) + + def test_levels_must_be_canonical_known_numeric_strings(self): + for level in (0, 1, True, False, -1, "-1", "3", "11", "01", "00", "+1", "1.0", " 1", "1 ", "1\n", "", "free", "True", "١"): + with self.subTest(level=level), self.assertRaises(ImproperlyConfigured): + validate_service_groups({level: [PAID_GROUP]}, {1, 2, 10}) + + def test_each_level_requires_a_list_not_a_string_or_other_container(self): + for groups in (PAID_GROUP, "", None, {}, (PAID_GROUP,), {PAID_GROUP}, 1, True): + with self.subTest(groups=groups), self.assertRaises(ImproperlyConfigured): + validate_service_groups({"1": groups}, {1}) + + def test_group_dns_require_nonempty_bounded_strings_without_controls(self): + for dn in (None, 1, True, [], {}, "", " ", "cn=" + "a" * 1022, "cn=bad\x00," + BASE, "cn=bad\n," + BASE, "cn=bad\t," + BASE): + with self.subTest(dn=dn), self.assertRaises(ImproperlyConfigured): + validate_service_groups({"1": [dn]}, {1}) + + def test_malformed_dns_are_rejected(self): + for dn in ("not-a-dn", "cn", "=missing-attribute", "cn=service,not-a-dn", "cn=service,,dc=example", "cn=service,dc"): + with self.subTest(dn=dn), self.assertRaises(ImproperlyConfigured): + validate_service_groups({"1": [dn]}, {1}) + + def test_outer_whitespace_is_rejected_not_silently_normalized(self): + for dn in (" " + PAID_GROUP, PAID_GROUP + " ", "\t" + PAID_GROUP, PAID_GROUP + "\n"): + with self.subTest(dn=dn), self.assertRaises(ImproperlyConfigured): + validate_service_groups({"1": [dn]}, {1}) + + def test_duplicate_dns_are_rejected_case_insensitively_within_level(self): + for duplicate in (PAID_GROUP, PAID_GROUP.upper()): + with self.subTest(duplicate=duplicate), self.assertRaises(ImproperlyConfigured): + validate_service_groups({"1": [PAID_GROUP, duplicate]}, {1}) + + def test_escaped_dn_values_are_valid(self): + mapping = {"1": [r"cn=service\, special," + BASE]} + self.assertEqual(validate_service_groups(mapping, {1}), mapping) + + def test_settings_load_and_validate_service_group_json(self): + environment = { + "MEMBERSHIP_TIERS_JSON": json.dumps(TIERS), + "MEMBERSHIP_SERVICE_GROUPS_JSON": json.dumps(SERVICE_GROUPS), + } + with patch.dict(os.environ, environment): + loaded = runpy.run_path(str(Path(__file__).absolute().parents[2] / "config" / "settings.py")) + self.assertEqual(loaded["MEMBERSHIP_SERVICE_GROUPS"], SERVICE_GROUPS) + + def test_settings_reject_invalid_service_group_json_or_mapping(self): + for encoded in ("{", "[]", json.dumps({"3": [PAID_GROUP]}), json.dumps({"1": PAID_GROUP})): + with self.subTest(encoded=encoded), patch.dict(os.environ, { + "MEMBERSHIP_TIERS_JSON": json.dumps(TIERS), + "MEMBERSHIP_SERVICE_GROUPS_JSON": encoded, + }), self.assertRaises(ImproperlyConfigured): + runpy.run_path(str(Path(__file__).absolute().parents[2] / "config" / "settings.py")) + + def test_configured_groups_unions_every_level_with_original_tier_groups(self): + self.assertEqual(configured_groups(TIERS, SERVICE_GROUPS), ALL_GROUPS) + + def test_configured_groups_deduplicates_shared_tier_and_service_targets(self): + self.assertEqual(configured_groups(TIERS, {"0": [TIER_GROUP], "1": [TIER_GROUP, PAID_GROUP]}), { + TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, PAID_GROUP, + }) + self.assertEqual(configured_groups({}, {}), set()) + self.assertEqual(configured_groups({}, {"0": [FREE_GROUP]}), {FREE_GROUP}) + + +class ServiceGroupFixture: + """Use local entitlement snapshots; fail immediately on any network access.""" + + def setUp(self): + super().setUp() + self.now = timezone.now() + self.member = Account.objects.create( + uid="alice", email="alice@example.org", ldap_dn=DN, + ldap_uuid="11111111-1111-1111-1111-111111111111", + stripe_customer="cus_alice", + ) + self.ldap = self.start_patch("members.billing.reconcile_groups", return_value=DN) + self.stripe = self.start_patch("members.billing.refresh_billing", side_effect=AssertionError("Local sweep must not refresh Stripe")) + self.crypto = self.start_patch("members.billing.shkeeper.refresh_account", side_effect=AssertionError("Local sweep must not refresh SHKeeper")) + self.start_patch("members.billing.client", side_effect=AssertionError("No Stripe transport in offline tests")) + self.start_patch("members.shkeeper.request", side_effect=AssertionError("No SHKeeper transport in offline tests")) + self.start_patch("members.directory.connection", side_effect=AssertionError("No LDAP connection in offline tests")) + self.start_patch("members.billing.timezone.now", return_value=self.now) + + def start_patch(self, target, **kwargs): + patcher = patch(target, **kwargs) + mocked = patcher.start() + self.addCleanup(patcher.stop) + return mocked + + def subscription(self, **changes): + values = { + "account": self.member, "stripe_id": "sub_service", "tier": "supporter", + "status": "active", "paid_until": self.now + timedelta(days=1), + } + return Subscription.objects.create(**{**values, **changes}) + + def invoice(self, **changes): + values = { + "account": self.member, "tier": "supporter", "usd_amount": "5.00", + "crypto": "BTC", "status": "PAID", "credited_at": self.now, + "access_start": self.now - timedelta(days=1), + "access_end": self.now + timedelta(days=1), + } + return CryptoInvoice.objects.create(**{**values, **changes}) + + def managed_groups(self): + return set(ManagedGroup.objects.filter(account=self.member).values_list("dn", flat=True)) + + def assert_sync(self, desired, level, managed=None): + self.ldap.reset_mock() + self.assertTrue(billing.sync_account(self.member.pk, refresh_stripe=False)) + self.ldap.assert_called_once() + synced_account, actual_desired, actual_managed = self.ldap.call_args.args + self.assertEqual(synced_account.pk, self.member.pk) + self.assertEqual(actual_desired, desired) + expected_managed = ALL_GROUPS if managed is None else managed + self.assertEqual(actual_managed, expected_managed) + self.assertEqual(self.managed_groups(), expected_managed) + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, level) + self.assertEqual(self.member.ldap_dn, DN) + self.assertEqual(self.member.last_ldap_sync, self.now) + self.stripe.assert_not_called() + self.crypto.assert_not_called() + + +@override_settings(MEMBERSHIP_TIERS=TIERS, MEMBERSHIP_SERVICE_GROUPS=SERVICE_GROUPS) +class ServiceGroupReconciliationTests(ServiceGroupFixture, TestCase): + def test_free_account_gets_only_level_zero_services(self): + self.assert_sync({FREE_GROUP}, 0) + + @override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}) + def test_free_service_grants_without_any_paid_tier_configuration(self): + self.assert_sync({FREE_GROUP}, 0, managed={FREE_GROUP}) + + def test_paid_stripe_grants_exact_multiple_services_and_original_tier_group(self): + self.subscription() + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + + def test_past_due_with_unexpired_paid_time_keeps_paid_services(self): + self.subscription(status="past_due") + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + + def test_trial_unpaid_and_other_ineligible_stripe_statuses_get_no_paid_grants(self): + sub = self.subscription() + for status in ("trialing", "unpaid", "canceled", "paused", "incomplete", "incomplete_expired", "missing"): + with self.subTest(status=status): + Subscription.objects.filter(pk=sub.pk).update(status=status) + self.assert_sync({FREE_GROUP}, 0) + + def test_active_stripe_without_paid_time_gets_no_paid_grants(self): + self.subscription(paid_until=None) + self.assert_sync({FREE_GROUP}, 0) + + def test_paid_and_overpaid_shkeeper_grant_services_and_original_tier_group(self): + invoice = self.invoice() + for status in ("PAID", "OVERPAID"): + with self.subTest(status=status): + CryptoInvoice.objects.filter(pk=invoice.pk).update(status=status) + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + + def test_unpaid_partial_and_unconfirmed_crypto_get_no_paid_grants(self): + invoice = self.invoice() + for status in ("creating", "UNPAID", "PARTIAL", "UNCONFIRMED"): + with self.subTest(status=status): + CryptoInvoice.objects.filter(pk=invoice.pk).update(status=status) + self.assert_sync({FREE_GROUP}, 0) + + def test_crypto_requires_current_access_window(self): + invoice = self.invoice() + for changes in ( + {"access_start": None}, + {"access_start": self.now + timedelta(seconds=1)}, + {"access_end": None}, + {"access_end": self.now}, + {"access_end": self.now - timedelta(seconds=1)}, + ): + with self.subTest(changes=changes): + CryptoInvoice.objects.filter(pk=invoice.pk).update( + access_start=self.now - timedelta(days=1), + access_end=self.now + timedelta(days=1), + ) + CryptoInvoice.objects.filter(pk=invoice.pk).update(**changes) + self.assert_sync({FREE_GROUP}, 0) + + def test_crypto_access_starts_at_inclusive_boundary(self): + self.invoice(access_start=self.now) + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + + def test_highest_stripe_level_only_no_service_inheritance(self): + self.subscription() + self.subscription(stripe_id="sub_patron", tier="patron") + self.subscription(stripe_id="sub_premier", tier="premier") + self.assert_sync({TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP}, 10) + + def test_highest_shkeeper_level_only_no_service_inheritance(self): + self.invoice() + self.invoice(tier="patron") + self.invoice(tier="premier") + self.assert_sync({TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP}, 10) + + def test_highest_level_is_max_across_both_providers_in_either_order(self): + sub = self.subscription(tier="premier") + invoice = self.invoice(tier="patron") + desired = {TOP_TIER_GROUP, OTHER_TIER_GROUP, TOP_SERVICE_GROUP} + self.assert_sync(desired, 10) + Subscription.objects.filter(pk=sub.pk).update(tier="patron") + CryptoInvoice.objects.filter(pk=invoice.pk).update(tier="premier") + self.assert_sync(desired, 10) + + def test_cached_level_is_not_an_entitlement(self): + Account.objects.filter(pk=self.member.pk).update(membership_level=10) + self.assert_sync({FREE_GROUP}, 0) + + def test_unknown_paid_tier_keys_cannot_grant_services(self): + self.subscription(tier="removed") + self.invoice(tier="removed") + self.assert_sync({FREE_GROUP}, 0) + + @override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP], "1": [PAID_GROUP]}) + def test_unmapped_paid_level_does_not_fall_back_to_free_or_lower_services(self): + self.subscription(tier="patron") + self.assert_sync({OTHER_TIER_GROUP}, 2, managed={ + TIER_GROUP, OTHER_TIER_GROUP, TOP_TIER_GROUP, FREE_GROUP, PAID_GROUP, + }) + + def test_expiry_falls_back_to_free_and_revokes_old_groups_without_providers(self): + sub = self.subscription() + invoice = self.invoice(tier="premier") + self.assert_sync({TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP}, 10) + Subscription.objects.filter(pk=sub.pk).update(paid_until=self.now) + CryptoInvoice.objects.filter(pk=invoice.pk).update(access_end=self.now) + self.assert_sync({FREE_GROUP}, 0) + self.assertTrue({TIER_GROUP, TOP_TIER_GROUP, TOP_SERVICE_GROUP} <= self.ldap.call_args.args[2]) + + def test_expired_high_tier_falls_back_to_remaining_paid_level_not_free(self): + self.subscription() + self.invoice(tier="premier", access_end=self.now - timedelta(seconds=1)) + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + + def test_removed_mapping_remains_managed_for_revocation(self): + self.subscription() + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + with override_settings(MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}): + self.assert_sync({TIER_GROUP}, 1) + self.assertIn(PAID_GROUP, self.ldap.call_args.args[2]) + self.assertIn(SECOND_PAID_GROUP, self.ldap.call_args.args[2]) + + def test_removed_tiers_and_service_configuration_revoke_all_historical_targets(self): + self.subscription() + self.assert_sync({TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}, 1) + with override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={}): + self.assert_sync(set(), 0) + + def test_preexisting_historical_managed_group_is_not_forgotten(self): + ManagedGroup.objects.create(account=self.member, dn=HISTORICAL_GROUP) + self.assert_sync({FREE_GROUP}, 0, managed=ALL_GROUPS | {HISTORICAL_GROUP}) + + def test_remember_groups_includes_all_maps_is_idempotent_and_never_uses_ldap(self): + ManagedGroup.objects.create(account=self.member, dn=HISTORICAL_GROUP) + views.remember_groups(self.member) + views.remember_groups(self.member) + self.assertEqual(self.managed_groups(), ALL_GROUPS | {HISTORICAL_GROUP}) + self.assertEqual(ManagedGroup.objects.filter(account=self.member).count(), len(ALL_GROUPS) + 1) + self.ldap.assert_not_called() + self.member.refresh_from_db() + self.assertEqual(self.member.membership_level, 0) + self.assertIsNone(self.member.last_ldap_sync) + + +@override_settings(MEMBERSHIP_TIERS=TIERS, MEMBERSHIP_SERVICE_GROUPS=SERVICE_GROUPS) +class ServiceGroupDurabilityTests(ServiceGroupFixture, TransactionTestCase): + def test_all_configuration_is_stored_before_first_ldap_call(self): + self.assertFalse(connection.in_atomic_block) + self.assertFalse(self.managed_groups()) + + def reconcile(account, desired, managed): + self.assertEqual(self.managed_groups(), ALL_GROUPS) + self.assertEqual(managed, ALL_GROUPS) + self.assertEqual(desired, {FREE_GROUP}) + return DN + + self.ldap.side_effect = reconcile + self.assert_sync({FREE_GROUP}, 0) + + def test_failed_ldap_intent_survives_rollback_and_removed_config_on_retry(self): + self.assertFalse(connection.in_atomic_block) + self.subscription() + + def fail_ldap(account, desired, managed): + self.assertEqual(self.managed_groups(), ALL_GROUPS) + self.assertEqual(managed, ALL_GROUPS) + self.assertEqual(desired, {TIER_GROUP, PAID_GROUP, SECOND_PAID_GROUP}) + raise OSError("LDAP unavailable") + + self.ldap.side_effect = fail_ldap + with self.assertRaisesRegex(OSError, "LDAP unavailable"): + billing.sync_account(self.member.pk, refresh_stripe=False) + self.assertFalse(connection.in_atomic_block) + self.assertEqual(self.managed_groups(), ALL_GROUPS) + self.member.refresh_from_db() + self.assertIsNone(self.member.last_ldap_sync) + self.assertEqual(self.member.membership_level, 0) + self.ldap.side_effect = None + with override_settings(MEMBERSHIP_TIERS={}, MEMBERSHIP_SERVICE_GROUPS={"0": [FREE_GROUP]}): + self.assert_sync({FREE_GROUP}, 0) diff --git a/membership/members/views.py b/membership/members/views.py index 8410ec3..89563d1 100755 --- a/membership/members/views.py +++ b/membership/members/views.py @@ -26,6 +26,7 @@ from django.views.decorators.http import require_GET, require_POST, require_http from . import billing, directory, profiles, shkeeper from .forms import PasswordForm, RegistrationForm +from .service_groups import configured_groups from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent @@ -66,8 +67,8 @@ def account_required(view): def remember_groups(account): - for tier in settings.MEMBERSHIP_TIERS.values(): - ManagedGroup.objects.get_or_create(account=account, dn=tier["group_dn"]) + for dn in configured_groups(settings.MEMBERSHIP_TIERS, settings.MEMBERSHIP_SERVICE_GROUPS): + ManagedGroup.objects.get_or_create(account=account, dn=dn) @require_GET diff --git a/membership/service-groups.eom.json b/membership/service-groups.eom.json new file mode 100755 index 0000000..8c508ac --- /dev/null +++ b/membership/service-groups.eom.json @@ -0,0 +1,16 @@ +{ + "0": [ + "cn=Dex Users,ou=Services,dc=eom,dc=dev", + "cn=Discourse Users,ou=Services,dc=eom,dc=dev", + "cn=Gitea Users,ou=Services,dc=eom,dc=dev", + "cn=Matrix Users,ou=Services,dc=eom,dc=dev" + ], + "1": [], + "2": [], + "3": [ + "cn=Mastodon Users,ou=Services,dc=eom,dc=dev", + "cn=PeerTube Users,ou=Services,dc=eom,dc=dev" + ], + "4": [], + "5": [] +}