feat(orgs): internal email-domain auto-join (Phase 5) #9

Closed
franz wants to merge 6 commits from feat/company-access-modes-phase5 into feat/company-access-modes-phase6
Owner

What this is

Phase 5 of "company access modes": internal email-domain auto-join. An internal org can register email domains it owns; once a domain is ownership-PROVEN, a new user whose VERIFIED email matches that domain is silently auto-joined to the org (Google-Workspace style) instead of landing in the Default org.

Stacked on the Phases 6-7 branch (PR #8) — this PR targets that branch, so its diff is Phase 5 only.

Mechanism

The verification-timing problem (registration issues a session with an unverified email; auto-join runs on the first authenticated request) is solved without any webhook or identity-metadata machinery. The auto-join middleware already re-runs whoami each request and gets the identity's live verifiable_addresses (this was confirmed against the running stack: an existing session reflects verification status live). So for a member-less user the middleware decides:

  • email domain matches no proven domain → join Default, exactly as before.
  • matches a proven domain but the matching address is not verified yet → do nothing (suppressed, not stranded; the next request after they click the verify link re-evaluates and joins).
  • matches a proven domain and the matching address is verified → join that org as member.

The verified check is on the specific verifiable_addresses entry whose value matches the email and verified == true (never the raw trait email), reusing the invite flow's exact predicate. Owner promotion stays Default-only; domain joins are always member. Fail-closed on access mode (only internal, non-Default orgs are auto-joinable).

Ownership proof: three owner-selectable methods

  • HTTP well-known file — GET https://<domain>/.well-known/forseti-domain-verify, body contains the token. Reuses the existing webhook SSRF guard (https-only, blocks loopback/RFC1918/CGNAT/link-local/IMDS, DNS-rebinding re-check at connect, no redirects, size-capped body, bounded timeouts).
  • DNS TXT — TXT at _forseti-verify.<domain> contains the token. Adds the hickory-resolver dependency (default features only, no TLS pulled in).
  • Email — token sent to admin@ and postmaster@ of the domain; the owner pastes it back. The confirmation compares hash-then-constant-time.

A domain can be verified under at most one org globally, enforced by a partial unique index (domain WHERE verified_at IS NOT NULL) plus a re-check, so no org can absorb another's users, and freemail/public domains are rejected at add time.

Admin surface

A /settings/organization(s)/{slug}/domains sub-page (owner + licensed + non-Default + internal-only, CSRF-protected, fail-closed on access mode): add a domain and method, run verification, remove. i18n across all 9 locales. Each method is individually operator-disableable via config.

Security review

A dedicated security audit found the core design sound (the verified-address gate, the SSRF guard application, global uniqueness, and the authorization surface all hold under tracing). Three findings were fixed in this PR: the email method is now gated at confirm time (not only add time); allowed domains are capped per org (config domain_max_per_org, default 100); and the challenge email now names the requesting org and actor, so abuse of the (paid-license, owner-gated) email method is directly attributable.

Verification

  • cargo clippy --all-targets -- -D warnings: clean. Full unit suite: 603 passed, 0 failed. i18n parity: pass.
  • The core "live verified state on next request" assumption was confirmed empirically against the running Kratos playground.

Known follow-ups

  • Per-destination rate limiting on the challenge-email and verify actions (currently bounded by the owner+license gate and the per-org domain cap).
  • Storing the email-method verification token as a digest at rest (HTTP/DNS tokens must stay readable so the owner can publish them).
  • A full register → verify → join integration test against the live stack (the decision-matrix unit tests are thorough; the end-to-end flow is not yet automated).
## What this is Phase 5 of "company access modes": internal email-domain auto-join. An internal org can register email domains it owns; once a domain is ownership-PROVEN, a new user whose VERIFIED email matches that domain is silently auto-joined to the org (Google-Workspace style) instead of landing in the Default org. Stacked on the Phases 6-7 branch (PR #8) — this PR targets that branch, so its diff is Phase 5 only. ## Mechanism The verification-timing problem (registration issues a session with an unverified email; auto-join runs on the first authenticated request) is solved without any webhook or identity-metadata machinery. The auto-join middleware already re-runs `whoami` each request and gets the identity's live `verifiable_addresses` (this was confirmed against the running stack: an existing session reflects verification status live). So for a member-less user the middleware decides: - email domain matches no proven domain → join Default, exactly as before. - matches a proven domain but the matching address is not verified yet → do nothing (suppressed, not stranded; the next request after they click the verify link re-evaluates and joins). - matches a proven domain and the matching address is verified → join that org as `member`. The verified check is on the specific `verifiable_addresses` entry whose value matches the email and `verified == true` (never the raw trait email), reusing the invite flow's exact predicate. Owner promotion stays Default-only; domain joins are always `member`. Fail-closed on access mode (only internal, non-Default orgs are auto-joinable). ## Ownership proof: three owner-selectable methods - **HTTP well-known file** — GET `https://<domain>/.well-known/forseti-domain-verify`, body contains the token. Reuses the existing webhook SSRF guard (https-only, blocks loopback/RFC1918/CGNAT/link-local/IMDS, DNS-rebinding re-check at connect, no redirects, size-capped body, bounded timeouts). - **DNS TXT** — TXT at `_forseti-verify.<domain>` contains the token. Adds the `hickory-resolver` dependency (default features only, no TLS pulled in). - **Email** — token sent to admin@ and postmaster@ of the domain; the owner pastes it back. The confirmation compares hash-then-constant-time. A domain can be verified under at most one org globally, enforced by a partial unique index (`domain WHERE verified_at IS NOT NULL`) plus a re-check, so no org can absorb another's users, and freemail/public domains are rejected at add time. ## Admin surface A `/settings/organization(s)/{slug}/domains` sub-page (owner + licensed + non-Default + internal-only, CSRF-protected, fail-closed on access mode): add a domain and method, run verification, remove. i18n across all 9 locales. Each method is individually operator-disableable via config. ## Security review A dedicated security audit found the core design sound (the verified-address gate, the SSRF guard application, global uniqueness, and the authorization surface all hold under tracing). Three findings were fixed in this PR: the email method is now gated at confirm time (not only add time); allowed domains are capped per org (config `domain_max_per_org`, default 100); and the challenge email now names the requesting org and actor, so abuse of the (paid-license, owner-gated) email method is directly attributable. ## Verification - `cargo clippy --all-targets -- -D warnings`: clean. Full unit suite: 603 passed, 0 failed. i18n parity: pass. - The core "live verified state on next request" assumption was confirmed empirically against the running Kratos playground. ## Known follow-ups - Per-destination rate limiting on the challenge-email and verify actions (currently bounded by the owner+license gate and the per-org domain cap). - Storing the email-method verification token as a digest at rest (HTTP/DNS tokens must stay readable so the owner can publish them). - A full register → verify → join integration test against the live stack (the decision-matrix unit tests are thorough; the end-to-end flow is not yet automated).
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!9
No description provided.