add backend for join form #308 #438
1 changed files with 79 additions and 8 deletions
|
|
@ -42,28 +42,49 @@
|
||||||
### 2.3 Data Flow
|
### 2.3 Data Flow
|
||||||
|
|
||||||
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields must be enforced on the server from the join-form settings (allowlist), not only in the UI, to prevent field injection or extra attributes from being stored.
|
- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields must be enforced on the server from the join-form settings (allowlist), not only in the UI, to prevent field injection or extra attributes from being stored.
|
||||||
- **Pre-confirmation:** On form submit, store pending data with a short-lived confirmation token (e.g. in a transient store or a “pending” record); send confirmation email; do **not** create the JoinRequest yet. **Implementation decision required:** The choice between a transient store (e.g. ETS with TTL) and a dedicated DB table (e.g. “pending join” records) must be made in the implementation spec. Either way: define TTL/expiry, cleanup of unconfirmed data, and minimal PII retention (DSGVO). Document the decision and retention rules.
|
- **Pre-confirmation:** On form submit, store pending data with a short-lived confirmation token (e.g. in a transient store or a “pending” record); send confirmation email; do **not** create the JoinRequest yet. **Store choice (critical):** See §2.3.1 for options, tradeoffs, and recommendation.
|
||||||
- **Post-confirmation:** When the user clicks the confirmation link, create the **JoinRequest** resource with:
|
- **Post-confirmation:** When the user clicks the confirmation link, create the **JoinRequest** resource with:
|
||||||
- Submitted payload (minimal, well-defined schema; only whitelisted fields).
|
- Submitted payload (minimal, well-defined schema; only whitelisted fields). See §2.3.2 for payload vs typed columns and schema versioning.
|
||||||
- Status: `submitted` (later: `approved`, `rejected`).
|
- Status: `submitted` (later: `approved`, `rejected`). See §2.3.2 for audit fields and idempotency.
|
||||||
- Server-set metadata: `submitted_at`, locale, source (e.g. `public_join`), optional abuse signals (e.g. IP hash, spam score).
|
- Server-set metadata: `submitted_at`, locale, source (e.g. `public_join`), optional abuse signals. See §2.3.2 for abuse-metadata classification.
|
||||||
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
|
- **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval).
|
||||||
|
|
||||||
|
#### 2.3.1 Pre-Confirmation Store: ETS vs DB vs Stateless Token
|
||||||
|
|
||||||
|
This decision has lasting impact (multi-node, retention, UX after deploy/restart). Choose explicitly.
|
||||||
|
|
||||||
|
| Option | Pros | Cons |
|
||||||
|
|--------|------|------|
|
||||||
|
| **A – DB table** (e.g. `pending_join_requests`) | Robust across deployments/restarts and multi-node; defined TTL cleanup (e.g. Oban cron); audit-friendly. | Persists PII before confirmation → stricter DSGVO/retention. |
|
||||||
|
| **B – ETS with TTL** | No DB migration; PII gone on restart (can be a plus). | Multi-node: confirm link can hit another node → token unknown. Deploy/restart invalidates pending confirmations → support load. Acceptable for single-node MVP only. |
|
||||||
|
| **C – Stateless token** (encrypted + signed payload, e.g. `Plug.Crypto.MessageEncryptor` / Phoenix.Token) | No PII persisted before confirmation (strong for DSGVO); multi-node ok (shared secret); no cleanup job. | Token size (custom fields!) and key rotation must be considered; payload must be re-validated on confirm (do this anyway). |
|
||||||
|
|
||||||
|
**Recommendation:** For strict "minimal PII retention", **Option C** is often the best tradeoff: persist only after confirmation; pending data is carried securely in the token. For very large forms, consider DB (Option A) later. If multi-node and auditability are top priorities from day one, choose Option A and define retention/cleanup clearly.
|
||||||
|
|
||||||
|
#### 2.3.2 JoinRequest: Data Model and Schema
|
||||||
|
|
||||||
|
- **Payload vs typed columns:** If storing payload as `map` (e.g. jsonb), also store **email** as a dedicated field (index, search, dedup, audit). Add a **schema_version** (e.g. tied to `member_fields()` evolution) so future changes do not break existing records. **Logger hygiene:** Do not log the full payload; follow CODE_GUIDELINES on log sanitization. Alternative: important fields typed (email, first_name, last_name) and only e.g. custom_field_values as jsonb.
|
||||||
|
- **Status and audit:** Besides `submitted`, plan for `approved` / `rejected` with **approved_at**, **rejected_at**, **reviewed_by_user_id** for audit.
|
||||||
|
- **Idempotency:** The confirm link must not create two JoinRequests. Enforce via e.g. **unique_index on confirmation_token_hash** or a dedicated token entity; on duplicate confirm, return success (idempotent) and do not create a second record.
|
||||||
|
- **Abuse metadata:** Define whether stored data (e.g. IP hash) is **security telemetry** or **personally identifiable** (DSGVO). Prefer storing only hashed/aggregated values (e.g. /24 prefix hash or keyed-hash), not raw IP; document the classification and retention.
|
||||||
|
|
||||||
### 2.4 Security
|
### 2.4 Security
|
||||||
|
|
||||||
- **Public path:** `/join` (and the confirmation route, e.g. `/join/confirm/:token`) must be treated as public. Add them to the public-path list used by the page-permission plug so unauthenticated access returns 200, not redirect to sign-in.
|
- **Public path:** `/join` and the confirmation route must be public (unauthenticated access returns 200). **Route choice:** Either (1) add `/join` and `/join/confirm*` to the page-permission plug’s `public_path?/1`, or (2) put the confirm route under **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it. **Recommendation:** Variant 2 keeps the plug unchanged and aligns with existing confirmation behaviour; fewer special cases.
|
||||||
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Phoenix/Elixir has standard-friendly options: e.g. **Hammer** with **Hammer.Plug** (ETS backend, no Redis required), easily scoped to the join flow (e.g. by IP). Use both from the start for public intake. Before or during implementation, verify the chosen rate-limit library (e.g. Hammer) for current version, Phoenix/Plug compatibility, and suitability (e.g. ETS vs. Redis in multi-node setups).
|
- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Phoenix/Elixir has standard-friendly options: e.g. **Hammer** with **Hammer.Plug** (ETS backend, no Redis required), easily scoped to the join flow (e.g. by IP). Use both from the start for public intake. Before or during implementation, verify the chosen rate-limit library (e.g. Hammer) for current version, Phoenix/Plug compatibility, and suitability (e.g. ETS vs. Redis in multi-node setups).
|
||||||
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO and leak risk when extending fields. If abuse signals (e.g. IP hash, spam score) are stored: store only hashed or aggregated values (e.g. IP hash, not plain IP); ensure DSGVO/compliance when persisting any identifying or behavioural data.
|
- **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO and leak risk when extending fields. If abuse signals (e.g. IP hash, spam score) are stored: store only hashed or aggregated values (e.g. IP hash, not plain IP); ensure DSGVO/compliance when persisting any identifying or behavioural data.
|
||||||
- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity.
|
- **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity.
|
||||||
|
- **Ash policies and actor:** JoinRequest must have **explicit public actions** that are allowed with `actor: nil` (e.g. dedicated `public_submit` / `confirm` actions). Model this via **policies** that permit these actions when actor is nil; do **not** pass `authorize?: false` unless the reason is documented and it is clear that this is not a privilege-escalation path.
|
||||||
- **No system-actor fallback:** The join and confirmation flow run without an authenticated user. When implementing backend actions (e.g. sending the confirmation email, creating the JoinRequest), do **not** use the system actor as a fallback for “missing actor”. Use an explicit unauthenticated/system context instead; never escalate privileges. See CODE_GUIDELINES §5.0.
|
- **No system-actor fallback:** The join and confirmation flow run without an authenticated user. When implementing backend actions (e.g. sending the confirmation email, creating the JoinRequest), do **not** use the system actor as a fallback for “missing actor”. Use an explicit unauthenticated/system context instead; never escalate privileges. See CODE_GUIDELINES §5.0.
|
||||||
|
|
||||||
### 2.5 Usability and UX
|
### 2.5 Usability and UX
|
||||||
|
|
||||||
|
- **No JoinRequest until confirmed:** Communicate clearly: e.g. "We have sent you an email … Your request will only be submitted after you click the link." (Exact copy in implementation spec.)
|
||||||
- Clear heading and short copy (e.g. “Become a member / Submit request” and “What happens next”).
|
- Clear heading and short copy (e.g. “Become a member / Submit request” and “What happens next”).
|
||||||
- Form only as simple as needed (conversion vs. data hunger).
|
- Form only as simple as needed (conversion vs. data hunger).
|
||||||
- Success message: neutral, no promise of an account (“We will get in touch”).
|
- Success message: neutral, no promise of an account (“We will get in touch”).
|
||||||
- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. “This link has expired”) and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec.
|
- **Expired confirmation link:** If the user clicks after the token has expired, show a clear message (e.g. “This link has expired”) and instruct them to submit the form again. Specify exact copy and behaviour in the implementation spec.
|
||||||
- **Re-send confirmation link:** Out of scope for Prio 1; document as a possible future improvement (e.g. “Request new confirmation email” on the “Please confirm your email” or expired-link page).
|
- **Re-send confirmation link:** Out of scope for Prio 1. A minimal "Resend" reduces support load; if not implemented in Prio 1, **create a separate ticket immediately** for this improvement. See also §6 (Resend confirmation). Example UX: “Request new confirmation email” on the “Please confirm your email” or expired-link page.
|
||||||
- Accessibility and i18n: same standards as rest of the app (e.g. labels, errors, Gettext).
|
- Accessibility and i18n: same standards as rest of the app (e.g. labels, errors, Gettext).
|
||||||
|
|
||||||
### 2.6 Admin Configurability: Join Form Settings
|
### 2.6 Admin Configurability: Join Form Settings
|
||||||
|
|
@ -101,7 +122,7 @@
|
||||||
- **Naming:** Resource name **JoinRequest** (one resource, status + optional approval/rejection timestamps).
|
- **Naming:** Resource name **JoinRequest** (one resource, status + optional approval/rejection timestamps).
|
||||||
- **No User/Member from `/join`:** Only a JoinRequest; record is created **after** email confirmation. Keeps abuse surface and policy complexity low.
|
- **No User/Member from `/join`:** Only a JoinRequest; record is created **after** email confirmation. Keeps abuse surface and policy complexity low.
|
||||||
- **Dedicated resource and action:** New resource `JoinRequest` and public actions (e.g. one for “submit form” → store pending + send email; one for “confirm token” → create JoinRequest). Member/User domain unchanged.
|
- **Dedicated resource and action:** New resource `JoinRequest` and public actions (e.g. one for “submit form” → store pending + send email; one for “confirm token” → create JoinRequest). Member/User domain unchanged.
|
||||||
- **Public path:** `/join` and the confirmation route (e.g. `/join/confirm/:token`) in the public-path whitelist (page-permission plug).
|
- **Public path:** `/join` and confirmation route public; prefer `/confirm_join/:token` so existing whitelist covers it (see §2.4).
|
||||||
- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional “required” per field.
|
- **Minimal data:** Email is technically required; other fields from admin-configured join-form field set, with optional “required” per field.
|
||||||
- **Security:** Honeypot + rate limiting (e.g. Hammer.Plug) in MVP; email confirmation before creating JoinRequest.
|
- **Security:** Honeypot + rate limiting (e.g. Hammer.Plug) in MVP; email confirmation before creating JoinRequest.
|
||||||
- **Tests:** Unauthenticated GET `/join` → 200; confirm flow creates one JoinRequest; honeypot and rate limiting covered; public paths in plug test matrix.
|
- **Tests:** Unauthenticated GET `/join` → 200; confirm flow creates one JoinRequest; honeypot and rate limiting covered; public paths in plug test matrix.
|
||||||
|
|
@ -124,6 +145,10 @@
|
||||||
- **Rate limiting:** Plan for **honeypot + rate limiting** from the start. Phoenix/Elixir ecosystem offers ready options (e.g. **Hammer** with **Hammer.Plug**, ETS backend) that are easy to integrate.
|
- **Rate limiting:** Plan for **honeypot + rate limiting** from the start. Phoenix/Elixir ecosystem offers ready options (e.g. **Hammer** with **Hammer.Plug**, ETS backend) that are easy to integrate.
|
||||||
- **Settings:** Own section “Onboarding / Join” in global settings (above custom fields, below club data). `join_form_enabled` plus field selection from all member fields and custom fields; display as list/badges with X to remove (UX reference: groups overview, add-members-to-group dialog). **Email** is the only technically required field; other required flags per field are configurable. Detailed UX for this subsection is to be specified in a **separate subtask**.
|
- **Settings:** Own section “Onboarding / Join” in global settings (above custom fields, below club data). `join_form_enabled` plus field selection from all member fields and custom fields; display as list/badges with X to remove (UX reference: groups overview, add-members-to-group dialog). **Email** is the only technically required field; other required flags per field are configurable. Detailed UX for this subsection is to be specified in a **separate subtask**.
|
||||||
- **Approval permission:** The approval UI and actions (list join requests, approve, reject) are gated by the existing permission set **normal_user**. JoinRequest read/update (or approve/reject actions) and the approval page are added to normal_user in PermissionSets; no new permission set or role required. The role that carries normal_user (e.g. Kassenwart) is the one that can perform approvals; the organisation assigns that role to the appropriate person(s).
|
- **Approval permission:** The approval UI and actions (list join requests, approve, reject) are gated by the existing permission set **normal_user**. JoinRequest read/update (or approve/reject actions) and the approval page are added to normal_user in PermissionSets; no new permission set or role required. The role that carries normal_user (e.g. Kassenwart) is the one that can perform approvals; the organisation assigns that role to the appropriate person(s).
|
||||||
|
- **Pre-confirmation store:** Choose explicitly among DB table (A), ETS (B), or stateless token (C); see §2.3.1. Recommendation: Option C for minimal PII retention; Option A if multi-node/audit from day one.
|
||||||
|
- **Confirmation route:** Prefer **`/confirm_join/:token`** so existing public-path logic (e.g. `starts_with?(path, "/confirm")`) covers it without plug changes.
|
||||||
|
- **JoinRequest schema:** Email as dedicated field when using payload map; add schema_version for evolution; approved_at, rejected_at, reviewed_by_user_id for audit; idempotent confirm (unique constraint on token); abuse metadata classified (telemetry vs PII) and stored hashed (e.g. /24 or keyed-hash). See §2.3.2.
|
||||||
|
- **Resend confirmation:** If not in Prio 1, create a separate ticket immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -137,7 +162,53 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. References
|
## 8. Implementation Plan (Subtasks)
|
||||||
|
|
||||||
|
The feature is split into a small number of well-bounded subtasks. **Resend confirmation** remains a separate ticket (see §2.5, §6).
|
||||||
|
|
||||||
|
### Prio 1 – Public Join (4 subtasks)
|
||||||
|
|
||||||
|
#### 1. JoinRequest resource and public policies
|
||||||
|
|
||||||
|
- **Scope:** Ash resource `JoinRequest` per §2.3.2 (email, payload/schema_version, status, submitted_at, approved_at, rejected_at, reviewed_by_user_id, source, optional abuse metadata); migration; idempotency key (e.g. unique_index on confirmation_token_hash).
|
||||||
|
- **Policies:** Explicit public actions (e.g. `confirm`) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`.
|
||||||
|
- **Boundary:** No UI, no emails, no pre-confirmation logic – only resource, persistence, and “creatable with nil actor”.
|
||||||
|
- **Done:** Resource and migration in place; tests for create/read with `actor: nil` and for idempotency (same token twice → no second record).
|
||||||
|
|
||||||
|
#### 2. Pre-confirmation store and confirm flow
|
||||||
|
|
||||||
|
- **Scope:** Decide store (A/B/C per §2.3.1). Implement: form submit → create token (stateless) or store in ETS/DB → send confirmation email; route **`/confirm_join/:token`** → verify token → create exactly one JoinRequest (idempotent). Email sender (reuse pattern from AshAuthentication).
|
||||||
|
- **Boundary:** No join-form UI, no admin settings – only “pending data + token” and “click token → create JoinRequest”. Depends on JoinRequest resource (subtask 1).
|
||||||
|
- **Done:** After submit, no JoinRequest; after link click, exactly one; double click idempotent; expired token shows clear message. Tests for these cases.
|
||||||
|
|
||||||
|
#### 3. Admin: Join form settings
|
||||||
|
|
||||||
|
- **Scope:** Section “Onboarding / Join” in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), “required” per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details can be a small sub-subtask).
|
||||||
|
- **Boundary:** No public form, no confirm logic – only save/load of config and **server-side allowlist** for use in subtask 4.
|
||||||
|
- **Done:** Settings save/load; allowlist available in backend for join form; tests.
|
||||||
|
|
||||||
|
#### 4. Public join page and anti-abuse
|
||||||
|
|
||||||
|
- **Scope:** Route **`/join`** (public), LiveView (or controller + form). Form only for fields from allowlist (subtask 3); copy per §2.5 (including “no JoinRequest until confirmed”). **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show “We have sent you an email …”. Expired-link page: clear message + “submit form again”. Public paths in page-permission plug (confirm that `/confirm_join` is already covered by existing rule if using recommended route).
|
||||||
|
- **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to confirm flow (subtask 2).
|
||||||
|
- **Done:** Unauthenticated GET `/join` → 200; submit → no JoinRequest created, email triggered; link click uses subtask 2; honeypot and rate limit tested; public-path tests updated.
|
||||||
|
|
||||||
|
### Order and dependencies
|
||||||
|
|
||||||
|
- **1 → 2:** Confirm flow creates JoinRequests (resource must exist).
|
||||||
|
- **3 before or in parallel with 4:** Form reads allowlist from settings; for MVP, subtask 4 can use a default allowlist and 3 can follow shortly after.
|
||||||
|
- **Recommended order:** **1** → **2** → **3** → **4** (or 3 in parallel with 2 if two people work on it).
|
||||||
|
|
||||||
|
### Step 2 – Approval (1 subtask, later)
|
||||||
|
|
||||||
|
#### 5. Approval UI (Vorstand)
|
||||||
|
|
||||||
|
- **Scope:** List JoinRequests (status “submitted”), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add page to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id).
|
||||||
|
- **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. References
|
||||||
|
|
||||||
- `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions.
|
- `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions.
|
||||||
- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests.
|
- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue