verify / verify (push) Successful in 4m25s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
183 lines
10 KiB
Markdown
183 lines
10 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
|
|
|
|
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:
|
|
|
|
```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.
|