Skip to content

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 ​

SystemValuesConfigurable?Governs
System role (User.role)normal, audit, administratorFixed enumInstall-wide operator actions (see below)
Project role (User2Project.role)readonly, tester, developer, maintainer, ownerFixed enumOperator resources inside a project
Tenant role (User2Tenant with a TenantRole)Project-defined; seeded member, admin, ownerConfigurable per projectTenant-scoped resources: members, invites, per-tenant app config
Application role (User2Application.role)readonly, normalFixed enumPer-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.

PermissionGrants
tenant.members.readList the tenant's members
tenant.members.manageAdd / remove members and change their roles (subject to the subset rule)
tenant.invites.manageCreate / resend / revoke invitations; list pending invites
tenant.roles.manageDefine, edit and delete the tenant's role set; list role definitions
tenant.apps.readSee which applications are enabled for the tenant and their config
tenant.apps.configureEdit per-(tenant, application) configuration, including the communications block
tenant.lifecycle.manageManage 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:

RolePermissions
membertenant.apps.read
admintenant.apps.read, tenant.members.read, tenant.members.manage, tenant.invites.manage, tenant.apps.configure
ownerall 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 TenantRole row 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:

graphql
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_only join 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.

ActionWho may call it
updateProject, deleteProjectProject maintainer or owner, or system administrator
updateApplication, deleteApplicationProject maintainer or owner, or system administrator
updateTrigger, deleteTriggerThe same bar createTrigger requires
createMachineUserWithCredentialsEnvironment management: the same bar that rotates and deletes a machine user
updateUser2Project, deleteUser2ProjectProject user management, plus the last-owner guard on the delete
updateUser2Application, deleteUser2ApplicationApplication user management
updateUser2Tenant, deleteUser2TenantProject member, tenant.members.manage, or administrator
withdrawApplicationFromTenantTenant-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 ​

ActionMutationGate
Add a memberaddUserToTenant(tenantId, userId, roleName)tenant access + tenant.members.manage + subset rule on the granted role
Change a member's roleupdateUserTenantRole(id, roleName)same
Remove a memberremoveUserFromTenant(id)same; also closes the member's live comms websockets for that tenant
List memberstenantMembers(tenantId)tenant.members.read
Create / resend / revoke an invitecreateTenantInvite, resendTenantInvite, revokeTenantInvitetenant.invites.manage; the invited role must satisfy the subset rule
List pending invitestenantPendingInvites(tenantId)tenant.invites.manage
Set the project join policysetProjectJoinPolicyproject role for user management (maintainer/owner)
Search the identity-provider directoryuserProviderSearch(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 ​

  1. System administrator outranks project roles for operator resources (projects, environments, applications, machine users, endpoints, triggers, audit logs).
  2. Project role governs operator resources within the project.
  3. 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/project edges of User2Project, the user/application edges of User2Application, and the role/tenant edges of User2Tenant are 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. updateLanguagePack authorizes 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: pass projectID to move the pack, or deleteLanguagePack to 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 as other for a machine-user or backend token past credential_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, entity ProjectList), 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, update and delete of 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 and runKey before 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), plus update, delete, and rotate from rotateMachineUserToken, with key_rotated in the metadata. updateMachineUser refuses key material outright, so an update row 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 the Settings/BackendSettings/FrontendSettings cascade, 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/deleteUser2Application and updateUser2Tenant/deleteUser2Tenant, on the same footing as their hand-written siblings.
  • Every writer of a Tenant2Application row: the project-side offer create and enablement flip, updateTenant2Application and the config copy, the tenant-side toggle, and withdrawApplicationFromTenant (recording the shape of the configuration leaf destroyed and never its values). A setApplicationEnabledForTenant(enabled: false) on a pair that has no row creates one, so that call is recorded as a create, not a flip.
  • Language packs (entity LanguagePack): createLanguagePack, updateLanguagePack, updateLanguagePackContent and deleteLanguagePack, 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 (entity PiletRollout, 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 revoke action, as a value: revocations exist but are written as update or delete. Nothing emits revoke.

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 CanPerform checks) 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:

HeaderMeaning
X-User-Tenant-RoleThe membership role name in the selected tenant
X-User-Tenant-PermissionsComma-separated permissions from the fixed vocabulary
X-User-PictureThe 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).