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
+4
View File
@@ -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).
+2
View File
@@ -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",
+72
View File
@@ -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
+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": []
}