Organization access
Organization access
The org-plane authorization model: who a member is (an ordered role), what extra access they hold (grants on host-defined resources, directly or through groups), and how a machine acts as them (a user API token whose scope is capped at the issuer's role). Everything is built on the relationship-tuple kernel — grants are tuples, so the boolean decision plane resolves them with no extra wiring.
Ordered membership roles
MembershipRole is strictly ordered: Owner > Admin > Developer > Member >
Viewer (weight(), outranks()). Two capability checks cover the common
gates: canManageOrganization() (Owner/Admin) and canWrite() (everyone but
Viewer). Hosts wanting the four-tier Owner/Admin/Developer/Viewer model simply
never assign Member.
Groups
Groups (contract) manages organization-local groups. The user_groups table
holds metadata only — membership lives as relationship tuples
(user_group:<id> #member @user:<id>), so there is exactly one membership
store and group-inherited access resolves through the kernel's userset
expansion. Deleting a group deletes its memberships and every grant held
through it — no dangling access.
Resource grants & effective role
ResourceAccess (contract) grants a role to a user or a group on a
host-defined ResourceRef — the package does not know what a "project" is;
a project-wide grant is a grant on the project's own ref, a row-level grant is
a grant on that row's ref.
The single query surface is effectiveRole($org, $userId, ...$resources):
the highest-weighted role across the user's active membership and every
grant matching the given refs, held directly or via any group. Null means no
access (deny-by-default). A grant can raise, never lower, the effective role.
Because grants are tuples, PolicyDecisionPoint::can($org, $subject, 'developer', $ref) also answers exact-role checks — including through groups — but note
raw PDP relations are exact-match: they do not imply lower roles. Use
effectiveRole() when ordering matters.
User API tokens
UserApiTokens (contract) issues cbid_pat_… bearer credentials that
authenticate as the user within one organization — authorization stays
with the user's effective role; there is no token-specific grant model.
- SHA-256-hashed at rest, looked up by hash (no timing oracle); non-secret
prefixfragment for listings;last_used_atstamped on resolve. - Every token carries a hard expiry —
cbox-id.user_api_tokens.default_ttl_days(90) applies when no explicit expiry is given. - Coarse scope (
read/write/admin) plus an optional resource-family list (null⇒ unrestricted); enforce families in your API layer viaUserApiToken::allowsFamily(). - Issuer-role cap, enforced in the service: a token never out-ranks its
minter.
adminscope requires an org-managing role,writea writing role, and a non-member mints nothing (TokenScopeExceedsIssuerRole).
Testing
InteractsWithAccess ships the helpers the package's own suite uses:
$group = $this->makeGroup($org->id, 'Engineering', members: ['user_1']);
$this->grantAccess($org->id, GrantSubject::group($group->id), MembershipRole::Developer, ResourceRef::of('project', 'p1'));
expect($this->effectiveRole($org->id, 'user_1', ResourceRef::of('project', 'p1')))
->toBe(MembershipRole::Developer);