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
+3 -2
View File
@@ -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."}}
+65 -1
View File
@@ -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"
}
]
}
}
+36 -24
View File
@@ -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
+5
View File
@@ -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"])
@@ -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
+8
View File
@@ -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
+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"]
+3 -3
View File
@@ -46,7 +46,7 @@
<p class="member-intro">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.</p>
{% if account %}
<div class="member-panel account-panel">
<div><p class="eyebrow">Your eom.dev account</p><h2>{{ account.uid }}</h2><p>{{ account.email }}</p></div>
<div><p class="eyebrow">Your eom.dev account · Membership level {{ account.membership_level }}</p><h2>{{ account.uid }}</h2><p>{{ account.email }}</p></div>
<div class="member-actions">
{% if account.stripe_customer %}<form action="{% url 'members:portal' %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Manage billing</button></form>{% endif %}
<form action="{% url 'members:logout' %}" method="post">{% csrf_token %}<button class="button button-secondary" type="submit">Sign out</button></form>
@@ -71,14 +71,14 @@
<div class="section-heading"><p class="eyebrow">Your support, your choice</p><h2 id="tiers-heading">Find your membership</h2><p>Public projects and ordinary community participation stay free. Membership adds a closer connection to the work, not a barrier to joining in.</p></div>
<div class="member-grid">
<article class="member-panel tier-card free-tier">
<p class="card-kicker">The starting point</p><h3>Community</h3><p class="tier-price">Free <span>/ always</span></p>
<p class="card-kicker">Level 0 · The starting point</p><h3>Community</h3><p class="tier-price">Free <span>/ always</span></p>
<p>One account for eom.dev, its community, and its services.</p>
<ul><li>No payment required</li><li>One account for compatible community services</li><li>Upgrade whenever you choose</li></ul>
{% if not account %}<a class="button button-secondary" href="{% url 'members:register' %}">Join the community</a>{% else %}<p class="tier-footnote">Your free account is ready.</p>{% endif %}
</article>
{% for key, tier in tiers.items %}
<article class="member-panel tier-card">
<p class="card-kicker">Independent work, supported together</p><h3>{{ tier.label }}</h3><p class="tier-price">{{ tier.price_label }}</p>
<p class="card-kicker">Level {{ tier.level }} · Independent work, supported together</p><h3>{{ tier.label }}</h3><p class="tier-price">{{ tier.price_label }}</p>
<p>{{ tier.description|default:"Help keep the projects and community running." }}</p>
<ul>{% for benefit in tier.benefits %}<li>{{ benefit }}</li>{% empty %}<li>Support independent projects and the community</li>{% endfor %}</ul>
{% if account %}<div class="tier-payment-actions"><form action="{% url 'members:checkout' key %}" method="post">{% csrf_token %}<button class="button button-primary" type="submit">Subscribe with Stripe</button></form>
+15 -10
View File
@@ -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",