Files
web/docs/ORGANIZATIONS.md
T
gamertan f142ac23a9
verify / verify (push) Successful in 4m15s
Invalidate old invitations when organization membership changes
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:12:40 -04:00

172 lines
9.6 KiB
Markdown

<!-- SPDX-License-Identifier: MPL-2.0 -->
# Organizations and scoped access
One `auth.User` may belong to many organizations without creating another
credential or browser session. Organizations own projects; projects own
environments; environments own application services. Teams are optional groups
of active organization members.
`organizations.Service` creates those resources and issues digest-backed,
expiring, single-use invitations. An invitation may carry up to sixteen direct
roles and sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership,
roles, teams, consumption marker, and audit event in one transaction. The
recipient and issuing member must remain active, fully registered users of an
active organization; a suspended recipient cannot use an invitation as implicit
reactivation. Existing members use the membership editor, not another invitation,
to change roles or teams. Duplicate or concurrent acceptance consumes the token
only once. Removal revokes older pending invitations for that recipient in the
same transaction; a new, intentional invitation is needed to rejoin later.
When `OwnerRole` is configured, invitations granting that role require a current
direct owner at creation and acceptance, and an owner for revocation. Set
`OwnerManagedInvitations: true` to apply that rule to every invitation, including
ordinary member invitations. Stored `RequiredOwnerRole` preserves the boundary
even when a link reaches another application service with different options.
A broad access-management permission can still administer ordinary invitations
when owner-managed policy is disabled, but cannot create or cancel owner access.
Applications own invitation pages, email or out-of-band delivery, active-source
checks before archival, and account recovery.
Use `InviteWithAccess.DirectRoles` for combinations and `RequestID` for the
creation audit correlation. `DirectRole` remains the legacy single-role form;
supplying both is rejected, not merged. The service copies and sorts role arrays
and rejects duplicates or unknown/unseeded roles before persistence. Repository
adapters implement `RoleInvitationRepository` to store and enforce role-set and
owner requirements atomically. An unsupported adapter returns
`ErrRoleInvitationUnsupported`; it must not issue a partly effective invitation.
The application restricts which roles may be offered and authenticates the actor;
never accept the owner-role policy or actor identity from submitted fields.
## Creating an organization with an owner
For an existing authenticated user creating a business, use
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
service. Seed that role first. This commits the organization, active membership,
direct organization-wide owner binding, and both creation/access audit events in
one transaction. `CreateOrganization.RequestID` correlates those audit events.
The owner must be an active user whose registration has completed.
The application authorizes creation and chooses the role; do not accept an owner
role name from a browser or API payload. A customer-owner role can intentionally
have different permissions from an installation's merchant-owner role. Creating
a customer organization grants no authority in any other organization.
```go
customers, err := organizations.New(store, organizations.Options{
OwnerRole: "customer.owner", // Application-defined, already seeded.
OwnerManagedInvitations: true,
})
if err != nil {
return err
}
business, err := customers.CreateOwnedOrganization(ctx, organizations.CreateOrganization{
Slug: "example-business", Name: "Example Business", OwnerUserID: principal.User.ID,
RequestID: requestID,
})
```
Repositories implement `OwnedOrganizationRepository` to support this operation.
There is no create-then-grant fallback: unsupported adapters return
`ErrOwnedCreationUnsupported`. The older `CreateOrganization` and
`CreatePersonalOrganization` retain their membership-only behavior; configuring
`OwnerRole` does not silently change them. The separate `account` package still
owns atomic public signup, including personal organization and credentials.
## Membership and access lifecycle
Organizations and teams use optimistic revisions and reversible
`active`/`archived` states. Archived objects keep their history but contribute
no effective authority. Memberships may be suspended, reactivated, or removed;
team membership can be removed independently. Configure `OwnerRole` when
constructing the service before exposing membership-removal operations. The
SQLite adapter then refuses to suspend or remove the final active direct owner.
Fresh-authentication administration pages should use
`ChangeMembershipStatus` and `RemoveMembershipIfCurrent`, passing the exact
displayed state as `ExpectedStatus`. The SQLite adapter acquires its write lock
before checking that state, verifies the actor is still an active member of an
active organization, and commits the lifecycle effects and audit together.
Suspension removes team memberships; reactivation does not infer or restore
them. Removal also revokes current direct bindings. A repository without the
optimistic extension fails closed instead of falling back to a stale mutation.
For a reviewed access-administration page, use `organizations.Members` to list
bounded active and suspended memberships, and
`access.OrganizationUserBindings` to list only current direct,
organization-wide user roles. The latter intentionally excludes team grants
and project, environment, or service bindings. Replace a member's direct role
with `access.ReplaceOrganizationUserRole`, passing the exact displayed binding
IDs as `ExpectedBindingIDs`. The SQLite adapter serializes that replacement,
rejects stale state, writes the new binding and audit event atomically, and
will not demote the final active direct owner. The application must still
authorize the administrator and bind any required fresh passkey assertion to
the organization, target user, target role, and expected IDs.
For combinations such as Buyer plus Billing Manager, use
`access.ReplaceOrganizationUserRoles` with a non-empty, unique `Roles` array
(maximum sixteen) and the same `ExpectedBindingIDs` convention. This operation
requires a current direct owner inside the write transaction for every change;
the older single-role API retains its delegated non-owner administration policy.
The replacement is all-or-nothing, leaves narrower grants untouched, and records
one audit. `ErrRoleChangeConflict` means refresh the displayed bindings, not retry
the old request silently. `RoleSetRepository` is required; separate grant/revoke
calls are not a fallback. A basic-member role with no permissions can represent
membership without purchasing or billing access.
Role names and capabilities remain application policy. In particular, customer
roles must not be replaceable with merchant roles merely because both policies
use the same database. Routine customer changes do not inherently require a
passkey ceremony; the application decides when an action needs fresh proof.
## Schema 10 compatibility
Schema 10 adds `direct_roles_json` and `required_owner_role` to stored invitations.
The explicit migration preserves legacy `direct_role`, hashes, dates, teams, and
consumption state. It does not guess which application role historically meant
owner. Configure the correct `OwnerRole` when accepting pre-schema-10 owner
invitations; that service policy supplies their acceptance-time owner check.
New invitations carry the persisted requirement themselves.
Use `OpenWithOptions(..., OpenOptions{Migrate: false})` plus
`RequireCurrentSchema` at application startup and an explicit operator migration
command. Retain a verified backup before migrating. Schema-9 binaries reject
schema 10 when using the startup check and are not approved writers after the
upgrade; a binary rollback must not overwrite newer accepted data.
`access.Service` evaluates a permission against a complete resource scope:
```go
decision, err := accessService.Authorize(ctx, principal.User.ID, access.Scope{
OrganizationID: organizationID,
ProjectID: projectID,
EnvironmentID: environmentID,
ServiceID: serviceID,
}, "telemetry.read")
```
A binding at organization scope covers its descendants. A narrower binding
covers only its matching branch. The repository resolves team membership; a
handler must never accept caller-supplied team identifiers as authority.
Platform roles in `auth.Principal` remain useful for installation health,
account administration, and other explicitly global operations. They do not
grant organization-data access. If an operator must inspect tenant data during
an incident, use a reasoned break-glass grant. It expires within one hour and
creates an append-only audit event in the same transaction.
An application that offers owner-assisted account recovery must not infer that
authority from a broad administration page. Use the dedicated
`authrecovery.IssueAssistedRecovery` boundary after an operation-bound passkey
assertion. The SQLite adapter requires a current active direct owner binding
and active target membership in the same transaction that invalidates the old
credentials and records the organization-visible recovery audit. Team,
break-glass, platform, and merely descriptive roles do not satisfy this owner
check.
The SQLite adapter namespaces all tables, enforces active organization and team
membership plus resource ancestry before accepting or evaluating a binding,
and keeps invitations and sessions as digests. Applications remain responsible
for database backup, filesystem ownership, retention, and presenting audit
history to organization owners.