Files
web/docs/ORGANIZATIONS.md
T
2026-09-05 01:19:57 -04:00

12 KiB

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 instance-wide administrative directories, the optional auth.UserDirectoryRepository and organizations.DirectoryRepository readers on authsqlite.Store list all identities/organizations, not just memberships. Authorize an explicit instance-read capability before every call. These are not customer self-service or public directory APIs; they deliberately include incomplete/inactive accounts and personal/archived organizations without exposing credentials, recovery material or invitations. Merchant classification remains application policy. Reading never creates a membership or grants a role.

Both queries accept literal Search (up to 128 bytes), exclusive AfterID, and Limit (default 50, maximum 200). An empty NextID ends the result. Preserve the search when following a cursor; reset it when changing the search. IDs give stable ordering despite renamed profiles, but pages are current views rather than a multi-request snapshot. New records sorting before a cursor appear on a fresh listing. SQLite search folds ASCII case; non-ASCII display-name text matches with its original case. Wildcards and SQL fragments are always literal search text. These optional readers do not change the required authentication/organization repository contracts or schema 10.

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.

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

For customer-owned businesses where only owners manage profiles and members, use UpdateOwnedOrganization, ChangeOwnedMembershipStatus, and RemoveOwnedMembershipIfCurrent. They recheck the service's configured direct owner after acquiring the write lock, including when the target is a non-owner. The actor must remain active and fully registered, their membership must remain active, and the organization must be active and non-personal. Profile changes only update name and slug; archiving and personal-account lifecycle are separate. Custom adapters must implement OwnerManagedRepository, with no fallback to an application preflight followed by an unguarded write. These additions retain schema 10 and do not change the existing delegated-administration methods below.

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:

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.