Tiered LDAP groups

This commit is contained in:
2026-10-29 14:17:16 -04:00
parent 0bffd7d993
commit 920a608fba
10 changed files with 565 additions and 8 deletions
+10
View File
@@ -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)
+6 -2
View File
@@ -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.
+8 -4
View File
@@ -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.")
+37
View File
@@ -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
}
+407
View File
@@ -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)
+3 -2
View File
@@ -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
+16
View File
@@ -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": []
}