This commit is contained in:
2026-10-27 15:56:06 -04:00
parent d01f4a8fb7
commit aa18f6b895
15 changed files with 243 additions and 62 deletions
+5 -1
View File
@@ -90,18 +90,22 @@ def refresh_billing(account):
def apply_groups(account):
now = timezone.now()
desired = set()
membership_level = 0
for sub in Subscription.objects.filter(account=account, status__in=["active", "past_due"], paid_until__gt=now):
tier = settings.MEMBERSHIP_TIERS.get(sub.tier)
if tier:
desired.add(tier["group_dn"])
membership_level = max(membership_level, tier.get("level", 0))
for invoice in CryptoInvoice.objects.filter(account=account, status__in=["PAID", "OVERPAID"], access_start__lte=now, access_end__gt=now):
tier = settings.MEMBERSHIP_TIERS.get(invoice.tier)
if tier:
desired.add(tier["group_dn"])
membership_level = max(membership_level, tier.get("level", 0))
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
account.save(update_fields=["ldap_dn", "last_ldap_sync"])
account.membership_level = membership_level
account.save(update_fields=["ldap_dn", "last_ldap_sync", "membership_level"])
return len(desired)
+61
View File
@@ -0,0 +1,61 @@
from django.db import migrations, models
from django.utils import timezone
LEGACY_LEVELS = {f"level-{level}": f"level-{level + 1}" for level in range(5)}
def _tier_level(tier):
prefix = "level-"
suffix = tier[len(prefix):] if tier.startswith(prefix) else ""
return int(suffix) if suffix.isdigit() else 0
def migrate_membership_levels(apps, schema_editor, reverse=False):
Account = apps.get_model("members", "Account")
Subscription = apps.get_model("members", "Subscription")
CryptoInvoice = apps.get_model("members", "CryptoInvoice")
using = schema_editor.connection.alias
levels = {old: new for old, new in LEGACY_LEVELS.items()}
if reverse:
levels = {new: old for old, new in levels.items()}
for model in (Subscription, CryptoInvoice):
for old, new in reversed(list(levels.items())):
model.objects.using(using).filter(tier=old).update(tier=new)
now = timezone.now()
account_levels = {}
active_subscriptions = Subscription.objects.using(using).filter(
status__in=("active", "past_due"), paid_until__gt=now
).values_list("account_id", "tier")
for account_id, tier in active_subscriptions.iterator():
account_levels[account_id] = max(account_levels.get(account_id, 0), _tier_level(tier))
active_invoices = CryptoInvoice.objects.using(using).filter(
status__in=("PAID", "OVERPAID"), access_start__lte=now, access_end__gt=now
).values_list("account_id", "tier")
for account_id, tier in active_invoices.iterator():
account_levels[account_id] = max(account_levels.get(account_id, 0), _tier_level(tier))
batch = []
for account in Account.objects.using(using).all().iterator(chunk_size=500):
account.membership_level = account_levels.get(account.pk, 0)
batch.append(account)
if len(batch) == 500:
Account.objects.using(using).bulk_update(batch, ["membership_level"])
batch.clear()
if batch:
Account.objects.using(using).bulk_update(batch, ["membership_level"])
class Migration(migrations.Migration):
dependencies = [("members", "0002_crypto_invoices")]
operations = [
migrations.AddField(
model_name="account",
name="membership_level",
field=models.PositiveSmallIntegerField(default=0),
),
migrations.RunPython(migrate_membership_levels, migrate_membership_levels),
]
+1
View File
@@ -10,6 +10,7 @@ class Account(models.Model):
ldap_dn = models.CharField(max_length=1024)
ldap_uuid = models.CharField(max_length=64, unique=True)
oidc_sub = models.CharField(max_length=512, unique=True, null=True)
membership_level = models.PositiveSmallIntegerField(default=0)
stripe_customer = models.CharField(max_length=128, unique=True, null=True)
checkout_id = models.CharField(max_length=128, blank=True)
checkout_key = models.UUIDField(default=uuid.uuid4)
+17 -8
View File
@@ -22,8 +22,8 @@ BASE = "ou=people,dc=example,dc=org"
DN = "uid=alice," + BASE
SENTINEL = "cn=sentinel,dc=example,dc=org"
TIERS = {
"supporter": {"label": "Supporter", "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP},
"patron": {"label": "Patron", "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_GROUP},
"supporter": {"label": "Supporter", "level": 1, "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP},
"patron": {"label": "Patron", "level": 2, "price_id": "price_patron", "price_label": "$15 / month", "group_dn": OTHER_GROUP},
}
@@ -66,6 +66,7 @@ class RegistrationTests(TestCase):
self.assertContains(response, "Account created")
provision.assert_called_once_with("alice", "alice@example.org", password)
self.assertEqual(Account.objects.count(), 1)
self.assertEqual(Account.objects.get().membership_level, 0)
self.assertFalse(Subscription.objects.exists())
self.assertFalse(Registration.objects.exists())
self.assertNotIn("account_id", self.client.session)
@@ -131,18 +132,20 @@ class RegistrationTests(TestCase):
@override_settings(MEMBERSHIP_TIERS=TIERS)
class IdentityTests(TestCase):
def claims(self, **changes):
return {"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "uuid-alice"}, **changes}
return {"iss": settings.DEX_ISSUER, "sub": "dex-alice", "preferred_username": "alice", "email_verified": True, "email": "alice@example.org", "federated_claims": {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "alice"}, **changes}
@patch("members.views.directory.identity_for_login")
def test_existing_directory_account_links_without_password(self, identity):
identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"}
first = views.link_identity(self.claims())
second = views.link_identity(self.claims())
second = views.link_identity(self.claims(sub="ldap-alice"))
self.assertEqual(first.pk, second.pk)
self.assertEqual(first.oidc_sub, "dex-alice")
first.refresh_from_db()
self.assertEqual(first.oidc_sub, "ldap-alice")
self.assertFalse(Subscription.objects.exists())
with self.assertRaises(ValueError):
views.link_identity(self.claims(sub="different-sub"))
views.link_identity(self.claims(sub="next-verified-subject"))
first.refresh_from_db()
self.assertEqual(first.oidc_sub, "next-verified-subject")
@patch("members.views.directory.identity_for_login")
def test_no_email_only_or_unverified_link(self, identity):
@@ -158,7 +161,7 @@ class IdentityTests(TestCase):
@patch("members.views.directory.identity_for_login")
def test_other_connector_or_recreated_ldap_account_rejected(self, identity):
identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"}
for federated in ({"connector_id": "github", "user_id": "uuid-alice"}, {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "different-uuid"}, {}):
for federated in ({"connector_id": "github", "user_id": "alice"}, {"connector_id": settings.DEX_CONNECTOR_ID, "user_id": "different-uid"}, {}):
with self.assertRaises(ValueError):
views.link_identity(self.claims(federated_claims=federated))
self.assertFalse(Account.objects.exists())
@@ -291,6 +294,8 @@ class ReconciliationTests(TestCase):
self.assertEqual(int(sub.paid_until.timestamp()), self.end)
self.assertEqual(sub.tier, "supporter")
self.assertEqual(ldap.call_args.args[1], {GROUP})
self.member.refresh_from_db()
self.assertEqual(self.member.membership_level, 1)
self.assertEqual(ManagedGroup.objects.count(), 2)
@patch("members.billing.reconcile_groups", return_value=DN)
@@ -345,6 +350,8 @@ class ReconciliationTests(TestCase):
factory.return_value = self.api()
billing.sync_account(self.member.pk)
self.assertEqual(ldap.call_args.args[1], set())
self.member.refresh_from_db()
self.assertEqual(self.member.membership_level, 0)
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.billing.client", side_effect=OSError("offline"))
@@ -426,6 +433,8 @@ class ReconciliationTests(TestCase):
factory.return_value = api
billing.sync_account(self.member.pk)
self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP})
self.member.refresh_from_db()
self.assertEqual(self.member.membership_level, 2)
@override_settings(LDAP_SEARCH_BASE_DN=BASE, LDAP_GROUP_PLACEHOLDER_DN=SENTINEL)
+2 -2
View File
@@ -46,7 +46,7 @@ class PostgreSQLLockTests(TransactionTestCase):
future.result(timeout=10)
self.assertTrue(acquired.is_set())
@override_settings(MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}})
@override_settings(MEMBERSHIP_TIERS={"test": {"level": 1, "group_dn": "cn=paid,dc=example,dc=org"}})
def test_intent_commits_before_external_grant_failure(self):
with patch("members.billing.reconcile_groups", side_effect=OSError("directory unavailable")):
with self.assertRaises(OSError):
@@ -55,7 +55,7 @@ class PostgreSQLLockTests(TransactionTestCase):
# not the previously committed record of the managed group.
self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org")
@override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}})
@override_settings(SHKEEPER_ENABLED=True, MEMBERSHIP_TIERS={"test": {"level": 1, "group_dn": "cn=paid,dc=example,dc=org"}})
def test_concurrent_crypto_verification_credits_one_month(self):
invoice = CryptoInvoice.objects.create(account=self.account, tier="test", usd_amount="5.00", crypto="BTC", provider_id="61", wallet="test-wallet", crypto_amount="0.0001", status="UNPAID")
data = {"status": "success", "invoices": [{"external_id": f"www-membership-{invoice.pk}", "fiat": "USD", "amount_fiat": "5.00", "balance_fiat": "5.00", "status": "PAID", "txs": [{"addr": "test-wallet", "crypto": "BTC", "status": "CONFIRMED", "txid": "tx-confirmed", "amount": "0.0001"}]}]}
@@ -173,12 +173,16 @@ class SHKeeperTests(TestCase):
request.return_value = self.remote(invoice)
self.assertTrue(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP})
self.member.refresh_from_db()
self.assertEqual(self.member.membership_level, 1)
request.side_effect = OSError("SHKeeper offline")
self.assertFalse(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP})
CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1))
billing.sync_account(self.member.pk, refresh_stripe=False)
self.assertEqual(ldap.call_args.args[1], set())
self.member.refresh_from_db()
self.assertEqual(self.member.membership_level, 0)
@patch("members.billing.reconcile_groups", return_value=DN)
@patch("members.shkeeper.request")
+12 -5
View File
@@ -168,16 +168,23 @@ def link_identity(claims):
raise ValueError("A verified Dex identity is required.")
identity = directory.identity_for_login(claims.get("preferred_username"))
federated = claims.get("federated_claims") or {}
if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["ldap_uuid"]:
raise ValueError("Dex must authenticate the configured LDAP connector with entryUUID as idAttr.")
if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["uid"]:
raise ValueError("Dex must authenticate the configured LDAP connector with uid as idAttr.")
if identity["email"].casefold() != str(claims.get("email", "")).casefold():
raise ValueError("Dex and LDAP email disagree.")
# Link using the signed LDAP uid plus immutable entryUUID, NEVER by email.
# Resolve by immutable LDAP entryUUID, never by email. Dex's federated user
# ID remains uid so the unified connector preserves existing OIDC subjects.
with transaction.atomic():
uid_owner = Account.objects.filter(uid=identity["uid"]).first()
if uid_owner and uid_owner.ldap_uuid != identity["ldap_uuid"]:
raise ValueError("Username is already linked to another directory entry.")
account, _ = Account.objects.get_or_create(ldap_uuid=identity["ldap_uuid"], defaults=identity)
account = Account.objects.select_for_update().get(pk=account.pk)
if account.oidc_sub and account.oidc_sub != claims["sub"]:
raise ValueError("Identity is already linked to another Dex subject.")
subject_owner = Account.objects.filter(oidc_sub=claims["sub"]).exclude(pk=account.pk).exists()
if subject_owner:
raise ValueError("Dex subject is already linked to another directory entry.")
# A previous login may have used the retired www-ldap connector, whose
# subjects used entryUUID. Rebind after validating the same LDAP entry.
account.oidc_sub = claims["sub"]
account.email = identity["email"]
account.ldap_dn = identity["ldap_dn"]