570 lines
36 KiB
Markdown
570 lines
36 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/**.
|
||
|
||
Optional SHKeeper support adds prepaid crypto membership alongside Stripe.
|
||
Both payment methods feed the same LDAP group reconciliation and local expiry.
|
||
|
||
## Membership flow
|
||
|
||
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 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 username. Local account ownership is pinned to the verified Dex issuer
|
||
and subject, never email alone. Membership makes **no LDAP connection during
|
||
login**, including first login by an existing Dex user. The worker separately
|
||
resolves and pins LDAP `entryUUID` before synchronizing paid groups.
|
||
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.
|
||
|
||
## Service access by membership level
|
||
|
||
Additional LDAP service benefits are configured separately from Stripe prices in
|
||
[membership/service-groups.eom.json](membership/service-groups.eom.json). Set
|
||
`MEMBERSHIP_SERVICE_GROUPS_FILE` to that file, or supply an equivalent object in
|
||
`MEMBERSHIP_SERVICE_GROUPS_JSON` (which takes precedence). The default is empty;
|
||
the shipped file contains empty lists for levels 0–5, granting nothing until
|
||
you supply real, reviewed group DNs. Existing paid tiers and their `group_dn`
|
||
continue working unchanged.
|
||
|
||
The shape is a level-to-list mapping, for example:
|
||
|
||
```json
|
||
{
|
||
"0": ["cn=community-chat,ou=membership-services,dc=example,dc=org"],
|
||
"1": ["cn=community-chat,ou=membership-services,dc=example,dc=org"],
|
||
"2": [
|
||
"cn=community-chat,ou=membership-services,dc=example,dc=org",
|
||
"cn=game-servers,ou=membership-services,dc=example,dc=org"
|
||
]
|
||
}
|
||
```
|
||
|
||
Keys are canonical level strings: `"0"` or an existing paid tier's positive
|
||
level. Values are lists of full LDAP group DNs. Omitted levels/empty lists grant
|
||
no additional service access. **The highest currently paid level selects exactly
|
||
one list**. There is no implicit inheritance: in this example level 2 gets chat
|
||
and games, while levels 3–5 get neither unless explicitly listed. Level 0 means
|
||
free accounts only, not a baseline automatically inherited by paid accounts.
|
||
To offer a service to everyone, repeat its DN under every configured level.
|
||
An active Stripe and SHKeeper combination still selects the highest paid level;
|
||
it does not combine lower service lists. Normal per-tier group grants remain
|
||
additive for each valid paid entitlement, as before.
|
||
|
||
The worker computes the level from paid subscription/invoice expiry on **every
|
||
pass**, never from cached Dex groups or the account's stored level. Expiry falls
|
||
back to level 0 and revokes paid-only service access without contacting payment
|
||
providers. Editing/removing a DN, removing a tier or changing levels reconciles
|
||
on the next sweep. Historical `ManagedGroup` records are retained so old grants
|
||
are revoked even after configuration removal; management intent commits before
|
||
LDAP changes so failed writes can be retried safely. Login/registration records
|
||
intent but does not grant groups directly. Level 0 grants cover local membership
|
||
accounts (including first-time Dex sign-ins), not every LDAP account that has
|
||
never used this application.
|
||
|
||
**Provision and secure the groups before enabling a mapping:**
|
||
|
||
- Targets must already be `groupOfUniqueNames` groups with the configured
|
||
sentinel entry. Allow the worker narrowly scoped read/write of `uniqueMember`
|
||
on these groups only. No LDAP schema, group or ACL is created automatically.
|
||
- Mapping a DN hands this application control of that group's membership for
|
||
**every local member**, including revocations of manual grants to noneligible
|
||
members. Use dedicated groups for membership-controlled service access. Do not
|
||
map administrator, staff, existing independently managed entitlement groups,
|
||
the sentinel DN or groups where such removals would be unsafe. Configure
|
||
services/Dex to authorize these dedicated groups and review any service-specific
|
||
user provisioning requirements separately. Only administrators edit mappings;
|
||
browser forms and Dex claims cannot choose entitlement groups.
|
||
- Keep removed groups and ACL access available until the worker has successfully
|
||
removed old grants. Missing groups/LDAP outages fail synchronization and delay
|
||
grants/revocations; they must not be hidden by deleting historical tracking.
|
||
- Group names/tokens already cached by Dex and relying services must refresh.
|
||
Revocation is not an instantaneous logout across services.
|
||
|
||
For Kubernetes, use the role's `www_membership_service_groups` variable, not an
|
||
image-baked file: the role mounts its own mapping next to its tiers configuration.
|
||
Change the variable and rerun the role; its revision includes this mapping and
|
||
rolls web/worker so they reload it. Directly changing a ConfigMap does not reload
|
||
Django settings. Build/deploy the updated image and role once before using the
|
||
new setting. No database migration is needed for service mappings. Changes to
|
||
the benefit display text remain separate from actual authorization rules.
|
||
|
||
## 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.
|
||
|
||
| Membership | Monthly USD | Existing live Price ID | Proposed dedicated LDAP group |
|
||
| --- | ---: | --- | --- |
|
||
| Level 0 · Open Lab | Free | — | — |
|
||
| Level 1 · Bronze | $4 | `price_1Rgv4xC5KPXqTvVLfMd6z7wn` | `www-level-1` |
|
||
| Level 2 · Silver | $8 | `price_1Rgv5QC5KPXqTvVLv1PaHWb5` | `www-level-2` |
|
||
| Level 3 · Gold | $16 | `price_1Rgv5rC5KPXqTvVL8Ol8sMUf` | `www-level-3` |
|
||
| Level 4 · Platinum | $32 | `price_1Rgv6FC5KPXqTvVL39mAxYjm` | `www-level-4` |
|
||
| Level 5 · Diamond | $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
|
||
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
|
||
when TLS is used. To deliberately use plaintext on a trusted internal network,
|
||
set `LDAP_URL=ldap://openldap.openldap.svc.cluster.local:389` and
|
||
`LDAP_ALLOW_INSECURE=1` in both web and worker environments. This skips StartTLS
|
||
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 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=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 from verified claims without querying LDAP during login. 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
|
||
|
||
### Dex-only login and upgrade
|
||
|
||
Deploy the new membership image and apply migration `0004_dex_login_identity`
|
||
before serving it. **Keep `DEX_ISSUER` unchanged during the upgrade**: the
|
||
migration pins existing non-null subjects to the currently configured issuer,
|
||
because older records did not store their issuer. Back up the database and
|
||
coordinate replacing web and worker together; older code cannot handle accounts
|
||
whose directory UUID has not yet been resolved. Do not roll back to the old
|
||
schema while unresolved accounts exist.
|
||
|
||
- Returning users sign in using their existing issuer/subject binding, without
|
||
LDAP or payment-provider calls. Signed verified email updates their contact
|
||
address; it never identifies an account or attaches billing history.
|
||
- First-time Dex users with no local username collision receive a free local
|
||
account. The worker discovers their LDAP DN/UUID on its next successful group
|
||
sync and thereafter uses only that UUID. Missing/ambiguous directory entries
|
||
stop synchronization; a missing pinned UUID never falls back to username.
|
||
- Website registration still provisions LDAP. Its browser session retains a
|
||
one-hour, server-side proof permitting the first Dex login to bind only the
|
||
just-created local account, with the same signed connector/username. Successful
|
||
login consumes that proof. Without it, existing unbound accounts require an
|
||
administrator mapping; matching email or username alone is insufficient.
|
||
- For an existing **unbound** account, the management command `bind_dex_identity`
|
||
requires `--account` (local account UUID), `--uid` and `--subject` (the exact
|
||
Dex `sub` for this issuer/client/connector). Independently verify the identity
|
||
with trusted Dex/directory administration before running it. Do not accept
|
||
subjects asserted by users or decode an unverified token as proof. It makes
|
||
no LDAP calls, preserves directory/billing data and refuses to replace an
|
||
existing binding or reuse another account's subject. Lost registration proof
|
||
can be recovered through this reviewed mapping, not by repeating registration.
|
||
- Issuer, connector, username and subject changes require an explicit identity
|
||
migration. The old automatic rebinding of retired `www-ldap` subjects is
|
||
intentionally removed; review those bindings separately before deployment.
|
||
|
||
The existing Dex LDAP connector uses `idAttr: uid`. **Do not recycle usernames**:
|
||
Dex itself cannot distinguish a recreated LDAP entry with the same connector/uid
|
||
from the previous identity. Directory UUID pinning still protects group writes,
|
||
but membership no longer checks directory recreation during login. Preserve the
|
||
connector mapping for other services; migrating Dex to immutable user IDs is a
|
||
separate coordinated change, not an automatic part of this release.
|
||
|
||
### Self-service account profile
|
||
|
||
Signed-in members can choose **Edit profile** from their membership page.
|
||
Deploy the new membership image, apply migration `0005_profile_email_change`,
|
||
and deploy the updated static CSS/proxy configuration. No new public settings
|
||
or credentials are required. Login remains Dex-only: the profile editor reads
|
||
LDAP **only when opened**, because the configured Dex LDAP connector exposes
|
||
username/email/name/groups, not arbitrary LDAP attributes. Cached OIDC claims
|
||
are neither a complete nor a current editable profile. Do not map the gamer tag
|
||
to `preferred_username` or change `idAttr` to expose it.
|
||
|
||
| Profile label | LDAP attribute |
|
||
| --- | --- |
|
||
| About you | `description` |
|
||
| Gamer tag (one value) | `displayName` |
|
||
| Email address (verified separately) | `mail` |
|
||
| First name / Last name | `givenName` / `sn` |
|
||
| Profile photo | `jpegPhoto` |
|
||
| City/town / State/province | `l` / `st` |
|
||
| Websites | `labeledURI` |
|
||
| Mobile numbers / Phone numbers | `mobile` / `telephoneNumber` |
|
||
| Organizations / Job titles | `o` / `title` |
|
||
| Postal addresses / Postal codes | `postalAddress` / `postalCode` |
|
||
| Street addresses / Room numbers | `street` / `roomNumber` |
|
||
| SSH public keys | `sshPublicKey` |
|
||
| Password | RFC 3062 Password Modify (never read back) |
|
||
|
||
The form preserves existing multi-value attributes. Enter one value per line;
|
||
separate multiline postal addresses with a blank line. Blank optional fields
|
||
are removed on save; `sn` remains required by the directory schema. JPEGs are
|
||
limited to 1 MiB and 4096 pixels per side/16 million pixels, resized to fit 512
|
||
pixels and reencoded without metadata. Existing photos are sanitized again
|
||
before private, authenticated delivery. No arbitrary URLs are fetched. Supported
|
||
SSH keys are Ed25519, RSA >=2048 bits and ECDSA NIST P-256/P-384/P-521; private
|
||
keys, authorized_keys options and malformed values are rejected.
|
||
|
||
**Directory prerequisites:**
|
||
|
||
- The service account needs read/search access to the profile attributes above,
|
||
plus `uid`, `entryUUID`, `objectClass` and `modifyTimestamp`, across the
|
||
intended account search tree. It never needs read access to `userPassword`.
|
||
Accounts are pinned to immutable UUIDs; missing/deleted entries do not fall
|
||
back to a reused username. LDAP unavailability disables editing rather than
|
||
presenting an empty form that could erase values.
|
||
- Every write binds with the member's **current LDAP password** and checks that
|
||
entry's UUID again. Configure directory **self-write ACLs** only for these
|
||
profile attributes and self Password Modify; do not grant writes to `uid`,
|
||
`entryUUID`, account DNs, POSIX IDs, group membership or administrative fields.
|
||
This supports existing Dex users outside the website registration OU without
|
||
granting the membership service unrestricted directory write permissions.
|
||
- Install the `ldapPublicKey` auxiliary schema before SSH editing. When a user
|
||
supplies keys and the class is absent, the application adds **only** the fixed
|
||
`ldapPublicKey` class in the same modify. Either pre-add that class to editable
|
||
accounts or permit precisely that self-add with value-scoped `objectClass`
|
||
ACLs. Never grant arbitrary self-write to `objectClass`. No schema/ACL changes
|
||
are automatically applied by the website or OpenLDAP role.
|
||
- OpenLDAP must support critical RFC 4528 assertion controls. Profile/email
|
||
writes assert UUID and last-modified time, and profile forms carry a signed,
|
||
one-hour revision to reject stale edits. A server rejecting the critical
|
||
control must fail closed; do not silently retry without it.
|
||
- Maintain directory-side unique `mail` and `uid` enforcement across all
|
||
registrars. A client-side email preflight cannot prevent concurrent claims
|
||
and may be restricted by self-read ACLs; the server uniqueness overlay is
|
||
required. Review which services use `mail` as an account/recovery identifier.
|
||
|
||
Email is changed separately: the user enters their current password, receives
|
||
a one-hour verification link at the new mailbox, then signs into the same local
|
||
account and enters their password again. GET/scanner visits never modify LDAP.
|
||
Tokens are stored only as hashes, bound to account/LDAP UUID, and consumed once.
|
||
Confirming replaces **all existing mail values** with the verified address and
|
||
updates the local contact address; it does not attach another user's billing
|
||
history. Old Dex/downstream tokens may retain the prior email until refreshed.
|
||
|
||
Password changes send current/new passwords through Password Modify as the
|
||
authenticated user, never a service-account reset or direct `userPassword`
|
||
write. **Hashing remains OpenLDAP's policy**: no MD5-Crypt hash is generated by
|
||
the application, no stored hash is exposed, and the application does not force
|
||
or silently change the server's scheme. A legacy MD5-Crypt directory can still
|
||
verify current passwords; do not introduce MD5-Crypt as a new application-side
|
||
hashing scheme. Audit/migrate weak server hashing separately. The existing
|
||
Argon2 gate for new registration remains unchanged. Password changes sign out
|
||
the current membership browser; other membership/Dex/service sessions are not
|
||
automatically revoked—use each service's session controls when necessary.
|
||
|
||
All writes are CSRF-protected and rate-limited per account/IP. Passwords and
|
||
profile form bodies must never be logged by ingress/APM/LDAP debug logging.
|
||
Profile data can be shared by directory-consuming services: the page is private,
|
||
but it does not make phone/address/SSH fields private in the directory. Enforce
|
||
directory read ACLs and tell users what other services can access. TLS defaults
|
||
are unchanged; the existing explicit plaintext opt-in also exposes profile
|
||
passwords/data and should not be used on untrusted networks.
|
||
|
||
The supplied Kubernetes ingress accepts up to 2 MiB; Apache allows that limit
|
||
only under `/members/profile/` and retains 256 KiB for other member routes.
|
||
Mirror the limits at any additional edge proxy. Oversized or invalid uploads
|
||
must be rejected before reaching image processing.
|
||
|
||
### Database ProgrammingError during callback
|
||
|
||
`Dex callback rejected kind=ProgrammingError` in older images is a database
|
||
error, not a password rejection. Updated images log a redacted `sqlstate`:
|
||
`42703` means an undefined column, `42P01` an undefined table, and `42501`
|
||
insufficient privileges. Raw SQL/exception messages are deliberately omitted
|
||
because they may contain identity data.
|
||
|
||
For `42703` after the Dex-only release, check that `members.0004_dex_login_identity`
|
||
is applied to the **same database/schema used by web and worker**. Using the
|
||
release image and its production environment, inspect `showmigrations members`
|
||
and `migrate --plan`, then run `migrate --noinput` through the deployment's
|
||
migration service/Job. These are Django management commands. Do not use
|
||
`makemigrations` or `--fake` as a production repair, or delete database volumes.
|
||
Keep the original `DEX_ISSUER` while applying this migration.
|
||
|
||
On Kubernetes, publish a new immutable membership image tag/digest and rerun the
|
||
role so it completes a new migration Job before deploying web/worker. Reusing an
|
||
image tag can leave both an old cached migration image (`IfNotPresent`) and an
|
||
already-completed Job: a rollout restart alone does not rerun migrations. Use
|
||
`www_rollout_revision` to request a fresh Job when appropriate, but prefer a new
|
||
immutable image reference. If the migration is marked applied yet columns are
|
||
missing, investigate the database/schema and migration history before any manual
|
||
DDL. For other SQLSTATEs, investigate the indicated permissions/schema problem
|
||
rather than assuming an unapplied migration.
|
||
|
||
### LDAP service credentials still required for directory writes
|
||
|
||
LDAP is still needed for registration and worker group synchronization. Configure
|
||
the least-privilege `LDAP_BIND_DN` and its **plaintext bind password** in
|
||
`LDAP_BIND_PASSWORD`, not a stored `{CRYPT}$1$...` hash or already-base64-encoded
|
||
value. Kubernetes Secret `data` encodes original values once. Check service
|
||
password rotation/account locks and redeploy web and worker after Secret updates;
|
||
existing pods do not refresh environment credentials automatically. Never use
|
||
root/admin credentials or disable TLS verification to bypass failures.
|
||
|
||
Older images may log `LDAP service bind rejected during Dex callback` or
|
||
`Dex callback rejected kind=LDAPInvalidCredentialsResult`. These login-time
|
||
service binds are removed by this release. OpenLDAP remains responsible for
|
||
verifying users' existing password hashes through Dex. `LDAP_PASSWORD_SCHEME`
|
||
only gates new registration; its Argon2 requirement is unchanged. Plan migration
|
||
away from weak legacy password hashes separately.
|
||
|
||
### SHKeeper prepaid crypto
|
||
|
||
Stripe remains the automatically recurring payment method. SHKeeper accepts
|
||
**one-time payments for one calendar month** of the same tiers, at the USD
|
||
amounts in [membership/tiers.eom.json](membership/tiers.eom.json). It does not
|
||
debit a wallet or automatically renew. The user must create and pay a new
|
||
invoice for each month. Amounts are server-side configuration, never supplied by
|
||
the browser and never parsed from the display-price label. Keep `usd_amount`
|
||
aligned with your Stripe prices when changing prices; existing orders retain
|
||
the original amount.
|
||
|
||
To enable:
|
||
|
||
1. Set `SHKEEPER_URL` to your actual HTTPS origin, `SHKEEPER_API_KEY` to its
|
||
invoice API key, and `SHKEEPER_CURRENCIES` to a comma-separated allowlist of
|
||
the API currency identifiers enabled on **your** instance (e.g. BTC or XMR).
|
||
These identifiers are network-specific for tokens (e.g. ETH-USDT is not
|
||
interchangeable with TRX-USDT). No instance URL, wallets or keys are assumed.
|
||
2. For an internal CA, mount the trusted CA file read-only into both web and
|
||
worker containers and set `SHKEEPER_CA_CERT_FILE`. TLS verification is always
|
||
enabled. API requests have timeouts, disallow redirects, and cap response
|
||
size; the API key never reaches the browser. No payout/UI administrator
|
||
credentials or wallet private keys are needed. Restrict network/API access
|
||
because SHKeeper's API key may expose more than invoice endpoints.
|
||
3. Review SHKeeper fee, exchange-rate recalculation, payment-tolerance and
|
||
confirmation settings. This integration requires a full invoice amount and
|
||
confirmed transactions; it intentionally does not grant access for partial,
|
||
underpaid or unconfirmed transfers, even if a provider tolerance says paid.
|
||
The payment page identifies its initial quote as such; a partial/late
|
||
payment may require administrator assistance with the updated remainder.
|
||
4. Each new invoice supplies the callback URL
|
||
`https://eom.dev/members/shkeeper/callback/`. Permit SHKeeper to reach it.
|
||
**SHKeeper's documented callbacks have no signature.** This endpoint only
|
||
acknowledges them: it never trusts amounts/status/IDs, writes payment state,
|
||
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 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
|
||
public crypto payments. The adapter follows the documented SHKeeper 2.1 API:
|
||
`POST /api/v1/{crypto}/payment_request` and
|
||
`GET /api/v1/invoices/{external_id}`. Require the GET response to include
|
||
`external_id`, `fiat`, `amount_fiat`, `balance_fiat`, `status` and `txs`
|
||
(transaction status, asset identifier, address and txid). Unsupported or
|
||
ambiguous responses do not grant access. Different SHKeeper versions must
|
||
be verified against these fields, especially transaction crypto identifiers.
|
||
|
||
Payment lifecycle:
|
||
|
||
- An authenticated user selects the currency, then sees a private invoice
|
||
with its deposit address and quote. One unfinished invoice is allowed per
|
||
account. Repeated submissions reopen that same invoice, even if a second
|
||
tab requests another tier or currency. The UUID and USD amount are committed
|
||
**before** contacting SHKeeper; retrying after a timeout reuses SHKeeper's
|
||
documented create/update-by-external-ID operation with the same parameters.
|
||
- There is no fabricated invoice deadline: SHKeeper deposit addresses can
|
||
receive late transfers. Unpaid/partial orders remain polled and cannot be
|
||
abandoned/switched from the UI. Contact the administrator if the wrong
|
||
network was selected; do not delete orders with potentially in-flight funds.
|
||
- Access begins when the worker first verifies a fully paid invoice with
|
||
matching USD order, amount, receiving address and asset and confirmed txs.
|
||
A renewal of the **same tier** starts at the later of now or that tier's
|
||
previous prepaid end and adds one calendar month in UTC. Month-end dates
|
||
clamp to the last day of the next month. Queued future terms do not grant
|
||
early access. Delayed worker verification starts a new term at verification,
|
||
not at an untrusted callback timestamp.
|
||
- Each invoice can grant **exactly one term**. Overpayments, repeated callbacks
|
||
and additional transfers to a paid address never buy extra months. Create a
|
||
new invoice for renewal. Refund/overpayment handling is manual; this app does
|
||
not issue cryptocurrency payouts.
|
||
- Active/queued paid crypto invoices are rechecked until their end. If an
|
||
invoice reverts to unpaid/partial/unconfirmed (e.g. a chain reorganization),
|
||
its grant is removed. Recovery restores only its original term, not another
|
||
month. During API outages, the last verified term remains valid only until
|
||
its stored expiry; local expiry never waits for provider recovery. Alert on
|
||
stale `last_shkeeper_sync`, failed polls and worker errors.
|
||
- LDAP desired groups are the **union** of all currently valid Stripe and
|
||
SHKeeper terms. One provider's failure/cancellation cannot revoke a still
|
||
valid grant from another. Removed tiers are revoked regardless of provider.
|
||
- To avoid accidental double payments, a current Stripe subscription or open
|
||
Stripe Checkout blocks a new crypto invoice. Unfinished or unexpired crypto
|
||
orders block a new Stripe subscription; another crypto tier must expire
|
||
before switching tiers. Same-tier crypto renewals remain available. This
|
||
does not cancel existing Stripe recurring charges: finish/cancel them through
|
||
Stripe's billing portal before changing methods. Provider-side/manual changes
|
||
can still create overlapping payments; grants remain a safe union.
|
||
- Leaving SHKeeper configuration empty hides new crypto checkouts. Previously
|
||
verified prepaid grants still expire locally, but remote status cannot be
|
||
rechecked while the provider is disabled. Do not delete payment history or
|
||
change your SHKeeper server/key to an unrelated instance while orders exist.
|
||
`CryptoInvoice` protects its parent Account from accidental deletion.
|
||
|
||
### General operations
|
||
|
||
- 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.
|
||
- 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
|
||
|
||
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. SHKeeper regressions additionally cover
|
||
API-key/TLS transport, immutable prices and account-scoped invoices, callback
|
||
spoofing, partial/unconfirmed/underpaid transfers, duplicate confirmation,
|
||
calendar-month renewals, overpayments, reorg recovery, provider outages, and
|
||
cross-provider overlap. PostgreSQL also verifies that concurrent workers credit
|
||
one crypto invoice only once.
|
||
|
||
|