Tiered LDAP groups
This commit is contained in:
@@ -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).
|
||||
|
||||
Vendored
+2
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Executable
+37
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Executable
+16
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user