feat(orgs): external self-serve join (Phase 3) #7

Closed
franz wants to merge 4 commits from feat/company-access-modes-phase3 into feat/company-access-modes
Owner

What this is

Phase 3 of "company access modes": the external self-serve join path. A public landing page (/o/{slug}) for an external org sends a visitor into registration, they land on an explicit /join/confirm page, confirm, and become a member of THAT org instead of the hardcoded Default org. This closes the gap where /o/{slug} was cosmetic and every registrant landed in Default.

Stacked on the Phases 1-2 branch (PR #6) — this PR targets that branch, so the diff is Phase 3 only.

Mechanism (validated by live-stack keystone spikes)

Before implementing, three spikes were run against the running Kratos/Hydra playground. They confirmed the mechanism and, importantly, made it simpler than first drafted:

  • Kratos registration issues a session immediately (hook: session), so a fresh registrant lands authenticated on a return_to we control. allowed_return_urls is origin-prefix, so /join/confirm needs no Kratos config change.
  • The auto-join middleware holds the request, so it can branch on the path. A one-line carve-out (skip the Default force-join when the path is /join/confirm) is enough — no identity metadata needed.
  • Because the system already issues a session at registration and tolerates unverified sessions (verify-later), the join happens immediately on explicit confirm with no pre-verification gate, consistent with today's Default auto-join. This removes the originally-planned identity-metadata stamping and verification-webhook finalization entirely.

Included

  • add_member_race_safe(db, identity, org, role): a race-safe single-INSERT joiner (unique constraint as the guard). Owner-promotion stays Default-only; the existing Default auto-join transaction is untouched.
  • resolve_signup_org(slug): fail-closed — returns an org only if it exists, is external, and has public_login_enabled=1, re-read at call time.
  • /o/{slug} binds the registration CTA to return_to=/join/confirm?org=<slug> for external+enabled orgs; internal orgs keep the plain /registration link.
  • Middleware path carve-out so the arrival request on /join/confirm is not force-joined to Default.
  • /join/confirm GET+POST: an explicit, CSRF-protected confirmation modelled on the invite-accept flow. GET 404s unknown/internal/disabled slugs (anti-enumeration), renders a register CTA when anonymous, redirects already-members, and shows the confirm form otherwise. POST re-resolves the org (TOCTOU-safe), joins as member, is idempotent, and audits the join as self-serve.
  • i18n for the confirm page across all 9 locales.
  • An e2e Playwright spec asserting a self-serve registrant lands in the target org and NOT in Default.

Explicitly not included

No metadata_admin writes, no verification.completed webhook, no email-verified gate (removed by the keystone revision). RP-initiated organization_id/consent-interstitial ordering is Phase 6. External guardrail hardening (rate limiting, CAPTCHA, audit of mode changes) is Phase 7.

Verification

  • cargo clippy --all-targets -- -D warnings: clean. Full unit suite: 561 passed, 0 failed. i18n parity harness: pass.
  • The Kratos-side foundations (session at registration, return_to round-trip) were exercised live during the keystone spikes.
  • The full browser flow is covered by the new licensed e2e spec (h-org-external-self-serve-join.spec.ts), which needs the licensed playground stack to run. It was not run here, and the flow was not exercised against a from-this-branch running portal (that would mean restarting the running instance).

Known follow-ups

  • The confirm page's non-English strings are a competent first pass; a native review is worth doing.
  • A double-submit on POST /join/confirm produces one membership row but could emit two audit entries (narrow race, audit-noise only).
## What this is Phase 3 of "company access modes": the external self-serve join path. A public landing page (`/o/{slug}`) for an external org sends a visitor into registration, they land on an explicit `/join/confirm` page, confirm, and become a member of THAT org instead of the hardcoded Default org. This closes the gap where `/o/{slug}` was cosmetic and every registrant landed in Default. Stacked on the Phases 1-2 branch (PR #6) — this PR targets that branch, so the diff is Phase 3 only. ## Mechanism (validated by live-stack keystone spikes) Before implementing, three spikes were run against the running Kratos/Hydra playground. They confirmed the mechanism and, importantly, made it simpler than first drafted: - Kratos registration issues a session immediately (`hook: session`), so a fresh registrant lands authenticated on a `return_to` we control. `allowed_return_urls` is origin-prefix, so `/join/confirm` needs no Kratos config change. - The auto-join middleware holds the request, so it can branch on the path. A one-line carve-out (skip the Default force-join when the path is `/join/confirm`) is enough — no identity metadata needed. - Because the system already issues a session at registration and tolerates unverified sessions (verify-later), the join happens immediately on explicit confirm with no pre-verification gate, consistent with today's Default auto-join. This removes the originally-planned identity-metadata stamping and verification-webhook finalization entirely. ## Included - `add_member_race_safe(db, identity, org, role)`: a race-safe single-INSERT joiner (unique constraint as the guard). Owner-promotion stays Default-only; the existing Default auto-join transaction is untouched. - `resolve_signup_org(slug)`: fail-closed — returns an org only if it exists, is `external`, and has `public_login_enabled=1`, re-read at call time. - `/o/{slug}` binds the registration CTA to `return_to=/join/confirm?org=<slug>` for external+enabled orgs; internal orgs keep the plain `/registration` link. - Middleware path carve-out so the arrival request on `/join/confirm` is not force-joined to Default. - `/join/confirm` GET+POST: an explicit, CSRF-protected confirmation modelled on the invite-accept flow. GET 404s unknown/internal/disabled slugs (anti-enumeration), renders a register CTA when anonymous, redirects already-members, and shows the confirm form otherwise. POST re-resolves the org (TOCTOU-safe), joins as `member`, is idempotent, and audits the join as self-serve. - i18n for the confirm page across all 9 locales. - An e2e Playwright spec asserting a self-serve registrant lands in the target org and NOT in Default. ## Explicitly not included No `metadata_admin` writes, no `verification.completed` webhook, no email-verified gate (removed by the keystone revision). RP-initiated `organization_id`/consent-interstitial ordering is Phase 6. External guardrail hardening (rate limiting, CAPTCHA, audit of mode changes) is Phase 7. ## Verification - `cargo clippy --all-targets -- -D warnings`: clean. Full unit suite: 561 passed, 0 failed. i18n parity harness: pass. - The Kratos-side foundations (session at registration, `return_to` round-trip) were exercised live during the keystone spikes. - The full browser flow is covered by the new licensed e2e spec (`h-org-external-self-serve-join.spec.ts`), which needs the licensed playground stack to run. It was not run here, and the flow was not exercised against a from-this-branch running portal (that would mean restarting the running instance). ## Known follow-ups - The confirm page's non-English strings are a competent first pass; a native review is worth doing. - A double-submit on `POST /join/confirm` produces one membership row but could emit two audit entries (narrow race, audit-noise only).
franz closed this pull request 2026-07-07 10:54:23 +00:00

Pull request closed

Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
franz/forseti!7
No description provided.