feat(forms): client request forms (MACD) by magic link #498
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-730-client-request-forms"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements PMS-730: a client fills in a request form from a magic link, and the submission becomes a ticket attributed to their company and carrying the KB article that describes how to perform the change. Builds on the PMS-731 substrate (#497) and the procedure link (#495).
Flow
/api/v1/form-request-linkswith a form definition, a company, and either a contact or an email address.forms.request_linkemail containing{app_url}/request-forms/{token}.GET /api/v1/public/request-forms/{token}returns the form to render.POSTthe same path validates the answers, stores the submission, creates the ticket, and burns the link, returning the ticket number.Decisions worth reviewing
The token is
{token_id}.{secret}, not the portal's{contact_id}.{secret}. This is the design question I flagged before starting.portal_setup_tokensstores a salted Argon2 hash, which cannot be looked up by value, so its redemption path fetches every token for the contact and verifies each in turn; that is affordable only becausecontact_idnarrows the candidate set first. (Migration 042 indexestoken_hashand the code cannot use that index, for exactly this reason.) A request link has no equivalent narrowing key, so copying the shape would Argon2-verify against the whole table on every submission. Keying the prefix on the row's own id makes resolution a primary-key lookup plus exactly one verify, with identical single-use and expiry semantics.Attribution comes from the token, never the payload. Company and contact are recorded when the link is issued, so a forwarded link cannot file a request against a different company no matter what the submitter types. The acting user on the created ticket is the MSP user who issued the link, because the submitter is a client with no
usersrow andtickets.created_by_idis NOT NULL.The token is burned last, and guarded. The submission and ticket are created first, then the link is marked used in the same transaction, so a failure anywhere rolls the whole thing back and the client retries with a live link. Burning first would spend a single-use link on a request that never produced a ticket. The final UPDATE carries
used_at IS NULL, so two submissions racing yield one ticket and the loser gets Gone rather than being quietly accepted.A rejected submission does NOT burn the link. A client who mistypes a date should not need a new link emailed to them. Pinned by a test.
The status contract is deliberately non-committal. Expired, malformed, unmatched, and a real token id with the wrong secret all return the same BadRequest with the same message; only an already-submitted link differs (Gone), which the holder of that link already knows. A response that distinguished the others would be an oracle for which token ids exist. Pinned by a test that compares the message bodies.
The client view is minimal. The public form response carries what is needed to render and validate the inputs and nothing else: no ids, no author, no timestamps, and no KB article, which is an internal procedure for whoever works the ticket rather than something the client is entitled to read.
Rate limiting is the backstop, not the control. Per-IP, in-memory, 30/min, deliberately looser than the portal's login bucket. The token is a 64 character secret behind an Argon2 verify, so the limiter exists to make grinding expensive rather than to be the thing that stops it, and a false 429 on a client-facing form is a support call.
A trap this had to fix
TenantService::copy_default_configcopies notification templates by an explicit event-type allowlist, so a template seeded by a migration reaches existing tenants but not ones created afterwards. Withoutforms.request_linkin that list, a tenant created from here on would persist tokens and silently send no email, since migration 097 removed the direct-send fallback and left the dispatcher as the only delivery path. That is its own commit.What is here
migrations/101_form_request_tokens.sql: the token table with its fail-closed RLS policy, plus theforms.request_linktemplate and rule seeded for every tenant (flat placeholders,E'...'body, avoiding both halves of the PMS-702 defect).src/modules/forms/request_links.rs: issue, resolve, redeem, list.src/modules/forms/public_routes.rs: the unauthenticated surface and its limiter./api/v1/publicnest outsideAuthMiddleware.tests/request_forms.rs: 5 tests covering every acceptance criterion.The tests recover the token the way the recipient does, out of the queued email body, because the API deliberately never returns it. That makes "an MSP user can send a client a request-form link by email" part of every test rather than something asserted once and then bypassed.
RLS and pool safety
form_request_tokensattachesENABLE+FORCE+ the NULLIF'd GUC comparison explicitly, since the 024 / 038 loops have already run. Resolution is pre-auth: the tenant is what the lookup resolves, so there is no GUC to set beforehand and that single query runs on the migrator pool with a SAFETY comment, exactly as portal setup-token redemption and thetenant_intake_tokensbearer lookup do.check-pool-safety.nupasses. Everything after resolution is tenant-scoped throughbegin_with_tenant.Verification
Full
just test-integrationgreen, plus fmt, clippy-D warnings, and all six guard scripts.Not here
Google and portal request paths (PMS-730 scopes v1 to change requests), and PMS-732's time aggregation, which consumes this.
Note on CI
The Playwright job fails on staging for a reason unrelated to this branch: the bunyip hub rejects the E2E account's TOTP at
/login/2fa, reproducing onmainand every open PR. Diagnosis is on #496.PMS-730: an MSP user sends a client a magic link to a form definition; the client fills it in without logging in and the submission becomes a ticket. The emailed token is `{token_id}.{secret}` with only the Argon2 hash of the secret stored, which deliberately does NOT copy the `portal_setup_tokens` shape. That token is `{contact_id}.{secret}`, and because a salted Argon2 hash cannot be looked up by value the redemption path fetches EVERY token for the contact and verifies each in turn; it is affordable only because `contact_id` narrows the set first. (Migration 042 indexes `token_hash` and the code cannot use that index, for exactly this reason.) A request link has no equivalent narrowing key, so the same shape would Argon2-verify against the whole table on every submission. Keying the prefix on the row's own id makes resolution a primary-key lookup plus exactly one verify, with the same single-use and expiry semantics. Company is recorded on the token at issue time, so the ticket is attributed from the link rather than from anything the submitter types and a forwarded link cannot file a request against a different company. The `forms.request_link` template and rule are seeded for EVERY tenant rather than the default one only, for the reason migration 097 established: the dispatcher is the only delivery path, so a tenant missing the row would get no mail at all. Placeholders are flat single-brace keys and the body is an E'...' literal, avoiding both halves of the PMS-702 defect that migration 096 had to repair. RLS is attached explicitly, and the migration carries a note for the serving code: resolving a presented token is pre-auth, so the tenant is what the lookup resolves and there is no GUC to set beforehand. That single query runs on the migrator pool with a SAFETY comment, as portal setup-token redemption and the tenant_intake_tokens bearer lookup already do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX