feat(orgs): company access modes (internal/external), data model + admin UI #6

Closed
franz wants to merge 9 commits from feat/company-access-modes into master
Owner

What this is

Phases 1-2 of "company access modes": each organization gains an explicit internal / external access mode, so an admin can distinguish an internal workforce org (invite-only) from an external, public-facing org, and the setup intent is a first-class, visible choice rather than an implicit one.

This lands the data model and the admin UI only. It does NOT yet route public sign-ups into an external org (that is deferred, see below), so today choosing "external" sets the mode, enables the branded public landing page, and locks the member directory to administrators. The actual self-serve join wiring comes in a later change.

Included

  • New access_mode column on organizations (postgres + sqlite), defaulting to internal, so the migration is behavior-preserving and every existing org (including the seeded Default) stays internal.
  • A fail-closed AccessMode enum: only the exact literal external is external; anything unknown degrades to internal, so an unrecognised value never opens a public surface.
  • Admin UI: a mode picker on org creation, the mode surfaced on the overview page, and a confirmed mode-change route. Mode changes are audited (org.access_mode.changed).
  • Enforced guardrails for external orgs: the member directory is hard-enforced to administrators-only (loosening it is rejected server-side). Public login stays togglable so an owner can pause sign-ups.
  • Full i18n across all nine locales.

Design decisions

  • External mode is a licensed feature (requires Feature::Orgs), and the Default org can never be set external: it is the operator's own internal org. OSS installs keep exactly today's behavior (one internal Default org).
  • The access_mode enum is intentionally open (a future customer variant fits without a schema change), keeping the door open for a later B2B-SaaS tenancy model.

Deferred (not in this PR)

The self-serve sign-up routing (a registration bound to an external org actually creating membership in that org, with email-verification gating and abuse controls) is deferred. It depends on live Kratos/Hydra behavior that has to be verified against a running stack before the mechanism is committed, and an OIDC review flagged one of those spikes as potentially forcing a mechanism change. It is tracked separately.

Because of that, the external-mode UI copy is forward-looking: it describes self-serve sign-up that becomes fully live with the deferred change.

Verification

  • cargo clippy --all-targets -- -D warnings: clean.
  • Full unit suite: 552 passed, 0 failed.
  • Integration tests and the new licensed Playwright spec (g-org-access-mode.spec.ts) require the live Ory playground stack and were not run here.

Known follow-ups

  • The onsubmit="confirm('...')" pattern used across the org settings templates breaks if the interpolated string contains an apostrophe. A pre-existing instance exists in the French team-delete confirm; the new French mode-switch strings were rewritten to avoid it, but the underlying pattern is worth hardening separately.
  • docs/commercial/organizations.md does not yet describe access modes.
## What this is Phases 1-2 of "company access modes": each organization gains an explicit `internal` / `external` access mode, so an admin can distinguish an internal workforce org (invite-only) from an external, public-facing org, and the setup intent is a first-class, visible choice rather than an implicit one. This lands the data model and the admin UI only. It does NOT yet route public sign-ups into an external org (that is deferred, see below), so today choosing "external" sets the mode, enables the branded public landing page, and locks the member directory to administrators. The actual self-serve join wiring comes in a later change. ## Included - New `access_mode` column on `organizations` (postgres + sqlite), defaulting to `internal`, so the migration is behavior-preserving and every existing org (including the seeded Default) stays internal. - A fail-closed `AccessMode` enum: only the exact literal `external` is external; anything unknown degrades to internal, so an unrecognised value never opens a public surface. - Admin UI: a mode picker on org creation, the mode surfaced on the overview page, and a confirmed mode-change route. Mode changes are audited (`org.access_mode.changed`). - Enforced guardrails for external orgs: the member directory is hard-enforced to administrators-only (loosening it is rejected server-side). Public login stays togglable so an owner can pause sign-ups. - Full i18n across all nine locales. ## Design decisions - External mode is a licensed feature (requires `Feature::Orgs`), and the Default org can never be set external: it is the operator's own internal org. OSS installs keep exactly today's behavior (one internal Default org). - The `access_mode` enum is intentionally open (a future `customer` variant fits without a schema change), keeping the door open for a later B2B-SaaS tenancy model. ## Deferred (not in this PR) The self-serve sign-up routing (a registration bound to an external org actually creating membership in that org, with email-verification gating and abuse controls) is deferred. It depends on live Kratos/Hydra behavior that has to be verified against a running stack before the mechanism is committed, and an OIDC review flagged one of those spikes as potentially forcing a mechanism change. It is tracked separately. Because of that, the external-mode UI copy is forward-looking: it describes self-serve sign-up that becomes fully live with the deferred change. ## Verification - `cargo clippy --all-targets -- -D warnings`: clean. - Full unit suite: 552 passed, 0 failed. - Integration tests and the new licensed Playwright spec (`g-org-access-mode.spec.ts`) require the live Ory playground stack and were not run here. ## Known follow-ups - The `onsubmit="confirm('...')"` pattern used across the org settings templates breaks if the interpolated string contains an apostrophe. A pre-existing instance exists in the French team-delete confirm; the new French mode-switch strings were rewritten to avoid it, but the underlying pattern is worth hardening separately. - `docs/commercial/organizations.md` does not yet describe access modes.
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!6
No description provided.