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

Available since 2.2

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 granted person:list see every person.
  • A caller without person:list sees exactly the members reachable via project.members — for each project the caller may view members of, the persons behind the visible memberships, with the same per-role filtering that project.members applies.
  • 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:

CodeCause
IDENTITY_NOT_FOUNDNo such identity.
INVALID_ROLEA 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

Available since 2.2

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.