Files
www/README.md
T
2026-10-23 12:02:50 -04:00

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.