Access Model
Who can do what in Productify: the four role systems, the fixed permission vocabulary that tenant roles are built from, the rule that decides which role wins, what the Manager records in its audit log, and the boundary between what the platform forwards to your application and what your application decides for itself.
The four role systems
| System | Values | Configurable? | Governs |
|---|---|---|---|
System role (User.role) | normal, audit, administrator | Fixed enum | Install-wide operator actions (see below) |
Project role (User2Project.role) | readonly, tester, developer, maintainer, owner | Fixed enum | Operator resources inside a project |
Tenant role (User2Tenant with a TenantRole) | Project-defined; seeded member, admin, owner | Configurable per project | Tenant-scoped resources: members, invites, per-tenant app config |
Application role (User2Application.role) | readonly, normal | Fixed enum | Per-application access |
Only tenant roles are configurable, because customer organizations need their own role vocabulary. System, project and application roles are fixed enums.
The system role audit is stored but nothing reads it: it grants and restricts nothing. Use a project readonly role for observers.
readonly is read-only for everything that already exists, not for everything
A readonly project member cannot rename or delete the project or its applications, change or delete a trigger, mint a machine user, edit or remove anybody's membership, or write settings. What it can still do is create a tenant, an application or a language pack: createTenant, createApplication and createLanguagePack require bare project membership with no role predicate. An observer cannot destroy or repoint anything, but they can add rows. If that matters for your install, do not hand out project membership at all; read access to a project is membership.
Fixed permission vocabulary
Roles are configurable; permissions are not. The platform owns a fixed list of tenant-scoped permissions. Extending it is a platform release, never a per-project change.
| Permission | Grants |
|---|---|
tenant.members.read | List the tenant's members |
tenant.members.manage | Add / remove members and change their roles (subject to the subset rule) |
tenant.invites.manage | Create / resend / revoke invitations; list pending invites |
tenant.roles.manage | Define, edit and delete the tenant's role set; list role definitions |
tenant.apps.read | See which applications are enabled for the tenant and their config |
tenant.apps.configure | Edit per-(tenant, application) configuration, including the communications block |
tenant.lifecycle.manage | Manage the tenant itself (profile, lifecycle) as a delegated tenant administrator |
Authorization checks test permissions, not role names. A role is a named bundle of these permissions. An unknown permission string on a role is dropped when the set is parsed, so it can never widen anyone's authority.
Seeded default roles
Every project is seeded with three roles so simple installs never touch the role machinery:
| Role | Permissions |
|---|---|
member | tenant.apps.read |
admin | tenant.apps.read, tenant.members.read, tenant.members.manage, tenant.invites.manage, tenant.apps.configure |
owner | all seven permissions (including tenant.roles.manage and tenant.lifecycle.manage) |
member is the default role for new tenant members. Projects may rename, add, or reshape roles over the vocabulary. Only owner is seeded with tenant.roles.manage.
Fail-closed rules
Every check here fails closed: an unknown or dangling reference denies, never allows.
- Unknown permission on a role: the permission is ignored, so the check denies.
- Dangling role reference (a membership whose
TenantRolerow is missing): denied. - Subset rule (no self-escalation): you may only assign or define roles whose permissions are a subset of your own. You cannot grant a permission you do not hold, nor edit a role to include one.
- No authenticated user in context: denied with a 401-class error, never a 500.
Editing the role set
Role definitions are editable through three mutations, all gated on tenant.roles.manage in the named tenant:
createTenantRoleDefinition(tenantId: ID!, name: String!, permissions: [String!]!): TenantRoleDefinition!
updateTenantRoleDefinition(tenantId: ID!, roleId: ID!, name: String, permissions: [String!]): TenantRoleDefinition!
deleteTenantRoleDefinition(tenantId: ID!, roleId: ID!): Boolean!On update, both name and permissions are optional, and permissions replaces the set rather than adding to it. tenantRoleDefinitions(tenantId) lists the current set (same gate).
The subset rule applies in both directions. You may only define a role whose permissions are within your own, and the role's existing permissions must also be within your own. Without the second half, a caller holding tenant.roles.manage and little else could narrow the administrator role down to nothing: that escalates nobody, so a requested-set-only check would allow it, while destroying the only role able to undo the change.
Refusals that keep the project administrable:
- A change is refused when it would leave the project with no assigned role carrying
tenant.roles.manage. Every other over-narrowing can be repaired by someone who can still edit roles; this one removes the repairer. - Deleting a role is refused while any membership or pending invite still points at it. Reassign those first.
- Deleting the role flagged default member role is refused. It is the role a self-provisioned member receives under a non-
invite_onlyjoin policy. Nominate a replacement first.
The seeded names are not protected. owner may be renamed, narrowed or deleted, as long as somebody can still administer roles. Role names are project-configurable throughout this product and are not a contract, so guarding the literal string "owner" would defend a label while missing the custom role that holds the authority.
Unknown permissions are refused on create, and refused when editing a role that already carries one (otherwise the parser would silently drop the entry). Deleting such a role is allowed; it only removes authority.
Project roles have their own last-owner refusal, separate from the tenant-role ones above: a removal or demotion that would leave a project with no owner is refused for every caller, administrators included.
Lifecycle actions on operator resources
Renaming or deleting a project, an application, a trigger or somebody else's membership requires maintainer or owner on the owning project, with a system administrator overriding project membership entirely. A readonly member can do none of these.
| Action | Who may call it |
|---|---|
updateProject, deleteProject | Project maintainer or owner, or system administrator |
updateApplication, deleteApplication | Project maintainer or owner, or system administrator |
updateTrigger, deleteTrigger | The same bar createTrigger requires |
createMachineUserWithCredentials | Environment management: the same bar that rotates and deletes a machine user |
updateUser2Project, deleteUser2Project | Project user management, plus the last-owner guard on the delete |
updateUser2Application, deleteUser2Application | Application user management |
updateUser2Tenant, deleteUser2Tenant | Project member, tenant.members.manage, or administrator |
withdrawApplicationFromTenant | Tenant-application availability management |
The three user2* pairs are the raw entgql-generated mutations. They carry the same rank check and the same stranding check as their purpose-built removeUserFrom* siblings, so calling the generated mutation is not a way around either.
The trigger and application-membership gates check ownership first, so an outsider is refused as an outsider rather than being told which role they lack on a row whose existence they have just confirmed. The project and application lifecycle gates put the administrator override first instead, because a non-member administrator may already empty the resource through every gate below it and refusing them only the emptied shell would be incoherent.
Reading configuration
The settings cascade (project, application, tenant, tenant2application) is readable by ordinary members for the frontend layer only; that is what the browser is served as window.__PRODUCTIFY__.config, scoped to the tenant the browser is acting as.
On the backend layer, communications, KeyValuePairs and the module lists are operator-readable only: the same bar as writing them (project maintainer or owner, system administrator, or a tenant principal holding tenant.apps.configure for the tenant-owned layers). A caller below that bar receives an empty value rather than an error, so the response carries communicationsRedacted, keyValuePairsRedacted and moduleListsRedacted; read the flag rather than inferring from emptiness. Writing back a value you were not allowed to read is a wipe.
Settings writes, including the six ent-generated settings mutations that carry only edge ids and can delete or re-parent whole cascade layers, are operator-only.
Walking to a user
User's reverse edges, everything that user created, edited or was granted across the whole install, are readable only by that user or a system administrator. Nobody else can enumerate foreign projects, endpoint upstreams, machine users or audit rows by walking from a row's creator.
Display identity (externalName, externalUsername, externalEmail, externalPicture) stays broadly readable: it is what every members table, audit actor column and creator chip renders. User.role is gated with the edges, because it decides audit export, access review and proxy-config push.
Membership and invitations
| Action | Mutation | Gate |
|---|---|---|
| Add a member | addUserToTenant(tenantId, userId, roleName) | tenant access + tenant.members.manage + subset rule on the granted role |
| Change a member's role | updateUserTenantRole(id, roleName) | same |
| Remove a member | removeUserFromTenant(id) | same; also closes the member's live comms websockets for that tenant |
| List members | tenantMembers(tenantId) | tenant.members.read |
| Create / resend / revoke an invite | createTenantInvite, resendTenantInvite, revokeTenantInvite | tenant.invites.manage; the invited role must satisfy the subset rule |
| List pending invites | tenantPendingInvites(tenantId) | tenant.invites.manage |
| Set the project join policy | setProjectJoinPolicy | project role for user management (maintainer/owner) |
| Search the identity-provider directory | userProviderSearch(name) | maintainer/owner on any project, tenant.members.manage in any tenant, or system administrator |
The directory gate is install-wide rather than per-resource because the query takes no scope argument: it is the picker behind every "Add User" dialog. It decides who may look a name up; the per-resource gate on addUserToProject / addUserToTenant / addUserToApplication decides whether anything is granted. A search upserts every match into the local users table, which is why it is gated at all.
Join policies
invite_only is the default: every grant flows through an explicit invite. open and domain_allowlist admit an uninvited user on first browser login, but only when the target is unambiguous: the application's project must have exactly one tenant with that application enabled and a nominated default member role. The user then receives one tenant membership at the default member role and one application membership at readonly, in a single transaction. With several tenants enabled, the platform cannot know which organization the stranger belongs to, so the policy behaves as invite_only and the refusal is logged. Self-provisioning never grants project membership.
Precedence
- System administrator outranks project roles for operator resources (projects, environments, applications, machine users, endpoints, triggers, audit logs).
- Project role governs operator resources within the project.
- Tenant-role permissions govern only tenant-scoped resources: members, invitations, and per-tenant application configuration. They never grant operator authority, and the system role never overrides them: a platform administrator is not implicitly a member of any customer organization.
A tenant owner is powerful within their organization and powerless over projects, environments, machine users, or other tenants.
What the system administrator role gates
These actions accept only a system administrator; no project or tenant role substitutes:
- Granting or changing system roles:
setUserSystemRole,systemRoleDirectory. Demoting the last administrator is refused. - The audit export and evidence routes:
GET /api/audit/export,GET /api/audit/evidence[/:name]. - The access-review report and attestation:
GET /api/access/review,POST /api/access/review/attest. - Pushing proxy configuration (
pfy proxy sync). - The proxy-status panel on the home dashboard.
On an install with no administrator, grant the first one with the compose bundle's ./install.sh --make-admin, which sets the system role of the user named by ADMIN_EMAIL to administrator.
Delegated tenant administration
A tenant member holding tenant.lifecycle.manage may manage their own tenant: the check passes for a system administrator, then for a member of the owning project, and finally for the tenant permission. This is the path the /tenant-admin/:tenantId UI uses.
Guarding against cross-scope writes
Generated GraphQL update inputs can carry edge references (projectID, tenantID, addXIDs). A resolver that authorizes only the row it was handed, and then writes the input verbatim, would let a caller re-point that row into a scope they were never granted. Three defenses are in place:
- Edges are removed from the inputs where they should never be settable. The
user/projectedges ofUser2Project, theuser/applicationedges ofUser2Application, and therole/tenantedges ofUser2Tenantare absent from the create and update inputs, so a membership row cannot be moved to another scope through the raw update. - Where an edge is legitimately settable, the destination is authorized too.
updateLanguagePackauthorizes the new project before saving. - Where "no scope" is not an operation, the clear is refused.
updateLanguagePack(input: {clearProject: true})returns an error naming the two things you can do instead: passprojectIDto move the pack, ordeleteLanguagePackto remove it. A pack with no project would be unreachable to every caller, because reading, updating and deleting a pack all require membership of its project and there is no administrator override.
A test in the Manager enumerates every settable ID-typed field on every reachable create/update input and fails unless each has a recorded verdict. It covers ID-typed fields only; scalar fields (such as deployment_mode) rely on per-resolver checks.
What is audited
Redaction and the hash chain are enforced for every AuditLog row by the ent create hook, not by any one writer. A row is written in the same transaction as the change it records, so a refused or rolled-back mutation leaves no row. Three kinds of row are deliberately written after the request settles, so a failed request cannot erase them: proxy-config pushes (the push already reached Caddy), personal-access-token creation (the token survives a failed request) and administrator cross-membership reads. The full event catalogue with framework mapping is in Compliance Controls; this is the access-model view.
Recorded (one row per accepted mutation):
- Tenant role definitions: create / update / delete (entity
TenantRole). - Tenant membership: add, role change, remove (entity
User2Tenant). - Invitations: create, resend, revoke and acceptance (entity
Invite). - Personal access tokens: create, revoke, and the CLI device-flow mint.
- System role changes (action
update). - Project join-policy changes.
- Login success and failure on the browser path (actions
login/login_failed). - Machine-user token rotation (
rotate), PAT expiry (expire), and the credential-staleness report the executor writes asotherfor a machine-user or backend token pastcredential_rotation_max_age. - Project membership: add, role change, remove (
User2Project), recording actor, project, target and the before/after role, for every caller. - Cross-membership reads by a system administrator (
view), written when a principal who is not a member reads a project. Coalesced to one row per (actor, project) per hour, because a page load fans out across many resolvers. - Administrator project enumeration (
view, entityProjectList), written when a system administrator lists projects they are not a member of, one row per actor per hour, with its own coalescing key so that opening one of those projects is still recorded. An ordinary member listing their own projects records nothing. - Project, application and tenant lifecycle:
create,updateanddeleteof each, with a before/after snapshot. The delete row is written before the delete, inside the request transaction, so it is the only surviving record of what was destroyed. - All three trigger mutations (entity
Trigger), carrying the schedule andrunKeybefore and after. - The whole machine-user lifecycle (entity
MachineUser): the mint (create), which records no token, hash or prefix and is not best-effort (a failed audit fails the mint), plusupdate,delete, androtatefromrotateMachineUserToken, withkey_rotatedin the metadata.updateMachineUserrefuses key material outright, so anupdaterow never describes a credential change. - Endpoints and endpoint access grants (
Endpoint,MachineUser2Endpoint), including which machine-user credential may call which endpoint. - Environment lifecycle (
Environment) and theSettings/BackendSettings/FrontendSettingscascade, which is where a config leaf is born, re-parented or destroyed. - Application membership: add, role change, remove (
User2Application), plus the join-policy auto-provisioning path. - The generated join-row mutations
updateUser2Project/deleteUser2Project,updateUser2Application/deleteUser2ApplicationandupdateUser2Tenant/deleteUser2Tenant, on the same footing as their hand-written siblings. - Every writer of a
Tenant2Applicationrow: the project-side offer create and enablement flip,updateTenant2Applicationand the config copy, the tenant-side toggle, andwithdrawApplicationFromTenant(recording the shape of the configuration leaf destroyed and never its values). AsetApplicationEnabledForTenant(enabled: false)on a pair that has no row creates one, so that call is recorded as acreate, not a flip. - Language packs (entity
LanguagePack):createLanguagePack,updateLanguagePack,updateLanguagePackContentanddeleteLanguagePack, in the request transaction, recording the pack's name, display name and entry count and never the translated strings. There is no REST write surface, so those four resolvers are the whole family. - Pilets: package update, enable and disable (entity
PiletPackage), rollout, canary promotion and rollback (entityPiletRollout, recording who published which build to which tenants), and the REST upload and delete of a package. - Deployment specs, services, data dependencies, dedicated-tenant topology changes, comms configuration, comms emit/publish, audit export, access review and attestation, retention prunes, chain anchors.
Not recorded:
- Logout (it happens in the proxy and the identity provider; the Manager never sees it).
- Failed PAT or machine-credential authentication (rate-limited and locked out, but not written to
AuditLog). - The
revokeaction, as a value: revocations exist but are written asupdateordelete. Nothing emitsrevoke.
Do not read the presence of the audit subsystem as a guarantee that a given operation is recorded; check the list above, and check the specific mutation before relying on a row existing.
Platform forwards facts; apps own capability
The framework grants access; applications grant capability:
- The platform forwards the membership fact, your user's role and permissions in the selected tenant, never an application-specific permission.
- In-app RBAC (for example, a consumer's own five roles and its
CanPerformchecks) stays application territory. The platform does not move it.
Opt-in identity forwarding
Forwarding is opt-in per (tenant, application) through the forward_role flag on Tenant2Application (default off; set it with updateTenant2Application). Apps that own their RBAC are unaffected. When enabled, the proxy adds these request headers:
| Header | Meaning |
|---|---|
X-User-Tenant-Role | The membership role name in the selected tenant |
X-User-Tenant-Permissions | Comma-separated permissions from the fixed vocabulary |
X-User-Picture | The Pocket ID picture claim, when present |
window.__PRODUCTIFY__ exposes the same values as tenantRole, tenantPermissions, and user.picture. The proxy's sanitizer strips any client-supplied copies of these headers at the edge.
Gate on permissions, not role names
Role names are project-configurable and therefore not a stable contract. An app that needs to gate a feature should check tenant.members.manage (a permission), not the string "admin" (a name one project may rename).
Related
- Authentication: how identity reaches the Manager
- Multi-tenancy: tenants, membership, and selection
- User Management: assigning roles in the UI
- Session Policy & Logout: sessions, cookies and stored credentials
- Compliance Controls: the audit event catalogue