diff --git a/.env.example b/.env.example
index 9a48571..0d31e52 100755
--- a/.env.example
+++ b/.env.example
@@ -17,7 +17,7 @@ POSTGRES_SSLMODE=prefer
DEX_ISSUER=https://dex.eom.dev/
DEX_CLIENT_ID=www-membership
DEX_CLIENT_SECRET=
-DEX_CONNECTOR_ID=www-ldap
+DEX_CONNECTOR_ID=ldap
LDAP_URL=ldaps://ldap.eom.dev
# Set to 1 ONLY with ldap:// on a trusted network to skip StartTLS.
# Bind and registration passwords will be sent unencrypted. LDAPS stays verified.
@@ -54,5 +54,6 @@ SHKEEPER_CA_CERT_FILE=
MEMBERSHIP_TIERS_FILE=/app/tiers.eom.json
# 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).
# Example shape (names/prices/group DNs are your choice, not created by the app):
-# MEMBERSHIP_TIERS_JSON={"supporter":{"label":"Supporter","price_id":"price_REPLACE","price_label":"$5 / month","group_dn":"cn=www-supporter,ou=groups,dc=eom,dc=dev","description":"Support independent projects and community services."}}
+# MEMBERSHIP_TIERS_JSON={"supporter":{"level":1,"label":"Supporter","price_id":"price_REPLACE","price_label":"$5 / month","group_dn":"cn=www-supporter,ou=groups,dc=eom,dc=dev","description":"Support independent projects and community services."}}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index dcdc05b..6985836 100755
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -18,6 +18,70 @@
"isBackground": false,
"problemMatcher": [],
"group": "test"
+ },
+ {
+ "label": "Test membership offline",
+ "type": "shell",
+ "command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe",
+ "args": [
+ "${workspaceFolder}/membership/manage.py",
+ "test",
+ "members.tests.test_membership",
+ "members.tests.test_oidc",
+ "members.tests.test_postgres",
+ "members.tests.test_shkeeper",
+ "--settings=config.test_settings",
+ "--verbosity=1"
+ ],
+ "isBackground": false,
+ "problemMatcher": [],
+ "group": "test"
+ },
+ {
+ "label": "Check membership migrations",
+ "type": "shell",
+ "command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe",
+ "args": [
+ "${workspaceFolder}/membership/manage.py",
+ "makemigrations",
+ "--check",
+ "--dry-run",
+ "--settings=config.test_settings"
+ ],
+ "isBackground": false,
+ "problemMatcher": [],
+ "group": "test"
+ },
+ {
+ "label": "Retest membership after expiry assertion",
+ "type": "shell",
+ "command": "C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe",
+ "args": [
+ "${workspaceFolder}/membership/manage.py",
+ "test",
+ "members.tests.test_membership",
+ "members.tests.test_oidc",
+ "members.tests.test_postgres",
+ "members.tests.test_shkeeper",
+ "--settings=config.test_settings",
+ "--verbosity=1"
+ ],
+ "isBackground": false,
+ "problemMatcher": [],
+ "group": "test"
+ },
+ {
+ "label": "Check production tier configuration",
+ "type": "shell",
+ "command": "$env:MEMBERSHIP_TIERS_FILE='Z:\\Repositories\\www\\membership\\tiers.eom.json'; C:/Users/ermeehan/AppData/Local/Microsoft/WindowsApps/python3.12.exe",
+ "args": [
+ "${workspaceFolder}/membership/manage.py",
+ "check",
+ "--settings=config.test_settings"
+ ],
+ "isBackground": false,
+ "problemMatcher": [],
+ "group": "test"
}
]
-}
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 50366b8..80cbf0c 100644
--- a/README.md
+++ b/README.md
@@ -8,15 +8,16 @@ Both payment methods feed the same LDAP group reconciliation and local expiry.
## Membership flow
-1. Anyone can request a free account. A one-hour, single-use email link allows
+1. Anyone can request a free level-0 account. A one-hour, single-use email link allows
them to choose a password. The account is created as an `inetOrgPerson` in
OpenLDAP; the password goes only through RFC 3062 Password Modify over
certificate-verified LDAPS/StartTLS by default (see the explicit plaintext
option below). This app never stores a password/hash.
-2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies
- the RS256 ID token, issuer, audience, signature and lifetime. A signed
- `federated_claims` connector/user ID must match the configured LDAP connector
- and LDAP `entryUUID`; accounts are **not** linked using email alone.
+2. Sign-in uses the single Dex LDAP option with Authorization Code + PKCE, state
+ and nonce. Authlib verifies the RS256 ID token, issuer, audience, signature
+ and lifetime. The signed connector/UID must match the configured connector
+ and LDAP identity; account ownership is anchored to immutable `entryUUID`,
+ never email alone.
3. Authenticated users choose a server-allowlisted recurring Stripe Price.
Stripe hosts Checkout and the billing portal. Return URLs never grant access.
4. Stripe webhooks are signature-verified and durably deduplicated. The worker
@@ -45,13 +46,22 @@ The live Stripe dashboard was inspected on 2026-10-23. This integration reuses
**Subscription**, product `prod_Sc9N5J0vNAdLCn`, without modifying its prices,
metadata, Discourse integration, or existing subscriptions.
-| Tier | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group |
+| Membership | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group |
| --- | ---: | --- | --- |
-| Level 0 | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-0` |
-| Level 1 | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-1` |
-| Level 2 | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-2` |
-| Level 3 | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-3` |
-| Level 4 | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-4` |
+| Level 0 · Community | Free | — | — |
+| Level 1 · Supporter | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-1` |
+| Level 2 · Insider | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-2` |
+| Level 3 · Collaborator | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-3` |
+| Level 4 · Sustainer | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-4` |
+| Level 5 · Patron | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-5` |
+
+Every account has a non-null `membership_level`: new/free accounts start at 0,
+and synchronization records the highest currently valid paid level. The former
+paid levels 0–4 are shifted to 1–5; Stripe Price IDs remain unchanged. The
+migration also updates stored subscription and prepaid-invoice tier keys and
+backfills current account levels. Existing level-0 LDAP group grants are
+revoked by the worker after the migration; retain that legacy group until the
+first successful synchronization of existing accounts.
The deployable mapping is in [membership/tiers.eom.json](membership/tiers.eom.json).
The **group DNs are deployment proposals**, not verified existing groups: create
@@ -97,17 +107,18 @@ of these five prices when inspected.
and exposes bind passwords, registration passwords and directory data to the
network. The default is `0` (StartTLS required for `ldap://`); TLS failures never
trigger a plaintext fallback, and `ldaps://` always verifies certificates.
-4. Add a dedicated Dex client/LDAP connector using
+4. Add the website client to Dex and merge website account entries into its
+ existing LDAP connector using
[membership/deploy/dex-membership.example.yaml](membership/deploy/dex-membership.example.yaml).
The exact callback is `https://eom.dev/members/oidc/callback/` (trailing slash).
Set the same client secret in Dex and `DEX_CLIENT_SECRET`, and set
- `DEX_CONNECTOR_ID=www-ldap`. This connector uses `idAttr: entryUUID`,
- `preferredUsernameAttr: uid`, `emailAttr: mail`; scopes include `federated:id`.
- **Do not change the existing connector's idAttr blindly**: that would change
- subjects in other apps. Use the additional connector instead. Dex must search
- the account OU without requiring membership in a paid group. RS256 and all
- discovery endpoints must be HTTPS on the issuer's origin. Existing lowercase
- LDAP usernames matching the registration syntax can sign in without signup.
+ `DEX_CONNECTOR_ID=ldap`. Keep the connector ID and `idAttr: uid` unchanged to
+ preserve subjects in other apps. Add the website `inetOrgPerson` branch to the
+ LDAP filter; this allows those entries to authenticate to all Dex clients, so
+ review that scope. Dex uses signed `federated:id` claims, while the app resolves
+ UID to LDAP and checks immutable `entryUUID`. RS256 and all discovery endpoints
+ must be HTTPS on the issuer's origin. Existing lowercase LDAP usernames
+ matching the registration syntax can sign in without signup.
5. The supplied tier file contains live Price IDs. Set `STRIPE_LIVE_MODE=1` only
with the matching live Stripe API key. For sandbox testing, override
`MEMBERSHIP_TIERS_JSON` with **test-mode** recurring prices and set live mode
@@ -193,8 +204,9 @@ To enable:
or triggers an API request. The worker polls known local invoice UUIDs over
authenticated HTTPS, so forged, repeated, missing or reordered callbacks
cannot grant access or extend a term.
-5. Redeploy the image and run the migration service, which applies
- [membership/members/migrations/0002_crypto_invoices.py](membership/members/migrations/0002_crypto_invoices.py).
+5. Redeploy the image and run the migration service, which applies the pending
+ membership schema/data migrations, including
+ [membership/members/migrations/0003_membership_levels.py](membership/members/migrations/0003_membership_levels.py).
Keep the membership worker running; it independently refreshes Stripe and
SHKeeper, and performs a local-only expiry sweep before remote requests.
6. Smoke-test a low-value invoice on your actual deployment before offering
@@ -274,9 +286,9 @@ Payment lifecycle:
current implementation follows paid invoices plus subscription state. If a
refund/dispute should revoke access, cancel/suspend the subscription as part of
your reviewed refund/dispute process.
-- Free accounts are `inetOrgPerson`, not automatically POSIX/shell accounts and
- not automatically members of any service group. Password resets remain your
- existing LDAP/Dex account recovery process.
+- Level-0 accounts are `inetOrgPerson`, not automatically POSIX/shell accounts
+ and not automatically members of any service group. Password resets remain
+ your existing LDAP/Dex account recovery process.
## Verification
diff --git a/membership/config/settings.py b/membership/config/settings.py
index 15a9d89..0945112 100755
--- a/membership/config/settings.py
+++ b/membership/config/settings.py
@@ -118,6 +118,7 @@ MEMBERSHIP_TIERS = json.loads(os.environ["MEMBERSHIP_TIERS_JSON"]) if "MEMBERSHI
if not isinstance(MEMBERSHIP_TIERS, dict):
raise ImproperlyConfigured("MEMBERSHIP_TIERS_JSON must be an object.")
prices = set()
+levels = set()
for key, tier in MEMBERSHIP_TIERS.items():
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,47}", key) or not isinstance(tier, dict):
raise ImproperlyConfigured("Invalid membership tier.")
@@ -127,6 +128,10 @@ for key, tier in MEMBERSHIP_TIERS.items():
if not tier["price_id"].startswith("price_") or tier["price_id"] in prices:
raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.")
prices.add(tier["price_id"])
+ level = tier.get("level")
+ if not isinstance(level, int) or isinstance(level, bool) or level < 1 or level in levels:
+ raise ImproperlyConfigured(f"Tier {key} must have a unique positive integer level; level 0 is reserved for free accounts.")
+ levels.add(level)
if "usd_amount" in tier:
try:
amount = Decimal(tier["usd_amount"])
diff --git a/membership/deploy/dex-membership.example.yaml b/membership/deploy/dex-membership.example.yaml
index 2f1ddff..277ae54 100755
--- a/membership/deploy/dex-membership.example.yaml
+++ b/membership/deploy/dex-membership.example.yaml
@@ -1,5 +1,5 @@
# Merge with the EXISTING Dex configuration; do not replace its storage/issuer.
-# A new connector avoids changing subjects for other applications using LDAP.
+# Extend the existing ldap connector instead of adding a second login option.
staticClients:
- id: www-membership
name: eom.dev Membership
@@ -9,8 +9,8 @@ staticClients:
connectors:
- type: ldap
- id: www-ldap
- name: eom.dev accounts
+ id: ldap
+ name: eom.dev account
config:
host: ldap.eom.dev:636
insecureNoSSL: false
@@ -19,10 +19,10 @@ connectors:
bindDN: cn=dex-reader,ou=system,dc=eom,dc=dev
bindPW: $DEX_LDAP_BIND_PASSWORD
userSearch:
- baseDN: ou=people,dc=eom,dc=dev
- filter: "(objectClass=inetOrgPerson)"
+ baseDN: dc=eom,dc=dev
+ filter: "(|(&(objectClass=posixAccount)(memberOf=cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev))(objectClass=inetOrgPerson))"
username: uid
- idAttr: entryUUID
+ idAttr: uid
emailAttr: mail
nameAttr: cn
preferredUsernameAttr: uid
diff --git a/membership/deploy/groups.example.ldif b/membership/deploy/groups.example.ldif
index 3ba4e38..0cc15ac 100755
--- a/membership/deploy/groups.example.ldif
+++ b/membership/deploy/groups.example.ldif
@@ -6,6 +6,8 @@ objectClass: organizationalRole
cn: membership-sentinel
description: Non-login placeholder for empty paid membership groups
+# Legacy paid level-0 group. Keep until existing deployments have synchronized
+# all accounts and removed their former Supporter grants.
dn: cn=www-level-0,ou=groups,dc=eom,dc=dev
objectClass: top
objectClass: groupOfUniqueNames
@@ -35,3 +37,9 @@ objectClass: top
objectClass: groupOfUniqueNames
cn: www-level-4
uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev
+
+dn: cn=www-level-5,ou=groups,dc=eom,dc=dev
+objectClass: top
+objectClass: groupOfUniqueNames
+cn: www-level-5
+uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev
diff --git a/membership/members/billing.py b/membership/members/billing.py
index b113ce9..9c650dc 100755
--- a/membership/members/billing.py
+++ b/membership/members/billing.py
@@ -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)
diff --git a/membership/members/migrations/0003_membership_levels.py b/membership/members/migrations/0003_membership_levels.py
new file mode 100755
index 0000000..5e47f89
--- /dev/null
+++ b/membership/members/migrations/0003_membership_levels.py
@@ -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),
+ ]
diff --git a/membership/members/models.py b/membership/members/models.py
index 549de0c..f985137 100755
--- a/membership/members/models.py
+++ b/membership/members/models.py
@@ -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)
diff --git a/membership/members/tests/test_membership.py b/membership/members/tests/test_membership.py
index 2ca7505..22b9f05 100755
--- a/membership/members/tests/test_membership.py
+++ b/membership/members/tests/test_membership.py
@@ -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)
diff --git a/membership/members/tests/test_postgres.py b/membership/members/tests/test_postgres.py
index 8a1fce9..8d1ad95 100755
--- a/membership/members/tests/test_postgres.py
+++ b/membership/members/tests/test_postgres.py
@@ -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"}]}]}
diff --git a/membership/members/tests/test_shkeeper.py b/membership/members/tests/test_shkeeper.py
index a5ed411..f68f65a 100755
--- a/membership/members/tests/test_shkeeper.py
+++ b/membership/members/tests/test_shkeeper.py
@@ -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")
diff --git a/membership/members/views.py b/membership/members/views.py
index d93506b..2d56bf4 100755
--- a/membership/members/views.py
+++ b/membership/members/views.py
@@ -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"]
diff --git a/membership/templates/members.html b/membership/templates/members.html
index de2a9f5..f1bbe60 100755
--- a/membership/templates/members.html
+++ b/membership/templates/members.html
@@ -46,7 +46,7 @@
A private place to connect, a closer look at what is being made, and opportunities to help shape it. Start with a free eom.dev account, then choose an optional membership to support the independent work.
{% if account %}
-
Your eom.dev account
{{ account.uid }}
{{ account.email }}
+
Your eom.dev account · Membership level {{ account.membership_level }}
{{ account.uid }}
{{ account.email }}
{% if account.stripe_customer %}
{% endif %}
@@ -71,14 +71,14 @@
Your support, your choice
Find your membership
Public projects and ordinary community participation stay free. Membership adds a closer connection to the work, not a barrier to joining in.
- The starting point
Community
Free / always
+ Level 0 · The starting point
Community
Free / always
One account for eom.dev, its community, and its services.
- No payment required
- One account for compatible community services
- Upgrade whenever you choose
{% if not account %}Join the community{% else %}{% endif %}
{% for key, tier in tiers.items %}
- Independent work, supported together
{{ tier.label }}
{{ tier.price_label }}
+ Level {{ tier.level }} · Independent work, supported together
{{ tier.label }}
{{ tier.price_label }}
{{ tier.description|default:"Help keep the projects and community running." }}
{% for benefit in tier.benefits %}- {{ benefit }}
{% empty %}- Support independent projects and the community
{% endfor %}
{% if account %}
diff --git a/membership/tiers.eom.json b/membership/tiers.eom.json
index 6324043..b72f7c2 100755
--- a/membership/tiers.eom.json
+++ b/membership/tiers.eom.json
@@ -1,10 +1,11 @@
{
- "level-0": {
+ "level-1": {
+ "level": 1,
"label": "Supporter",
"price_id": "price_1Rgv4xC5KPXqTvVLfMd6z7wn",
"price_label": "$4 USD / month",
"usd_amount": "4.00",
- "group_dn": "cn=www-level-0,ou=groups,dc=eom,dc=dev",
+ "group_dn": "cn=www-level-1,ou=groups,dc=eom,dc=dev",
"description": "A private place to connect and a closer look at what is being made.",
"benefits": [
"Access to the shared members' Matrix space",
@@ -12,12 +13,13 @@
"Optional recognition on the supporters page"
]
},
- "level-1": {
+ "level-2": {
+ "level": 2,
"label": "Insider",
"price_id": "price_1Rgv5QC5KPXqTvVLv1PaHWb5",
"price_label": "$8 USD / month",
"usd_amount": "8.00",
- "group_dn": "cn=www-level-1,ou=groups,dc=eom,dc=dev",
+ "group_dn": "cn=www-level-2,ou=groups,dc=eom,dc=dev",
"description": "See new experiments take shape, and try things before their public release.",
"benefits": [
"Everything in Supporter",
@@ -25,12 +27,13 @@
"Invitations to member playtests and listening sessions"
]
},
- "level-2": {
+ "level-3": {
+ "level": 3,
"label": "Collaborator",
"price_id": "price_1Rgv5rC5KPXqTvVL8Ol8sMUf",
"price_label": "$16 USD / month",
"usd_amount": "16.00",
- "group_dn": "cn=www-level-2,ou=groups,dc=eom,dc=dev",
+ "group_dn": "cn=www-level-3,ou=groups,dc=eom,dc=dev",
"description": "Share your perspective and help shape the next community experiment.",
"benefits": [
"Everything in Insider",
@@ -38,12 +41,13 @@
"Invitations to occasional small-group feedback sessions"
]
},
- "level-3": {
+ "level-4": {
+ "level": 4,
"label": "Sustainer",
"price_id": "price_1Rgv6FC5KPXqTvVL39mAxYjm",
"price_label": "$32 USD / month",
"usd_amount": "32.00",
- "group_dn": "cn=www-level-3,ou=groups,dc=eom,dc=dev",
+ "group_dn": "cn=www-level-4,ou=groups,dc=eom,dc=dev",
"description": "The same community access, with a larger contribution to keeping it independent.",
"benefits": [
"Everything in Collaborator, with the same access",
@@ -51,12 +55,13 @@
"Choose this level if you would like to give more"
]
},
- "level-4": {
+ "level-5": {
+ "level": 5,
"label": "Patron",
"price_id": "price_1Rgv6mC5KPXqTvVLkcmznWZa",
"price_label": "$64 USD / month",
"usd_amount": "64.00",
- "group_dn": "cn=www-level-4,ou=groups,dc=eom,dc=dev",
+ "group_dn": "cn=www-level-5,ou=groups,dc=eom,dc=dev",
"description": "Help underwrite the public work and the community around it.",
"benefits": [
"Everything in Collaborator, with the same access",