191 lines
12 KiB
Markdown
191 lines
12 KiB
Markdown
# WWW
|
|
|
|
Apache serves the static eom.dev website. A separate Django service provides
|
|
public accounts, Dex sign-in, and Stripe membership under **/members/**.
|
|
|
|
## Membership flow
|
|
|
|
1. Anyone can request a free 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. 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.
|
|
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
|
|
rereads canonical subscriptions and paid invoice lines, so duplicate, missing,
|
|
or reordered webhooks cannot restore expired access.
|
|
5. The worker first sweeps local expiries for all accounts, then reconciles
|
|
Stripe state. An account belongs to the configured tier group only while it
|
|
has paid service time. A failed renewal does not extend that time. Immediate
|
|
cancellation, pause, unpaid status or unknown prices revoke access. Cancel
|
|
at period end retains access until the paid expiry. Trialing does not grant
|
|
paid access. A zero-total invoice marked paid (e.g. a coupon) counts as paid.
|
|
6. Expiry removes **group membership, not the LDAP account**. LDAP writes are
|
|
idempotent, and failed synchronization is retried on every sweep. Old group
|
|
mappings remain in the database so deleting/changing a tier also revokes its
|
|
former group. Account row locks serialize Checkout and reconciliation.
|
|
|
|
The worker interval is 60 seconds **after a complete sweep**, not a hard
|
|
one-minute expiry SLA. LDAP/network outages delay changes until recovery.
|
|
Stripe outages do not extend locally recorded paid expiry. Already-issued Dex
|
|
tokens and downstream sessions do not instantly lose their cached claims; set
|
|
short token lifetimes and enforce reauthorization in each relying application.
|
|
|
|
## Existing Stripe product
|
|
|
|
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 |
|
|
| --- | ---: | --- | --- |
|
|
| 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` |
|
|
|
|
The deployable mapping is in [membership/tiers.eom.json](membership/tiers.eom.json).
|
|
The **group DNs are deployment proposals**, not verified existing groups: create
|
|
them or edit the mapping to your dedicated groups before starting the worker.
|
|
Higher tiers do not implicitly inherit lower-tier groups. Map your applications'
|
|
authorization accordingly. Never use administrator or unrelated service groups.
|
|
Display prices are informational; the configured Stripe Price is authoritative.
|
|
For tax obligations, review the product tax code and Stripe settings separately.
|
|
|
|
Existing Discourse customers/subscriptions are **not automatically imported**.
|
|
New website subscriptions carry `metadata.membership_account` and are scoped to
|
|
the signed-in account's stored customer ID. Never attach a pre-existing customer
|
|
or subscription based only on matching email; a separately reviewed ownership
|
|
migration is required. The dashboard showed zero active subscriptions on each
|
|
of these five prices when inspected.
|
|
|
|
## Production setup
|
|
|
|
1. Copy [.env.example](.env.example) to the ignored local environment file. Fill
|
|
secrets directly through your secret manager/editor, not chat. Generate
|
|
independent, high-entropy Django, PostgreSQL, Dex and LDAP credentials.
|
|
Fill SMTP settings and a real sender address for verification mail.
|
|
2. Create the account OU and sentinel/groups after reviewing
|
|
[membership/deploy/groups.example.ldif](membership/deploy/groups.example.ldif).
|
|
Configure a **least-privilege** LDAP service account allowed to:
|
|
- search the account tree for `uid`, `mail`, `entryUUID` and DN;
|
|
- add/delete entries only within the website account OU;
|
|
- perform Password Modify only on those newly provisioned entries;
|
|
- read and add/delete `uniqueMember` only on the dedicated paid groups;
|
|
- read the sentinel, which must never be a login identity.
|
|
Do not use the directory root/admin bind. Permit Dex a separate read/search
|
|
service identity. Prevent users from writing their own paid group membership.
|
|
3. Verify OpenLDAP's Argon2 module/password policy (`olcPasswordHash: {ARGON2}`),
|
|
require suitable password strength/history, reject unauthenticated binds,
|
|
and enforce directory-side **unique uid and mail across the full search
|
|
tree**, including other account registrars. Only then set
|
|
`LDAP_PASSWORD_SCHEME={ARGON2}`. The app does not alter slapd configuration.
|
|
If a private CA is used, mount its certificate read-only into both membership
|
|
and worker services and set `LDAP_CA_CERT_FILE`. TLS verification is mandatory.
|
|
4. Add a dedicated Dex client/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.
|
|
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
|
|
to 0. Set the JSON object to `{}` to disable paid tiers. Grant the API key only
|
|
required customer, Checkout, subscription, invoice/line read, Price read, and
|
|
billing portal permissions; the app never creates Products or Prices.
|
|
6. Create/configure a Stripe billing portal with **only these five allowed
|
|
prices**, payment method updates and cancellation. Set its configuration ID in
|
|
`STRIPE_PORTAL_CONFIGURATION`. For tier changes, use immediate paid proration
|
|
for upgrades and period-end downgrades. Access always follows the current
|
|
price's paid invoice lines; an unpaid upgrade does not grant the higher group.
|
|
An arbitrary instant downgrade without a paid line for the new price can
|
|
suspend access until Stripe invoices it; use scheduled downgrades instead.
|
|
7. Add a Stripe webhook endpoint at
|
|
`https://eom.dev/members/stripe/webhook/`. Subscribe to
|
|
`checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
|
`checkout.session.async_payment_failed`, `customer.subscription.created`,
|
|
`customer.subscription.updated`, `customer.subscription.deleted`,
|
|
`invoice.paid`, `invoice.payment_failed`. Use API version
|
|
**2025-03-31.basil** and set the endpoint signing secret in
|
|
`STRIPE_WEBHOOK_SECRET`. Do not edit/delete the Discourse webhook. The worker's
|
|
full sweep recovers missing events without relying on payload arrival order.
|
|
8. Deploy with Docker Compose. A one-shot migration service runs before the web
|
|
and worker services. PostgreSQL has no published port. Its Compose network is
|
|
private; use `POSTGRES_SSLMODE=verify-full` with a mounted root CA for remote
|
|
PostgreSQL, and use encrypted disks/backups. Do not use the SQLite setting in
|
|
production: real PostgreSQL row locks are required.
|
|
9. Terminate TLS at your existing edge and forward only to Apache's private
|
|
listener. The host port now defaults to loopback (127.0.0.1:8080); if your TLS
|
|
proxy is on another host/container, explicitly set a private bind address and
|
|
firewall access. Apache **overwrites**, rather than trusts, forwarded scheme
|
|
and client-IP headers. Do not expose the membership service port. Do not
|
|
enable `MEMBERSHIP_TRUST_PROXY` outside this isolated topology. For actual IP
|
|
throttling behind another proxy, configure Apache's trusted `mod_remoteip`
|
|
proxies; otherwise the default conservatively throttles by the TLS edge IP.
|
|
10. Disable/redact access/error tracing for verification URLs and OIDC callback
|
|
codes at **every** edge, CDN, APM and log collector. Apache suppresses member
|
|
access logs; Gunicorn access logs are disabled. Do not enable request/body
|
|
debug logging. Sessions are server-side, rotate on login, and expire in one
|
|
hour. CSRF protects state-changing forms; only signed Stripe webhooks are
|
|
CSRF-exempt. Backups contain identity/billing metadata; restrict access,
|
|
encrypt them, and define a retention/deletion policy.
|
|
|
|
Configuration does not automatically provision remote LDAP/Dex/Stripe resources.
|
|
No production credentials or live account changes are made by the test suite.
|
|
|
|
## Operations and limitations
|
|
|
|
- Keep `membership-worker` running. Alert on its errors and on accounts whose
|
|
`last_ldap_sync` or `last_billing_sync` is stale. A one-shot
|
|
`sync_memberships` command exits nonzero on a failed sweep. Logs record account
|
|
IDs/error classes, not passwords/tokens or raw webhook payloads.
|
|
- The worker removes expired registration/session/rate-limit records and retains
|
|
processed webhook IDs for 30 days. A later replay remains harmless because
|
|
grants are always recalculated from canonical Stripe data.
|
|
- Do not delete Account/ManagedGroup rows before removing their LDAP grants.
|
|
Do not manually remove an LDAP account without first revoking paid groups.
|
|
Directory renames are reconciled using `entryUUID`; the old DN is removed too.
|
|
- Registration cannot atomically commit LDAP and PostgreSQL together. If LDAP
|
|
creation succeeds and the database write fails, the UI directs the user to
|
|
Dex; successful login safely recovers the directory account by its UUID.
|
|
Failed LDAP rollback may require administrator cleanup. Never reset an
|
|
existing account's password merely because someone verified its email.
|
|
- Full Stripe invoice history is paginated for correctness. For a large member
|
|
base, add a durable incremental invoice cache and shard workers before relying
|
|
on a tight revocation latency. No refunds/dispute policy is inferred: the
|
|
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.
|
|
|
|
## Verification
|
|
|
|
Offline tests use an isolated SQLite database, mocked Stripe/SMTP/LDAP network
|
|
calls, real signed Stripe webhook verification, and real RSA-signed Authlib
|
|
state/PKCE/nonce/issuer/audience/signature tests. PostgreSQL tests run the same
|
|
suite against a disposable container using
|
|
[membership/compose.test.yaml](membership/compose.test.yaml).
|
|
|
|
The suite covers verification expiry/replay, password validation, CSRF, directory
|
|
identity linking, allowed checkout prices, customer scoping, duplicate/reordered
|
|
webhooks, paid invoice periods, cancellation, failed renewals, unavailable Stripe,
|
|
LDAP retries, tier removal, multiple subscriptions, and the last member's removal
|
|
from a `groupOfUniqueNames` using its sentinel. Live Stripe, Dex, SMTP and OpenLDAP
|
|
must still be smoke-tested together in a non-production environment before
|
|
enabling public registration/payment.
|
|
|
|
|