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_ISSUER=https://dex.eom.dev/
DEX_CLIENT_ID=www-membership DEX_CLIENT_ID=www-membership
DEX_CLIENT_SECRET= DEX_CLIENT_SECRET=
DEX_CONNECTOR_ID=www-ldap DEX_CONNECTOR_ID=ldap
LDAP_URL=ldaps://ldap.eom.dev LDAP_URL=ldaps://ldap.eom.dev
# Set to 1 ONLY with ldap:// on a trusted network to skip StartTLS. # Set to 1 ONLY with ldap:// on a trusted network to skip StartTLS.
# Bind and registration passwords will be sent unencrypted. LDAPS stays verified. # 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 MEMBERSHIP_TIERS_FILE=/app/tiers.eom.json
# These are existing LIVE-mode prices; use separate test-mode prices in sandbox. # 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). # 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): # 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, "isBackground": false,
"problemMatcher": [], "problemMatcher": [],
"group": "test" "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 ## 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 them to choose a password. The account is created as an `inetOrgPerson` in
OpenLDAP; the password goes only through RFC 3062 Password Modify over OpenLDAP; the password goes only through RFC 3062 Password Modify over
certificate-verified LDAPS/StartTLS by default (see the explicit plaintext certificate-verified LDAPS/StartTLS by default (see the explicit plaintext
option below). This app never stores a password/hash. option below). This app never stores a password/hash.
2. Sign-in uses Dex Authorization Code + PKCE, state and nonce. Authlib verifies 2. Sign-in uses the single Dex LDAP option with Authorization Code + PKCE, state
the RS256 ID token, issuer, audience, signature and lifetime. A signed and nonce. Authlib verifies the RS256 ID token, issuer, audience, signature
`federated_claims` connector/user ID must match the configured LDAP connector and lifetime. The signed connector/UID must match the configured connector
and LDAP `entryUUID`; accounts are **not** linked using email alone. and LDAP identity; account ownership is anchored to immutable `entryUUID`,
never email alone.
3. Authenticated users choose a server-allowlisted recurring Stripe Price. 3. Authenticated users choose a server-allowlisted recurring Stripe Price.
Stripe hosts Checkout and the billing portal. Return URLs never grant access. Stripe hosts Checkout and the billing portal. Return URLs never grant access.
4. Stripe webhooks are signature-verified and durably deduplicated. The worker 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, **Subscription**, product `prod_Sc9N5J0vNAdLCn`, without modifying its prices,
metadata, Discourse integration, or existing subscriptions. 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 0 · Community | Free | — | — |
| Level 1 | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-1` | | Level 1 · Supporter | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-1` |
| Level 2 | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-2` | | Level 2 · Insider | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-2` |
| Level 3 | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-3` | | Level 3 · Collaborator | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-3` |
| Level 4 | $64 | `price_1Rgv6mC5KPXqTvVLkcmznWZa` | `www-level-4` | | 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 deployable mapping is in [membership/tiers.eom.json](membership/tiers.eom.json).
The **group DNs are deployment proposals**, not verified existing groups: create 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 and exposes bind passwords, registration passwords and directory data to the
network. The default is `0` (StartTLS required for `ldap://`); TLS failures never network. The default is `0` (StartTLS required for `ldap://`); TLS failures never
trigger a plaintext fallback, and `ldaps://` always verifies certificates. 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). [membership/deploy/dex-membership.example.yaml](membership/deploy/dex-membership.example.yaml).
The exact callback is `https://eom.dev/members/oidc/callback/` (trailing slash). 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 Set the same client secret in Dex and `DEX_CLIENT_SECRET`, and set
`DEX_CONNECTOR_ID=www-ldap`. This connector uses `idAttr: entryUUID`, `DEX_CONNECTOR_ID=ldap`. Keep the connector ID and `idAttr: uid` unchanged to
`preferredUsernameAttr: uid`, `emailAttr: mail`; scopes include `federated:id`. preserve subjects in other apps. Add the website `inetOrgPerson` branch to the
**Do not change the existing connector's idAttr blindly**: that would change LDAP filter; this allows those entries to authenticate to all Dex clients, so
subjects in other apps. Use the additional connector instead. Dex must search review that scope. Dex uses signed `federated:id` claims, while the app resolves
the account OU without requiring membership in a paid group. RS256 and all UID to LDAP and checks immutable `entryUUID`. RS256 and all discovery endpoints
discovery endpoints must be HTTPS on the issuer's origin. Existing lowercase must be HTTPS on the issuer's origin. Existing lowercase LDAP usernames
LDAP usernames matching the registration syntax can sign in without signup. matching the registration syntax can sign in without signup.
5. The supplied tier file contains live Price IDs. Set `STRIPE_LIVE_MODE=1` only 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 with the matching live Stripe API key. For sandbox testing, override
`MEMBERSHIP_TIERS_JSON` with **test-mode** recurring prices and set live mode `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 or triggers an API request. The worker polls known local invoice UUIDs over
authenticated HTTPS, so forged, repeated, missing or reordered callbacks authenticated HTTPS, so forged, repeated, missing or reordered callbacks
cannot grant access or extend a term. cannot grant access or extend a term.
5. Redeploy the image and run the migration service, which applies 5. Redeploy the image and run the migration service, which applies the pending
[membership/members/migrations/0002_crypto_invoices.py](membership/members/migrations/0002_crypto_invoices.py). 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 Keep the membership worker running; it independently refreshes Stripe and
SHKeeper, and performs a local-only expiry sweep before remote requests. SHKeeper, and performs a local-only expiry sweep before remote requests.
6. Smoke-test a low-value invoice on your actual deployment before offering 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 current implementation follows paid invoices plus subscription state. If a
refund/dispute should revoke access, cancel/suspend the subscription as part of refund/dispute should revoke access, cancel/suspend the subscription as part of
your reviewed refund/dispute process. your reviewed refund/dispute process.
- Free accounts are `inetOrgPerson`, not automatically POSIX/shell accounts and - Level-0 accounts are `inetOrgPerson`, not automatically POSIX/shell accounts
not automatically members of any service group. Password resets remain your and not automatically members of any service group. Password resets remain
existing LDAP/Dex account recovery process. your existing LDAP/Dex account recovery process.
## Verification ## 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): if not isinstance(MEMBERSHIP_TIERS, dict):
raise ImproperlyConfigured("MEMBERSHIP_TIERS_JSON must be an object.") raise ImproperlyConfigured("MEMBERSHIP_TIERS_JSON must be an object.")
prices = set() prices = set()
levels = set()
for key, tier in MEMBERSHIP_TIERS.items(): 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): if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,47}", key) or not isinstance(tier, dict):
raise ImproperlyConfigured("Invalid membership tier.") 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: if not tier["price_id"].startswith("price_") or tier["price_id"] in prices:
raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.") raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.")
prices.add(tier["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: if "usd_amount" in tier:
try: try:
amount = Decimal(tier["usd_amount"]) amount = Decimal(tier["usd_amount"])
@@ -1,5 +1,5 @@
# Merge with the EXISTING Dex configuration; do not replace its storage/issuer. # 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: staticClients:
- id: www-membership - id: www-membership
name: eom.dev Membership name: eom.dev Membership
@@ -9,8 +9,8 @@ staticClients:
connectors: connectors:
- type: ldap - type: ldap
id: www-ldap id: ldap
name: eom.dev accounts name: eom.dev account
config: config:
host: ldap.eom.dev:636 host: ldap.eom.dev:636
insecureNoSSL: false insecureNoSSL: false
@@ -19,10 +19,10 @@ connectors:
bindDN: cn=dex-reader,ou=system,dc=eom,dc=dev bindDN: cn=dex-reader,ou=system,dc=eom,dc=dev
bindPW: $DEX_LDAP_BIND_PASSWORD bindPW: $DEX_LDAP_BIND_PASSWORD
userSearch: userSearch:
baseDN: ou=people,dc=eom,dc=dev baseDN: dc=eom,dc=dev
filter: "(objectClass=inetOrgPerson)" filter: "(|(&(objectClass=posixAccount)(memberOf=cn=Dex Users,ou=Dex,ou=Services,dc=eom,dc=dev))(objectClass=inetOrgPerson))"
username: uid username: uid
idAttr: entryUUID idAttr: uid
emailAttr: mail emailAttr: mail
nameAttr: cn nameAttr: cn
preferredUsernameAttr: uid preferredUsernameAttr: uid
+8
View File
@@ -6,6 +6,8 @@ objectClass: organizationalRole
cn: membership-sentinel cn: membership-sentinel
description: Non-login placeholder for empty paid membership groups 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 dn: cn=www-level-0,ou=groups,dc=eom,dc=dev
objectClass: top objectClass: top
objectClass: groupOfUniqueNames objectClass: groupOfUniqueNames
@@ -35,3 +37,9 @@ objectClass: top
objectClass: groupOfUniqueNames objectClass: groupOfUniqueNames
cn: www-level-4 cn: www-level-4
uniqueMember: cn=membership-sentinel,ou=system,dc=eom,dc=dev 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): def apply_groups(account):
now = timezone.now() now = timezone.now()
desired = set() desired = set()
membership_level = 0
for sub in Subscription.objects.filter(account=account, status__in=["active", "past_due"], paid_until__gt=now): for sub in Subscription.objects.filter(account=account, status__in=["active", "past_due"], paid_until__gt=now):
tier = settings.MEMBERSHIP_TIERS.get(sub.tier) tier = settings.MEMBERSHIP_TIERS.get(sub.tier)
if tier: if tier:
desired.add(tier["group_dn"]) 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): 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) tier = settings.MEMBERSHIP_TIERS.get(invoice.tier)
if tier: if tier:
desired.add(tier["group_dn"]) 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)) 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.ldap_dn = reconcile_groups(account, desired, managed) if managed else account.ldap_dn
account.last_ldap_sync = now 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) 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_dn = models.CharField(max_length=1024)
ldap_uuid = models.CharField(max_length=64, unique=True) ldap_uuid = models.CharField(max_length=64, unique=True)
oidc_sub = models.CharField(max_length=512, unique=True, null=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) stripe_customer = models.CharField(max_length=128, unique=True, null=True)
checkout_id = models.CharField(max_length=128, blank=True) checkout_id = models.CharField(max_length=128, blank=True)
checkout_key = models.UUIDField(default=uuid.uuid4) 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 DN = "uid=alice," + BASE
SENTINEL = "cn=sentinel,dc=example,dc=org" SENTINEL = "cn=sentinel,dc=example,dc=org"
TIERS = { TIERS = {
"supporter": {"label": "Supporter", "price_id": "price_supporter", "price_label": "$5 / month", "group_dn": GROUP}, "supporter": {"label": "Supporter", "level": 1, "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}, "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") self.assertContains(response, "Account created")
provision.assert_called_once_with("alice", "alice@example.org", password) provision.assert_called_once_with("alice", "alice@example.org", password)
self.assertEqual(Account.objects.count(), 1) self.assertEqual(Account.objects.count(), 1)
self.assertEqual(Account.objects.get().membership_level, 0)
self.assertFalse(Subscription.objects.exists()) self.assertFalse(Subscription.objects.exists())
self.assertFalse(Registration.objects.exists()) self.assertFalse(Registration.objects.exists())
self.assertNotIn("account_id", self.client.session) self.assertNotIn("account_id", self.client.session)
@@ -131,18 +132,20 @@ class RegistrationTests(TestCase):
@override_settings(MEMBERSHIP_TIERS=TIERS) @override_settings(MEMBERSHIP_TIERS=TIERS)
class IdentityTests(TestCase): class IdentityTests(TestCase):
def claims(self, **changes): 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") @patch("members.views.directory.identity_for_login")
def test_existing_directory_account_links_without_password(self, identity): 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"} identity.return_value = {"uid": "alice", "email": "alice@example.org", "ldap_dn": DN, "ldap_uuid": "uuid-alice"}
first = views.link_identity(self.claims()) 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.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()) self.assertFalse(Subscription.objects.exists())
with self.assertRaises(ValueError): views.link_identity(self.claims(sub="next-verified-subject"))
views.link_identity(self.claims(sub="different-sub")) first.refresh_from_db()
self.assertEqual(first.oidc_sub, "next-verified-subject")
@patch("members.views.directory.identity_for_login") @patch("members.views.directory.identity_for_login")
def test_no_email_only_or_unverified_link(self, identity): def test_no_email_only_or_unverified_link(self, identity):
@@ -158,7 +161,7 @@ class IdentityTests(TestCase):
@patch("members.views.directory.identity_for_login") @patch("members.views.directory.identity_for_login")
def test_other_connector_or_recreated_ldap_account_rejected(self, identity): 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"} 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): with self.assertRaises(ValueError):
views.link_identity(self.claims(federated_claims=federated)) views.link_identity(self.claims(federated_claims=federated))
self.assertFalse(Account.objects.exists()) self.assertFalse(Account.objects.exists())
@@ -291,6 +294,8 @@ class ReconciliationTests(TestCase):
self.assertEqual(int(sub.paid_until.timestamp()), self.end) self.assertEqual(int(sub.paid_until.timestamp()), self.end)
self.assertEqual(sub.tier, "supporter") self.assertEqual(sub.tier, "supporter")
self.assertEqual(ldap.call_args.args[1], {GROUP}) 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) self.assertEqual(ManagedGroup.objects.count(), 2)
@patch("members.billing.reconcile_groups", return_value=DN) @patch("members.billing.reconcile_groups", return_value=DN)
@@ -345,6 +350,8 @@ class ReconciliationTests(TestCase):
factory.return_value = self.api() factory.return_value = self.api()
billing.sync_account(self.member.pk) billing.sync_account(self.member.pk)
self.assertEqual(ldap.call_args.args[1], set()) 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.reconcile_groups", return_value=DN)
@patch("members.billing.client", side_effect=OSError("offline")) @patch("members.billing.client", side_effect=OSError("offline"))
@@ -426,6 +433,8 @@ class ReconciliationTests(TestCase):
factory.return_value = api factory.return_value = api
billing.sync_account(self.member.pk) billing.sync_account(self.member.pk)
self.assertEqual(ldap.call_args.args[1], {GROUP, OTHER_GROUP}) 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) @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) future.result(timeout=10)
self.assertTrue(acquired.is_set()) 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): def test_intent_commits_before_external_grant_failure(self):
with patch("members.billing.reconcile_groups", side_effect=OSError("directory unavailable")): with patch("members.billing.reconcile_groups", side_effect=OSError("directory unavailable")):
with self.assertRaises(OSError): with self.assertRaises(OSError):
@@ -55,7 +55,7 @@ class PostgreSQLLockTests(TransactionTestCase):
# not the previously committed record of the managed group. # not the previously committed record of the managed group.
self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org") 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): 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") 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"}]}]} 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) request.return_value = self.remote(invoice)
self.assertTrue(billing.sync_account(self.member.pk)) self.assertTrue(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP}) 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") request.side_effect = OSError("SHKeeper offline")
self.assertFalse(billing.sync_account(self.member.pk)) self.assertFalse(billing.sync_account(self.member.pk))
self.assertEqual(ldap.call_args.args[1], {GROUP}) self.assertEqual(ldap.call_args.args[1], {GROUP})
CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1)) CryptoInvoice.objects.update(access_end=timezone.now() - timedelta(seconds=1))
billing.sync_account(self.member.pk, refresh_stripe=False) billing.sync_account(self.member.pk, refresh_stripe=False)
self.assertEqual(ldap.call_args.args[1], set()) 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.reconcile_groups", return_value=DN)
@patch("members.shkeeper.request") @patch("members.shkeeper.request")
+12 -5
View File
@@ -168,16 +168,23 @@ def link_identity(claims):
raise ValueError("A verified Dex identity is required.") raise ValueError("A verified Dex identity is required.")
identity = directory.identity_for_login(claims.get("preferred_username")) identity = directory.identity_for_login(claims.get("preferred_username"))
federated = claims.get("federated_claims") or {} federated = claims.get("federated_claims") or {}
if federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != identity["ldap_uuid"]: 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 entryUUID as idAttr.") raise ValueError("Dex must authenticate the configured LDAP connector with uid as idAttr.")
if identity["email"].casefold() != str(claims.get("email", "")).casefold(): if identity["email"].casefold() != str(claims.get("email", "")).casefold():
raise ValueError("Dex and LDAP email disagree.") 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(): 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.get_or_create(ldap_uuid=identity["ldap_uuid"], defaults=identity)
account = Account.objects.select_for_update().get(pk=account.pk) account = Account.objects.select_for_update().get(pk=account.pk)
if account.oidc_sub and account.oidc_sub != claims["sub"]: subject_owner = Account.objects.filter(oidc_sub=claims["sub"]).exclude(pk=account.pk).exists()
raise ValueError("Identity is already linked to another Dex subject.") 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.oidc_sub = claims["sub"]
account.email = identity["email"] account.email = identity["email"]
account.ldap_dn = identity["ldap_dn"] 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> <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 %} {% if account %}
<div class="member-panel account-panel"> <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"> <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 %} {% 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> <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="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"> <div class="member-grid">
<article class="member-panel tier-card free-tier"> <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> <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> <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 %} {% 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> </article>
{% for key, tier in tiers.items %} {% for key, tier in tiers.items %}
<article class="member-panel tier-card"> <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> <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> <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> {% 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", "label": "Supporter",
"price_id": "price_1Rgv4xC5KPXqTvVLfMd6z7wn", "price_id": "price_1Rgv4xC5KPXqTvVLfMd6z7wn",
"price_label": "$4 USD / month", "price_label": "$4 USD / month",
"usd_amount": "4.00", "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.", "description": "A private place to connect and a closer look at what is being made.",
"benefits": [ "benefits": [
"Access to the shared members' Matrix space", "Access to the shared members' Matrix space",
@@ -12,12 +13,13 @@
"Optional recognition on the supporters page" "Optional recognition on the supporters page"
] ]
}, },
"level-1": { "level-2": {
"level": 2,
"label": "Insider", "label": "Insider",
"price_id": "price_1Rgv5QC5KPXqTvVLv1PaHWb5", "price_id": "price_1Rgv5QC5KPXqTvVLv1PaHWb5",
"price_label": "$8 USD / month", "price_label": "$8 USD / month",
"usd_amount": "8.00", "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.", "description": "See new experiments take shape, and try things before their public release.",
"benefits": [ "benefits": [
"Everything in Supporter", "Everything in Supporter",
@@ -25,12 +27,13 @@
"Invitations to member playtests and listening sessions" "Invitations to member playtests and listening sessions"
] ]
}, },
"level-2": { "level-3": {
"level": 3,
"label": "Collaborator", "label": "Collaborator",
"price_id": "price_1Rgv5rC5KPXqTvVL8Ol8sMUf", "price_id": "price_1Rgv5rC5KPXqTvVL8Ol8sMUf",
"price_label": "$16 USD / month", "price_label": "$16 USD / month",
"usd_amount": "16.00", "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.", "description": "Share your perspective and help shape the next community experiment.",
"benefits": [ "benefits": [
"Everything in Insider", "Everything in Insider",
@@ -38,12 +41,13 @@
"Invitations to occasional small-group feedback sessions" "Invitations to occasional small-group feedback sessions"
] ]
}, },
"level-3": { "level-4": {
"level": 4,
"label": "Sustainer", "label": "Sustainer",
"price_id": "price_1Rgv6FC5KPXqTvVL39mAxYjm", "price_id": "price_1Rgv6FC5KPXqTvVL39mAxYjm",
"price_label": "$32 USD / month", "price_label": "$32 USD / month",
"usd_amount": "32.00", "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.", "description": "The same community access, with a larger contribution to keeping it independent.",
"benefits": [ "benefits": [
"Everything in Collaborator, with the same access", "Everything in Collaborator, with the same access",
@@ -51,12 +55,13 @@
"Choose this level if you would like to give more" "Choose this level if you would like to give more"
] ]
}, },
"level-4": { "level-5": {
"level": 5,
"label": "Patron", "label": "Patron",
"price_id": "price_1Rgv6mC5KPXqTvVLkcmznWZa", "price_id": "price_1Rgv6mC5KPXqTvVLkcmznWZa",
"price_label": "$64 USD / month", "price_label": "$64 USD / month",
"usd_amount": "64.00", "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.", "description": "Help underwrite the public work and the community around it.",
"benefits": [ "benefits": [
"Everything in Collaborator, with the same access", "Everything in Collaborator, with the same access",