Skip to content

feat: organizational identity domain verification and JIT provisioning - #462

Open
mfcarroll wants to merge 2 commits into
piffio:mainfrom
mfcarroll:feat/domain-jit
Open

feat: organizational identity domain verification and JIT provisioning#462
mfcarroll wants to merge 2 commits into
piffio:mainfrom
mfcarroll:feat/domain-jit

Conversation

@mfcarroll

Copy link
Copy Markdown
Contributor

Implements #216.

Organizations on Business tier and above verify ownership of their organizational identity domain(s) via a DNS TXT challenge. Users signing in with an email on a verified domain are auto-provisioned into that org as a member (existing users gain membership, brand-new users skip personal-org creation).

Scope vs the 216 proposal: this builds the DNS-verification path (Option A) and the auto-join flow. OIDC (Option B) and home realm discovery are left as future work as described in 216. One deliberate divergence: auto-join is based on the verified email domain for any provider not the Google hd claim I'd originally imagined. This is broader, covering GitHub SSO now and other potential future sign in methods as well.

Naming: routes are /api/orgs/:id/org-domains (+ verify-org-domain), not /domains as that was taken by the custom link-domains feature. These are the org's identity domains, as opposed to the link-serving hostnames. It's a distinct table (org_domains).

Commit 1 is org-membership hardening that this feature depends on

  • JIT provisioning assigns a user to a matched org on login. Without this OrgRepository::get_member's users.org_id self-heal fallback would silently mint an owner membership row for such a user which is a privilege escalation. (On the current main that fallback is effectively dead code as nothing sets users.org_id without also inserting the membership row, so it never fires. But... JIT is precisely the flow that would trip it.) This removes the fallback so membership creation is always explicit.
  • It also hardens two related gaps that do affect main: it validates org membership on every authenticated request (session and API-key paths) so a removed member loses access immediately rather than at token expiry. API keys previously never re-checked, so access persisted indefinitely.
  • It also resolves the session role from the DB rather than the JWT claim, so role changes take effect without re-login.

Commit 2 is the actual feature

  • TXT verification is server-side via Cloudflare DoH (utils/dns.rs). List endpoint computes an is_cloudflare hint server-side so the frontend needs no cross-origin DNS calls under the CSP.
  • Owner/admin + Business+ is enforced server-side. Only one outstanding challenge allowed per domain, one verified domain holder.
  • OpenAPI regenerated, and integration tests cover the endpoint lifecycle/auth.

Running in production on my work instance, including the auto-join flow and signups-disabled bypass for verified domains.

- Remove the users.org_id self-healing fallback from
  OrgRepository::get_member: it silently re-added removed members with
  the 'owner' role. Pre-migration users were backfilled by
  0011_org_members.sql, and every membership-creating path inserts the
  row explicitly, so the fallback was purely a privilege escalation
  hazard.
- Validate org membership on every authenticated request (session and
  API-key paths), so access is revoked immediately when a user is
  removed from an org instead of persisting until token expiry.
- Resolve the session role from the database rather than the JWT claim,
  so role changes take effect without re-login.
- Adjust the billing transfer test cleanup to switch the billing user's
  session back to their own org before removing their membership, since
  a session pointing at an org the user has left is now rejected.
Organizations on Business tier and above can verify ownership of their
email domains via a DNS TXT challenge. Users signing in with an email
on a verified org domain are automatically provisioned into that
organization as a member: existing users gain membership, and brand-new
users skip personal-org creation and land directly in the org.

Backend:
- org_domains table (migration 0041), OrgDomain model, and
  OrgDomainRepository; distinct from the custom link domains feature
- OrgService domain methods with owner/admin + Business+ enforcement
- Endpoints under /api/orgs/:id/org-domains (+ verify-org-domain),
  named to avoid colliding with the custom-domains API from piffio#369
- DNS-over-HTTPS TXT verification in utils/dns.rs, plus a server-side
  Cloudflare-nameserver hint (is_cloudflare) computed during list so
  the frontend needs no cross-origin DNS calls under the CSP
- JIT provisioning in the OAuth flow for all three login paths;
  matching pending invitations are auto-accepted, and explicitly
  invited users may sign in even when public signups are disabled
- Invite flow: already-members (e.g. via JIT) get a graceful accept
  that upgrades their role if the invitation offers a different one,
  and invite info reports is_member so the UI can skip the accept step

Frontend:
- OrgDomains management card on the org page (owner + Business+),
  with TXT record instructions, copy button, verify and remove actions
- Invite page routes already-members straight to the dashboard

Includes integration tests for the org-domain endpoints and an OpenAPI
spec regeneration.
@piffio piffio linked an issue Jul 24, 2026 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enterprise feature: domain auto-join (JIT provisioning)

1 participant