A membership role
Use a role when people get access by belonging to something, such as the
editors of a company. A role is stored in a record of its own: a
membership entity, whose membership: section names three of its fields:
scope: the record the role is held in, such as the company;member: who holds it, a reference to the user entity;role: the field that holds the role, typed with an enum.
The role enum lives beside the membership in the same domain, and other domains use it by its full name. There is no global roles file.
// domains/accounts/types/company_role.jadpotype CompanyRole = Enum { owner editor viewer}// domains/accounts/entities/company_membership.jadpoentity CompanyMembership { id: Uuid company_id: Company.id { on_delete: OnDelete.cascade } user_id: User.id { on_delete: OnDelete.cascade } role: CompanyRole membership: { scope: company_id member: user_id role: role } policy: { create: [CompanyRole.owner] read: [CompanyRole.owner, user_id] update: [CompanyRole.owner] delete: [CompanyRole.owner, user_id] }}
Here only a company's owners may add, change or remove members. Each member may also read their own membership, and delete it to leave.
Using a role on other records
A role applies to another entity through that entity's reference to the
scope, the same reference tenancy uses. A Project with a
company_id takes its grants from the roles held in its company:
// domains/projects/entities/project.jadpoentity Project { id: Uuid company_id: Company.id { on_delete: OnDelete.cascade } name: ProjectName policy: { create: [CompanyRole.editor] read: [CompanyRole.viewer, CompanyRole.editor] update: [CompanyRole.editor] delete: [CompanyRole.owner] }}
How roles combine
- Roles have no ranking. A role lets in only where a list names it, so an owner who may also edit is listed under both.
- Only current memberships count. A membership its lifecycle hides grants nothing, and removing one takes effect on the next request.
- A role held across the whole application, such as staff, has a
membership with
scope: MembershipScope.application. - An entity can have two references to the scope, such as a handover with
a
from_team_idand ato_team_id. Then each grant names the reference it means:TeamRole.lead(from_team_id)is the lead of the from-team.
Roles that need a way in
Staff often sign in through their company's provider, such as Google or
Microsoft, so that switching their account off there ends their access
everywhere. If they could also sign in by magic link and keep their
roles, it wouldn't. A membership's requires: says, for each role, how
its holder must have signed in for the role to count:
// domains/desk/entities/staff_member.jadpoentity StaffMember { id: Uuid patron_id: Patron.id { on_delete: OnDelete.cascade } role: StaffRole membership: { scope: MembershipScope.application member: patron_id role: role requires: { StaffRole.admin: { sign_in: [SignInMethod.google] strength: AuthenticationStrength.multi_factor } StaffRole.front_desk: { sign_in: [SignInMethod.google] } } }}
sign_in:lists the ways in, any of which will do: a provider thatsign_in: { providers: }names (Providers), orSignInMethod.external_token. A magic link or a passkey proves an address or a device, not that the company still vouches for the person, so neither can be listed.strength: AuthenticationStrength.multi_factorasks for a second factor: a passkey, or a provider whose ID token says the person used one (How strongly a person signed in).StaffRole.front_desk: {}needs nothing. Once one role has a line, every value of the enum has one, so a role added later is decided rather than open by default.
A role counts on a request only when the caller's current session meets
every line of its block. Otherwise the person is just themselves,
without that role: Ann, signed in by link, is a customer like any other.
Nothing is refused when she signs in. The requirement applies wherever
the role is named: grants, a field's own policy:, lines under
actions:, listed queries and what the proofs count on. You write it
once.
A membership whose role doesn't count doesn't make its holder a member of its tenant for that request either, so tenancy fails closed (Tenancy).
A session started through a provider that any role's requirement names lasts a day, not 30 days, so a person the company switches off loses the role within a day (How a session started).
The permissions report lists each role's requirement, and
access.jadpo_record records it. Any change to a role's requirement,
tighter or looser, and adding or removing a role's line, needs a person's
acceptance, PolicyChange.role_requirements_changed
(Acceptances).
What the compiler checks:
- A role or a way in the enum doesn't have is
type.unknown_enum_variant. - A role left out once another has a line is
policy.requirements_incomplete. - Anything else a role's line can't hold, an empty
sign_in:,strength: AuthenticationStrength.primary, a way Jadpo's own sign-in gives, or a role given twice ispolicy.requirement_invalid. - A line no way the app declares could ever meet, such as
SignInMethod.external_tokenwithoutexternal_token:, ispolicy.requirement_unsatisfiable. - A route whose API can't carry the sign-in that every role letting
anyone in needs is
policy.effect_ungranted, which names the requirement. A provider's sign-in comes as a browser session andexternal_token's as its token; an API token or a service key carries neither.
A route test says how its user signed in with sign_in: and strength:
beside as:; without them, as: is someone who signed in by magic link
with one factor (Signing in for a route test).
Markdown for agents: /docs/a-membership-role.md.Built from Jadpo main at aee2e8f.