An identity can be a member of any project with some role and optionally variables assigned. This is called a membership. A project admin (and a superadmin) can manage project memberships by default, you can also setup Tenant ACL permissions for other user roles.
Creating a project membership
Add an existing identity to a project. For this operation you need to know an identity ID. If you want to add a user by an email check invite mutation
mutation {
addProjectMember(
projectSlug: "my-blog"
identityId: "2f673a53-af33-42b1-9e17-e1305fa26d9d"
memberships: [
{
role: "editor",
variables: [{name: "language", values: ["cs"]}]
}
]
) {
ok
error {
code
}
}
}
a mutation can fail with following errors:
enum AddProjectMemberErrorCode {
PROJECT_NOT_FOUND
IDENTITY_NOT_FOUND
VARIABLE_NOT_FOUND
ALREADY_MEMBER
}
Updating a project membership
You can update existing member of an project using updateProjectMember mutation. Arguments and response structure of the mutation are the same as of addProjectMeber mutation. Only error codes differs:
enum UpdateProjectMemberErrorCode {
PROJECT_NOT_FOUND
VARIABLE_NOT_FOUND
NOT_MEMBER
}
Revoking a project membership
You can remove a project member using following mutation
mutation {
removeProjectMember(
projectSlug: "my-blog"
identityId: "2f673a53-af33-42b1-9e17-e1305fa26d9d"
) {
ok
error {
code
}
}
}
Viewing project members
query {
projectBySlug(slug: "my-blog") {
name
members {
identity {
id
person {
email
}
}
memberships {
role
variables {
name
values
}
}
}
}
}
Listing persons across the tenant
projectBySlug { members } lists the members of one project. To list persons across the whole tenant — for a user-management UI — use the top-level persons query:
query {
persons(filter: { email: "jane" }, limit: 50, offset: 0) {
id
email
name
otpEnabled
emailOtpEnabled
identity {
id
roles
}
}
}
filter (all fields combined with AND, all optional) matches by email (case-insensitive substring), personId, or identityId. limit / offset paginate; the page size is capped server-side (default 100, max 1000), so an unbounded limit can't dump the whole table.
Visibility is enforced per caller and gated by the person:list permission:
SUPER_ADMIN,PROJECT_ADMIN, and a custom role grantedperson:listsee every person.- A caller without
person:listsees exactly the members reachable viaproject.members— for each project the caller may view members of, the persons behind the visible memberships, with the same per-role filtering thatproject.membersapplies. - A caller who may not list anyone gets an empty list rather than an error.
The projection deliberately omits password hashes and TOTP secrets. See two-factor for the otpEnabled / emailOtpEnabled MFA flags.
Global identity roles
Project memberships scope a person's access to a single project. Global roles — the built-in ones such as super_admin and project_admin, plus any custom role — are attached directly to an identity and are not project-scoped. Two mutations grant and revoke them:
mutation {
addGlobalIdentityRoles(
identityId: "2f673a53-af33-42b1-9e17-e1305fa26d9d",
roles: ["super_admin", "support"]
) {
ok
error { code developerMessage }
result { identity { id roles } }
}
}
mutation {
removeGlobalIdentityRoles(
identityId: "2f673a53-af33-42b1-9e17-e1305fa26d9d",
roles: ["support"]
) {
ok
error { code }
result { identity { id roles } }
}
}
Errors:
| Code | Cause |
|---|---|
IDENTITY_NOT_FOUND | No such identity. |
INVALID_ROLE | A role in the list is neither a globally assignable built-in role nor an existing custom role. |
The assignable vocabulary is the globally assignable built-in roles — super_admin, project_creator, project_admin, entrypoint_deployer, login, person — plus any custom role that currently exists. self and project_member are derived per request and are rejected. Removal is deliberately not validated, so a slug left over from a deleted custom role can always be cleaned up.
Both are gated by identity:addGlobalRoles / identity:removeGlobalRoles against the role list and the target identity — SUPER_ADMIN can grant any role; a global PROJECT_ADMIN can grant anything from that vocabulary except super_admin and project_creator, and cannot act on an identity holding either; and a custom role is further bounded by its own configured roles.allowed and target constraints. result.identity.roles echoes the new role set after the mutation — but only to a caller that may read it: the field resolves to null without identity:viewPermissions, which SUPER_ADMIN and PROJECT_ADMIN hold by default and a custom role must be granted explicitly.
Audit
Every successful addProjectMember, updateProjectMember, and removeProjectMember is recorded in the audit log as project_membership_create / project_membership_update / project_membership_remove, with a {before, after} snapshot of the affected memberships and target_person_id resolved from the affected identity.
Global role mutations (addGlobalIdentityRoles, removeGlobalIdentityRoles) are audited the same way as global_role_grant / global_role_revoke.