feat(orgs): consent tenant-pinning + external-mode guardrails (Phases 6-7) #8

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

What this is

Phases 6 and 7 of "company access modes", stacked on Phase 3 (PR #7) — this PR targets that branch, so its diff is Phases 6-7 only.

  • Phase 6 — OAuth consent tenant-pinning correctness.
  • Phase 7 — external-mode guardrails: rate limiting, audit gaps, docs (CAPTCHA deliberately deferred).

When a relying party's /oauth2/auth request carries organization_id=X, the id_token org and groups claims must reflect X and must never assert a different tenant. Today consent falls back to memberships.first() when no active-org cookie matches, so an RP that requested org X could receive org Y's claim for a user in both — a wrong-tenant assertion.

resolve_claim_active_org now pins the claim:

  • requested org that the subject is a member of → that org's claim.
  • requested org the subject is not a member of → no org/groups claim (suppressed; never a different org).
  • no organization_id on the request → unchanged (active-org cookie, else first membership).

The pin is applied at both finalize_consent call sites (the auto-grant/skip path and the interactive Allow path), so a skip_consent client cannot bypass it.

Scope note: the plan's RP-initiated join interstitial (auto-joining a brand-new user into an org mid-consent) is a Model-2 / B2B customer-tenant pattern and is deferred to that model. In Model 1 the self-serve join path is the landing page (Phase 3). This PR fixes the claim correctness; it does not auto-join a stranger.

Phase 7: external-mode guardrails

  • Rate limiting on the two previously-unthrottled public routes, /o/{slug} and /registration: the existing per-IP dual_window plus a new global (all-callers-share-one-bucket) pair, so distributed signup and X-Forwarded-For spoofing (which per-IP alone can't catch) are bounded. All limits are config knobs with sane code-side defaults. A per-org dimension is not built (a documented limitation): /registration's target org lives inside the opaque Kratos flow, not the URL, so a per-org key extractor isn't cheaply buildable there.
  • Audit gaps filled: creating an org directly as external (the overview mode-change was already audited, creation was not), and a blocked attempt to loosen an external org's admins-only directory (recorded as a failed, warning-severity event).
  • Docs: access modes are now documented for buyers (docs/commercial/organizations.md), operators (docs/operator-guide.md — the new config knobs, the trust_forwarded_for prerequisite, and the deliberate no-verification-gate-on-join design), and contributors (docs/dev/organizations-internals.md).

CAPTCHA: deferred by design, not built

Forseti's /registration is GET-only; the browser submits registration directly to Kratos's own public endpoint, which Forseti never sees. A real Turnstile gate would need a blocking Kratos before hook, a new Forseti verify webhook, and a client-side widget — a multi-system integration disproportionate to this phase. A client-side-only widget would be a placebo with no server enforcement. The gap and the mechanism a real fix needs are documented instead. Rate limiting plus the existing unverified-account reaper bound the abuse surface in the meantime.

Verification

  • cargo clippy --all-targets -- -D warnings: clean. Full unit suite: 566 passed, 0 failed. i18n parity: pass.
  • No new user-facing strings (rate limiting reuses the existing 429 body; audit is backend-only), so no locale changes.

Known follow-ups

  • Per-org rate-limit dimension (see the documented limitation) if Forseti later mints its own /registration?org=&flow= redirect instead of relying on Kratos's opaque return_to.
  • CAPTCHA, per the deferral above.
## What this is Phases 6 and 7 of "company access modes", stacked on Phase 3 (PR #7) — this PR targets that branch, so its diff is Phases 6-7 only. - **Phase 6** — OAuth consent tenant-pinning correctness. - **Phase 7** — external-mode guardrails: rate limiting, audit gaps, docs (CAPTCHA deliberately deferred). ## Phase 6: consent tenant pinning When a relying party's `/oauth2/auth` request carries `organization_id=X`, the id_token `org` and `groups` claims must reflect X and must never assert a different tenant. Today consent falls back to `memberships.first()` when no active-org cookie matches, so an RP that requested org X could receive org Y's claim for a user in both — a wrong-tenant assertion. `resolve_claim_active_org` now pins the claim: - requested org that the subject is a member of → that org's claim. - requested org the subject is not a member of → no `org`/`groups` claim (suppressed; never a different org). - no `organization_id` on the request → unchanged (active-org cookie, else first membership). The pin is applied at both `finalize_consent` call sites (the auto-grant/skip path and the interactive Allow path), so a `skip_consent` client cannot bypass it. Scope note: the plan's RP-initiated *join interstitial* (auto-joining a brand-new user into an org mid-consent) is a Model-2 / B2B customer-tenant pattern and is deferred to that model. In Model 1 the self-serve join path is the landing page (Phase 3). This PR fixes the claim correctness; it does not auto-join a stranger. ## Phase 7: external-mode guardrails - **Rate limiting** on the two previously-unthrottled public routes, `/o/{slug}` and `/registration`: the existing per-IP `dual_window` plus a new global (all-callers-share-one-bucket) pair, so distributed signup and X-Forwarded-For spoofing (which per-IP alone can't catch) are bounded. All limits are config knobs with sane code-side defaults. A per-org dimension is not built (a documented limitation): `/registration`'s target org lives inside the opaque Kratos flow, not the URL, so a per-org key extractor isn't cheaply buildable there. - **Audit gaps filled:** creating an org directly as external (the overview mode-change was already audited, creation was not), and a blocked attempt to loosen an external org's admins-only directory (recorded as a failed, warning-severity event). - **Docs:** access modes are now documented for buyers (`docs/commercial/organizations.md`), operators (`docs/operator-guide.md` — the new config knobs, the `trust_forwarded_for` prerequisite, and the deliberate no-verification-gate-on-join design), and contributors (`docs/dev/organizations-internals.md`). ### CAPTCHA: deferred by design, not built Forseti's `/registration` is GET-only; the browser submits registration directly to Kratos's own public endpoint, which Forseti never sees. A real Turnstile gate would need a blocking Kratos `before` hook, a new Forseti verify webhook, and a client-side widget — a multi-system integration disproportionate to this phase. A client-side-only widget would be a placebo with no server enforcement. The gap and the mechanism a real fix needs are documented instead. Rate limiting plus the existing unverified-account reaper bound the abuse surface in the meantime. ## Verification - `cargo clippy --all-targets -- -D warnings`: clean. Full unit suite: 566 passed, 0 failed. i18n parity: pass. - No new user-facing strings (rate limiting reuses the existing 429 body; audit is backend-only), so no locale changes. ## Known follow-ups - Per-org rate-limit dimension (see the documented limitation) if Forseti later mints its own `/registration?org=&flow=` redirect instead of relying on Kratos's opaque `return_to`. - CAPTCHA, per the deferral above.
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!8
No description provided.