# Permio documentation (full text) > Permio (perms.io) is permissions as a service: resource-level role-based access control (RBAC) behind an API. > You define permissions and roles, grant them to principals (and groups) on hierarchical resource URIs, and ask > from your backend whether a principal may do something on a resource. Grants are inherited by every URI beneath > them. Permio only does authorisation; principals are the user IDs your own login system already uses. This file is generated from the pages at https://docs.perms.io/ by `scripts/build_llms_full.py`. Do not edit it by hand. A page index is at https://docs.perms.io/llms.txt. --- Source: https://docs.perms.io/ ## Overview Permio ([perms.io](https://perms.io)) is permissions as a service. It gives you **resource-level role-based access control (RBAC)**: you define permissions and roles, grant them to your users on **resource URIs**, and ask one question from your code: > May principal **alice** do **document.read** on **/documents/42**? Manage everything in the dashboard at [app.perms.io](https://app.perms.io), and check permissions through the API. ### Key features #### Resource-level RBAC Grant access to individual objects, such as `/projects/42` or `/documents/42/comments/7`, not just to whole types. #### Hierarchical inheritance A grant on `/documents` applies to `/documents/42` and everything beneath it, including resources created later. The `_` wildcard segment lets one grant cover a whole level. See [Resources and inheritance](https://docs.perms.io/concepts/resources/). #### Groups Grant to a group such as `group:editors` once, and every member holds the grant. See [Groups](https://docs.perms.io/concepts/groups/). #### Bring your own authentication perms.io only does authorisation. Principals are whatever IDs your app already uses, so it works with any login system. #### API first - One **REST/JSON**, **gRPC** and **gRPC-web** API. See the [API reference](https://docs.perms.io/api-reference/). - Official SDKs for **[TypeScript](https://docs.perms.io/sdk/typescript/)**, **[Go](https://docs.perms.io/sdk/go/)**, **[Python](https://docs.perms.io/sdk/python/)**, **[Rust](https://docs.perms.io/sdk/rust/)** and **[Java](https://docs.perms.io/sdk/java/)**, with check helpers, typed errors and retries. - A dashboard for managing projects, permissions, roles, grants and API keys. ### Get started - **[Quickstart](https://docs.perms.io/quickstart/)**: from sign-up to a passing check in five minutes. - **[How it works](https://docs.perms.io/how-it-works/)**: permissions, roles, principals and grants. - **[API keys](https://docs.perms.io/concepts/api-keys/)**: key format, access levels and invalidation. - **[IAM fundamentals](https://docs.perms.io/iam-basics/)**: background on access control in general. ### For AI assistants If you use a coding assistant or an LLM, point it at these files: - [`llms.txt`](https://docs.perms.io/llms.txt) (`https://docs.perms.io/llms.txt`): an index of every page on this site, with a one-line description of each. - [`llms-full.txt`](https://docs.perms.io/llms-full.txt) (`https://docs.perms.io/llms-full.txt`): the concepts, quickstart, SDKs and API reference in one Markdown file, to load straight into the model's context. For example, give your assistant this prompt: ```text Add Permio permissions to this app using the `permio` npm package. Docs: https://docs.perms.io/llms.txt ``` Install it with `npm install permio`. See the [TypeScript SDK](https://docs.perms.io/sdk/typescript/). ### Support Questions or problems? Email [hello@perms.io](mailto:hello@perms.io). --- Source: https://docs.perms.io/quickstart/ ## Quickstart This guide takes you from a new account to a passing permission check in about five minutes. You'll use the dashboard to set things up, then make the check from code. ### 1. Sign up Go to [app.perms.io](https://app.perms.io) and click **Sign up**. You can sign up with email and password or with Google. Next, name your organisation and click **Create organisation**. The organisation holds your projects, API keys, billing and team. ### 2. Use the `default` project A project called `default` is created with your organisation, and the dashboard selects it for you. Permissions, roles, grants and API keys all belong to a project. You can switch projects, or create more, from the project switcher at the top of the dashboard. This guide uses `default` throughout. ### 3. Create a permission and a role A **permission** is an action your code checks, such as `document.read`. A **role** is a named set of permissions. 1. Open **Permissions** and click **Create Permission**. Enter the name `document.read`, optionally add a description, and click **Create permission**. 2. Open **Roles** and click **Create New Role**. Enter the name `viewer`, tick `document.read` under **Permissions**, and click **Create role**. **Tip** A permission's name is the string your code checks, and it can't be renamed later. A `resource.action` style such as `document.read` works well. ### 4. Grant the role to a principal on a resource A **principal** is whoever you are checking: usually one of your users, identified by the ID your app already uses. perms.io doesn't need to know about the principal in advance. 1. Open **Principals** and click **Grant Access**. 2. Fill in: - **Principal ID**: `alice` - **Resource URI**: `/documents` - **Roles**: `viewer` 3. Click **Grant access**. Alice can now read `/documents` and **everything beneath it**. ### 5. Create an API key 1. Open **API Keys** and click **Create New Key**. 2. Name it, for example `quickstart`, and click **Create key**. 3. Copy the key. **It is only shown once.** Keys look like `perms_` followed by 38 letters and digits. Keep the key on your server. See [API keys](https://docs.perms.io/concepts/api-keys/) for details. ### 6. Make a check Ask whether `alice` may read document 42: **Note: API host** The API is at `https://api.perms.io`. The examples on this site read the host from a `PERMIO_API` shell variable: ```bash export PERMIO_API=https://api.perms.io export PERMIO_API_KEY=perms_... # your API key ``` **TypeScript** ```bash npm install permio ``` ```ts import { Permio, resource } from "permio"; const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default", }); const allowed = await permio.check({ principal: "alice", resource: resource("documents", 42), // "/documents/42" permission: "document.read", }); console.log(allowed); // true ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/check" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "project_name": "default", "principal_id": "alice", "resource_uris": ["/documents/42"], "permissions": ["document.read"] }' ``` ```json {"passed": true} ``` The check passes even though you never granted anything on `/documents/42`. The grant on `/documents` is inherited by every resource beneath it. #### A denied check Now check a resource outside `/documents`: **TypeScript** ```ts const result = await permio.checkDetailed({ principal: "alice", resource: "/invoices/1", permission: "document.read", }); console.log(result); // { allowed: false, missing: [{ resource: "/invoices/1", permissions: ["document.read"] }] } ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/check" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "project_name": "default", "principal_id": "alice", "resource_uris": ["/invoices/1"], "permissions": ["document.read"] }' ``` ```json {"missingPermissions": [{"resourceUri": "/invoices/1", "missingPermissions": ["document.read"]}]} ``` JSON responses use camelCase field names and leave out fields with default values, so `"passed": false` is not printed. A denial is a normal answer, not an error. You get a successful response with `passed` false and a list of what is missing. Errors are kept for problems with the request itself: a bad key, malformed input or a key that isn't allowed to check. See [Errors](https://docs.perms.io/api-reference/#errors). ### Next steps - [Resources and inheritance](https://docs.perms.io/concepts/resources/): design your resource URIs, and use the `_` wildcard. - [Groups](https://docs.perms.io/concepts/groups/): grant to a team once instead of to each user. - [TypeScript SDK](https://docs.perms.io/sdk/typescript/): `assert`, `checkAny`, error handling, and Express and Next.js examples. - [API reference](https://docs.perms.io/api-reference/): every endpoint, for cURL, gRPC and the generated clients. --- Source: https://docs.perms.io/how-it-works/ ## How it works Permio implements **resource-level role-based access control**. Your application keeps handling sign-in. perms.io stores who may do what, on which resources, and answers permission checks. ### The building blocks #### Organisations and projects Your **organisation** holds your team, billing and projects. A **project** is a separate space for permissions, roles, grants and API keys. Every organisation starts with a project called `default`. You might add others, for example to separate environments (`staging`, `production`) or products. Nothing is shared between projects. A check in one project never sees another project's grants. #### Permissions A **permission** is a named action that your code checks, for example: - `document.read` - `document.write` - `invoice.pay` Names can use letters, digits, `.`, `_` and `-`, up to 128 characters. A `resource.action` style works well. #### Roles A **role** is a named set of permissions, such as `viewer` = `document.read`, or `editor` = `document.read` and `document.write`. Granting a role grants all of its permissions. #### Principals A **principal** is whoever you check: usually a user, but it could be a service, a device or anything else. It is identified by a string that **you** choose, typically the user ID from your auth provider. You don't register principals in advance. A principal appears in perms.io once it has a grant. API keys are principals too. Each key is granted a role on its own project, which decides what it may call. See [API keys](https://docs.perms.io/concepts/api-keys/). #### Groups A **group** is a named set of principals in a project, such as `editors`. Grant to the group's principal ID, `group:editors`, and every member holds the grant; checks include a principal's groups automatically. See [Groups](https://docs.perms.io/concepts/groups/). #### Resources A **resource** is anything you protect, identified by a URI path such as `/documents/42`. See [Resources and inheritance](https://docs.perms.io/concepts/resources/) for the rules. #### Grants A **grant** gives a principal some permissions and/or roles on one resource URI. A grant applies to that resource and to everything beneath it: ``` grant viewer to alice on /documents check alice document.read /documents/42 → passed check alice document.read /invoices/1 → failed, missing document.read on /invoices/1 ``` ### Checks A check asks: does a principal hold **every** listed permission on **every** listed resource? You can check up to 100 resources and 100 permissions in one call. The answer is `passed: true`, or `passed: false` with the missing permissions per resource. A denial is a normal response, not an error. API errors mean the request itself couldn't be carried out, for example because the key is invalid or the input is malformed. ### Where perms.io fits in perms.io only does authorisation. It doesn't do authentication. ``` 1. Client ──────────→ Your app: request with a session or token 2. Your app ────────→ Your auth provider: who is this? → user ID "alice" 3. Your app ────────→ perms.io: may alice do document.read on /documents/42? 4. perms.io ────────→ Your app: passed / missing permissions 5. Your app ────────→ Client: the data, or 403 ``` Your backend calls perms.io with an API key. Keep the key on the server; never ship it to browsers or mobile apps. ### Managing access - **Dashboard** ([app.perms.io](https://app.perms.io)): create projects, permissions and roles, grant and revoke access on the **Principals** page, and manage API keys and your team. - **API**: every dashboard action is also an RPC. See the [API reference](https://docs.perms.io/api-reference/). Writes need an API key with full access, which is [on its way](https://docs.perms.io/concepts/api-keys/#access-levels). ### Patterns #### Multi-tenant applications Put the tenant at the top of every URI and grant tenant admins on the tenant: ``` /tenants/acme ← grant tenant_admin to the Acme admin /tenants/acme/projects/web-app ← grant editor to project members /tenants/acme/projects/web-app/documents/42 ``` A grant under `/tenants/acme` can never reach `/tenants/globex`. #### Per-object sharing Grant on the exact object to share one document with one user: ``` grant viewer to bob on /documents/42 ``` #### The same rule at every level Use `_` in a grant to match any single segment, for example to let a moderator manage the comments on every document: ``` grant comment_moderator to carol on /documents/_/comments ``` ### Next steps - Follow the [Quickstart](https://docs.perms.io/quickstart/). - Add checks to your app with the [TypeScript SDK](https://docs.perms.io/sdk/typescript/), including Express and Next.js examples, or the [Go](https://docs.perms.io/sdk/go/), [Python](https://docs.perms.io/sdk/python/), [Rust](https://docs.perms.io/sdk/rust/) or [Java](https://docs.perms.io/sdk/java/) SDK. --- Source: https://docs.perms.io/concepts/resources/ ## Resources and inheritance Every grant and every check in perms.io names a **resource URI**. A resource URI is a `/`-separated path that you choose to identify something in your application. Your URIs don't need to match your app's real URLs; they only need to be consistent. ``` /documents /documents/42 /documents/42/comments/7 /tenants/acme/projects/web-app ``` ### Rules for URIs A resource URI: - starts with `/`; - has no empty segments, so no `//` and no trailing `/`; - contains no whitespace or control characters; - is at most 2048 bytes long; - contains none of these characters in any segment: `( ) [ ] { } * + ? | ^ $ \`. A request that breaks these rules is rejected with `InvalidArgument`. If your IDs can contain reserved characters, percent-encode each segment. The TypeScript SDK's [`resource()`](https://docs.perms.io/sdk/typescript/#resourcesegments) helper does this for you. ### Grants apply to everything beneath them A grant on a URI applies to that URI **and every URI below it**. Matching works segment by segment, so a grant on `/documents` covers `/documents/42` but not `/documents-archive`. ``` /documents ← grant document.read to alice here ├── /documents/42 ✔ alice can read │ └── /documents/42/comments/7 ✔ alice can read └── /documents/43 ✔ alice can read /invoices/1 ✘ not covered ``` | Grant on | Check on | Result | | --- | --- | --- | | `/documents` | `/documents` | allowed | | `/documents` | `/documents/42` | allowed | | `/documents` | `/documents/42/comments/7` | allowed | | `/documents/42` | `/documents` | **denied** (grants don't flow upwards) | | `/documents` | `/invoices/1` | **denied** | Inheritance also covers resources that didn't exist when the grant was made. Grant on a tenant or a folder once, and everything you later create beneath it is covered. ### The `_` wildcard segment A segment that is exactly `_` in a **stored grant** matches exactly one segment, whatever its value: | Grant on | Check on | Result | | --- | --- | --- | | `/documents/_/comments` | `/documents/42/comments` | allowed | | `/documents/_/comments` | `/documents/42/comments/7` | allowed (beneath a match) | | `/documents/_/comments` | `/documents/42` | **denied** (the grant is deeper) | | `/documents/_/comments` | `/documents/42/attachments` | **denied** | In a URI being **checked**, `_` is literal. It is not a wildcard, and it only matches grants made with `_` in that position. You can't widen a check by sending `_`. ### How a check is decided A check asks: does principal **P** hold **every** permission in a list on **every** resource in a list, within one project? 1. A permission counts if the principal holds it directly, or through a role, in a grant on the resource or on any ancestor of it (including `_` matches). 2. If every permission is held on every resource, the check passes (`passed: true`). 3. Otherwise it fails (`passed: false`) and `missing_permissions` lists, per resource, the permissions that weren't found. A failed check is a normal response, not an error. Checks fail closed: a URI with no applicable grant is denied. Grants belong to a **project**. A check in one project never sees grants made in another. ### Designing your URIs - **Put the thing you grant on at the top.** If you usually grant per tenant, start URIs with the tenant: `/tenants/{tenantId}/...`. - **Use stable IDs, not names** that can change. - **Build URIs the same way everywhere.** A grant on `/Documents/42` doesn't cover `/documents/42`. - **Check the most specific URI** for the object being accessed. Inheritance takes care of broader grants. ``` /tenants/acme ← tenant admins /tenants/acme/projects/web-app ← project members /tenants/acme/projects/web-app/documents/42 ← what your code checks ``` --- Source: https://docs.perms.io/concepts/groups/ ## Groups A **group** is a named set of principals in a project. You grant permissions and roles to the group once, and every member holds them. Use groups for teams, departments or any set of users that should share access, instead of repeating the same grants for each user. ``` group:editors ── document.write on /documents ├── alice ✔ can write /documents/42 └── bob ✔ can write /documents/42 ``` ### The `group:` principal Each group has a **slug**, its name: 1–64 lower-case letters, digits and hyphens, unique within the project. Grants to the group are made to the principal ID `group:`. You use it exactly like any other principal ID when you grant, revoke or list assignments: **TypeScript** ```ts import { group } from "permio"; await permio.permissions.grantUserPermissionsAndRolesOnResource({ projectName: "default", userId: group("editors"), // "group:editors" resourceUri: "/documents", permissions: ["document.write"], }); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/assign" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "user_id": "group:editors", "resource_uri": "/documents", "permissions": ["document.write"]}' ``` The `group:` prefix is reserved: - A grant to `group:` is only accepted if that group exists in the project. Otherwise the call fails with `NOT_FOUND`. A `group:` ID that isn't a valid slug is rejected with `INVALID_ARGUMENT`. - Revoking from a `group:` principal always works, even if the group no longer exists, so stray grants can be cleaned up. - A `group:` ID can never be a member of a group. ### Groups belong to a project Like permissions, roles and grants, a group belongs to one **project**. Its members and its grants only apply in that project. Two projects can each have a group called `editors`, and they are unrelated. ### Members A member is any principal ID, the same IDs you pass to a check. perms.io doesn't need to know the principal in advance. You add and remove up to 100 members per call. The response tells you which principals were added and which were already members (or, when removing, which weren't members). Groups are **flat**: a group can't contain another group. Adding a `group:` principal as a member is rejected with `INVALID_ARGUMENT`. You can also see the groups a principal belongs to (`ListGroupsForPrincipal`), and list a group's members. ### How checks use groups When you check a principal, perms.io counts the principal's own grants **and** the grants of every group it is a member of in that project. Nothing changes in your code: you still check `alice`, and access she has through `group:editors` counts. The same rules apply everywhere else a check is answered: [BatchCheck](https://docs.perms.io/api-reference/permissions/#batchcheck) and [ListAccess](https://docs.perms.io/api-reference/permissions/#listaccess) include the principal's groups too. [ListPrincipalsWithAccess](https://docs.perms.io/api-reference/permissions/#listprincipalswithaccess) lists the group itself, and with `expand_groups` also lists its members. See [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/). Membership changes apply to the next check straight away. Adding alice to `editors` gives her the group's grants everywhere in the project, including grants made to the group later. ### Changing and deleting a group - **No renaming.** A group's slug can't be changed, because `group:` is the principal ID its grants are stored under. You can change its description. To rename, create a new group, grant it the same access, move the members and delete the old one. - **Deleting a group** also removes all its memberships and every grant made to `group:`, in one transaction. Members keep any access they have through their own grants or other groups. Deleting a group that doesn't exist succeeds. ### Billing Groups don't count towards your plan's unique principals. Their **members** do: a principal that is a member of a group counts as a unique principal, just as if it had been granted something directly. A principal is counted once, however many groups and grants it has. Adding a member who is new to your organisation goes through the same billing check as granting to a new principal. If your organisation isn't allowed to add principals, the call fails with `PERMISSION_DENIED`. ### Permissions you need Managing groups needs these permissions on the project. In the dashboard they come with your organisation role. For an API key, see [Access levels](https://docs.perms.io/concepts/api-keys/#access-levels). | Action | Needs | | --- | --- | | Create a group | `group.create` | | View or list groups and members | `group.get`, `group.list`, `group.member.list` | | Change a description | `group.update` | | Delete a group | `group.delete`, plus `principal.assignment.delete`, because it revokes the group's grants | | Add or remove members | `group.member.add` / `group.member.remove`, **plus** `principal.assignment.create` on the whole project | | Grant to a group | The same as any grant: `principal.assignment.create` on the resource | Changing membership requires the right to grant across the whole project because a member gets the group's grants anywhere in the project. Only someone who could already grant that access directly can add members. This stops group membership from being used to get around the limits on who may grant what. A **check-access** API key can read groups and members. Creating, changing and deleting groups and changing membership needs a **full-access** key. ### Managing groups - **Dashboard:** use the Groups page in the dashboard to create groups and manage their members. - **TypeScript SDK:** `permio.groups` (available from 0.2.0). See [Groups](https://docs.perms.io/sdk/typescript/#groups). - **Other SDKs:** `client.Groups()` in [Go](https://docs.perms.io/sdk/go/#groups), `permio.groups` in [Python](https://docs.perms.io/sdk/python/#groups), `permio.groups()` in [Rust](https://docs.perms.io/sdk/rust/#groups) and [Java](https://docs.perms.io/sdk/java/#groups). - **API:** the group RPCs in the [API reference](https://docs.perms.io/api-reference/permissions/#groups). ```ts await permio.groups.create("editors", { description: "Can edit every document" }); await permio.groups.addMembers("editors", ["alice", "bob"]); // grant to group("editors") as above, then: await permio.check({ principal: "alice", resource: "/documents/42", permission: "document.write" }); // true ``` --- Source: https://docs.perms.io/concepts/api-keys/ ## API keys Your backend authenticates to the perms.io API with an **API key**. Each key belongs to one project. ### Creating a key In the [dashboard](https://app.perms.io), pick the project in the project switcher, open **API Keys** and click **Create New Key**. Give the key a name, ideally after the service or environment that will use it, and click **Create key**. **Warning: Shown once** The full key is only shown once, when it is created. Copy it into your secrets manager straight away. perms.io only stores a hash of the key, so it can't show it to you again. If you lose a key, create a new one and invalidate the old one. ### Format Keys are opaque, 44-character strings: ``` perms_ + 32 random base62 characters + 6-character checksum ``` - The `perms_` prefix makes leaked keys easy for secret scanners to spot. - The last 6 characters are a CRC32 checksum of the random part. Clients can use them to reject a mistyped or truncated key before sending it. The [TypeScript SDK](https://docs.perms.io/sdk/typescript/) does this in its constructor. - The checksum only catches typos. Whether a key exists and is still valid is decided by the API. Older keys were issued as JWTs. These are still accepted. ### Sending a key Send the key as a bearer token on every request: **HTTP** ``` Authorization: Bearer perms_... ``` **gRPC metadata** ``` authorization: Bearer perms_... ``` A request with a missing, unknown or invalidated key is rejected with `Unauthenticated` (HTTP 401). ### Access levels A key is a principal in its own right. When it is created, the key is granted a role on its project, and that role decides which calls the key may make. | Access | Role | Allows | | --- | --- | --- | | **Check** (default) | `apikey.checker` | `Check`, `BatchCheck`, `ListAccess` and `ListPrincipalsWithAccess`. Reading permissions, roles, principals, assignments, groups and group members (`permission.check`, `permission.get`, `permission.list`, `role.get`, `role.list`, `principal.list`, `principal.assignment.get`, `principal.assignment.list`, `group.get`, `group.list`, `group.member.list`). | | **Full access** | `apikey.manager` | Everything above, plus creating, updating and deleting permissions, roles and groups, granting and revoking, and adding and removing group members (`permission.create/update/delete`, `role.create/update/delete`, `principal.assignment.create/delete`, `group.create/update/delete`, `group.member.add/remove`). | Each RPC's level is listed in the [API reference](https://docs.perms.io/api-reference/permissions/). #### Choosing the level The level is set when the key is created, with the `access` field of [`CreateAPIKey`](https://docs.perms.io/api-reference/organizations/#api-keys): | `access` | Level | | --- | --- | | `API_KEY_ACCESS_CHECK` | Check. | | `API_KEY_ACCESS_FULL` | Full access. | | `API_KEY_ACCESS_UNSPECIFIED` (or left out) | Check. | The key's `access` field reports its level when you list keys. A key's level can't be changed later: create a new key with the level you need and invalidate the old one. To create a key with a given level, you must hold every permission that level grants on the project, so nobody can create a key with more access than they have. Otherwise the call fails with `PERMISSION_DENIED`. Choosing the level when creating a key in the dashboard is being added. Until it is available there, keys created in the dashboard get **check** access. Give a key the least access it needs. A service that only checks permissions, filters lists or shows who has access needs only check access. Use full access for services that manage permissions, roles, grants or groups. A call the key isn't allowed to make is rejected with `PermissionDenied` (HTTP 403). This is about the key's rights, not the principal you are checking. For `Check`, the key needs `permission.check` on the checked resources and on the principal, which the check role gives it across its whole project. Neither role includes organisation-level permissions. Most [Organisation service](https://docs.perms.io/api-reference/organizations/) calls are meant for the dashboard and will be denied for an API key. ### Invalidating a key On the **API Keys** page, click **Invalidate** next to the key and confirm. The key stops working immediately and this can't be undone. Its grants on the project are removed too. Invalidated keys stay listed, marked **Invalidated**. Keys created in the dashboard don't expire, so invalidate any key you no longer need. ### Good practice - Use one key per service and environment, so you can invalidate one without affecting the others. - Keep keys on the server. Never ship a key in browser or mobile code: anyone who loads the page can read it. - Store keys in a secrets manager or environment variables, not in source control. --- Source: https://docs.perms.io/guides/filtering-and-sharing/ ## Filtering lists and building share dialogs `Check` answers one question about one object. Lists and share dialogs need different questions: | You want to… | Use | SDK (from 0.2.0) | | --- | --- | --- | | Show only the rows a user may see, filtered **in your database** | [ListAccess](https://docs.perms.io/api-reference/permissions/#listaccess) | `listAccess()` → `toPrefixes()`, `toRegExp()` | | Filter rows you **already loaded** | ListAccess, or [BatchCheck](https://docs.perms.io/api-reference/permissions/#batchcheck) | `allows()`, `batchCheck()` | | Show **who** can access an object | [ListPrincipalsWithAccess](https://docs.perms.io/api-reference/permissions/#listprincipalswithaccess) | `whoHasAccess()` | All three apply exactly the same rules as `Check`: inheritance, the `_` wildcard, roles and [groups](https://docs.perms.io/concepts/groups/). The examples use the [TypeScript SDK](https://docs.perms.io/sdk/typescript/). The methods on this page are **available from 0.2.0**. The [API reference](https://docs.perms.io/api-reference/permissions/#access-queries) has the same calls over REST and gRPC. ### Permio returns scopes, not records perms.io doesn't know your rows. It only knows the grants you made. So `ListAccess` can't return "documents 4, 7 and 9". It returns **scopes**: the places in your URI tree where the principal holds the permission. Because a grant applies to its URI [and everything beneath it](https://docs.perms.io/concepts/resources/#grants-apply-to-everything-beneath-them), each scope stands for a whole subtree. A grant on `/foo/bar` gives the scope `/foo/bar`, and it covers `/foo/bar`, `/foo/bar/baz` and anything later created below it. Your code turns the scopes into a filter on your own data. ``` grants for alice (document.read) scopes under /documents /documents/4 → /documents/4 SUBTREE /documents/7 → /documents/7 SUBTREE /documents/_/comments → /documents/_/comments PATTERN ``` There are three possible answers: - **`coversAll`**: the principal holds the permission on the `under` URI itself, so on everything below it. Don't filter at all. - **SUBTREE scopes**: a URI and everything below it. These turn into prefix filters. - **PATTERN scopes**: a grant made on a URI with `_` wildcard segments, such as `/documents/_/comments`. Each `_` matches exactly one segment, and everything below a match is covered too. No prefix can express this, so use `toRegExp()` or filter in memory with `allows()`. To use any of the filters below, store each row's resource URI (here in a `path` column), built the same way as when you grant, for example with [`resource()`](https://docs.perms.io/sdk/typescript/#resourcesegments). ### Ask once with `listAccess` ```ts import { Permio } from "permio"; const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default" }); const access = await permio.listAccess({ principal: userId, permission: "document.read", under: "/documents", // only resources at or below this URI are considered }); ``` `listAccess` follows every page and returns an `AccessScopes` object. The parts you use for filtering: | Member | Use | | --- | --- | | `coversAll` | `true`: no filter needed. Check it first. | | `toPrefixes()` | The SUBTREE URIs. Prefixes already covered by a shorter one are dropped. | | `hasPatterns` / `patterns()` | Whether there are PATTERN scopes, and their URIs. | | `toRegExp()` | One anchored `RegExp` that matches exactly the covered URIs, patterns included. | | `allows(uri)` | Whether one URI is covered. | The answer is only about resources at or below `under`. For a URI outside `under`, `allows()` returning `false` means "not asked", not "denied". **Warning: Match on segment boundaries** A prefix `p` stands for `p` itself and everything **below** it. Match `path = p OR path starts with p + '/'`. A bare "starts with `p`" is wrong: the scope `/documents/1` would also match `/documents/12`. Don't use `LIKE p || '%'` either. In SQL `LIKE`, `_` and `%` are wildcards, and `_` is common in IDs, so it matches rows it shouldn't unless you escape them. #### PostgreSQL `starts_with` (PostgreSQL 11+) compares literally, so it is safe with any ID: ```ts const prefixes = access.toPrefixes(); const { rows } = access.coversAll ? await pool.query("SELECT * FROM documents") : await pool.query( `SELECT * FROM documents WHERE path = ANY($1::text[]) OR EXISTS (SELECT 1 FROM unnest($1::text[]) AS p WHERE starts_with(path, p || '/')) OR ($2::text IS NOT NULL AND path ~ $2)`, [prefixes, access.hasPatterns ? access.toRegExp().source : null], ); ``` The last condition handles PATTERN scopes. `toRegExp().source` is valid PostgreSQL regular-expression syntax. On its own, `path ~ $1` with `toRegExp().source` also works for every scope, but it can't use an index. #### Prisma Prisma's `startsWith` is literal, so the boundary match is safe: ```ts let docs: Document[]; if (access.coversAll) { docs = await prisma.document.findMany(); } else if (!access.hasPatterns) { const prefixes = access.toPrefixes(); docs = await prisma.document.findMany({ where: { OR: [{ path: { in: prefixes } }, ...prefixes.map((p) => ({ path: { startsWith: p + "/" } }))] }, }); } else { // Prisma has no portable regex filter: load the candidates under /documents and keep the allowed ones. const candidates = await prisma.document.findMany({ where: { path: { startsWith: "/documents/" } } }); docs = candidates.filter((d) => access.allows(d.path)); } ``` If your rows have only an ID and their URI is `/documents/`, the prefixes exactly one segment below `under` are the IDs the user can read (when `hasPatterns` is `false`; a pattern such as `/documents/_` stands for every document): ```ts const ids = access .toPrefixes() .filter((p) => p.split("/").length === 3) .map((p) => decodeURIComponent(p.split("/")[2]!)); const docs = access.coversAll ? await prisma.document.findMany() : await prisma.document.findMany({ where: { id: { in: ids } } }); ``` A deeper prefix such as `/documents/7/comments` gives access below document 7, not to document 7 itself, so it is rightly left out. #### MongoDB `toRegExp()` covers SUBTREE, PATTERN and EXACT scopes in one anchored regular expression: ```ts const filter = access.coversAll ? {} : { path: access.toRegExp() }; const docs = await db.collection("documents").find(filter).toArray(); ``` With no scopes at all, `toRegExp()` matches nothing. ### Filter rows you already have #### `allows()` in memory When the rows are already loaded, test each URI against the scopes. The number of calls to perms.io doesn't depend on how many rows you have, and `allows()` handles PATTERN scopes: ```ts import { resource } from "permio"; const visible = docs.filter((d) => access.allows(resource("documents", d.id))); ``` #### `batchCheck` for a fetched page For a short list, such as one page of search results, `batchCheck` checks every resource separately and returns a `Map` from URI to `true`/`false`: ```ts const uris = docs.map((d) => resource("documents", d.id)); const allowed = await permio.batchCheck({ principal: userId, resources: uris, permission: "document.read" }); const visible = docs.filter((d) => allowed.get(resource("documents", d.id))); ``` - A resource is allowed only if the principal holds **every** listed permission on it. - Each API call takes up to 100 resources. The SDK splits longer lists into parallel calls. - `batchCheckDetailed` returns `{ resource, allowed, missing }` for each resource, in request order. Filtering after fetching means a page can come back with fewer rows than you asked for. When you need full pages, filter in the database with `listAccess`. **Note: API key access** `batchCheck` needs `permission.check`, like `check`. `listAccess` also needs `principal.assignment.get` on the principal. A [check-access key](https://docs.perms.io/concepts/api-keys/#access-levels) has both. ### Build a share dialog with `whoHasAccess` A share dialog shows who can already open an object. `whoHasAccess` lists every principal with a grant on the resource, on an ancestor of it, or on a `_` wildcard URI that matches it, with the grants that give the access: ```ts const entries = await permio.whoHasAccess({ resource: "/documents/42", permission: "document.read", // optional: only grants that confer this permission expandGroups: true, // also list each group's members }); for (const e of entries) { const how = e.via .map((g) => (g.source.type === "role" ? g.source.name : "direct") + (g.inherited ? ` from ${g.resource}` : "")) .join(", "); console.log(e.principal, e.isGroup ? "(group)" : "", e.viaGroup ? `via ${e.viaGroup}` : "", how); } // For example: // alice viewer from /documents // group:editors (group) direct // bob via editors direct ``` Each entry (`AccessEntry`) has: | Field | Meaning | | --- | --- | | `principal` | The principal ID. Groups appear as `group:`. | | `isGroup` | `true` for a group. | | `viaGroup` | For entries added by `expandGroups`: the slug of the group the access comes through. `undefined` for the principal's own grants. | | `via` | The grants that give the access, most specific first: `resource` (where the grant was made), `inherited`, `match` (`"exact"`, `"ancestor"` or `"wildcard"`) and `source` (a role with its `id` and `name`, or `"direct"`). | Tips for the dialog: - Pass `permission`. A principal is then listed exactly when a check for it passes through its own grants, and an entry with `viaGroup` exactly when it passes through that group. Without `permission`, any grant on the resource or above counts, whatever it allows. - Without `expandGroups`, group members aren't listed; only the group is. With it, a member of two groups appears once per group, so de-duplicate by `principal` if you show people once. - Use `inherited` and `resource` to show "inherited from `/documents`" and to send people to the right place to change it. Revoking on `/documents/42` doesn't remove a grant made on `/documents`. - Entries are ordered by principal ID. `whoHasAccess` needs `principal.list` and `principal.assignment.list` on the project, which a check-access key has. ### Choosing an approach | Situation | Approach | | --- | --- | | Paginated list from your database | `listAccess` + `toPrefixes()` (and `toRegExp()` if `hasPatterns`) in the query | | Small list already in memory | `listAccess` + `allows()`, or `batchCheck` | | Buttons for a page of results ("can edit?") | `batchCheck` with that permission | | Share dialog or access review | `whoHasAccess` with `permission` and `expandGroups` | | One object, one request | `check` | --- Source: https://docs.perms.io/sdk/typescript/ ## TypeScript SDK `permio` is the official TypeScript/JavaScript client for the perms.io API. It wraps the API in a few calls for asking "may this principal do X on this resource?". It also gives you typed errors, retries for idempotent reads and async iterators over list endpoints. The full generated API is there too, as an escape hatch. - Runs on Node 18+, edge runtimes and browsers. It talks gRPC-web over `fetch`. - Ships ESM and CommonJS builds with bundled type definitions. - Has three runtime dependencies: `@bufbuild/protobuf`, `@connectrpc/connect` and `@connectrpc/connect-web`. **Note: Versions** [Batch checks](https://docs.perms.io/sdk/typescript/#batch-checks), [groups](https://docs.perms.io/sdk/typescript/#groups) and [access queries](https://docs.perms.io/sdk/typescript/#access-queries) are available from **0.2.0** ([npm](https://www.npmjs.com/package/permio)). ### Install ```bash npm install permio ``` ### Create a client ```ts import { Permio } from "permio"; const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default", // default project for the helpers below }); ``` The constructor checks the API key's format and checksum straight away. A mistyped or truncated key throws `PermioConfigError` before any request is sent. See [API keys](https://docs.perms.io/concepts/api-keys/) for the format. **Danger: Keep API keys on the server** The SDK works in browsers, but an API key in a browser bundle is visible to anyone who loads the page. Call perms.io from your backend, route handlers or middleware. #### Options | Option | Type | Default | Description | | --- | --- | --- | --- | | `apiKey` | `string` | required | A `perms_…` key from the dashboard, or a legacy JWT key. | | `baseUrl` | `string` | `https://api.perms.io` | API host. | | `project` | `string` | none | Default project name for `check*` and `list*` helpers. Each call can override it. | | `timeoutMs` | `number` | `10000` | Timeout for each attempt, in ms. `0` disables it. | | `retries` | `number \| RetryOptions` | `2` | Retries for idempotent reads. A number sets `maxRetries`; see [Retries and timeouts](https://docs.perms.io/sdk/typescript/#retries-and-timeouts). | | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom `fetch`, for example to add an agent, or for tests. | | `transport` | `Transport` | gRPC-web | Advanced: a Connect transport to use instead, for example `createRouterTransport` in tests. `baseUrl` and `fetch` are then ignored. | The production default is `https://api.perms.io`. Versions 0.2.1 and earlier default to `https://temp-api.perms.io`, which is being retired: upgrade, or set `baseUrl`. The client also exposes `permio.project`, `permio.baseUrl` and `permio.apiKeyKind` (`"opaque"` or `"jwt"`). ### Checking permissions All the check helpers take the same parameters (`CheckParams`): | Parameter | Description | | --- | --- | | `principal` | Required. The ID of the user, service or other principal being checked. | | `resource` / `resources` | One resource URI, or several. Every resource is checked. | | `permission` / `permissions` | One permission name, or several. | | `project` | Project to check in. Defaults to the client's `project`. | | `signal` | An `AbortSignal` that cancels the call. | | `timeoutMs` | Overall deadline for this call in ms, across retries. | | `headers` | Extra request headers. | You can pass both the singular and the plural form; they are merged. Duplicates are removed before sending. A single check accepts up to 100 resources and 100 permissions. #### `check` Resolves to `true` when the principal holds **every** permission on **every** resource: ```ts import { resource } from "permio"; const allowed = await permio.check({ principal: "alice", resource: resource("documents", 42), // "/documents/42" permission: "document.read", }); ``` A principal that lacks the permission is a normal answer: `check` resolves to `false`. It only throws when the call itself fails, for example because of a bad key, malformed input or network trouble. #### `checkDetailed` Same question, but it also tells you what is missing, per resource and in request order: ```ts const { allowed, missing } = await permio.checkDetailed({ principal: "alice", resources: ["/documents/1", "/documents/2"], permissions: ["document.read", "document.write"], }); // allowed: false // missing: [{ resource: "/documents/2", permissions: ["document.write"] }] ``` `missing` is an array of `{ resource: string; permissions: string[] }` (`MissingPermission`), and is empty when `allowed` is `true`. #### `assert` Resolves when the principal is allowed and throws `PermioDeniedError` when it is not. The error carries `principal` and `missing`: ```ts await permio.assert({ principal: "alice", resource: "/documents/1", permission: "document.write" }); ``` #### `checkAny` Resolves to `true` when, **on every resource**, the principal holds **at least one** of the permissions. It is still a single API call: ```ts await permio.checkAny({ principal: "alice", resource: "/documents/1", permissions: ["document.admin", "document.write"], }); ``` #### `checkAll` An alias of `check`: every permission on every resource. ### Batch checks **Note: Available from 0.2.0** `check` gives one answer for all its resources together. `batchCheck` answers **each resource separately**, which is what you need to filter a list or decide which rows get an "edit" button. #### `batchCheck` Resolves to a `Map` from resource URI to allowed, with one entry per distinct resource, in request order: ```ts import { resource } from "permio"; const uris = docs.map((d) => resource("documents", d.id)); const allowed: Map = await permio.batchCheck({ principal: "alice", resources: uris, permission: "document.read", }); const visible = docs.filter((d) => allowed.get(resource("documents", d.id))); ``` #### `batchCheckDetailed` Same question, but resolves to a `BatchCheckEntry[]` in request order: ```ts const entries = await permio.batchCheckDetailed({ principal: "alice", resources: ["/documents/1", "/documents/2"], permissions: ["document.read", "document.write"], }); // [ // { resource: "/documents/1", allowed: true, missing: [] }, // { resource: "/documents/2", allowed: false, missing: ["document.write"] }, // ] ``` `BatchCheckParams`: | Parameter | Description | | --- | --- | | `principal` | Required. The principal being checked. | | `resources` | Required. Resource URIs, each answered separately. Duplicates are sent once. More than 100 are split into several calls, sent in parallel. | | `permission` / `permissions` | One or several permission names, up to 100. A resource is allowed only with **all** of them. | | `project`, `signal`, `timeoutMs`, `headers` | As for `check`. | The SDK fails closed: if the API's answer doesn't have exactly one result for each requested resource, it throws a `PermioError` instead of guessing. ### Granting and revoking Grant and revoke through the [raw client](https://docs.perms.io/sdk/typescript/#raw-clients-full-api). Permissions are given by **name** and roles by **ID**, and the key needs full access: ```ts await permio.permissions.grantUserPermissionsAndRolesOnResource({ projectName: "default", userId: "alice", resourceUri: "/documents", roles: [roleId], }); await permio.permissions.revokeUserPermissionsAndRolesOnResource({ projectName: "default", userId: "alice", resourceUri: "/documents", roles: [roleId], }); ``` **Note: Coming in the next release** `permio.grant({ principal, resource, permission(s), role(s), project })` and `permio.revoke(...)` helpers, and `permio.whoAmI()` to see which principal, organisation, project and access level an API key has. ### Groups **Note: Available from 0.2.0** A [group](https://docs.perms.io/concepts/groups/) is a named set of principals in a project. Grant to the group's principal ID, `group:`, and every member holds the grant. Checks include the principal's groups automatically. #### `group(slug)` Returns the group's principal ID. It throws `PermioValidationError` unless the slug is 1–64 lower-case letters, digits and hyphens. ```ts import { group } from "permio"; group("editors"); // "group:editors" await permio.groups.create("editors", { description: "Can edit every document" }); await permio.groups.addMembers("editors", ["alice", "bob"]); await permio.permissions.grantUserPermissionsAndRolesOnResource({ projectName: "default", userId: group("editors"), resourceUri: "/documents", permissions: ["document.write"], }); await permio.check({ principal: "alice", resource: "/documents/42", permission: "document.write" }); // true ``` #### `permio.groups` Every method that takes a group accepts its slug (`"editors"`) or its principal ID (`"group:editors"`). | Method | Resolves to | | --- | --- | | `create(name, { description? })` | `Group`. Throws a `PermioError` with code `Code.AlreadyExists` if the project already has a group with this name. | | `get(name)` | `Group`. Throws `PermioNotFoundError` if it doesn't exist. | | `update(name, { description })` | `Group`. Replaces the description; a group can't be renamed. Throws `PermioNotFoundError` if it doesn't exist. | | `delete(name)` | `void`. Deletes the group, its memberships and every grant made to it. Resolves whether or not it existed. | | `list({ search? })` | Async iterator of `Group`, ordered by name. | | `addMembers(name, principals)` | `AddMembersResult`: `{ added: string[]; existing: string[] }`, in request order. | | `removeMembers(name, principals)` | `RemoveMembersResult`: `{ removed: string[]; notMembers: string[] }`, in request order. | | `listMembers(name)` | Async iterator of `GroupMember` (`principalId`, `createdAt`), ordered by principal ID. | | `listForPrincipal(principal)` | Async iterator of the `Group`s the principal is a member of, ordered by name. | - `addMembers` and `removeMembers` take 1–100 principals; duplicates are removed. `addMembers` rejects `group:` IDs with `PermioValidationError`, because groups can't contain groups. - Every method also takes `project`, `signal`, `timeoutMs` and `headers`. The list methods take `pageSize` too, and their `timeoutMs` and `headers` apply to each page. - `Group` has `id`, `organisationId`, `projectName`, `name`, `description`, `principalId` (`"group:"`), `createdAt` and `updatedAt`. ```ts const { added, existing } = await permio.groups.addMembers("group:editors", ["alice", "carol"]); for await (const m of permio.groups.listMembers("editors")) console.log(m.principalId); for await (const g of permio.groups.listForPrincipal("alice")) console.log(g.name); ``` Reading groups works with a check-access API key. Creating, updating and deleting groups and changing members needs a key with full access, and changing members also needs the right to grant across the project. See [Permissions you need](https://docs.perms.io/concepts/groups/#permissions-you-need). ### Access queries **Note: Available from 0.2.0** These answer "where?" and "who?" rather than "may they?". The [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/) guide shows them in context. #### `listAccess` Answers "where may this principal use this permission, at or below `under`?". It follows every page and resolves to an `AccessScopes` object: ```ts import { type AccessScopes } from "permio"; const access: AccessScopes = await permio.listAccess({ principal: "alice", permission: "document.read", under: "/documents", }); if (access.coversAll) { // alice may read everything under /documents } else { console.log(access.toPrefixes()); // e.g. ["/documents/4", "/documents/7"] } ``` `ListAccessParams`: | Parameter | Description | | --- | --- | | `principal` | Required. Access through roles and groups counts. | | `permission` | Required. One permission name. | | `under` | Required. Only resources at or below this URI are considered. A `_` segment here is literal. | | `project`, `pageSize`, `signal`, `timeoutMs`, `headers` | As for the list helpers: `timeoutMs` and `headers` apply to each page. | `AccessScopes`: | Member | Type | Description | | --- | --- | --- | | `under` | `string` | The URI that was asked about. | | `coversAll` | `boolean` | The principal holds the permission on `under` itself, so on everything below it. `scopes` is then a single subtree scope for `under`. | | `scopes` | `readonly AccessScopeInfo[]` | `{ uri, kind }`, ordered by URI. `kind` is `"subtree"`, `"pattern"`, `"exact"` (reserved, not returned today) or `"unknown"` (a kind this SDK version doesn't know; it never matches). | | `isEmpty` | `boolean` | No access anywhere under `under`. | | `allows(uri)` | `boolean` | Whether `uri` is covered. Matches per segment: `/a/b` covers `/a/b/c` but not `/a/bc`. Malformed URIs are never allowed. | | `toPrefixes()` | `string[]` | The subtree URIs, for prefix filters. Prefixes covered by a shorter one are dropped. Pattern scopes aren't included. | | `patterns()` | `string[]` | The pattern URIs, with `_` segments. | | `hasPatterns` | `boolean` | Some scope is a pattern, so `toPrefixes()` alone doesn't cover everything. | | `exact()` | `string[]` | The exact URIs. Empty today. | | `toRegExp()` | `RegExp` | One anchored regular expression matching exactly what `allows()` accepts. Usable in MongoDB `$regex` and, via `.source`, PostgreSQL `~`. Matches nothing when there are no scopes. | **Warning** A prefix `p` from `toPrefixes()` means `p` **and everything below it**. Filter with `path = p OR path starts with p + "/"`, never a bare "starts with `p`", which would let `/documents/1` match `/documents/12`. In SQL, use `starts_with(path, p || '/')` rather than `LIKE`, where `_` is a wildcard. For a URI outside `under`, `allows()` returning `false` means "not asked", not "denied". The API key needs `permission.check` on `under` and `principal.assignment.get` on the principal. #### `whoHasAccess` Answers "who has access to this resource?". It follows every page and resolves to an `AccessEntry[]`, ordered by principal ID: ```ts import { type AccessEntry } from "permio"; const entries: AccessEntry[] = await permio.whoHasAccess({ resource: "/documents/42", permission: "document.read", expandGroups: true, }); // [ // { principal: "alice", isGroup: false, viaGroup: undefined, // via: [{ resource: "/documents", inherited: true, match: "ancestor", // source: { type: "role", id: "ROLE_ID", name: "viewer" } }] }, // { principal: "bob", isGroup: false, viaGroup: "editors", via: [...] }, // { principal: "group:editors", isGroup: true, viaGroup: undefined, via: [...] }, // ] ``` `WhoHasAccessParams`: | Parameter | Description | | --- | --- | | `resource` | Required. The resource to look at. A `_` segment here is literal. | | `permission` | Optional. Only count grants that confer this permission, so a principal is listed exactly when `check()` passes for it through its own grants (or, for entries with `viaGroup`, through that group). Unset, any grant counts. | | `expandGroups` | Optional. Also list every member of each listed group, once per group, with `viaGroup` set. | | `project`, `pageSize`, `signal`, `timeoutMs`, `headers` | As for the list helpers: `timeoutMs` and `headers` apply to each page. | `AccessEntry` has `principal`, `isGroup`, `viaGroup` (a group slug, or `undefined`) and `via`, the grants that give the access, most specific first. Each grant (`AccessGrantInfo`) has: - `resource`: the URI the grant was made on (the resource, an ancestor, or a `_` wildcard URI that matches it); - `inherited`: `true` unless the grant was made on exactly the resource; - `match`: `"exact"`, `"ancestor"`, `"wildcard"` or `"unknown"`; - `source`: `{ type: "role", id, name }`, `{ type: "direct" }` for directly granted permissions, or `{ type: "unknown" }`. The API key needs `principal.list` and `principal.assignment.list` on the project. ### Resource URIs Resource URIs are paths such as `/projects/p1/documents/42`. A grant on a URI also covers every URI beneath it. See [Resources and inheritance](https://docs.perms.io/concepts/resources/) for the full rules. #### `resource(...segments)` Builds a URI from raw segments. It adds the leading `/` and percent-encodes each segment, so an ID that contains `/`, spaces or other reserved characters stays a single segment: ```ts import { resource } from "permio"; resource("projects", "p1", "documents", 42); // "/projects/p1/documents/42" resource("files", "reports/2026.pdf"); // "/files/reports%2F2026.pdf" ``` On top of `encodeURIComponent`, it also encodes `! ' ( ) *`, because the API rejects `( ) *` in segments. Empty segments throw `PermioValidationError`. **Tip** Build URIs the same way when you grant and when you check, or they will not match. #### Other helpers | Export | Description | | --- | --- | | `encodeSegment(segment)` | Encodes one segment the way `resource()` does. | | `assertResourceUri(uri)` | Throws `PermioValidationError` unless `uri` has the basic shape the API accepts: a leading `/`, no empty segments, no whitespace or control characters, and at most 2048 bytes. | | `MAX_RESOURCE_URI_LENGTH` | `2048`. | Every check runs `assertResourceUri` on its URIs before sending. The API enforces the remaining rules, such as no `( ) [ ] { } * + ? | ^ $ \` in segments, and answers with `InvalidArgument`, which the SDK raises as `PermioValidationError`. ### Errors Every error the SDK throws extends `PermioError`. It has these properties: - `code`: the gRPC status `Code`. `Code` is re-exported from `permio`. - `codeName`: the code's name in lower case, for example `"permission_denied"`. - `metadata`: the response headers and trailers. - `cause`: the original error, usually a `ConnectError`. | Class | When | | --- | --- | | `PermioConfigError` | Bad client setup: a malformed API key, no `project`, an invalid `baseUrl` or no `fetch`. | | `PermioAuthError` | `Unauthenticated`: the key is missing, unknown or invalidated. | | `PermioPermissionError` | `PermissionDenied`: the **API key** lacks the rights for this call. `missing` lists what it lacks when the API says. | | `PermioValidationError` | `InvalidArgument`, or the SDK's own input checks (for example a URI without a leading `/`). | | `PermioNotFoundError` | `NotFound`, for example `getPermission` for a name that does not exist. | | `PermioUnavailableError` | `Unavailable`, including network failures. | | `PermioTimeoutError` | `DeadlineExceeded`, including the SDK's own timeouts. | | `PermioDeniedError` | Thrown only by `assert()`, when the **principal** is not allowed. Has `principal` and `missing`. | Any other status is a plain `PermioError` with the matching `code`. **Note: Denied is not an error** `PermioPermissionError` is about the *caller*. It means your API key is not allowed to make the call: for `Check`, the key needs `permission.check` on the checked resources and on the principal. A principal failing a check is a normal result: `check()` returns `false` and `assert()` throws `PermioDeniedError`. `PermioPermissionError.missing` uses the API's fully qualified URIs (`/organisation/{org}/project/{project}/...`), not the URIs you sent. ```ts import { PermioAuthError, PermioDeniedError, PermioError, PermioPermissionError } from "permio"; try { await permio.assert({ principal: "alice", resource: "/documents/1", permission: "document.write" }); } catch (err) { if (err instanceof PermioDeniedError) { // the principal may not do this: respond with 403 } else if (err instanceof PermioPermissionError) { // the API key may not run this check (it needs permission.check) console.error(err.missing); } else if (err instanceof PermioAuthError) { // the API key is missing, unknown or invalidated } else if (err instanceof PermioError) { console.error(err.codeName, err.message); } } ``` `toPermioError(error)` converts any thrown value into the matching `PermioError` subclass. `parseMissingPermissions(message)` extracts the missing list from a `PermissionDenied` message. You only need these if you build your own transport or client. ### Retries and timeouts - **Retries:** idempotent reads (`Check`, `BatchCheck`, and every `Get*` and `List*` RPC, including `ListAccess`, `ListPrincipalsWithAccess` and the group reads) are retried on `Unavailable` and `DeadlineExceeded`. They are retried twice by default, with exponential backoff and jitter. Network failures count as `Unavailable`. - **Writes** (every Create, Update, Delete, Grant, Revoke, Invite, adding and removing group members, and so on) are never retried. - **Timeouts:** - The client's `timeoutMs` applies to each attempt (default 10 s). - A call's `timeoutMs` is an overall deadline across all attempts. - Aborting `signal` cancels the call and any pending retry. ```ts const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default", timeoutMs: 5_000, retries: { maxRetries: 3, baseDelayMs: 100, maxDelayMs: 2_000 }, }); await permio.check({ principal: "alice", resource: "/documents/1", permission: "document.read", timeoutMs: 8_000 }); ``` `RetryOptions` has these fields: - `maxRetries`: retries after the first attempt. Default 2. - `baseDelayMs`: the first delay, which doubles each time. Default 100. - `maxDelayMs`: the cap on a single delay. Default 2000. `isIdempotentMethod("permio.permissions.v1.PermissionsService.Check")` tells you whether the SDK retries a given RPC. ### Listing with iterators List RPCs are paginated with a cursor (see [Pagination](https://docs.perms.io/api-reference/pagination/)). The helpers return async iterators that fetch pages as you go: ```ts for await (const p of permio.listPermissions({ search: "document." })) console.log(p.name); for await (const role of permio.listRoles()) console.log(role.name, role.permissions.length); for await (const id of permio.listPrincipals()) console.log(id); for await (const a of permio.listAssignments({ principal: "alice" })) console.log(a.resourceUri, a.roles); ``` | Method | Yields | | --- | --- | | `listPermissions(params?)` | `Permission` | | `listRoles(params?)` | `Role` (with its `permissions`) | | `listPrincipals(params?)` | `string` principal IDs that have grants in the project | | `listAssignments({ principal, ... })` | `Assignment` (`resourceUri`, `permissions`, `roles`) | | `groups.list(params?)` | `Group` (from 0.2.0; see [Groups](https://docs.perms.io/sdk/typescript/#groups)) | | `groups.listMembers(name, params?)` | `GroupMember` (from 0.2.0) | | `groups.listForPrincipal(principal, params?)` | `Group` (from 0.2.0) | Each method takes these parameters: - `project` - `pageSize`: 1–100, default 100 - `signal`: stops iteration and any in-flight request - `timeoutMs`: per page - `headers` `listPermissions`, `listRoles` and `listPrincipals` also take `search`, which filters by name. For any other list RPC, use `paginate`, which follows `next_cursor` until the API returns an empty one: ```ts import { paginate } from "permio"; for await (const key of paginate( (pagination, signal) => permio.organisations.listAPIKeys({ organisationId: "your-org-id", projectName: "default", pagination }, { signal }), (res) => res.apiKeys, )) { console.log(key.name); } ``` ### Raw clients (full API) `permio.permissions` and `permio.organisations` are the generated [Connect](https://connectrpc.com/docs/web/using-clients) clients for `permio.permissions.v1.PermissionsService` and `permio.organisation.v1.OrganisationService`. Authentication, timeouts, retries and error mapping are already applied. Every RPC in the [API reference](https://docs.perms.io/api-reference/) is available, with request and response types generated from the protobuf definitions. Method names are the RPC names in lower camel case, and fields are camelCase: ```ts await permio.permissions.createPermission({ projectName: "default", name: "document.read", description: "Read documents", }); const role = await permio.permissions.createRole({ projectName: "default", name: "viewer", permissions: ["document.read"], // permission names }); await permio.permissions.grantUserPermissionsAndRolesOnResource({ projectName: "default", userId: "alice", resourceUri: "/documents", roles: [role.id], // role IDs }); const res = await permio.permissions.check({ projectName: "default", principalId: "alice", resourceUris: ["/documents/42"], permissions: ["document.read"], }); console.log(res.passed, res.missingPermissions); ``` **Note: What a key can call** By default an API key has **check** access: it can run checks and access queries, and read permissions, roles, principals, assignments and groups. Creating, updating or deleting permissions, roles and groups, granting or revoking, and changing group members needs a key with full access. See [API keys](https://docs.perms.io/concepts/api-keys/#access-levels). The generated messages are exported as the namespaces `permissionsv1`, `organisationv1` and `paginationv1`. The service descriptors are exported as `PermissionsService` and `OrganisationService`: ```ts import { create } from "@bufbuild/protobuf"; import { permissionsv1 } from "permio"; const req = create(permissionsv1.CheckPermissionRequestSchema, { projectName: "default" }); ``` ### API key helpers | Export | Description | | --- | --- | | `validateApiKey(key)` | Returns `"opaque"` for `perms_` keys and `"jwt"` for legacy JWT keys. Throws `PermioConfigError` if the key is malformed. | | `isValidApiKey(key)` | `true` if `validateApiKey` would pass. | | `API_KEY_LENGTH` | `44`. | These only check the shape and checksum. Only the API can tell you whether a key exists and is still valid. ### Express ```ts import express, { type RequestHandler } from "express"; import { Permio, PermioDeniedError, resource } from "permio"; const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default" }); const app = express(); const can = (permission: string): RequestHandler => async (req, res, next) => { try { await permio.assert({ principal: res.locals.userId, // set by your auth middleware resource: resource("documents", req.params.id!), permission, }); next(); } catch (err) { if (err instanceof PermioDeniedError) { res.status(403).json({ error: "forbidden" }); return; } next(err); // any other PermioError: auth, validation, network... } }; app.get("/documents/:id", can("document.read"), (req, res) => { res.json({ id: req.params.id }); }); ``` ### Next.js Create one client and import it on the server only: ```ts // lib/permio.ts import { Permio } from "permio"; export const permio = new Permio({ apiKey: process.env.PERMIO_API_KEY!, project: "default" }); ``` #### Route handler (App Router) ```ts // app/documents/[id]/route.ts import { resource } from "permio"; import { permio } from "@/lib/permio"; import { getUserId } from "@/lib/auth"; // your auth export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const allowed = await permio.check({ principal: await getUserId(), resource: resource("documents", id), permission: "document.delete", }); if (!allowed) return new Response("Forbidden", { status: 403 }); // ...delete the document return new Response(null, { status: 204 }); } ``` #### Middleware The client only needs `fetch`, so it also runs in the Edge runtime and in middleware: ```ts // middleware.ts import { NextResponse, type NextRequest } from "next/server"; import { resource } from "permio"; import { permio } from "@/lib/permio"; import { getUserIdFromRequest } from "@/lib/auth"; // your auth export async function middleware(req: NextRequest) { const userId = await getUserIdFromRequest(req); if (!userId) return NextResponse.redirect(new URL("/login", req.url)); const allowed = await permio.check({ principal: userId, resource: resource("admin"), permission: "admin.access", }); return allowed ? NextResponse.next() : new NextResponse("Forbidden", { status: 403 }); } export const config = { matcher: "/admin/:path*" }; ``` ### How it talks to the API The SDK uses the gRPC-web protocol over `fetch` (through `@connectrpc/connect-web`). The perms.io gateway accepts it directly from servers, edge runtimes and browsers. The key is sent as `Authorization: Bearer `. Timeouts are enforced on the client, so no `grpc-timeout` header is sent. --- Source: https://docs.perms.io/sdk/go/ ## Go SDK `github.com/PrivateJAR/permio-go` is the official Go client for the perms.io API. Its package, `permio`, wraps the API in a few calls for asking "may this principal do X on this resource?". It also gives you typed errors, retries for idempotent reads and `iter.Seq2` iterators over list endpoints. The generated gRPC clients ship in the same module, as an escape hatch to the full API. - Needs Go 1.24 or later. - Talks native gRPC over HTTP/2 with TLS (1.2 or later), through `google.golang.org/grpc`. - Depends on `google.golang.org/grpc`, `google.golang.org/protobuf`, `google.golang.org/genproto` and `github.com/envoyproxy/protoc-gen-validate`. **Note: Versions** The SDK hasn't had a tagged release yet; the first will be **v0.1.0**. Tags up to v0.0.18 contain only the generated stubs, without the `permio` package described here. If `go get …@latest` gives you one of those, install the default branch with `go get github.com/PrivateJAR/permio-go@main` until v0.1.0 is out. ### Install ```bash go get github.com/PrivateJAR/permio-go@latest ``` ### Quick start ```go import ( "context" "log" "os" permio "github.com/PrivateJAR/permio-go" ) client, err := permio.New(permio.Options{ APIKey: os.Getenv("PERMIO_API_KEY"), Project: "default", // default project for the helpers below }) if err != nil { log.Fatal(err) } defer client.Close() allowed, err := client.Check(ctx, "alice", "document.read", permio.Resource("documents", "42")) if err != nil { // the check could not be answered (bad key, network...): treat it as not allowed } ``` A principal that lacks the permission is a normal answer: `allowed` is `false` and `err` is `nil`. `New` checks the API key's format and checksum straight away, so a mistyped or truncated key fails there with an error of kind `KindConfig`, before any request is sent. See [API keys](https://docs.perms.io/concepts/api-keys/) for the format. `New` doesn't connect: the connection is made on the first call. A `*permio.Client` is safe for concurrent use, so create one and share it. ### Configuration `permio.New` takes an `Options` struct. Only an API key is required: | Field | Type | Default | Description | | --- | --- | --- | --- | | `APIKey` | `string` | `$PERMIO_API_KEY` | A `perms_…` key from the dashboard, or a legacy JWT key. When empty, the `PERMIO_API_KEY` environment variable is used. | | `Endpoint` | `string` | `permio.DefaultEndpoint` | API host, as `host:port`, a bare host (port 443) or a URL such as `https://api.perms.io`. An `http://` URL dials without TLS. | | `Project` | `string` | none | Default project for every helper. Helpers fail with `KindConfig` when it is empty. | | `Timeout` | `time.Duration` | `10 * time.Second` | Timeout for each attempt. Zero means the default; a negative value disables it. | | `Retry` | `permio.RetryOptions` | 2 retries | Retries for idempotent reads. See [Retries and rate limits](https://docs.perms.io/sdk/go/#retries-and-rate-limits). | | `Insecure` | `bool` | `false` | Dial without TLS. Only for local servers. | | `UserAgent` | `string` | none | Prepended to grpc-go's user agent. | | `DialOptions` | `[]grpc.DialOption` | none | Appended to the SDK's dial options. | | `Conn` | `grpc.ClientConnInterface` | none | Advanced: a connection to use instead of dialling `Endpoint`, for example a bufconn connection in tests. Auth, timeouts, retries and error mapping still apply. You own it: `Close` doesn't close it. | `permio.DefaultEndpoint` is `api.perms.io:443`, the production API (`https://api.perms.io`). ```go client, err := permio.New(permio.Options{ APIKey: os.Getenv("PERMIO_API_KEY"), Endpoint: permio.DefaultEndpoint, Project: "default", Timeout: 5 * time.Second, Retry: permio.RetryOptions{MaxRetries: 3}, }) ``` There are no per-call options. Instead: - **Project:** `client.WithProject("other")` returns a client for another project that shares the same connection. Close only the original client. - **Deadline and cancellation:** use the `context.Context` you pass to each call. Its deadline is an overall deadline across all attempts; `Options.Timeout` applies to each attempt. The client also has `client.Project()`, `client.APIKeyKind()` (`permio.APIKeyOpaque` or `permio.APIKeyJWT`) and `client.Close()`. #### Verifying the key Only the API can tell you whether a key exists and is still valid. `client.WhoAmI(ctx)` describes the credential and needs no project, so you can call it at startup: ```go id, err := client.WhoAmI(ctx) if err != nil { log.Fatal(err) // KindAuth if the key is unknown or invalidated } if id.APIKeyAccess != permio.APIKeyAccessFull { log.Fatal("this service needs a full-access API key") } ``` `Identity` has `PrincipalID` (the key's ID), `OrganisationID`, `Kind` (`PrincipalAPIKey`, `PrincipalUser`, `PrincipalServiceAccount` or `PrincipalUnknown`), `Project` (the project the key belongs to), `APIKeyAccess` (`APIKeyAccessCheck`, `APIKeyAccessFull` or `APIKeyAccessUnknown`, for API keys only) and `Email` (for users). See [Access levels](https://docs.perms.io/concepts/api-keys/#access-levels). ### Checking permissions Every check helper validates its input before sending: the principal must not be empty, there must be at least one resource and one permission, and every resource URI must pass `ValidateResourceURI` (see [Resource URIs](https://docs.perms.io/sdk/go/#resource-uris)). Duplicates are removed. A single check takes up to 100 resources and 100 permissions (`permio.MaxBatch`). Input errors are of kind `KindValidation`. | Method | Returns | | --- | --- | | `Check(ctx, principal, permission, resource)` | `(bool, error)`. `true` when the principal holds the permission on the resource. | | `CheckAll(ctx, principal, permissions, resource)` | `(bool, error)`. `true` when it holds **every** permission. | | `CheckAny(ctx, principal, permissions, resource)` | `(bool, error)`. `true` when it holds **at least one**. Still a single API call. | | `CheckDetailed(ctx, permio.CheckRequest)` | `(*permio.CheckResult, error)`. Several resources, with what is missing. | | `Assert(ctx, principal, permission, resource)` | `error`. `nil` when allowed; an error of kind `KindDenied` when not. | | `AssertAll(ctx, permio.CheckRequest)` | `error`. As `Assert`, for every permission on every resource. | #### `Check`, `CheckAll` and `CheckAny` ```go ok, err := client.Check(ctx, "alice", "document.read", "/documents/42") ok, err = client.CheckAll(ctx, "alice", []string{"document.read", "document.write"}, "/documents/42") ok, err = client.CheckAny(ctx, "alice", []string{"document.admin", "document.write"}, "/documents/42") ``` A denial is `false` with a `nil` error. These only return an error when the call itself fails, for example because of a bad key, malformed input or network trouble. `CheckAny` fails closed: if the API denies without saying what is missing, it returns `false`. #### `CheckDetailed` Checks every permission on every resource and tells you what is missing, per resource: ```go res, err := client.CheckDetailed(ctx, permio.CheckRequest{ Principal: "alice", Resources: []string{"/documents/1", "/documents/2"}, Permissions: []string{"document.read", "document.write"}, }) if err != nil { return err } // res.Allowed: false // res.Missing: []permio.MissingPermission{{Resource: "/documents/2", Permissions: []string{"document.write"}}} ``` `CheckResult` has `Allowed` and `Missing`, a `[]MissingPermission` (`Resource`, `Permissions`) that is empty when `Allowed` is `true`. #### `Assert` and `AssertAll` Return `nil` when the principal is allowed, and a `*permio.Error` of kind `KindDenied` when it is not. The error carries `Principal` and `Missing`: ```go err := client.Assert(ctx, "alice", "document.write", "/documents/1") if errors.Is(err, permio.ErrDenied) { // respond with 403 } ``` `AssertAll` takes a `CheckRequest`, like `CheckDetailed`. #### Batch checks `CheckAll` gives one answer for all its resources together. `BatchCheck` answers **each resource separately**, which is what you need to filter a list or decide which rows get an "edit" button. It returns a `map[string]bool` from resource URI to allowed: ```go uris := make([]string, len(docs)) for i, d := range docs { uris[i] = permio.Resource("documents", d.ID) } allowed, err := client.BatchCheck(ctx, "alice", []string{"document.read"}, uris) if err != nil { return err } var visible []Document for _, d := range docs { if allowed[permio.Resource("documents", d.ID)] { visible = append(visible, d) } } ``` `BatchCheckDetailed` takes the same arguments and returns a `[]permio.BatchResult`, one per distinct resource, in request order: ```go entries, err := client.BatchCheckDetailed(ctx, "alice", []string{"document.read", "document.write"}, []string{"/documents/1", "/documents/2"}) // [{Resource: "/documents/1", Allowed: true, Missing: []}, // {Resource: "/documents/2", Allowed: false, Missing: ["document.write"]}] ``` - A resource is allowed only with **all** the permissions (up to 100). - Duplicate resources are sent once. More than 100 resources are split into several calls, made one after another. - The SDK fails closed: if the API's answer doesn't have a result for every requested resource, it returns an error instead of guessing. #### `ListAccess` Answers "where may this principal use this permission, at or below `under`?". Access through roles and groups counts. It follows every page and returns an `*AccessScopes`. The [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/) guide shows it in context. ```go access, err := client.ListAccess(ctx, "alice", "document.read", "/documents") if err != nil { return err } switch { case access.CoversAll: // alice may read everything under /documents: no filter needed case !access.HasPatterns(): fmt.Println(access.Prefixes()) // e.g. [/documents/4 /documents/7] default: fmt.Println(access.RegexpSource()) // one regex covering prefixes and patterns } ``` | Member | Type | Description | | --- | --- | --- | | `Under` | `string` | The URI that was asked about. A `_` segment here is literal. | | `CoversAll` | `bool` | The principal holds the permission on `Under` itself, so on everything below it. | | `Scopes` | `[]AccessScope` | `{URI, Kind}`, ordered by URI. `Kind` is `permio.ScopeSubtree`, `ScopePattern`, `ScopeExact` (reserved, not returned today) or `ScopeUnknown` (a kind this SDK version doesn't know; it never matches). | | `IsEmpty()` | `bool` | No access anywhere under `Under`. | | `Allows(uri)` | `bool` | Whether `uri` is covered. Matches per segment: `/a/b` covers `/a/b/c` but not `/a/bc`. Malformed URIs are never allowed. | | `Prefixes()` | `[]string` | The subtree URIs, for prefix filters. Prefixes covered by a shorter one are dropped. Pattern scopes aren't included. | | `Patterns()` | `[]string` | The pattern URIs, with `_` segments. | | `HasPatterns()` | `bool` | Some scope is a pattern, so `Prefixes()` alone doesn't cover everything. | | `Exact()` | `[]string` | The exact URIs. Empty today. | | `RegexpSource()` | `string` | One anchored regular expression matching exactly what `Allows` accepts, in syntax common to RE2, PCRE and POSIX ERE, so usable in PostgreSQL `~` and MongoDB `$regex`. Matches nothing when there are no scopes. | | `Regexp()` | `*regexp.Regexp` | `RegexpSource()`, compiled. | **Warning** A prefix `p` from `Prefixes()` means `p` **and everything below it**. Filter with `path = p OR path starts with p + "/"`, never a bare "starts with `p`", which would let `/documents/1` match `/documents/12`. In SQL, use `starts_with(path, p || '/')` rather than `LIKE`, where `_` is a wildcard. For a URI outside `Under`, `Allows` returning `false` means "not asked", not "denied". The API key needs `permission.check` on `under` and `principal.assignment.get` on the principal. #### `WhoHasAccess` Answers "who has access to this resource?" (the `ListPrincipalsWithAccess` RPC). It follows every page and returns an `[]AccessEntry`, ordered by principal ID. The options may be `nil`: ```go entries, err := client.WhoHasAccess(ctx, "/documents/42", &permio.WhoHasAccessOptions{ Permission: "document.read", ExpandGroups: true, }) if err != nil { return err } // [ // {Principal: "alice", Via: [{Resource: "/documents", Inherited: true, Match: "ancestor", // Source: "role", RoleID: "ROLE_ID", RoleName: "viewer"}]}, // {Principal: "bob", ViaGroup: "editors", Via: [...]}, // {Principal: "group:editors", IsGroup: true, Via: [...]}, // ] ``` `WhoHasAccessOptions`: | Field | Description | | --- | --- | | `Permission` | Only count grants that confer this permission, so a principal is listed exactly when a check passes for it through its own grants (or, for entries with `ViaGroup`, through that group). Empty: any grant counts. | | `ExpandGroups` | Also list every member of each listed group, once per group, with `ViaGroup` set. | `AccessEntry` has `Principal`, `IsGroup`, `ViaGroup` (a group slug, or `""`) and `Via`, the grants that give the access, most specific first. Each `AccessVia` has: - `Resource`: the URI the grant was made on (the resource, an ancestor, or a `_` wildcard URI that matches it); - `Inherited`: `true` unless the grant was made on exactly the resource; - `Match`: `"exact"`, `"ancestor"`, `"wildcard"` or `"unknown"`; - `Source`: `"role"` (with `RoleID` and `RoleName`), `"direct"` for directly granted permissions, or `"unknown"`. The API key needs `principal.list` and `principal.assignment.list` on the project. ### Granting and revoking Grants and revokes need a key with full access. See [API keys](https://docs.perms.io/concepts/api-keys/#access-levels). ```go err := client.Grant(ctx, permio.GrantRequest{ Principal: "alice", Resource: "/documents", // also covers everything below it Permissions: []string{"document.read"}, Roles: []string{roleID}, // role IDs, not names }) err = client.Revoke(ctx, permio.GrantRequest{ Principal: "alice", Resource: "/documents", Roles: []string{roleID}, }) ``` `GrantRequest` has `Principal`, `Resource`, `Permissions` (permission names) and `Roles` (role IDs). It needs at least one permission or role. - `Grant` is additive: what the principal already holds there is kept. Unknown permissions or roles fail with `KindValidation`. - `Revoke` applies to exactly `Resource`. Names the grant doesn't hold are ignored, so revoking twice is harmless. Grants on ancestor URIs aren't touched, so the principal may still pass a check afterwards; `WhoHasAccess` shows where access comes from. - Both are sent once and never retried automatically (see [Retries and rate limits](https://docs.perms.io/sdk/go/#retries-and-rate-limits)). - `client.GetGrant(ctx, principal, resource)` returns the `*Grant` made on exactly that URI, not inherited ones. The API returns role IDs only, so `RoleRef.Name` is empty there. #### Permissions and roles | Method | Returns | | --- | --- | | `CreatePermission(ctx, name, description)` | `*Permission`. Creating one that already exists with the same description succeeds; with another description it fails with `KindAlreadyExists`. | | `GetPermission(ctx, name)` | `*Permission`, or an error of kind `KindNotFound`. | | `UpdatePermission(ctx, name, newName, description)` | `*Permission`. Replaces the description and, when `newName` isn't empty, renames it. | | `DeletePermission(ctx, name)` | `error`. Also deletes every grant of it. Succeeds if it doesn't exist. | | `CreateRole(ctx, name, description, permissions)` | `*Role`, with its `ID`. Fails with `KindAlreadyExists` if a role with this name has other permissions or another description. | | `GetRole(ctx, id)` | `*Role`. | | `UpdateRole(ctx, id, name, description, permissions)` | `*Role`. Replaces the name, description and permissions. | | `DeleteRole(ctx, id)` | `error`. | `Permission` has `Name` and `Description`. `Role` has `ID`, `Name`, `Description` and `Permissions` (names, sorted). ```go role, err := client.CreateRole(ctx, "viewer", "Read documents", []string{"document.read"}) if err != nil { return err } err = client.Grant(ctx, permio.GrantRequest{Principal: "alice", Resource: "/documents", Roles: []string{role.ID}}) ``` ### Groups A [group](https://docs.perms.io/concepts/groups/) is a named set of principals in a project. Grant to the group's principal ID, `group:`, and every member holds the grant. Checks include the principal's groups automatically. `permio.GroupPrincipal(slug)` returns the group's principal ID. `client.Groups()` returns the group operations, in the client's project: ```go groups := client.Groups() if _, err := groups.Create(ctx, "editors", "Can edit every document"); err != nil { return err } if _, err := groups.AddMembers(ctx, "editors", []string{"alice", "bob"}); err != nil { return err } err := client.Grant(ctx, permio.GrantRequest{ Principal: permio.GroupPrincipal("editors"), // "group:editors" Resource: "/documents", Permissions: []string{"document.write"}, }) ok, err := client.Check(ctx, "alice", "document.write", "/documents/42") // true ``` Every method that takes a group accepts its slug (`"editors"`) or its principal ID (`"group:editors"`). The slug must be 1–64 lower-case letters, digits and hyphens, or the call fails with `KindValidation` before anything is sent (`permio.ValidateGroupName` runs the same check). | Method | Returns | | --- | --- | | `Create(ctx, name, description)` | `*Group`. Fails with `KindAlreadyExists` if the project already has a group with this name. | | `Get(ctx, name)` | `*Group`, or an error of kind `KindNotFound`. | | `Update(ctx, name, description)` | `*Group`. Replaces the description; a group can't be renamed. | | `Delete(ctx, name)` | `error`. Deletes the group, its memberships and every grant made to it. Succeeds whether or not it existed. | | `List(ctx, opts)` | `iter.Seq2[Group, error]`, ordered by name. `opts.Search` filters by name. | | `AddMembers(ctx, name, principals)` | `*MembershipChange`: `Changed` (added) and `Unchanged` (already members), in request order. | | `RemoveMembers(ctx, name, principals)` | `*MembershipChange`: `Changed` (removed) and `Unchanged` (weren't members), in request order. | | `ListMembers(ctx, name, opts)` | `iter.Seq2[GroupMember, error]` (`PrincipalID`, `CreatedAt`), ordered by principal ID. | | `ListForPrincipal(ctx, principal, opts)` | `iter.Seq2[Group, error]`: the groups the principal is a member of, ordered by name. | - `AddMembers` and `RemoveMembers` remove duplicates and send 100 principals per call. If a later call fails, the result holds the changes made by the calls that succeeded, alongside the error. - `AddMembers` rejects `group:` IDs with `KindValidation`, because groups can't contain groups. - `Group` has `Name`, `Description`, `PrincipalID` (`"group:"`), `CreatedAt` and `UpdatedAt`. ```go change, err := groups.AddMembers(ctx, "group:editors", []string{"alice", "carol"}) // change.Changed: [carol], change.Unchanged: [alice] for m, err := range groups.ListMembers(ctx, "editors", nil) { if err != nil { return err } fmt.Println(m.PrincipalID) } ``` Reading groups works with a check-access API key. Creating, updating and deleting groups and changing members needs a key with full access, and changing members also needs the right to grant across the project. See [Permissions you need](https://docs.perms.io/concepts/groups/#permissions-you-need). ### Resource URIs Resource URIs are paths such as `/projects/p1/documents/42`. A grant on a URI also covers every URI beneath it. See [Resources and inheritance](https://docs.perms.io/concepts/resources/) for the full rules. #### `Resource(segments...)` Builds a URI from raw segments. It adds the leading `/` and percent-encodes each segment, so an ID that contains `/`, spaces or other reserved characters stays a single segment: ```go permio.Resource("projects", "p1", "documents", "42") // "/projects/p1/documents/42" permio.Resource("files", "reports/2026.pdf") // "/files/reports%2F2026.pdf" ``` Only `A–Z`, `a–z`, `0–9` and `- _ . ~` are left as they are. Segments are strings, so convert numeric IDs yourself (`strconv.Itoa`). An empty segment produces a URI that every call rejects. **Tip** Build URIs the same way when you grant and when you check, or they will not match. #### Other helpers | Export | Description | | --- | --- | | `EncodeSegment(segment)` | Encodes one segment the way `Resource` does. | | `ValidateResourceURI(uri)` | Returns an error of kind `KindValidation` unless `uri` has the basic shape the API accepts: a leading `/`, no empty segments, no whitespace or control characters, no regular-expression metacharacters (below) and at most 2048 bytes. | | `MaxResourceURILength` | `2048`. | | `GroupPrincipal(slug)` | `"group:" + slug`. | | `GroupPrefix` | `"group:"`. | | `ValidateGroupName(slug)` | Returns an error of kind `KindValidation` unless the slug is 1–64 lower-case letters, digits and hyphens. | | `WildcardSegment` | `"_"`, which in a stored grant URI or a pattern scope matches exactly one segment. | The metacharacters `ValidateResourceURI` rejects are `( ) [ ] { } * + ? | ^ $ \`. Every helper that takes a resource URI runs it before sending; the API enforces the remaining rules and answers with `InvalidArgument` (`KindValidation`). ### Errors Every call that fails returns a `*permio.Error`, including calls made through the [raw clients](https://docs.perms.io/sdk/go/#raw-clients-full-api). It has these fields: | Field | Description | | --- | --- | | `Kind` | A `permio.Kind` that classifies the error (below). | | `Code` | The gRPC `codes.Code`. `codes.OK` for errors the SDK raises itself (`KindConfig`, `KindDenied`), `codes.InvalidArgument` for its own input checks. | | `Message` | The server's or the SDK's message. | | `Missing` | For `KindPermission`, what the API key lacks; for `KindDenied`, what the principal lacks. | | `Principal` | The checked principal, for `KindDenied`. | | `RetryAfter` | The delay the server asked for, for `KindRateLimited` and sometimes `KindUnavailable`. Zero when it gave none. | | `Metadata` | The response headers and trailers. | Match a kind with `errors.Is` and the `Err…` sentinel, and read the fields with `errors.As`: | Kind | Sentinel | When | | --- | --- | --- | | `KindConfig` | `ErrConfig` | Bad client setup: a malformed API key, an invalid endpoint or no project. | | `KindAuth` | `ErrAuth` | `Unauthenticated`: the key is missing, unknown or invalidated. | | `KindPermission` | `ErrPermission` | `PermissionDenied`: the **API key** lacks the rights for this call. | | `KindValidation` | `ErrValidation` | `InvalidArgument`, or the SDK's own input checks. | | `KindNotFound` | `ErrNotFound` | `NotFound`, for example `GetPermission` for a name that doesn't exist. | | `KindAlreadyExists` | `ErrAlreadyExists` | `AlreadyExists`. | | `KindUnavailable` | `ErrUnavailable` | `Unavailable`, including network failures. | | `KindTimeout` | `ErrTimeout` | `DeadlineExceeded`, from `Options.Timeout` or the context's deadline. | | `KindRateLimited` | `ErrRateLimited` | `ResourceExhausted`: the organisation's rate limit is used up. | | `KindDenied` | `ErrDenied` | Returned only by `Assert` and `AssertAll`, when the **principal** is not allowed. | | `KindOther` | none | Anything else, including cancellation of the context. | `permio.KindOf(err)` returns the kind (or `""` for other errors), and `permio.IsNotFound(err)` is a shortcut. `*Error` also implements `GRPCStatus()`, so `status.Code(err)` works, and `Unwrap()` returns the underlying gRPC or context error. **Note: Denied is not an error** `KindPermission` is about the *caller*. It means your API key is not allowed to make the call: for `Check`, the key needs `permission.check` on the checked resources and on the principal. A principal failing a check is a normal result: `Check` returns `false` with a `nil` error, and only `Assert` returns an error, of kind `KindDenied`. For `KindPermission`, `Missing` uses the API's fully qualified URIs (`/organisation/{org}/project/{project}/...`), not the URIs you sent. ```go err := client.Assert(ctx, "alice", "document.write", "/documents/1") var perr *permio.Error switch { case err == nil: // allowed case errors.Is(err, permio.ErrDenied): // the principal may not do this: respond with 403 case errors.Is(err, permio.ErrPermission): // the API key may not run this check (it needs permission.check) errors.As(err, &perr) log.Println(perr.Missing) case errors.Is(err, permio.ErrAuth): // the API key is missing, unknown or invalidated case errors.As(err, &perr): log.Println(perr.Kind, perr.Code, perr.Message) } ``` `permio.ParseMissingPermissions(message)` extracts the missing list from a `PermissionDenied` message. You only need it if you call the API without the SDK's connection. ### Retries and rate limits - **Retried:** idempotent reads (`Check`, `BatchCheck`, `WhoAmI`, and every `Get*` and `List*` RPC, including `ListAccess`, `ListPrincipalsWithAccess` and the group reads) are retried on `Unavailable` and `DeadlineExceeded`. They are retried twice by default, with exponential backoff and jitter. - **Never retried:** writes (every Create, Update, Delete, Grant, Revoke, adding and removing group members, and so on) are sent exactly once. Grant and revoke converge on the server, so you can repeat them yourself after a timeout, as long as nobody else is changing that grant in the meantime. - **Rate limits:** a rate-limited read (`ResourceExhausted`) is retried after the delay the server asks for (a `google.rpc.RetryInfo` detail, else a `retry-after` header), when that delay is within `RetryOptions.MaxDelay`. Otherwise, and for writes, the call fails with `KindRateLimited` and `RetryAfter` set. `ResourceExhausted` without a delay is not retried. A `retry-after` on `Unavailable` is used as the minimum backoff. - **Timeouts:** `Options.Timeout` applies to each attempt (default 10 s). The context's deadline is an overall deadline across all attempts, and cancelling the context stops any pending retry. ```go client, err := permio.New(permio.Options{ APIKey: os.Getenv("PERMIO_API_KEY"), Project: "default", Timeout: 5 * time.Second, Retry: permio.RetryOptions{ MaxRetries: 3, BaseDelay: 100 * time.Millisecond, MaxDelay: 2 * time.Second, }, }) ctx, cancel := context.WithTimeout(ctx, 8*time.Second) // overall deadline for this call defer cancel() ok, err := client.Check(ctx, "alice", "document.read", "/documents/1") ``` `RetryOptions` has these fields: - `MaxRetries`: retries after the first attempt. Zero means the default, 2; a negative value disables retries. - `BaseDelay`: the first delay, which doubles each time. Default 100 ms. - `MaxDelay`: the cap on a single delay, and the longest server-requested delay the SDK will wait for. Default 2 s. If you handle rate limiting yourself, wait for `RetryAfter`: ```go var perr *permio.Error if errors.As(err, &perr) && perr.Kind == permio.KindRateLimited { time.Sleep(perr.RetryAfter) } ``` `permio.IsIdempotent("/permio.permissions.v1.PermissionsService/Check")` tells you whether the SDK retries a given RPC. ### Listing with iterators List RPCs are paginated with a cursor (see [Pagination](https://docs.perms.io/api-reference/pagination/)). The list helpers return `iter.Seq2[T, error]` iterators that fetch pages as you go. Breaking out of the loop stops fetching: ```go for p, err := range client.ListPermissions(ctx, &permio.ListOptions{Search: "document."}) { if err != nil { return err } fmt.Println(p.Name) } roles, err := permio.Collect(client.ListRoles(ctx, nil)) ``` A failing page yields the error once and ends the iteration. `permio.Collect` gathers a listing into a slice and stops at the first error. | Method | Yields | | --- | --- | | `ListPermissions(ctx, opts)` | `Permission` | | `ListRoles(ctx, opts)` | `Role` (with its `Permissions`) | | `ListPrincipals(ctx, opts)` | `string` principal IDs that have grants in the project, groups included as `group:` | | `ListAssignments(ctx, principal, opts)` | `Grant` (`Principal`, `Resource`, `Roles`, `Permissions`), one per resource URI | | `Groups().List(ctx, opts)` | `Group` | | `Groups().ListMembers(ctx, name, opts)` | `GroupMember` | | `Groups().ListForPrincipal(ctx, principal, opts)` | `Group` | `opts` is a `*permio.ListOptions` and may be `nil`: - `PageSize`: 1–100, default 100 (`permio.MaxPageSize`). - `Search`: filters by name. `ListPermissions`, `ListRoles`, `ListPrincipals` and `Groups().List` use it; the others ignore it. There is no public helper for other list RPCs. Follow `next_cursor` yourself through the raw clients, as in the example below. ### Raw clients (full API) `client.Permissions()` and `client.Organisations()` return the generated gRPC clients for `permio.permissions.v1.PermissionsService` and `permio.organisation.v1.OrganisationService`. Authentication, timeouts, retries and error mapping are already applied. Every RPC in the [API reference](https://docs.perms.io/api-reference/) is available, with request and response types generated from the protobuf definitions. Method names are the RPC names, and fields are in Go's CamelCase. Set `ProjectName` on requests yourself; the client's default project isn't applied: ```go import ( permissionsv1 "github.com/PrivateJAR/permio-go/proto/permissions/v1" ) perm, err := client.Permissions().CreatePermission(ctx, &permissionsv1.CreatePermissionRequest{ ProjectName: "default", Name: "document.read", Description: "Read documents", }) res, err := client.Permissions().Check(ctx, &permissionsv1.CheckPermissionRequest{ ProjectName: "default", PrincipalId: "alice", ResourceUris: []string{"/documents/42"}, Permissions: []string{"document.read"}, }) if err != nil { return err } fmt.Println(res.GetPassed(), res.GetMissingPermissions()) ``` A list RPC without a helper, following the cursor by hand: ```go import ( organisationv1 "github.com/PrivateJAR/permio-go/proto/organisation/v1" paginationv1 "github.com/PrivateJAR/permio-go/proto/pagination/v1" "google.golang.org/protobuf/proto" ) var cursor *string for { page, err := client.Organisations().ListAPIKeys(ctx, &organisationv1.ListAPIKeysRequest{ OrganisationId: "your-org-id", ProjectName: "default", Pagination: &paginationv1.PaginationRequest{Limit: proto.Int32(100), Cursor: cursor}, }) if err != nil { return err } for _, k := range page.GetApiKeys() { fmt.Println(k.GetName()) } next := page.GetPagination().GetNextCursor() if next == "" { break } cursor = proto.String(next) } ``` `client.Conn()` returns the wrapped `grpc.ClientConnInterface`, for any other generated client. | Import path | Package | Contents | | --- | --- | --- | | `github.com/PrivateJAR/permio-go/proto/permissions/v1` | `permissionsv1` | `PermissionsService` client; permission, role, check, access and group messages | | `github.com/PrivateJAR/permio-go/proto/organisation/v1` | `organisationv1` | `OrganisationService` client; organisation, project and API key messages | | `github.com/PrivateJAR/permio-go/proto/pagination/v1` | `paginationv1` | Pagination request and response messages | **Note: What a key can call** By default an API key has **check** access: it can run checks and access queries, and read permissions, roles, principals, assignments and groups. Creating, updating or deleting permissions, roles and groups, granting or revoking, and changing group members needs a key with full access. See [API keys](https://docs.perms.io/concepts/api-keys/#access-levels). ### API key helpers | Export | Description | | --- | --- | | `ValidateAPIKey(key)` | Returns `APIKeyOpaque` for `perms_` keys and `APIKeyJWT` for legacy JWT keys, or an error of kind `KindConfig` if the key is malformed. | | `APIKeyLength` | `44`, the length of a `perms_` key. | | `EnvAPIKey` | `"PERMIO_API_KEY"`. | These only check the shape and checksum. Only the API can tell you whether a key exists and is still valid: use [`WhoAmI`](https://docs.perms.io/sdk/go/#verifying-the-key). ### net/http ```go func requirePermission(client *permio.Client, permission string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := userIDFrom(r.Context()) // set by your auth middleware err := client.Assert(r.Context(), userID, permission, permio.Resource("documents", r.PathValue("id"))) switch { case err == nil: next.ServeHTTP(w, r) case errors.Is(err, permio.ErrDenied): http.Error(w, "forbidden", http.StatusForbidden) default: // any other error: auth, validation, network... http.Error(w, "authorisation unavailable", http.StatusServiceUnavailable) } }) } mux := http.NewServeMux() mux.Handle("GET /documents/{id}", requirePermission(client, "document.read", http.HandlerFunc(getDocument))) ``` ### How it talks to the API The SDK uses native gRPC over HTTP/2 with TLS, through `google.golang.org/grpc`. The key is sent as `authorization: Bearer ` metadata on every call. Timeouts are applied as context deadlines on each attempt. --- Source: https://docs.perms.io/sdk/python/ ## Python SDK `permio` is the official Python client for the perms.io API. It wraps the API in a few calls for asking "may this principal do X on this resource?", granting and revoking, managing groups and answering "where?" and "who?" questions. It also gives you typed errors, retries for idempotent reads and iterators over list endpoints. The full generated API is there too, as an escape hatch. - Runs on Python 3.9 and later. It is fully typed (`py.typed`). - Comes with two clients that have the same methods: `Permio` (synchronous) and `AsyncPermio` (asyncio). - Talks native gRPC over TLS through `grpcio` (`grpc.aio` for `AsyncPermio`). - Has three runtime dependencies: `grpcio`, `protobuf` and `googleapis-common-protos`. **Note: Versions** This page describes **0.1.0**, the first release on [PyPI](https://pypi.org/project/permio/). ### Install ```bash pip install permio ``` ### Quick start **Sync** ```python from permio import Permio, resource permio = Permio(project="default") # API key from $PERMIO_API_KEY if permio.check("alice", "document.read", resource("documents", 42)): # "/documents/42" print("alice may read document 42") ``` **Async** ```python from permio import AsyncPermio, resource async with AsyncPermio(project="default") as permio: # API key from $PERMIO_API_KEY if await permio.check("alice", "document.read", resource("documents", 42)): print("alice may read document 42") ``` The arguments are always `principal, permission, resource`, in that order. A principal that lacks the permission is a normal answer: `check` returns `False`. ### Configuration ```python from permio import Permio, RetryOptions permio = Permio( api_key="perms_...", # default: $PERMIO_API_KEY base_url="https://api.perms.io", # the default project="default", # default project for every call timeout=10, # seconds per attempt retries=RetryOptions(max_retries=2, base_delay=0.1, max_delay=2.0), ) ``` The constructor checks the API key's format and checksum straight away. A missing, mistyped or truncated key raises `PermioConfigError` before any request is sent. See [API keys](https://docs.perms.io/concepts/api-keys/) for the format. #### Options `api_key` is the only positional argument; the rest are keyword-only. `AsyncPermio` takes the same options. | Option | Type | Default | Description | | --- | --- | --- | --- | | `api_key` | `str` | `$PERMIO_API_KEY` | A `perms_…` key from the dashboard, or a legacy JWT key. | | `base_url` | `str` | `https://api.perms.io` | API host: `https://host[:port]` (TLS), `http://host:port` (plaintext, for a local server) or `host:port` (TLS). It can't have a path. | | `project` | `str` | none | Default project for every call. Each call can override it with `project=`. A call with neither raises `PermioConfigError`. | | `timeout` | `float` | `10` | Timeout for each attempt, in seconds. `0` disables it. | | `retries` | `int \| RetryOptions` | `2` | Retries for idempotent reads. An `int` sets `max_retries`; see [Retries and rate limits](https://docs.perms.io/sdk/python/#retries-and-rate-limits). | | `channel` | `grpc.Channel` | none | Your own channel (`grpc.aio.Channel` for `AsyncPermio`), for example for a proxy or custom credentials. `base_url` and `channel_options` are then ignored, and the client doesn't close it. | | `channel_options` | `list[tuple[str, Any]]` | none | Extra gRPC channel options, used when the client creates the channel. | #### API host `DEFAULT_BASE_URL` is `https://api.perms.io`, the default. A key belongs to one project. `who_am_i()` tells you which organisation and project a key belongs to and whether it can write. It is the one call that needs no project, which makes it useful for checking a key at startup: ```python me = permio.who_am_i() print(me.kind, me.project, me.api_key_access) # "api_key" "default" "check" (or "full") ``` `WhoAmI` has `principal_id`, `organisation_id`, `kind` (`"user"`, `"api_key"`, `"service_account"` or `"unknown"`), `project`, `api_key_access` (`"check"`, `"full"` or `"unknown"`; `None` for credentials other than API keys) and `email`. The client also exposes `permio.project`, `permio.base_url`, `permio.api_key_kind` (`"opaque"` or `"jwt"`) and `permio.channel`. #### Per-call options Every method also takes these keyword arguments: | Argument | Description | | --- | --- | | `project` | Project for this call. Defaults to the client's `project`. (`who_am_i` doesn't take it.) | | `timeout` | Overall deadline for this call in seconds, across retries. | | `metadata` | Extra request headers, as `[("name", "value"), ...]`. An `authorization` entry is ignored; the client always sends its own key. | #### Closing the client Create one client per process and share it. `close()` closes the gRPC channel if the client created it. Both clients are context managers: **Sync** ```python with Permio(project="default") as permio: ... # or permio = Permio(project="default") ... permio.close() ``` **Async** ```python async with AsyncPermio(project="default") as permio: ... # or permio = AsyncPermio(project="default") ... await permio.close() ``` **Note: Async** On `AsyncPermio`, every method on this page is a coroutine and must be awaited, except the list helpers, which return async iterators for `async for`. The examples below use the sync client. ### Checking permissions The check helpers all take the same arguments: | Argument | Description | | --- | --- | | `principal` | Required. The ID of the user, service or other principal being checked. | | `permission` | Required. One permission name, or a list of up to 100. | | `resource` | Required. One resource URI, or a list of up to 100. Every resource is checked. | | `project`, `timeout`, `metadata` | As in [Per-call options](https://docs.perms.io/sdk/python/#per-call-options). | Duplicates are removed before sending. Each URI is checked with `assert_resource_uri()` first, so a malformed one raises `PermioValidationError` without a request. #### `check` Returns `True` when the principal holds **every** permission on **every** resource: ```python permio.check("alice", "document.read", "/documents/42") # -> bool permio.check("alice", ["document.read", "document.write"], "/documents/42") # both permio.check("alice", "document.read", ["/documents/42", "/invoices/1"]) # on both ``` It only raises when the call itself fails, for example because of a bad key, malformed input or network trouble. #### `check_detailed` Same question, but it also tells you what is missing, per resource: ```python result = permio.check_detailed("alice", "document.read", ["/documents/42", "/invoices/1"]) result.allowed # False result.missing # [MissingPermission(resource="/invoices/1", permissions=["document.read"])] ``` It returns a `CheckResult` with `allowed` and `missing`, a list of `MissingPermission` (`resource`, `permissions`). `missing` is empty when `allowed` is `True`. A `CheckResult` is truthy when `allowed` is `True`. #### `assert_allowed` Returns `None` when the principal is allowed and raises `PermioDeniedError` when it is not. (`assert` is a Python keyword, hence the name.) The error carries `principal` and `missing`: ```python permio.assert_allowed("alice", "document.write", "/documents/42") ``` #### `check_any` Returns `True` when, **on every resource**, the principal holds **at least one** of the permissions. It is still a single API call: ```python permio.check_any("alice", ["document.admin", "document.write"], "/documents/42") ``` #### `check_all` An alias of `check`: every permission on every resource. #### Batch checks `check` gives one answer for all its resources together. `batch_check` answers **each resource separately**, which is what you need to filter a list or decide which rows get an "edit" button. It returns a `dict` from resource URI to allowed, with one entry per distinct resource, in request order: ```python from permio import resource allowed = permio.batch_check("alice", "document.read", [resource("documents", d.id) for d in docs]) visible = [d for d in docs if allowed[resource("documents", d.id)]] ``` `batch_check_detailed` asks the same question but returns a list of `BatchCheckEntry` (`resource`, `allowed`, `missing`) in request order. Here `missing` is a list of permission names: ```python for entry in permio.batch_check_detailed( "alice", ["document.read", "document.write"], ["/documents/1", "/documents/2"] ): print(entry.resource, entry.allowed, entry.missing) # /documents/1 True [] # /documents/2 False ['document.write'] ``` - `resources` must be a list (or other iterable) of URIs, not a single string. Duplicates are sent once. - A resource is allowed only with **all** the permissions (up to 100). - More than 100 resources are split into several `BatchCheck` calls. `Permio` sends them one after another; `AsyncPermio` sends them in parallel. - The SDK fails closed: if the API's answer lacks a result for any requested resource, it raises a `PermioError` instead of guessing. #### Access queries These answer "where?" and "who?" rather than "may they?". The [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/) guide shows them in context. ##### `list_access` `list_access(principal, permission, under)` answers "where may this principal use this permission, at or below `under`?". It follows every page and returns an `AccessScopes` object: ```python access = permio.list_access("alice", "document.read", "/documents") if access.covers_all: ... # alice may read everything under /documents: no filter needed else: print(access.to_prefixes()) # e.g. ["/documents/4", "/documents/7"] ``` Access through roles and groups counts. `permission` is a single name, and a `_` segment in `under` is literal. It also takes `page_size`, and its `timeout` and `metadata` apply to each page. The API key needs `permission.check` on `under` and `principal.assignment.get` on the principal. `AccessScopes`: | Member | Type | Description | | --- | --- | --- | | `under` | `str` | The URI that was asked about. | | `covers_all` | `bool` | The principal holds the permission on `under` itself, so on everything below it. Check this first. | | `scopes` | `tuple[AccessScopeInfo, ...]` | `AccessScopeInfo(uri, kind)`, ordered by URI. `kind` is `"subtree"`, `"pattern"`, `"exact"` (reserved, not returned today) or `"unknown"` (a kind this SDK version doesn't know; it never matches). | | `is_empty` | `bool` | No access anywhere under `under`. | | `allows(uri)` | `bool` | Whether `uri` is covered. Matches per segment: `/a/b` covers `/a/b/c` but not `/a/bc`. Malformed URIs are never allowed. | | `to_prefixes()` | `list[str]` | The subtree URIs, for prefix filters. Prefixes covered by a shorter one are dropped. Pattern scopes aren't included. | | `patterns()` | `list[str]` | The pattern URIs, with `_` segments. | | `has_patterns` | `bool` | Some scope is a pattern, so `to_prefixes()` alone doesn't cover everything. | | `exact()` | `list[str]` | The exact URIs. Empty today. | | `to_regex()` | `re.Pattern[str]` | One anchored regular expression matching exactly what `allows()` accepts. Use `.pattern` for MongoDB `$regex` or PostgreSQL `~`. Matches nothing when there are no scopes. | **Warning** A prefix `p` from `to_prefixes()` means `p` **and everything below it**. Filter with `path = p OR path starts with p + "/"`, never a bare "starts with `p`", which would let `/documents/1` match `/documents/12`. In SQL, use `starts_with(path, p || '/')` rather than `LIKE`, where `_` is a wildcard. For a URI outside `under`, `allows()` returning `False` means "not asked", not "denied". ##### `who_has_access` `who_has_access(resource, *, permission=None, expand_groups=False)` answers "who has access to this resource?". It follows every page and returns a list of `AccessEntry`, ordered by principal ID: ```python for entry in permio.who_has_access("/documents/42", permission="document.read", expand_groups=True): print(entry.principal, entry.is_group, entry.via_group, [(g.resource, g.match, g.source.type) for g in entry.via]) # alice False None [('/documents', 'ancestor', 'role')] # bob False editors [('/documents', 'ancestor', 'direct')] # group:editors True None [('/documents', 'ancestor', 'direct')] ``` | Argument | Description | | --- | --- | | `resource` | Required. The resource to look at. A `_` segment here is literal. | | `permission` | Optional. Only count grants that confer this permission, so a principal is listed exactly when `check()` passes for it through its own grants (or, for entries with `via_group`, through that group). Unset, any grant counts. | | `expand_groups` | Optional. Also list every member of each listed group, once per group, with `via_group` set. | | `project`, `page_size`, `timeout`, `metadata` | As for `list_access`. | `AccessEntry` has `principal`, `is_group`, `via_group` (a group slug, or `None`) and `via`, the grants that give the access, most specific first. Each grant (`AccessGrantInfo`) has: - `resource`: the URI the grant was made on (the resource, an ancestor, or a `_` wildcard URI that matches it); - `inherited`: `True` unless the grant was made on exactly the resource; - `match`: `"exact"`, `"ancestor"`, `"wildcard"` or `"unknown"`; - `source`: an `AccessGrantSource` whose `type` is `"role"` (with the role's `id` and `name`), `"direct"` for directly granted permissions, or `"unknown"`. The API key needs `principal.list` and `principal.assignment.list` on the project. ### Granting and revoking `grant` and `revoke` need an API key with full access. Both take a principal and one resource URI, then `permissions` (permission names) and `roles` (role IDs) as keyword arguments. Each takes one value or a list, and at least one permission or role is required. ```python permio.grant("alice", "/documents/42", permissions="document.read") permio.grant("alice", "/documents", permissions=["document.read", "document.write"], roles="ROLE_ID") permio.revoke("alice", "/documents/42", permissions="document.read") ``` - A grant applies to the URI and everything beneath it. Grants are additive: what the principal already holds there is kept. A permission or role that doesn't exist in the project raises `PermioValidationError`. - `revoke` removes names from the grant on **exactly** that URI. Grants on ancestor URIs stay, so `check()` may still pass afterwards; `who_has_access()` shows where access comes from. Names the grant doesn't hold are ignored, so revoking twice is harmless. - Roles are referenced by ID. Find them with `list_roles()`. - Neither call is ever retried automatically. After a timeout, the first attempt may still have landed. - Both return `None`. There are no helpers for creating permissions and roles; use the [raw client](https://docs.perms.io/sdk/python/#raw-client-full-api). ### Groups A [group](https://docs.perms.io/concepts/groups/) is a named set of principals in a project. Grant to the group's principal ID, `group:`, and every member holds the grant. Checks include the principal's groups automatically. #### `group(slug)` Returns the group's principal ID. It raises `PermioValidationError` unless the slug is 1–64 lower-case letters, digits and hyphens. ```python from permio import group group("editors") # "group:editors" permio.groups.create("editors", description="Can edit every document") permio.groups.add_members("editors", ["alice", "bob"]) permio.grant(group("editors"), "/documents", permissions="document.write") permio.check("alice", "document.write", "/documents/42") # True, through the group ``` #### `permio.groups` Every method that takes a group accepts its slug (`"editors"`) or its principal ID (`"group:editors"`). | Method | Returns | | --- | --- | | `create(name, *, description="")` | `Group`. Raises a `PermioError` with code `grpc.StatusCode.ALREADY_EXISTS` if the project already has a group with this name. | | `get(name)` | `Group`. Raises `PermioNotFoundError` if it doesn't exist. | | `update(name, *, description)` | `Group`. Replaces the description; a group can't be renamed. | | `delete(name)` | `None`. Deletes the group, its memberships and every grant made to it, whether or not it existed. | | `list(*, search=None)` | Iterator of `Group`, ordered by name. | | `add_members(name, principals)` | `AddMembersResult(added, existing)`, each a list in request order. | | `remove_members(name, principals)` | `RemoveMembersResult(removed, not_members)`, each a list in request order. | | `list_members(name)` | Iterator of `GroupMember` (`principal_id`, `created_at`), ordered by principal ID. | | `list_for_principal(principal)` | Iterator of the `Group`s the principal is a member of, ordered by name. | - `add_members` and `remove_members` take a list of 1–100 principals; duplicates are removed. `add_members` rejects `group:` IDs with `PermioValidationError`, because groups can't contain groups. - Every method also takes `project`, `timeout` and `metadata`. The list methods take `page_size` too, and their `timeout` and `metadata` apply to each page. - `Group` and `GroupMember` are the generated protobuf messages. `Group` has `id`, `organisation_id`, `project_name`, `name`, `description`, `principal_id` (`"group:"`), `created_at` and `updated_at`. - On `AsyncPermio`, `permio.groups` is an `AsyncGroups` with the same methods. ```python res = permio.groups.add_members("group:editors", ["alice", "carol"]) print(res.added, res.existing) for m in permio.groups.list_members("editors"): print(m.principal_id) for g in permio.groups.list_for_principal("alice"): print(g.name) ``` Reading groups works with a check-access API key. Creating, updating and deleting groups and changing members needs a key with full access, and changing members also needs the right to grant across the project. See [Permissions you need](https://docs.perms.io/concepts/groups/#permissions-you-need). ### Resource URIs Resource URIs are paths such as `/projects/p1/documents/42`. A grant on a URI also covers every URI beneath it. See [Resources and inheritance](https://docs.perms.io/concepts/resources/) for the full rules. #### `resource(*segments)` Builds a URI from raw segments (strings or integers). It adds the leading `/` and percent-encodes each segment, so an ID that contains `/`, spaces or other reserved characters stays a single segment: ```python from permio import resource resource("projects", "p1", "documents", 42) # "/projects/p1/documents/42" resource("files", "reports/2026.pdf") # "/files/reports%2F2026.pdf" ``` Everything except `A-Z a-z 0-9 - _ . ~` is encoded as UTF-8, including `( ) *`, which the API rejects in segments. The output matches the TypeScript SDK's `resource()`. Empty segments, or no segments, raise `PermioValidationError`. **Tip** Build URIs the same way when you grant and when you check, or they will not match. #### Other helpers | Export | Description | | --- | --- | | `encode_segment(segment)` | Encodes one segment the way `resource()` does. | | `assert_resource_uri(uri)` | Raises `PermioValidationError` unless `uri` has the basic shape the API accepts: a leading `/`, no empty segments, no whitespace or control characters, and at most 2048 bytes. | | `MAX_RESOURCE_URI_LENGTH` | `2048`. | | `WILDCARD_SEGMENT` | `"_"`. | | `GROUP_PRINCIPAL_PREFIX` | `"group:"`. | Every helper runs `assert_resource_uri` on its URIs before sending. The API enforces the remaining rules, such as no `( ) [ ] { } * + ? | ^ $ \` in segments, and answers with `INVALID_ARGUMENT`, which the SDK raises as `PermioValidationError`. ### Errors Every error the SDK raises extends `PermioError`. It has these attributes: - `code`: the `grpc.StatusCode`, or `None` for errors the SDK raises itself (bad configuration, failed input checks, `PermioDeniedError`). - `code_name`: the code's name in lower case, for example `"permission_denied"`, or `None`. - `message`: the error message. - `metadata`: the response headers and trailers, as a tuple of `(name, value)` pairs. The original `grpc.RpcError` is chained as `__cause__`. | Class | When | | --- | --- | | `PermioConfigError` | Bad client setup: a missing or malformed API key, no `project`, an invalid `base_url`, `timeout` or `retries`. | | `PermioAuthError` | `UNAUTHENTICATED`: the key is missing, unknown or invalidated. | | `PermioPermissionError` | `PERMISSION_DENIED`: the **API key** lacks the rights for this call. `missing` lists what it lacks when the API says. | | `PermioValidationError` | `INVALID_ARGUMENT`, or the SDK's own input checks (for example a URI without a leading `/`). | | `PermioNotFoundError` | `NOT_FOUND`, for example `groups.get()` for a group that doesn't exist. | | `PermioUnavailableError` | `UNAVAILABLE`, including network failures. | | `PermioTimeoutError` | `DEADLINE_EXCEEDED`, including the SDK's own deadlines. | | `PermioRateLimitError` | `RESOURCE_EXHAUSTED`: the organisation's rate limit is used up. `retry_after` is the delay the server asked for, in seconds, or `None`. | | `PermioDeniedError` | Raised only by `assert_allowed()`, when the **principal** is not allowed. Has `principal` and `missing`. | Any other status, such as `ALREADY_EXISTS`, is a plain `PermioError` with the matching `code`. **Note: Denied is not an error** `PermioPermissionError` is about the *caller*. It means your API key is not allowed to make the call: for `Check`, the key needs `permission.check` on the checked resources and on the principal. A principal failing a check is a normal result: `check()` returns `False` and `assert_allowed()` raises `PermioDeniedError`. `PermioPermissionError.missing` uses the API's fully qualified URIs (`/organisation/{org}/project/{project}/...`), not the URIs you sent. ```python from permio import PermioAuthError, PermioDeniedError, PermioError, PermioPermissionError, resource try: permio.assert_allowed(user_id, "document.write", resource("documents", doc_id)) except PermioDeniedError: ... # the principal may not do this: respond with 403 except PermioPermissionError as err: print(err.missing) # the API key may not run this check (it needs permission.check) except PermioAuthError: ... # the API key is missing, unknown or invalidated except PermioError as err: print(err.code_name, err.message) ``` `parse_missing_permissions(message)` extracts the missing list from a `PERMISSION_DENIED` message. You only need it if you call the generated stubs without the SDK. ### Retries and rate limits - **Retries:** idempotent reads (`Check`, `BatchCheck`, `WhoAmI`, and every `Get*` and `List*` RPC, including `ListAccess`, `ListPrincipalsWithAccess` and the group reads) are retried on `UNAVAILABLE` and `DEADLINE_EXCEEDED`. They are retried twice by default, with exponential backoff and jitter. Network failures count as `UNAVAILABLE`. - **Writes** (every Create, Update, Delete, Grant, Revoke, Invite, adding and removing group members, and so on) are never retried. - **Timeouts:** - The client's `timeout` applies to each attempt (default 10 seconds). - A call's `timeout=` is an overall deadline across all attempts. If a retry couldn't finish its backoff before the deadline, the last error is raised instead. - On `AsyncPermio`, cancelling the task cancels the call and any pending retry. ```python permio = Permio( project="default", timeout=5, retries=RetryOptions(max_retries=3, base_delay=0.1, max_delay=2.0), ) permio.check("alice", "document.read", "/documents/1", timeout=8) ``` `RetryOptions` is a frozen dataclass with these fields: - `max_retries`: retries after the first attempt. Default 2. `retries=0` turns retries off. - `base_delay`: the first delay in seconds, which doubles each time. Default 0.1. - `max_delay`: the cap on a single delay in seconds. Default 2.0. Each delay is picked at random between half and all of `min(max_delay, base_delay * 2**attempt)`. #### Rate limits When the API answers `RESOURCE_EXHAUSTED`, the SDK looks for the delay the server asked for: a `google.rpc.RetryInfo` detail, or else a `retry-after` header (seconds or an HTTP date). - An idempotent read is retried after that delay, plus a little jitter, if the delay is no longer than `max_delay` and a retry is left. - Otherwise, including when the server gave no delay and for every write, the call raises `PermioRateLimitError` with `retry_after` set. Waiting and trying again is then up to you. - A `retry-after` header on `UNAVAILABLE` is honoured as the minimum backoff. ```python import time from permio import PermioRateLimitError try: permio.grant("alice", "/documents/42", permissions="document.read") except PermioRateLimitError as err: time.sleep(err.retry_after or 1) # ...try again, or queue the write ``` `is_idempotent_method("permio.permissions.v1.PermissionsService.Check")` tells you whether the SDK retries a given RPC. ### Listing with iterators List RPCs are paginated with a cursor (see [Pagination](https://docs.perms.io/api-reference/pagination/)). The list helpers return iterators that fetch pages as you go. Each item is the generated protobuf message: **Sync** ```python for p in permio.list_permissions(search="document."): print(p.name) for role in permio.list_roles(): print(role.id, role.name, list(role.permissions)) for principal_id in permio.list_principals(): print(principal_id) for a in permio.list_assignments("alice"): print(a.resource_uri, list(a.roles)) ``` **Async** ```python async for p in permio.list_permissions(search="document."): print(p.name) async for role in permio.list_roles(): print(role.id, role.name, list(role.permissions)) async for principal_id in permio.list_principals(): print(principal_id) async for a in permio.list_assignments("alice"): print(a.resource_uri, list(a.roles)) ``` | Method | Yields | | --- | --- | | `list_permissions(*, search=None)` | `Permission` | | `list_roles(*, search=None)` | `Role` (with its `permissions`) | | `list_principals(*, search=None)` | `str` principal IDs that have grants in the project | | `list_assignments(principal)` | `Assignment` (`resource_uri`, `permissions`, `roles`) | | `groups.list(*, search=None)` | `Group` (see [Groups](https://docs.perms.io/sdk/python/#groups)) | | `groups.list_members(name)` | `GroupMember` | | `groups.list_for_principal(principal)` | `Group` | Each method also takes these keyword arguments: - `project` - `page_size`: 1–100, default 100 - `timeout`: per page - `metadata` The project and principal are checked when you call the method; the first request is sent when you start iterating. `list_access` and `who_has_access` follow every page themselves and return the complete result. For any other list RPC, call the [raw client](https://docs.perms.io/sdk/python/#raw-client-full-api) and follow `pagination.next_cursor` until it is empty: ```python from permio import raw org = raw.organisation_pb2 page = raw.pagination_pb2.PaginationRequest(limit=100) while True: res = permio.organisations.ListAPIKeys( org.ListAPIKeysRequest(organisation_id="your-org-id", project_name="default", pagination=page) ) for key in res.api_keys: print(key.name) if not res.pagination.next_cursor: break page.cursor = res.pagination.next_cursor ``` ### Raw client (full API) `permio.permissions` and `permio.organisations` are the generated gRPC stubs for `permio.permissions.v1.PermissionsService` and `permio.organisation.v1.OrganisationService`, with authentication, timeouts, retries (reads only) and error mapping already applied. Every RPC in the [API reference](https://docs.perms.io/api-reference/) is available. Method names are the RPC names, requests are the generated messages with the proto field names, and each method also takes `timeout` (an overall deadline) and `metadata`. On `AsyncPermio` they are coroutines. The generated modules are re-exported from `permio.raw`: `permissions_pb2`, `permissions_pb2_grpc`, `organisation_pb2`, `organisation_pb2_grpc` and `pagination_pb2`. They also live at `permio.permissions.v1`, `permio.organisation.v1` and `permio.pagination.v1`. ```python from permio import raw pb = raw.permissions_pb2 permio.permissions.CreatePermission( pb.CreatePermissionRequest(project_name="default", name="document.read", description="Read documents") ) role = permio.permissions.CreateRole( pb.CreateRoleRequest(project_name="default", name="viewer", permissions=["document.read"]) # permission names ) permio.grant("alice", "/documents", roles=role.id) # role IDs res = permio.permissions.Check( pb.CheckPermissionRequest( project_name="default", principal_id="alice", resource_uris=["/documents/42"], permissions=["document.read"], ) ) print(res.passed, res.missing_permissions) ``` **Note: What a key can call** By default an API key has **check** access: it can run checks and access queries, and read permissions, roles, principals, assignments and groups. Creating, updating or deleting permissions, roles and groups, granting or revoking, and changing group members needs a key with full access. See [API keys](https://docs.perms.io/concepts/api-keys/#access-levels). For the bare stubs with no SDK behaviour, build `raw.permissions_pb2_grpc.PermissionsServiceStub(channel)` on your own channel and send `("authorization", "Bearer ")` metadata yourself. ### API key helpers | Export | Description | | --- | --- | | `validate_api_key(key)` | Returns `"opaque"` for `perms_` keys and `"jwt"` for legacy JWT keys. Raises `PermioConfigError` if the key is missing or malformed. | | `is_valid_api_key(key)` | `True` if `validate_api_key` would pass. | | `API_KEY_LENGTH` | `44`. | These only check the shape and checksum. Only the API can tell you whether a key exists and is still valid. ### How it talks to the API The SDK uses native gRPC over HTTP/2 through `grpcio`, with TLS for `https://` and `host:port` base URLs. The production default connects to `api.perms.io:443`. The key is sent as `authorization: Bearer ` metadata, and the client identifies itself with the user agent `permio-python/`. Timeouts are passed to gRPC as per-attempt deadlines. --- Source: https://docs.perms.io/sdk/rust/ ## Rust SDK `permio` is the official Rust client for the perms.io API. It wraps the API in a few calls for asking "may this principal do X on this resource?". It also gives you one typed error enum, retries for idempotent reads and `Stream`s over list endpoints. The full generated API is there too, as an escape hatch. - Needs Rust 1.88 or newer (the minimum for tonic 0.14). - Async on Tokio. The client must be built inside a Tokio runtime. - Talks native gRPC over HTTP/2 through [tonic](https://docs.rs/tonic), with prost-generated messages. - TLS comes from rustls, through the `tls` feature, which is on by default. **Note: Not on crates.io yet** The crate is version 0.1.0 and isn't published on crates.io yet. Until it is, depend on the Git repository, as shown below. Publishing to crates.io is coming. ### Install ```toml [dependencies] permio = { git = "https://github.com/PrivateJAR/permio-rust" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } futures-util = "0.3" # only for the list streams, see Listing with streams ``` #### Features | Feature | Default | Description | | --- | --- | --- | | `tls` | on | rustls (with ring) for `https://` endpoints such as `https://api.perms.io`. It trusts the platform's certificates and the bundled webpki roots. | Without `tls`, only `http://` endpoints work: building a client for an `https://` endpoint fails with `Error::Config`. ### Quick start ```rust #[tokio::main] async fn main() -> Result<(), permio::Error> { let permio = permio::Client::builder().project("default").build()?; // key from PERMIO_API_KEY let doc = permio::resource!("documents", 42)?; // "/documents/42" if permio.check("alice", "document.read", &doc).await? { println!("alice may read {doc}"); } Ok(()) } ``` A principal that lacks the permission is a normal answer: `check` returns `Ok(false)`. It only returns `Err` when the call itself fails, for example because of a bad key, malformed input or network trouble. ### Configuration ```rust use std::time::Duration; let permio = permio::Client::builder() .api_key(std::env::var("PERMIO_API_KEY").unwrap()) // the default source anyway .endpoint(permio::DEFAULT_ENDPOINT) // https://api.perms.io .project("default") .timeout(Duration::from_secs(5)) .retries(3) .build()?; ``` `build()` checks the API key's format and checksum straight away. A mistyped or truncated key returns `Error::Config` before any request is sent. See [API keys](https://docs.perms.io/concepts/api-keys/) for the format. The connection itself is made lazily, on the first call. #### Builder options | Method | Type | Default | Description | | --- | --- | --- | --- | | `api_key` | `impl Into` | `PERMIO_API_KEY` env var | A `perms_…` key from the dashboard, or a legacy JWT key. `Error::Config` if neither is set. | | `endpoint` | `impl Into` | `DEFAULT_ENDPOINT` | API URL. Must start with `http://` or `https://`; a trailing `/` is dropped. | | `project` | `impl Into` | none | Default project for every call. Without one, calls that need a project fail with `Error::Config`. | | `timeout` | `Duration` | 10 s (`DEFAULT_TIMEOUT`) | Timeout for each attempt, also used as the connect timeout. `Duration::ZERO` disables it. | | `retries` | `u32` | `2` | Retries for idempotent reads. Sets `max_retries` and keeps the other retry settings. | | `retry_config` | `RetryConfig` | see [Retries and rate limits](https://docs.perms.io/sdk/rust/#retries-and-rate-limits) | All retry settings at once. | | `channel` | `tonic::transport::Channel` | none | Advanced: a tonic channel to use instead of connecting to `endpoint`, for example one connected to an in-process test server. Authentication, timeouts and retries still apply. | `build()` returns `Error::Config` if the key is missing or malformed, the endpoint isn't an http(s) URL, an `https` endpoint is used without the `tls` feature, or it's called outside a Tokio runtime. `permio::Client::from_env("default")` is a shorthand for `Client::builder().project("default").build()`: production endpoint, key from `PERMIO_API_KEY`. #### API host `permio::DEFAULT_ENDPOINT` is `https://api.perms.io`, the default. To use another endpoint, pass it to `.endpoint(...)` on the builder. An API key only works in the project it was created in. #### Sharing the client and switching project `Client` is cheap to clone and safe to share between tasks. Clones share one HTTP/2 connection, so create one client at startup and clone it where you need it. Most helpers have no per-call project parameter. `with_project` returns a clone with another default project: ```rust let other = permio.with_project("other-project"); ``` The exceptions are `Check` and `BatchCheck`, which take `.project(...)` (see below). The client also exposes `permio.project()` (`Option<&str>`), `permio.endpoint()` and `permio.api_key_kind()` (`ApiKeyKind::Opaque` or `ApiKeyKind::Jwt`). #### `who_am_i` Describes the credential the client uses. It needs no project, so it is a good way to verify a key at startup: ```rust let me = permio.who_am_i().await?; println!("{:?} {:?} {:?}", me.kind, me.project, me.api_key_access); ``` `WhoAmI` has `principal_id`, `organisation_id`, `kind` (`PrincipalKind::ApiKey`, `User`, `ServiceAccount` or `Unknown`), `project` (the project an API key belongs to), `api_key_access` (`Some(ApiKeyAccess::Check)`, `Some(ApiKeyAccess::Full)` or `Some(ApiKeyAccess::Unknown)` for an API key, otherwise `None`) and `email`. See [access levels](https://docs.perms.io/concepts/api-keys/#access-levels). ### Checking permissions #### `check` Returns `true` when the principal holds the permission on the resource, directly, through a role, through a group, through an ancestor URI or through a `_` wildcard grant: ```rust let allowed: bool = permio.check("alice", "document.read", "/documents/42").await?; ``` The arguments are `(principal, permission, resource)`, all `&str`. #### `check_all` and `check_any` Both take one resource and several permissions, and make a single `Check` call: ```rust // every permission permio.check_all("alice", ["document.read", "document.write"], "/documents/42").await?; // at least one permission permio.check_any("alice", ["document.admin", "document.write"], "/documents/42").await?; ``` `check_any` works out its answer from the missing list of one `Check`, so it costs the same as `check_all`. #### `check_detailed` For several resources, several permissions, another project, or to find out what is missing, build a `Check`: ```rust use permio::Check; let result = permio .check_detailed( Check::new("alice") .resources(["/documents/1", "/documents/2"]) .permissions(["document.read", "document.write"]), ) .await?; // result.allowed: false // result.missing: [MissingPermission { resource: "/documents/2", permissions: ["document.write"] }] ``` `CheckResult` has `allowed` (the principal holds **every** permission on **every** resource) and `missing`, a `Vec` (`resource`, `permissions`) in request order, empty when `allowed` is `true`. `Check` builder methods: | Method | Description | | --- | --- | | `Check::new(principal)` | The principal being checked: one of your user or service IDs, or `group:`. | | `resource(uri)` / `resources(uris)` | Adds one or several resource URIs. Every resource is checked. | | `permission(name)` / `permissions(names)` | Adds one or several permission names. | | `project(name)` | Checks in this project instead of the client's default. | Duplicates are removed before sending. A check needs 1–100 resources and 1–100 permissions, none of them empty, or it returns `Error::Validation` without calling the API. #### `assert` and `assert_detailed` Return `Ok(())` when the principal is allowed and `Err(Error::Denied { .. })` when it is not. The error carries `principal` and `missing`: ```rust permio.assert("alice", "document.write", "/documents/1").await?; permio.assert_detailed(Check::new("alice").permission("document.read").resource("/documents/1")).await?; ``` #### `batch_check` `check_detailed` gives one answer for all its resources together. `batch_check` answers **each resource separately**, which is what you need to filter a list or decide which rows get an "edit" button: ```rust use permio::BatchCheck; let uris = docs .iter() .map(|d| permio::resource!("documents", d.id)) .collect::, _>>()?; let entries = permio .batch_check(BatchCheck::new("alice").resources(uris).permission("document.read")) .await?; let visible: Vec<&str> = entries.iter().filter(|e| e.allowed).map(|e| e.resource.as_str()).collect(); ``` It returns a `Vec`, one per distinct resource, in request order. Each entry has `resource`, `allowed` (the principal holds **every** requested permission on it) and `missing` (the requested permissions it lacks there). `BatchCheck` has the same builder methods as `Check`. Duplicate resources are sent once. There is no upper limit on resources: more than 100 are split into several `BatchCheck` calls, sent concurrently. Permissions are limited to 1–100. The SDK fails closed: if the API's answer doesn't include a result for each requested resource, `batch_check` returns `Error::Other` instead of guessing. #### `list_access` Answers "where may this principal use this permission, at or below `under`?". The arguments are `(principal, permission, under)`. It follows every page and returns an `AccessScopes`: ```rust let access = permio.list_access("alice", "document.read", "/documents").await?; if access.covers_all { // alice may read everything under /documents } else { let prefixes = access.to_prefixes(); // e.g. ["/documents/4", "/documents/7"] } ``` The [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/) guide shows it in context. `AccessScopes`: | Member | Type | Description | | --- | --- | --- | | `under` | `String` | The URI that was asked about. | | `covers_all` | `bool` | The principal holds the permission on `under` itself, so on everything below it. `scopes` is then a single subtree scope for `under`. | | `scopes` | `Vec` | `{ uri, kind }`, ordered by URI. `kind` is `ScopeKind::Subtree`, `Pattern`, `Exact` (reserved, not returned today) or `Unknown` (a kind this SDK version doesn't know; it never matches). | | `is_empty()` | `bool` | No access anywhere under `under`. | | `allows(uri)` | `bool` | Whether `uri` is covered. Matches per segment: `/a/b` covers `/a/b/c` but not `/a/bc`. Malformed URIs are never allowed. | | `to_prefixes()` | `Vec` | The subtree URIs, for prefix filters. Prefixes covered by a shorter one are dropped. Pattern scopes aren't included. | | `patterns()` | `Vec` | The pattern URIs, with `_` segments. | | `has_patterns()` | `bool` | Some scope is a pattern, so `to_prefixes()` alone doesn't cover everything. | | `exact()` | `Vec` | The exact URIs. Empty today. | | `regex_pattern()` | `String` | One anchored regular expression matching exactly what `allows()` accepts, for PostgreSQL `~`, MongoDB `$regex` or the `regex` crate. With no scopes it is `$.^`, which matches nothing. | **Warning** A prefix `p` from `to_prefixes()` means `p` **and everything below it**. Filter with `path = p OR path starts with p + "/"`, never a bare "starts with `p`", which would let `/documents/1` match `/documents/12`. In SQL, use `starts_with(path, p || '/')` rather than `LIKE`, where `_` is a wildcard. For a URI outside `under`, `allows()` returning `false` means "not asked", not "denied". A `_` segment in `under` is literal. The API key needs `permission.check` on `under` and `principal.assignment.get` on the principal. #### `who_has_access` Answers "who has access to this resource?". It follows every page and returns a `Vec`, ordered by principal ID: ```rust use permio::{GrantSource, WhoHasAccess}; let entries = permio .who_has_access( WhoHasAccess::new("/documents/42") .permission("document.read") .expand_groups(true), ) .await?; for e in &entries { for g in &e.via { let source = match &g.source { GrantSource::Role { name, .. } => format!("role {name}"), GrantSource::Direct => "direct".to_string(), _ => "unknown".to_string(), }; println!("{} on {} ({source})", e.principal, g.resource); } } ``` `WhoHasAccess` builder methods: | Method | Description | | --- | --- | | `WhoHasAccess::new(resource)` | The resource to look at. A `_` segment here is literal. | | `permission(name)` | Only count grants that confer this permission, so a principal is listed exactly when a check passes for it through its own grants (or, for entries with `via_group`, through that group). Unset, any grant counts. | | `expand_groups(bool)` | Also list every member of each listed group, once per group, with `via_group` set. | | `page_size(n)` | Items per page, 1–100. Default 100. | `AccessEntry` has `principal`, `is_group`, `via_group` (a group slug, or `None`) and `via`, the grants that give the access, most specific first. Each `AccessGrant` has: - `resource`: the URI the grant was made on (the resource, an ancestor, or a `_` wildcard URI that matches it); - `inherited`: `true` unless the grant was made on exactly the resource; - `match_kind`: `GrantMatch::Exact`, `Ancestor`, `Wildcard` or `Unknown`; - `source`: `GrantSource::Role { id, name }`, `GrantSource::Direct` for directly granted permissions, or `GrantSource::Unknown`. `GrantMatch` and `GrantSource` are `#[non_exhaustive]`, so a `match` on them needs a `_` arm. The API key needs `principal.list` and `principal.assignment.list` on the project. ### Granting and revoking Build a `Grant` with the principal and resource, then add permission names, role IDs or both: ```rust use permio::Grant; permio.grant(Grant::new("alice", "/documents").permission("document.read")).await?; permio.grant(Grant::new("alice", "/documents").role(role_id)).await?; // role IDs, see list_roles permio .grant(Grant::new("alice", "/documents").permissions(["document.read", "document.write"]).roles([role_id])) .await?; permio.revoke(Grant::new("alice", "/documents").permission("document.read")).await?; ``` - `grant` is additive: what the principal already holds there is kept. The grant also applies to every resource below the URI. Unknown permissions or roles return `Error::Validation`. - `revoke` removes names from the principal's grant on **exactly** that URI and ignores names the grant doesn't have, so revoking twice is harmless. Grants on ancestor URIs stay, so the principal may still pass a check; `who_has_access` shows where access comes from. - A `Grant` needs at least one permission or role. Empty names return `Error::Validation`. - Both use the client's default project; use `with_project` for another one. - Both need a key with full access, and neither is ever retried automatically. To grant to every member of a group, use `permio::group("editors")?` as the principal (see [Groups](https://docs.perms.io/sdk/rust/#groups)). ### Groups A [group](https://docs.perms.io/concepts/groups/) is a named set of principals in a project. Grant to the group's principal ID, `group:`, and every member holds the grant. Checks include the principal's groups automatically. #### `permio::group(slug)` Returns the group's principal ID. It returns `Error::Validation` unless the slug is 1–64 lower-case letters, digits and hyphens. `GROUP_PRINCIPAL_PREFIX` is `"group:"`. ```rust use permio::Grant; let groups = permio.groups(); groups.create("editors", Some("Can edit every document")).await?; groups.add_members("editors", ["alice", "bob"]).await?; permio .grant(Grant::new(permio::group("editors")?, "/documents").permission("document.write")) .await?; permio.check("alice", "document.write", "/documents/42").await?; // true, through the group ``` #### `permio.groups()` Returns a `Groups` handle. Every method that takes a group accepts its slug (`"editors"`) or its principal ID (`"group:editors"`). Calls use the client's default project; use `with_project` for another one. | Method | Returns | | --- | --- | | `create(name, description: Option<&str>)` | `Group`. `Error::Other` with code `AlreadyExists` if the project already has a group with this name. | | `get(name)` | `Group`. `Error::NotFound` if it doesn't exist. | | `update(name, description: &str)` | `Group`. Replaces the description; a group can't be renamed. `Error::NotFound` if it doesn't exist. | | `delete(name)` | `()`. Deletes the group, its memberships and every grant made to it. Succeeds whether or not it existed. | | `list(ListOptions)` | `ListStream`, ordered by name. | | `add_members(name, principals)` | `AddMembersResult { added, existing }`, in request order. | | `remove_members(name, principals)` | `RemoveMembersResult { removed, not_members }`, in request order. | | `list_members(name, ListOptions)` | `ListStream` (`principal_id`, `created_at`), ordered by principal ID. | | `list_for_principal(principal, ListOptions)` | `ListStream` of the groups the principal is a member of, ordered by name. | - `add_members` and `remove_members` take 1–100 principals; duplicates are removed. `add_members` rejects `group:` IDs with `Error::Validation`, because groups can't contain groups. - `Group` is the generated message, re-exported as `permio::Group`. It has `id`, `organisation_id`, `project_name`, `name`, `description`, `principal_id` (`"group:"`), `created_at` and `updated_at`. ```rust use futures_util::TryStreamExt; let res = permio.groups().add_members("group:editors", ["alice", "carol"]).await?; println!("added {:?}, already members {:?}", res.added, res.existing); let mut members = permio.groups().list_members("editors", Default::default()); while let Some(m) = members.try_next().await? { println!("{}", m.principal_id); } ``` Reading groups works with a check-access API key. Creating, updating and deleting groups and changing members needs a key with full access, and changing members also needs the right to grant across the project. See [Permissions you need](https://docs.perms.io/concepts/groups/#permissions-you-need). ### Resource URIs Resource URIs are paths such as `/projects/p1/documents/42`. A grant on a URI also covers every URI beneath it. See [Resources and inheritance](https://docs.perms.io/concepts/resources/) for the full rules. #### `resource!` and `resource()` Build a URI from raw segments. They add the leading `/` and percent-encode each segment, so an ID that contains `/`, spaces or other reserved characters stays a single segment. The macro takes segments of mixed `Display` types; the function takes any iterator of one `Display` type: ```rust permio::resource!("projects", "p1", "documents", 42)?; // "/projects/p1/documents/42" permio::resource(["files", "reports/2026.pdf"])?; // "/files/reports%2F2026.pdf" ``` Everything except ASCII letters, digits and `- _ . ~` is encoded, as UTF-8. Both return `Error::Validation` for an empty segment or no segments at all. **Tip** Build URIs the same way when you grant and when you check, or they will not match. #### Other helpers | Item | Description | | --- | --- | | `encode_segment(segment)` | Encodes one segment the way `resource()` does. | | `validate_resource_uri(uri)` | Returns `Error::Validation` unless `uri` has the basic shape the API accepts: a leading `/`, no empty segments (so no `//`, trailing `/` or bare `/`), no whitespace or control characters, and at most 2048 bytes. | | `MAX_RESOURCE_URI_LENGTH` | `2048`. | | `WILDCARD_SEGMENT` | `"_"`, the segment that matches exactly one segment in a stored grant. | Every helper runs `validate_resource_uri` on its URIs before sending. The API enforces the remaining rules, such as no `( ) [ ] { } * + ? | ^ $ \` in segments, and answers with `InvalidArgument`, which the SDK returns as `Error::Validation`. ### Errors Every fallible call returns `Result<_, permio::Error>`. `Error` is an enum with one variant per kind of failure: | Variant | When | | --- | --- | | `Config { message }` | Bad client setup: a missing or malformed API key, no project, an invalid endpoint, `https` without the `tls` feature, or no Tokio runtime. | | `Auth { .. }` | `Unauthenticated`: the key is missing, unknown or invalidated. | | `Permission { missing, .. }` | `PermissionDenied`: the **API key** lacks the rights for this call. `missing` lists what it lacks when the API says. | | `Validation { message, status }` | `InvalidArgument`, or the SDK's own input checks (then `status` is `None`). | | `NotFound { .. }` | `NotFound`, for example `groups().get` for a group that doesn't exist. | | `Unavailable { .. }` | `Unavailable`, including connection and TLS failures. | | `Timeout { .. }` | `DeadlineExceeded`, including the SDK's own per-attempt timeout. | | `RateLimited { retry_after, .. }` | `ResourceExhausted`: the organisation's rate limit is used up. `retry_after` is the delay the server asked for, if it gave one. | | `Denied { principal, missing, .. }` | Returned only by `assert` and `assert_detailed`, when the **principal** is not allowed. | | `Other { message, status }` | Any other gRPC code (for example `AlreadyExists` or `Internal`), or an inconsistent API response (then `status` is `None`). | Every variant has a `message`. The methods on `Error` are: - `code()`: the gRPC `Code`, when the error came from the API (`None` for `Config`, `Denied` and local validation); - `message()`: the message without the `permio: :` prefix; - `status()`: the original `tonic::Status`; - `metadata()`: the response headers and trailers. `tonic` is re-exported as `permio::tonic`, so you can name `permio::tonic::Code` without adding tonic as a dependency. **Note: Denied is not an error** `Error::Permission` is about the *caller*. It means your API key is not allowed to make the call: for `Check`, the key needs `permission.check` on the checked resources and on the principal. A principal failing a check is a normal result: `check()` returns `Ok(false)` and only `assert()` returns `Error::Denied`. `Error::Permission`'s `missing` uses the API's fully qualified URIs (`/organisation/{org}/project/{project}/...`), not the URIs you sent. `Error` is `#[non_exhaustive]`, so a `match` needs a catch-all arm: ```rust use permio::Error; match permio.assert("alice", "document.write", "/documents/1").await { Ok(()) => { /* allowed */ } Err(Error::Denied { missing, .. }) => { // the principal may not do this: respond with 403 } Err(Error::Permission { missing, .. }) => { // the API key may not run this check (it needs permission.check) eprintln!("API key lacks {missing:?}"); } Err(Error::Auth { .. }) => { // the API key is missing, unknown or invalidated } Err(err) => eprintln!("{:?} {}", err.code(), err.message()), } ``` `permio::Error::from(status)` converts a `tonic::Status` into the matching variant, for errors from the [raw clients](https://docs.perms.io/sdk/rust/#raw-clients-full-api). `parse_missing_permissions(message)` extracts the missing list from a `PermissionDenied` message. `server_retry_delay(&status)` reads the delay a rate-limited status asks for, and `rate_limited_status(message, delay)` builds one, for fake servers in tests. ### Retries and rate limits - **Retries:** idempotent reads are retried on `Unavailable` and `DeadlineExceeded`, including the SDK's own per-attempt timeout. They are `Check`, `BatchCheck`, `ListAccess`, `ListPrincipalsWithAccess`, `WhoAmI`, the group reads and every other `Get*` and `List*` RPC. `permio::is_idempotent_method(name)` tells you whether a given RPC is retried, for example `is_idempotent_method("permio.permissions.v1.PermissionsService.Check")`. - **Backoff:** exponential with jitter. Before retry *n* (counting from 0), the SDK waits a random time between half and all of `min(base_delay × 2ⁿ, max_delay)`: with the defaults, 50–100 ms, then 100–200 ms. - **Rate limits:** a rate-limited read (`ResourceExhausted`) is retried only when the server says how long to wait, through a `google.rpc.RetryInfo` detail or a `retry-after` header in seconds, and only if that delay is at most `max_delay`. The SDK then waits at least that long, plus a little jitter. Otherwise, and for every rate-limited write, the call returns `Error::RateLimited` with `retry_after` set when the server gave a delay. These retries count towards `max_retries`. - **Writes** (every Create, Update, Delete, Grant, Revoke, adding and removing group members, and so on) are sent exactly once and never retried. - **Timeouts:** the client's `timeout` applies to each attempt (default 10 s). There is no overall deadline across retries; wrap the call in `tokio::time::timeout` for one. `RetryConfig`: | Field | Default | Description | | --- | --- | --- | | `max_retries` | `2` | Retries after the first attempt. | | `base_delay` | 100 ms | The delay before the first retry, which doubles each time. | | `max_delay` | 2 s | The cap on a single delay, and on a server-requested delay the SDK will wait for. | ```rust use std::time::Duration; let permio = permio::Client::builder() .project("default") .timeout(Duration::from_secs(5)) .retry_config(permio::RetryConfig { max_retries: 3, base_delay: Duration::from_millis(200), max_delay: Duration::from_secs(5), }) .build()?; // an overall deadline of 8 s across all attempts let allowed = tokio::time::timeout( Duration::from_secs(8), permio.check("alice", "document.read", "/documents/1"), ) .await; ``` `RetryConfig::none()` turns retries off. ### Listing with streams List RPCs are paginated with a cursor (see [Pagination](https://docs.perms.io/api-reference/pagination/)). The list helpers return a `permio::ListStream`, a boxed `Stream>` that fetches pages lazily as you poll it. Dropping the stream stops the listing. Use `futures_util::TryStreamExt` to consume it: ```rust use futures_util::TryStreamExt; use permio::ListOptions; let mut perms = permio.list_permissions(ListOptions::default().search("document.")); while let Some(p) = perms.try_next().await? { println!("{}", p.name); } let roles: Vec = permio.list_roles(Default::default()).try_collect().await?; let principals: Vec = permio.list_principals(Default::default()).try_collect().await?; let mut assignments = permio.list_assignments("alice", Default::default()); while let Some(a) = assignments.try_next().await? { println!("{} {:?}", a.resource_uri, a.roles); } ``` | Method | Yields | | --- | --- | | `list_permissions(options)` | `Permission` | | `list_roles(options)` | `Role` (with its `permissions`) | | `list_principals(options)` | `String` principal IDs that have grants in the project | | `list_assignments(principal, options)` | `Assignment` (`resource_uri`, `permissions`, `roles`) | | `groups().list(options)` | `Group` (see [Groups](https://docs.perms.io/sdk/rust/#groups)) | | `groups().list_members(name, options)` | `GroupMember` | | `groups().list_for_principal(principal, options)` | `Group` | `Permission`, `Role`, `Assignment`, `Group` and `GroupMember` are the generated messages, re-exported at the crate root. `ListOptions` has two settings: - `page_size(n)`: items per page, clamped to 1–100 (`MAX_PAGE_SIZE`). Default 100. - `search(text)`: filters by name, where the RPC supports it (permissions, roles, principals, groups). It is ignored elsewhere. All list helpers use the client's default project; use `with_project` for another one. A configuration or input error is returned as the stream's first item. For any other list RPC, use `permio::paginate(page_size, fetch)`, which follows `next_cursor` until the API returns an empty (or repeated) one: ```rust use futures_util::TryStreamExt; use permio::raw::organisation::v1::ListApiKeysRequest; let raw = permio.raw_organisations(); let keys: Vec<_> = permio::paginate(100, move |pagination| { let mut raw = raw.clone(); async move { let res = raw .list_api_keys(ListApiKeysRequest { project_name: "default".into(), pagination: Some(pagination), ..Default::default() }) .await? .into_inner(); Ok((res.api_keys, res.pagination)) } }) .try_collect() .await?; ``` ### Raw clients (full API) `permio::raw` holds the generated prost messages and tonic clients for `permio.permissions.v1` (`permio::raw::permissions::v1`), `permio.organisation.v1` (`permio::raw::organisation::v1`) and `pagination` (`permio::raw::pagination`). `permio.raw_permissions()` and `permio.raw_organisations()` return a `PermissionsServiceClient` and an `OrganisationServiceClient` that share the SDK's connection, authentication, timeouts and retries. Every RPC in the [API reference](https://docs.perms.io/api-reference/) is available. Method names are the RPC names in snake case, and fields are the proto field names: ```rust use permio::raw::permissions::v1::{ CheckPermissionRequest, CreatePermissionRequest, CreateRoleRequest, GrantUserPermissionsAndRolesOnResourceRequest, }; let mut raw = permio.raw_permissions(); raw.create_permission(CreatePermissionRequest { project_name: "default".into(), name: "document.read".into(), description: "Read documents".into(), }) .await?; let role = raw .create_role(CreateRoleRequest { project_name: "default".into(), name: "viewer".into(), permissions: vec!["document.read".into()], // permission names ..Default::default() }) .await? .into_inner(); raw.grant_user_permissions_and_roles_on_resource(GrantUserPermissionsAndRolesOnResourceRequest { project_name: "default".into(), user_id: "alice".into(), resource_uri: "/documents".into(), roles: vec![role.id], // role IDs ..Default::default() }) .await?; let res = raw .check(CheckPermissionRequest { project_name: "default".into(), principal_id: "alice".into(), resource_uris: vec!["/documents/42".into()], permissions: vec!["document.read".into()], }) .await? .into_inner(); println!("{} {:?}", res.passed, res.missing_permissions); ``` Raw calls return `Result, tonic::Status>`. In a function returning `Result<_, permio::Error>`, `?` converts the status for you; elsewhere use `.map_err(permio::Error::from)`. The raw request types don't take a default project: set `project_name` on every request. **Note: What a key can call** By default an API key has **check** access: it can run checks and access queries, and read permissions, roles, principals, assignments and groups. Creating, updating or deleting permissions, roles and groups, granting or revoking, and changing group members needs a key with full access. See [API keys](https://docs.perms.io/concepts/api-keys/#access-levels). ### API key helpers | Item | Description | | --- | --- | | `validate_api_key(key)` | Returns `ApiKeyKind::Opaque` for `perms_` keys and `ApiKeyKind::Jwt` for legacy JWT keys. Returns `Error::Config` if the key is malformed. | | `is_valid_api_key(key)` | `true` if `validate_api_key` would pass. | | `API_KEY_LENGTH` | `44`. | | `API_KEY_ENV` | `"PERMIO_API_KEY"`, the variable the builder reads. | These only check the shape and checksum. Only the API can tell you whether a key exists and is still valid; use `who_am_i` for that. ### How it talks to the API The SDK speaks native gRPC over HTTP/2 through tonic, not gRPC-web or REST. Every call, from the helpers and the raw clients alike, goes through one tower service (`permio::Transport`) that adds `authorization: Bearer `, applies the per-attempt timeout and retries idempotent reads. It sends the user agent `permio-rust/`. Request and response bodies are buffered, which suits the perms.io API, where every public RPC is unary. --- Source: https://docs.perms.io/sdk/java/ ## Java SDK `io.perms:permio` is the official Java client for the perms.io API. It wraps the API in a few calls for asking "may this principal do X on this resource?". It also gives you typed exceptions, retries for idempotent reads and lazy iterables over list endpoints. The generated gRPC stubs for the whole API are there too, as an escape hatch. - Runs on Java 11 or later. - Talks gRPC over HTTP/2 with TLS, using [grpc-java](https://grpc.io/docs/languages/java/) and protobuf-java 4. - Depends on `grpc-api`, `grpc-stub`, `grpc-protobuf`, `protobuf-java` and `pgv-java-stub`, plus `grpc-netty-shaded` at runtime. **Note: Coming soon to Maven Central** The Java SDK isn't on Maven Central yet. The coordinates below are the ones it will be published under, and they won't resolve until then. This page describes version **0.1.0**. ### Install **Gradle** ```kotlin dependencies { implementation("io.perms:permio:0.1.0") } ``` **Maven** ```xml io.perms permio 0.1.0 ``` ### Quick start ```java import io.perms.Permio; // The API key is read from the PERMIO_API_KEY environment variable. try (Permio permio = Permio.builder().project("default").build()) { boolean allowed = permio.check("alice", "document.read", Permio.resource("documents", 42)); } ``` The arguments are `principal`, `permission`, `resource`, in that order. They are all strings, so the compiler won't catch them swapped round. A principal that lacks the permission is a normal answer: `check` returns `false`. It only throws when the call itself fails, for example because of a bad key, malformed input or network trouble. ### Configuration ```java import io.perms.Permio; import java.time.Duration; Permio permio = Permio.builder() .apiKey(System.getenv("PERMIO_API_KEY")) // the default when not set .baseUrl(Permio.DEFAULT_BASE_URL) // the default .project("default") // default project for every helper .timeout(Duration.ofSeconds(5)) // per attempt; default 10 s .retries(3) // idempotent reads only; default 2 .build(); ``` `build()` checks the API key's format and checksum straight away. A missing, mistyped or truncated key throws `PermioConfigException` before any request is sent. So does a malformed base URL. See [API keys](https://docs.perms.io/concepts/api-keys/) for the key format. `Permio.create(apiKey)` is a shortcut for a production client with no default project. #### Options | Builder method | Default | Description | | --- | --- | --- | | `apiKey(String)` | `$PERMIO_API_KEY` | A `perms_…` key from the dashboard, or a legacy JWT key. | | `baseUrl(String)` / `endpoint(String)` | `https://api.perms.io` | API host: `https://host[:port]` (TLS), or `http://host[:port]` (plaintext, for local testing). No path, query or fragment. | | `project(String)` | none | Default project for every helper. Override it with `withProject` (below). | | `timeout(Duration)` | 10 s | Timeout for each attempt. `Duration.ZERO`, or a negative duration, disables it. | | `retries(int)` | `2` | Retries for idempotent reads. Shorthand for `retryPolicy(RetryPolicy.ofMaxRetries(n))`. | | `retryPolicy(RetryPolicy)` | `RetryPolicy.DEFAULT` | Full retry settings; see [Retries and rate limits](https://docs.perms.io/sdk/java/#retries-and-rate-limits). | | `channel(io.grpc.Channel)` | built from `baseUrl` | Advanced: a gRPC channel to use instead, for example an in-process channel in tests. `baseUrl` is then ignored. Auth, timeouts and retries still apply. The client doesn't shut it down. | | `executor(Executor)` | a cached pool of daemon threads | Runs the `*Async` methods. An executor you pass isn't shut down by `close()`. | | `configureChannel(Consumer>)` | none | Advanced: adjust the channel builder (keepalive, proxy, interceptors) before the channel is built. Ignored when `channel` is set. | The client has no default project. Every helper except `whoAmI()` needs one, and throws `PermioConfigException` when there is none. #### API host `Permio.DEFAULT_BASE_URL` is `https://api.perms.io`, the default. An API key belongs to one project. Other constants: `Permio.API_KEY_ENV` (`"PERMIO_API_KEY"`), `Permio.DEFAULT_TIMEOUT` (10 s) and `Permio.VERSION` (the SDK version, also sent in the user agent). #### Views `withProject(name)` and `withCallTimeout(duration)` return views of the client that share its channel: ```java Permio billing = permio.withProject("billing"); billing.check("alice", "invoice.read", "/invoices/1"); Permio strict = permio.withCallTimeout(Duration.ofSeconds(2)); ``` - `withProject` uses another default project. An empty name throws `PermioConfigException`. - `withCallTimeout` gives each call an overall deadline across all its attempts, on top of the per-attempt timeout. The duration must be positive. List helpers apply it to each page request. The client also exposes `project()` (an `Optional`), `baseUrl()` (`null` when you passed a channel) and `apiKeyKind()` (`ApiKeys.Kind.OPAQUE` or `ApiKeys.Kind.JWT`). #### Checking the key `whoAmI()` asks the API who the key is. It is useful to verify a key at startup, and it needs no project: ```java import io.perms.WhoAmI; WhoAmI me = permio.whoAmI(); me.principalId(); // the key's ID me.organisationId(); me.project(); // Optional: the key's project me.apiKeyAccess(); // Optional: CHECK, FULL or UNKNOWN ``` `ApiKeys.validate(key)` runs the same local format and checksum check as `build()` and returns the key's `Kind`. `ApiKeys.isValid(key)` returns a boolean instead of throwing. `ApiKeys.API_KEY_LENGTH` is `44`. Only the API can tell you whether a key exists and is still valid. #### Closing the client A `Permio` is thread-safe and owns a gRPC channel. Create one at startup, share it, and close it at shutdown. `close()` shuts down the channel and the executors the client created, waiting up to 5 seconds for calls in flight. A channel or executor you passed to the builder is left alone, and closing a view does nothing. ### Checking permissions | Method | Returns | | --- | --- | | `check(principal, permission, resource)` | `boolean`: the principal holds the permission on the resource. | | `checkAll(principal, permissions, resources)` | `boolean`: every permission on every resource. `resources` can also be a single `String`. | | `checkAny(principal, permissions, resources)` | `boolean`: on every resource, at least one of the permissions. `resources` can also be a single `String`. | | `checkDetailed(principal, permissions, resources)` | `CheckResult`: the answer and what is missing. Also `checkDetailed(principal, permission, resource)`. | | `assertAllowed(principal, permission, resource)` | Nothing; throws `PermioDeniedException` when denied. Also takes collections of permissions and resources. | `permissions` and `resources` are `Collection`s. Each takes 1–100 values; duplicates are removed before sending. The principal must not be empty, and every resource URI is checked with [`Resources.assertResourceUri`](https://docs.perms.io/sdk/java/#other-helpers) first. Breaking any of these rules throws `PermioValidationException` without sending a request. Every check runs in the client's project. #### `check` ```java boolean allowed = permio.check("alice", "document.read", "/documents/42"); ``` #### `checkDetailed` Same question, but it also tells you what is missing, per resource and in request order: ```java import io.perms.CheckResult; import java.util.List; CheckResult result = permio.checkDetailed( "alice", List.of("document.read", "document.write"), List.of("/documents/1", "/documents/2")); result.allowed(); // false result.missing(); // [MissingPermission{resource=/documents/2, permissions=[document.write]}] ``` `missing()` is a `List`, each with `resource()` and `permissions()`. It is empty when `allowed()` is `true`. #### `assertAllowed` Returns when the principal is allowed and throws `PermioDeniedException` when it is not. The exception carries `principal()` and `missing()`: ```java permio.assertAllowed("alice", "document.write", "/documents/1"); ``` #### `checkAny` Returns `true` when, **on every resource**, the principal holds **at least one** of the permissions. It is still a single API call: ```java permio.checkAny("alice", List.of("document.admin", "document.write"), "/documents/1"); ``` #### `checkAll` Every permission on every resource, like `check` with collections: ```java permio.checkAll("alice", List.of("document.read", "document.write"), "/documents/1"); ``` ### Batch checks `check` gives one answer for all its resources together. `batchCheck` answers **each resource separately**, which is what you need to filter a list or decide which rows get an "edit" button. #### `batchCheck` Returns an unmodifiable `Map` from resource URI to allowed, with one entry per distinct resource, in request order: ```java import java.util.Map; import java.util.stream.Collectors; List uris = docs.stream() .map(d -> Permio.resource("documents", d.id())) .collect(Collectors.toList()); Map allowed = permio.batchCheck("alice", "document.read", uris); List visible = docs.stream() .filter(d -> allowed.get(Permio.resource("documents", d.id()))) .collect(Collectors.toList()); ``` `batchCheck(principal, permissions, resources)` takes up to 100 permissions; a resource is then allowed only with **all** of them. Duplicate resources are sent once. More than 100 resources are split into several calls, sent in parallel. #### `batchCheckDetailed` Same question, but returns a `List` in request order. Each entry has `resource()`, `allowed()` and `missing()`, the permission names it lacks there: ```java import io.perms.BatchCheckEntry; List entries = permio.batchCheckDetailed( "alice", List.of("document.read", "document.write"), List.of("/documents/1", "/documents/2")); // [BatchCheckEntry{resource=/documents/1, allowed=true, missing=[]}, // BatchCheckEntry{resource=/documents/2, allowed=false, missing=[document.write]}] ``` The SDK fails closed: if the API's answer doesn't include a result for each requested resource, it throws a `PermioException` instead of guessing. ### Access queries These answer "where?" and "who?" rather than "may they?". Both follow every page before returning. The [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/) guide shows them in context. #### `listAccess` `listAccess(principal, permission, under)` answers "where may this principal use this permission, at or below `under`?". Access through roles and groups counts. It returns an `AccessScopes`: ```java import io.perms.AccessScopes; AccessScopes access = permio.listAccess("alice", "document.read", "/documents"); if (access.coversAll()) { // alice may read everything under /documents } else { System.out.println(access.toPrefixes()); // e.g. [/documents/4, /documents/7] } ``` | Method | Returns | Description | | --- | --- | --- | | `under()` | `String` | The URI that was asked about. | | `coversAll()` | `boolean` | The principal holds the permission on `under` itself, so on everything below it. `scopes()` is then a single subtree scope for `under`. | | `scopes()` | `List` | Each has `uri()` and `kind()`, ordered by URI. `Kind` is `SUBTREE`, `PATTERN`, `EXACT` (reserved, not returned today) or `UNKNOWN` (a kind this SDK version doesn't know; it never matches). | | `isEmpty()` | `boolean` | No access anywhere under `under`. | | `allows(uri)` | `boolean` | Whether `uri` is covered. Matches per segment: `/a/b` covers `/a/b/c` but not `/a/bc`. Malformed URIs are never allowed. | | `toPrefixes()` | `List` | The subtree URIs, for prefix filters. Prefixes covered by a shorter one are dropped. Pattern scopes aren't included. | | `patterns()` | `List` | The pattern URIs, with `_` segments. | | `hasPatterns()` | `boolean` | Some scope is a pattern, so `toPrefixes()` alone doesn't cover everything. | | `exact()` | `List` | The exact URIs. Empty today. | | `toRegex()` | `String` | One anchored regular expression matching exactly what `allows()` accepts. Usable in MongoDB `$regex` and PostgreSQL `~`. Matches nothing when there are no scopes. | | `toPattern()` | `java.util.regex.Pattern` | `toRegex()`, compiled. | **Warning** A prefix `p` from `toPrefixes()` means `p` **and everything below it**. Filter with `path = p OR path starts with p + "/"`, never a bare "starts with `p`", which would let `/documents/1` match `/documents/12`. In SQL, use `starts_with(path, p || '/')` rather than `LIKE`, where `_` is a wildcard. For a URI outside `under`, `allows()` returning `false` means "not asked", not "denied". The API key needs `permission.check` on `under` and `principal.assignment.get` on the principal. #### `whoHasAccess` Answers "who has access to this resource?". It returns a `List`, ordered by principal ID: ```java import io.perms.AccessEntry; List entries = permio.whoHasAccess("/documents/42", "document.read", true); for (AccessEntry e : entries) { System.out.println(e.principal() + " " + e.viaGroup().orElse("") + " " + e.via()); } ``` `whoHasAccess(resource)` counts any grant. `whoHasAccess(resource, permission, expandGroups)` takes: | Parameter | Description | | --- | --- | | `resource` | The resource to look at. A `_` segment here is literal. | | `permission` | Only count grants that confer this permission, so a principal is listed exactly when `check` passes for it through its own grants (or, for entries with `viaGroup`, through that group). `null` counts any grant. | | `expandGroups` | Also list every member of each listed group, once per group, with `viaGroup` set. | `AccessEntry` has `principal()`, `isGroup()`, `viaGroup()` (an `Optional` group slug) and `via()`, the grants that give the access, most specific first. Each grant (`AccessGrantInfo`) has: - `resource()`: the URI the grant was made on (the resource, an ancestor, or a `_` wildcard URI that matches it); - `inherited()`: `true` unless the grant was made on exactly the resource; - `match()`: `EXACT`, `ANCESTOR`, `WILDCARD` or `UNKNOWN`; - `source()`: `ROLE`, `DIRECT` (directly granted permissions) or `UNKNOWN`, with `roleId()` and `roleName()` as `Optional`s for roles. The API key needs `principal.list` and `principal.assignment.list` on the project. ### Async variants These methods return a `CompletableFuture` and run on the client's executor: | Method | Completes with | | --- | --- | | `checkAsync(principal, permission, resource)` | `Boolean` | | `checkDetailedAsync(principal, permissions, resources)` | `CheckResult` | | `batchCheckAsync(principal, permission, resources)` | `Map` | | `listAccessAsync(principal, permission, under)` | `AccessScopes` | | `whoHasAccessAsync(resource, permission, expandGroups)` | `List` | | `grantAsync(grant)` / `revokeAsync(grant)` | `Void` | | `whoAmIAsync()` | `WhoAmI` | A failed future completes exceptionally with the same exception types as the blocking methods. `join()` and `get()` wrap them in `CompletionException` and `ExecutionException`; `PermioException.from(e)` unwraps both. ### Granting and revoking Grants need an API key with full access, and are sent once: they are never retried automatically. ```java import io.perms.Grant; permio.grant("alice", "document.read", "/documents"); permio.grant(Grant.to("alice").on("/documents").permissions("document.read").roles(roleId)); permio.revoke(Grant.to("alice").on("/documents").roles(roleId)); ``` | Method | Description | | --- | --- | | `grant(principal, permission, resource)` | Grants one permission. | | `grant(Grant)` | Grants permissions and roles to a principal on a resource, and so on everything below it. Additive: what the principal already holds is kept. | | `revoke(principal, permission, resource)` | Revokes one permission. | | `revoke(Grant)` | Removes permissions and roles from the principal's grant on exactly the resource. Names the grant doesn't hold are ignored. | `Grant` is immutable; each method returns a copy: - `Grant.to(principal)` starts a grant. Use `Permio.group("editors")` to grant to a group. - `on(resource)` sets the resource URI. - `permissions(...)` adds permission **names**, as varargs or a `Collection`. - `roles(...)` adds role **IDs**, as varargs or a `Collection`. A grant needs at least one permission or role, or it throws `PermioValidationException`. So does a permission or role that doesn't exist in the project (the API answers `INVALID_ARGUMENT`). **Note: Revoking doesn't touch ancestors** `revoke` only changes the grant on exactly the URI you pass. Grants on ancestor URIs, or on `_` wildcard URIs that match it, are kept, so the principal may still pass `check` afterwards. `whoHasAccess` shows where access comes from. ### Groups A [group](https://docs.perms.io/concepts/groups/) is a named set of principals in a project. Grant to the group's principal ID, `group:`, and every member holds the grant. Checks include the principal's groups automatically. #### `Permio.group(slug)` Returns the group's principal ID. It throws `PermioValidationException` unless the slug is 1–64 lower-case letters, digits and hyphens. `Groups.principal(slug)` does the same, and `Groups.GROUP_PRINCIPAL_PREFIX` is `"group:"`. ```java import io.perms.Grant; import io.perms.Permio; Permio.group("editors"); // "group:editors" permio.groups().create("editors", "Can edit every document"); permio.groups().addMembers("editors", List.of("alice", "bob")); permio.grant(Grant.to(Permio.group("editors")).on("/documents").permissions("document.write")); permio.check("alice", "document.write", "/documents/42"); // true ``` #### `permio.groups()` Every method that takes a group accepts its slug (`"editors"`) or its principal ID (`"group:editors"`). Groups use the client's project. | Method | Returns | | --- | --- | | `create(name)` / `create(name, description)` | `Group`. Throws a `PermioException` with code `ALREADY_EXISTS` if the project already has a group with this name. | | `get(name)` | `Group`. Throws `PermioNotFoundException` if it doesn't exist. | | `update(name, description)` | `Group`. Replaces the description; a group can't be renamed. Throws `PermioNotFoundException` if it doesn't exist. | | `delete(name)` | Nothing. Deletes the group, its memberships and every grant made to it. Succeeds whether or not it existed. | | `list()` / `list(ListOptions)` | `PagedIterable`, ordered by name. Takes a search filter. | | `addMembers(name, principals)` | `AddMembersResult`: `added()` and `existing()`, in request order. | | `removeMembers(name, principals)` | `RemoveMembersResult`: `removed()` and `notMembers()`, in request order. | | `listMembers(name)` | `PagedIterable` (`getPrincipalId()`, `getCreatedAt()`), ordered by principal ID. | | `listForPrincipal(principal)` | `PagedIterable`: the groups the principal is a member of, ordered by name. | - `addMembers` and `removeMembers` take 1–100 principals; duplicates are removed. `addMembers` rejects `group:` IDs with `PermioValidationException`, because groups can't contain groups. - `listMembers` and `listForPrincipal` also take a `ListOptions` for the page size. - `Group` and `GroupMember` are the generated protobuf messages from `io.perms.proto.permissions.v1`. `Group` has `getId()`, `getOrganisationId()`, `getProjectName()`, `getName()`, `getDescription()`, `getPrincipalId()` (`"group:"`), `getCreatedAt()` and `getUpdatedAt()`. - The group methods have no async variants. ```java import io.perms.AddMembersResult; import io.perms.proto.permissions.v1.Group; import io.perms.proto.permissions.v1.GroupMember; AddMembersResult res = permio.groups().addMembers("group:editors", List.of("alice", "carol")); res.added(); // [carol] res.existing(); // [alice] for (GroupMember m : permio.groups().listMembers("editors")) System.out.println(m.getPrincipalId()); for (Group g : permio.groups().listForPrincipal("alice")) System.out.println(g.getName()); ``` Reading groups works with a check-access API key. Creating, updating and deleting groups and changing members needs a key with full access, and changing members also needs the right to grant across the project. See [Permissions you need](https://docs.perms.io/concepts/groups/#permissions-you-need). ### Resource URIs Resource URIs are paths such as `/projects/p1/documents/42`. A grant on a URI also covers every URI beneath it. See [Resources and inheritance](https://docs.perms.io/concepts/resources/) for the full rules. #### `Permio.resource(segments...)` Builds a URI from raw segments. It adds the leading `/` and percent-encodes each segment, so an ID that contains `/`, spaces or other reserved characters stays a single segment. Each segment is converted with `String.valueOf`, so numbers and other objects work too: ```java Permio.resource("projects", "p1", "documents", 42); // "/projects/p1/documents/42" Permio.resource("files", "reports/2026.pdf"); // "/files/reports%2F2026.pdf" ``` Every character except `A-Z a-z 0-9 - _ . ~` is encoded as UTF-8 bytes. This matches the TypeScript SDK's `resource()`, so both build the same URIs. A null or empty segment, or no segments at all, throws `PermioValidationException`. `Resources.resource(...)` is the same method. **Tip** Build URIs the same way when you grant and when you check, or they will not match. #### Other helpers | Member | Description | | --- | --- | | `Resources.encodeSegment(segment)` | Encodes one segment the way `resource()` does. | | `Resources.assertResourceUri(uri)` | Throws `PermioValidationException` unless `uri` has the basic shape the API accepts: a leading `/`, no empty segments, no whitespace or control characters, and at most 2048 bytes. | | `Resources.MAX_RESOURCE_URI_LENGTH` | `2048`. | Every check, access query and grant runs `assertResourceUri` on its URIs before sending. The API enforces the remaining rules, such as no `( ) [ ] { } * + ? | ^ $ \` in segments, and answers with `INVALID_ARGUMENT`, which the SDK throws as `PermioValidationException`. ### Errors Every exception the SDK throws is an unchecked `PermioException`. It has: - `code()`: the gRPC `Status.Code`, as an `Optional`. Empty when the error didn't come from the API. - `codeName()`: the code's name in lower case, for example `"permission_denied"`, as an `Optional`. - `metadata()`: the response headers and trailers. - `getCause()`: the original exception, usually a `StatusRuntimeException`. | Exception | When | | --- | --- | | `PermioConfigException` | Bad client setup: a missing or malformed API key, an invalid base URL, or no project. | | `PermioAuthException` | `UNAUTHENTICATED`: the key is missing, unknown or invalidated. | | `PermioPermissionException` | `PERMISSION_DENIED`: the **API key** lacks the rights for this call. `missing()` lists what it lacks when the API says. | | `PermioValidationException` | `INVALID_ARGUMENT`, or the SDK's own input checks (then no request is sent and `code()` is empty). | | `PermioNotFoundException` | `NOT_FOUND`, for example `groups().get` for a group that doesn't exist. | | `PermioUnavailableException` | `UNAVAILABLE`, including network failures, after any retries. | | `PermioTimeoutException` | `DEADLINE_EXCEEDED`, including the SDK's own timeouts. | | `PermioRateLimitException` | `RESOURCE_EXHAUSTED`. `retryAfter()` is the delay the server asked for, as an `Optional`. | | `PermioDeniedException` | Thrown only by `assertAllowed`, when the **principal** is not allowed. Has `principal()` and `missing()`. | Any other status, such as `ALREADY_EXISTS` or `INTERNAL`, is a plain `PermioException` with the matching code. **Note: Denied is not an error** `PermioPermissionException` is about the *caller*. It means your API key is not allowed to make the call: for `Check`, the key needs `permission.check` on the checked resources and on the principal. A principal failing a check is a normal result: `check` returns `false` and `assertAllowed` throws `PermioDeniedException`. `PermioPermissionException.missing()` uses the API's fully qualified URIs (`/organisation/{org}/project/{project}/...`), not the URIs you sent. ```java import io.perms.PermioAuthException; import io.perms.PermioDeniedException; import io.perms.PermioException; import io.perms.PermioPermissionException; try { permio.assertAllowed("alice", "document.write", "/documents/1"); } catch (PermioDeniedException e) { // the principal may not do this: respond with 403 } catch (PermioPermissionException e) { // the API key may not run this check (it needs permission.check) System.err.println(e.missing()); } catch (PermioAuthException e) { // the API key is missing, unknown or invalidated } catch (PermioException e) { System.err.println(e.codeName().orElse("error") + ": " + e.getMessage()); } ``` `PermioException.from(throwable)` converts any exception into the matching subclass. It unwraps `ExecutionException` and `CompletionException`, and maps gRPC status exceptions by code. Anything else becomes a plain `PermioException` with code `UNKNOWN`. `PermioException.parseMissingPermissions(message)` extracts the missing list from a `PERMISSION_DENIED` message. You only need these with the [raw client](https://docs.perms.io/sdk/java/#raw-client). ### Retries and rate limits - **Retries:** idempotent reads are retried on `UNAVAILABLE` and `DEADLINE_EXCEEDED`. These are `Check`, `BatchCheck`, `WhoAmI` and the `Get*` and `List*` RPCs, including `ListAccess`, `ListPrincipalsWithAccess`, the group reads and the read-only organisation calls. By default they are retried twice, with exponential backoff and jitter. Network failures count as `UNAVAILABLE`. - **Writes** (every Create, Update, Delete, Grant, Revoke, Invite, adding and removing group members, and so on) are sent exactly once. After a timeout the first attempt may still have landed, and repeating it could undo another caller's change. Retry them yourself if that is safe for you. - **Backoff:** the delay before retry *n* is a random value between half and all of `min(maxDelay, baseDelay × 2^(n−1))`. If the server sends a `retry-after` header with `UNAVAILABLE`, the SDK waits at least that long, or doesn't retry when it is longer than `maxDelay`. - **Rate limits:** a read that fails with `RESOURCE_EXHAUSTED` is retried only when the server says how long to wait (a `google.rpc.RetryInfo` error detail, or a `retry-after` header) and that delay is within `maxDelay`. The SDK then waits at least that long, plus up to 100 ms of jitter. Otherwise it throws `PermioRateLimitException` with `retryAfter()` set. Writes are never retried, whatever the code. - **Timeouts:** - The builder's `timeout` applies to each attempt (default 10 s). - `withCallTimeout`, or a deadline you set on a raw stub, is an overall deadline across all attempts. If it would expire before the next retry, the call fails with `PermioTimeoutException` straight away. ```java import io.perms.RetryPolicy; Permio permio = Permio.builder() .project("default") .timeout(Duration.ofSeconds(5)) .retryPolicy(RetryPolicy.builder() .maxRetries(3) .baseDelay(Duration.ofMillis(100)) .maxDelay(Duration.ofSeconds(2)) .build()) .build(); ``` `RetryPolicy` has these settings: - `maxRetries`: retries after the first attempt. Default 2. Negative values count as 0. - `baseDelay`: the first delay, which doubles each time. Default 100 ms. - `maxDelay`: the cap on a single delay, and on a server-requested delay the SDK will wait. Default 2 s. `RetryPolicy.DEFAULT` holds the defaults and `RetryPolicy.NONE` turns retries off. `RetryPolicy.isIdempotent("permio.permissions.v1.PermissionsService/Check")` tells you whether the SDK retries a given RPC. The dotted form, `permio.permissions.v1.PermissionsService.Check`, works too. To back off from a rate limit yourself: ```java import io.perms.PermioRateLimitException; try { permio.check("alice", "document.read", "/documents/42"); } catch (PermioRateLimitException e) { Duration wait = e.retryAfter().orElse(Duration.ofSeconds(1)); // try again after `wait` } ``` ### Listing with iterators List RPCs are paginated with a cursor (see [Pagination](https://docs.perms.io/api-reference/pagination/)). The list helpers return a `PagedIterable`, which fetches pages lazily as you iterate: ```java import io.perms.ListOptions; import io.perms.proto.permissions.v1.Assignment; import io.perms.proto.permissions.v1.Permission; import io.perms.proto.permissions.v1.Role; for (Permission p : permio.listPermissions(ListOptions.defaults().withSearch("document."))) { System.out.println(p.getName()); } for (Role role : permio.listRoles()) System.out.println(role.getName() + " " + role.getPermissionsCount()); for (String id : permio.listPrincipals()) System.out.println(id); for (Assignment a : permio.listGrants("alice")) System.out.println(a.getResourceUri() + " " + a.getRolesList()); List principals = permio.listPrincipals().toList(); long roles = permio.listRoles().stream().count(); ``` | Method | Yields | | --- | --- | | `listPermissions()` / `listPermissions(ListOptions)` | `Permission` | | `listRoles()` / `listRoles(ListOptions)` | `Role` (with its permissions) | | `listPrincipals()` / `listPrincipals(ListOptions)` | `String` principal IDs that have grants in the project | | `listGrants(principal)` / `listGrants(principal, ListOptions)` | `Assignment` (`getResourceUri()`, `getPermissionsList()`, `getRolesList()`). `listAssignments(principal)` is an alias. | | `groups().list(...)`, `groups().listMembers(...)`, `groups().listForPrincipal(...)` | See [Groups](https://docs.perms.io/sdk/java/#groups). | The items are the generated protobuf messages from `io.perms.proto.permissions.v1`. `ListOptions` is immutable: - `ListOptions.defaults()`: no search filter, 100 items per page. - `withSearch(text)`: filters by name. It applies to `listPermissions`, `listRoles`, `listPrincipals` and `groups().list`; the other list methods ignore it. - `withPageSize(n)`: items per page, clamped to 1–100. `PagedIterable` is an `Iterable`, and also has `stream()` and `toList()` (which fetches every page). Each new iteration or stream starts again from the first page. Iteration stops when the API returns an empty cursor, or one it has already returned. API errors are thrown from `hasNext()` as `PermioException`s. For any other list RPC, build a `PagedIterable` yourself. It passes you a `PaginationRequest`; you return a `PagedIterable.Page` with the items and the next cursor: ```java import io.perms.PagedIterable; import io.perms.proto.organisation.v1.APIKey; import io.perms.proto.organisation.v1.ListAPIKeysRequest; import io.perms.proto.organisation.v1.ListAPIKeysResponse; PagedIterable keys = new PagedIterable<>( page -> { ListAPIKeysResponse res = permio.raw().organisations().listAPIKeys( ListAPIKeysRequest.newBuilder() .setOrganisationId("your-org-id") .setProjectName("default") .setPagination(page) .build()); return new PagedIterable.Page<>(res.getApiKeysList(), res.getPagination().getNextCursor()); }, 100); for (APIKey key : keys) System.out.println(key.getName()); ``` ### Raw client `permio.raw()` returns a `RawClient` with the generated [grpc-java](https://grpc.io/docs/languages/java/) stubs for `permio.permissions.v1.PermissionsService` and `permio.organisation.v1.OrganisationService`. They run over the client's channel, so authentication, timeouts and retries already apply. Every RPC in the [API reference](https://docs.perms.io/api-reference/) is available. Method names are the RPC names in lower camel case, and the request builders use camelCase setters: | Method | Stub | | --- | --- | | `permissions()` | `PermissionsServiceGrpc.PermissionsServiceBlockingStub` | | `permissionsFuture()` | `PermissionsServiceGrpc.PermissionsServiceFutureStub` (`ListenableFuture`) | | `permissionsAsync()` | `PermissionsServiceGrpc.PermissionsServiceStub` (`StreamObserver`) | | `organisations()`, `organisationsFuture()`, `organisationsAsync()` | The same three for `OrganisationServiceGrpc` | | `channel()` | The client's `io.grpc.Channel`, for building any stub yourself | ```java import io.perms.proto.permissions.v1.CheckPermissionRequest; import io.perms.proto.permissions.v1.CheckPermissionResponse; import io.perms.proto.permissions.v1.CreatePermissionRequest; import io.perms.proto.permissions.v1.CreateRoleRequest; import io.perms.proto.permissions.v1.GrantUserPermissionsAndRolesOnResourceRequest; import io.perms.proto.permissions.v1.Role; var stub = permio.raw().permissions(); stub.createPermission(CreatePermissionRequest.newBuilder() .setProjectName("default") .setName("document.read") .setDescription("Read documents") .build()); Role role = stub.createRole(CreateRoleRequest.newBuilder() .setProjectName("default") .setName("viewer") .addPermissions("document.read") // permission names .build()); stub.grantUserPermissionsAndRolesOnResource(GrantUserPermissionsAndRolesOnResourceRequest.newBuilder() .setProjectName("default") .setUserId("alice") .setResourceUri("/documents") .addRoles(role.getId()) // role IDs .build()); CheckPermissionResponse res = stub.check(CheckPermissionRequest.newBuilder() .setProjectName("default") .setPrincipalId("alice") .addResourceUris("/documents/42") .addPermissions("document.read") .build()); System.out.println(res.getPassed() + " " + res.getMissingPermissionsList()); ``` - Raw requests don't use the client's default project: set `project_name` on each one. - Raw errors are plain `io.grpc.StatusRuntimeException`s. Convert them with `PermioException.from(e)` to get the typed exceptions above. - On a `withCallTimeout` view, the raw stubs carry that deadline too. - The generated messages live in `io.perms.proto.permissions.v1`, `io.perms.proto.organisation.v1` and `io.perms.proto.pagination.v1`. **Note: What a key can call** By default an API key has **check** access: it can run checks and access queries, and read permissions, roles, principals, assignments and groups. Creating, updating or deleting permissions, roles and groups, granting or revoking, and changing group members needs a key with full access. See [API keys](https://docs.perms.io/concepts/api-keys/#access-levels). --- Source: https://docs.perms.io/api-reference/ ## API overview The perms.io API has two services: | Service | Proto | What it does | | --- | --- | --- | | [Permissions](https://docs.perms.io/api-reference/permissions/) | `permio.permissions.v1.PermissionsService` | Permissions, roles, grants and **checks**. This is the service your application calls. | | [Organisations](https://docs.perms.io/api-reference/organizations/) | `permio.organisation.v1.OrganisationService` | Organisations, projects, API keys and team members. Mostly used by the dashboard. | Both are defined in Permio's protobuf definitions. The same host serves them as REST/JSON, gRPC and gRPC-web. (See "API host" earlier in this file.) ### Protocols | Protocol | How to call it | | --- | --- | | **REST/JSON** | The HTTP method and path listed for each RPC in this reference, for example `POST $PERMIO_API/permissions-service/v1/permissions/check`. Request bodies accept the proto field names (`project_name`). Responses use camelCase JSON names (`missingPermissions`) and leave out fields that have default values. | | **gRPC** | Connect to the host on port 443 with TLS, for example `api.perms.io:443`, and send the key as `authorization` metadata. The [Go](https://docs.perms.io/sdk/go/), [Python](https://docs.perms.io/sdk/python/), [Rust](https://docs.perms.io/sdk/rust/) and [Java](https://docs.perms.io/sdk/java/) SDKs use it. | | **gRPC-web** | The protocol the [TypeScript SDK](https://docs.perms.io/sdk/typescript/) uses. It works from servers, edge runtimes and browsers. | For `GET` and `DELETE` requests over REST, fields that aren't in the path go in the query string. Nested fields use dot notation, for example `?project_name=default&pagination.limit=50`. ### Authentication Every request needs an [API key](https://docs.perms.io/concepts/api-keys/), sent as a bearer token: ``` Authorization: Bearer perms_... ``` Keys are 44 characters long: `perms_`, 32 random base62 characters and a 6-character checksum. A key is shown once, when you create it in the dashboard. Only a hash of it is stored, and you can invalidate it at any time. Older JWT API keys are still accepted. A key acts within its own **project**. Your organisation is worked out from the key, so permission requests don't include an organisation ID. A key gets its own grants on its project: by default **check** access, which allows `Check`, the access queries and the read-only calls. See [Access levels](https://docs.perms.io/concepts/api-keys/#access-levels). ### Errors A **denied check is not an error**. `Check` returns success with `passed: false` and a list of `missing_permissions`. Errors mean that the request itself could not be carried out: | gRPC status | HTTP | Meaning | | --- | --- | --- | | `INVALID_ARGUMENT` (3) | 400 | Malformed input: an invalid resource URI, an empty permissions list, a bad project name, more than 100 items in a check, and so on. | | `UNAUTHENTICATED` (16) | 401 | The API key is missing, malformed, unknown or invalidated. | | `PERMISSION_DENIED` (7) | 403 | The **API key** isn't allowed to make this call. For example, a key without `permission.check` calling `Check`, or a check-only key creating a role. The message lists what is missing. | | `NOT_FOUND` (5) | 404 | The permission, role, group or other entity doesn't exist. Also returned when a `group:` principal names a group that doesn't exist. | | `ALREADY_EXISTS` (6) | 409 | A group with this name already exists in the project. | | `INTERNAL` (13) | 500 | Something went wrong on our side. | | `UNAVAILABLE` (14) | 503 | The service is temporarily unavailable. Safe to retry for reads. | Over REST, the error body carries the gRPC code and a message: ```json {"code": 16, "message": "invalid api key"} ``` Over gRPC and gRPC-web, the code arrives as `grpc-status` and the text as `grpc-message`. ### Clients Official SDKs: | Language | Package | Status | | --- | --- | --- | | [TypeScript](https://docs.perms.io/sdk/typescript/) | `permio` on npm | Released | | [Go](https://docs.perms.io/sdk/go/) | `github.com/PrivateJAR/permio-go` | Untagged; the first release will be v0.1.0 | | [Python](https://docs.perms.io/sdk/python/) | `permio` on PyPI | Released (0.1.0) | | [Rust](https://docs.perms.io/sdk/rust/) | `permio` crate | Install from Git; crates.io coming soon | | [Java](https://docs.perms.io/sdk/java/) | `io.perms:permio` | Coming soon to Maven Central | **TypeScript** The [TypeScript SDK](https://docs.perms.io/sdk/typescript/) (`npm install permio`) is the recommended client. Besides the check helpers, it exposes every RPC on this page through `permio.permissions` and `permio.organisations`. **cURL** Every RPC has a REST path, listed with each method in this reference. ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/check" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "principal_id": "alice", "resource_uris": ["/documents/42"], "permissions": ["document.read"]}' ``` **Go** The [Go SDK](https://docs.perms.io/sdk/go/) (`go get github.com/PrivateJAR/permio-go@latest`, Go 1.24+) is the recommended client. Besides the check helpers, it exposes every RPC on this page through `client.Permissions()` and `client.Organisations()`, the generated gRPC clients with auth, timeouts, retries and typed errors applied. ```go import permio "github.com/PrivateJAR/permio-go" client, err := permio.New(permio.Options{ APIKey: os.Getenv("PERMIO_API_KEY"), Project: "default", }) // handle err defer client.Close() allowed, err := client.Check(ctx, "alice", "document.read", "/documents/42") ``` **Python** The [Python SDK](https://docs.perms.io/sdk/python/) (`pip install permio`) gives you a synchronous `Permio` client and an asyncio `AsyncPermio` client with the same methods. Besides the check helpers, both expose every RPC on this page through `permio.permissions` and `permio.organisations`, with the generated messages in `permio.raw`. ```python from permio import Permio permio = Permio(api_key=api_key, project="default") # api_key defaults to $PERMIO_API_KEY permio.check("alice", "document.read", "/documents/42") # True or False ``` **Rust** The [Rust SDK](https://docs.perms.io/sdk/rust/) (crate `permio`) is the recommended client. It isn't on crates.io yet, so depend on the Git repository. Besides the check helpers, it exposes every RPC on this page through `permio.raw_permissions()` and `permio.raw_organisations()`, with authentication, timeouts and retries already applied. ```toml [dependencies] permio = { git = "https://github.com/PrivateJAR/permio-rust" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` ```rust let permio = permio::Client::builder() .api_key(api_key) // defaults to the PERMIO_API_KEY env var .project("default") .build()?; let allowed = permio.check("alice", "document.read", "/documents/42").await?; ``` **Java** The [Java SDK](https://docs.perms.io/sdk/java/) (`io.perms:permio`, Java 11+) wraps the check, access-query, grant and group RPCs, and exposes every RPC on this page as generated gRPC stubs through `permio.raw()`. It is coming soon to Maven Central. ```java import io.perms.Permio; Permio permio = Permio.builder() .apiKey(System.getenv("PERMIO_API_KEY")) .project("default") .build(); boolean allowed = permio.check("alice", "document.read", "/documents/42"); ``` Field names in this reference are the proto names (`project_name`). The generated clients use their language's convention: `ProjectName` in Go, `project_name` in Python and Rust, `projectName` in TypeScript, and `setProjectName` / `getProjectName` in Java. #### Other languages Permio is a gRPC and REST/JSON API, so you can use it from any language: - **Generate a client** from Permio's public protobuf definitions with your language's gRPC tooling. The public protos repository is coming soon. - **Call the REST API** with any HTTP client, using the paths listed for each RPC in this reference and the `Authorization: Bearer` header described in [Authentication](https://docs.perms.io/api-reference/#authentication). --- Source: https://docs.perms.io/api-reference/permissions/ ## Permissions & Roles `permio.permissions.v1.PermissionsService` stores permissions, roles and grants, and answers checks. Every request names a `project_name`. Your organisation comes from the API key. (See "API host" earlier in this file.) In the tables below, **Key access** is the [API key access level](https://docs.perms.io/concepts/api-keys/#access-levels) needed to make the call. **Check** is the default for new keys, and **Full** is the level that also allows writes. | RPC | REST | Key access | | --- | --- | --- | | [Check](https://docs.perms.io/api-reference/permissions/#check) | `POST /permissions-service/v1/permissions/check` | Check | | [BatchCheck](https://docs.perms.io/api-reference/permissions/#batchcheck) | `POST /permissions-service/v1/permissions/check/batch` | Check | | [CreatePermission](https://docs.perms.io/api-reference/permissions/#createpermission) | `POST /permissions-service/v1/permissions` | Full | | [GetPermission](https://docs.perms.io/api-reference/permissions/#getpermission) | `GET /permissions-service/v1/permissions/{name}` | Check | | [UpdatePermission](https://docs.perms.io/api-reference/permissions/#updatepermission) | `PUT /permissions-service/v1/permissions/{name}` | Full | | [DeletePermission](https://docs.perms.io/api-reference/permissions/#deletepermission) | `DELETE /permissions-service/v1/permissions/{name}` | Full | | [ListPermissions](https://docs.perms.io/api-reference/permissions/#listpermissions) | `GET /permissions-service/v1/permissions` | Check | | [CreateRole](https://docs.perms.io/api-reference/permissions/#createrole) | `POST /permissions-service/v1/roles` | Full | | [GetRole](https://docs.perms.io/api-reference/permissions/#getrole) | `GET /permissions-service/v1/roles/{id}` | Check | | [UpdateRole](https://docs.perms.io/api-reference/permissions/#updaterole) | `PUT /permissions-service/v1/roles/{id}` | Full | | [DeleteRole](https://docs.perms.io/api-reference/permissions/#deleterole) | `DELETE /permissions-service/v1/roles/{id}` | Full | | [ListRoles](https://docs.perms.io/api-reference/permissions/#listroles) | `GET /permissions-service/v1/roles` | Check | | [GrantUserPermissionsAndRolesOnResource](https://docs.perms.io/api-reference/permissions/#grantuserpermissionsandrolesonresource) | `POST /permissions-service/v1/permissions/assign` | Full | | [RevokeUserPermissionsAndRolesOnResource](https://docs.perms.io/api-reference/permissions/#revokeuserpermissionsandrolesonresource) | `POST /permissions-service/v1/permissions/revoke` | Full | | [GetUserPermissionsAndRolesOnResource](https://docs.perms.io/api-reference/permissions/#getuserpermissionsandrolesonresource) | `GET /permissions-service/v1/permissions/user/{user_id}/resource/{resource_uri=**}` | Check | | [ListPrincipals](https://docs.perms.io/api-reference/permissions/#listprincipals) | `GET /permissions-service/v1/permissions/principals` | Check | | [GetAllAssignmentsForPrincipal](https://docs.perms.io/api-reference/permissions/#getallassignmentsforprincipal) | `GET /permissions-service/v1/permissions/assignments/principal/{principal_id=**}` | Check | | [ListAccess](https://docs.perms.io/api-reference/permissions/#listaccess) | `GET /permissions-service/v1/access` | Check | | [ListPrincipalsWithAccess](https://docs.perms.io/api-reference/permissions/#listprincipalswithaccess) | `GET /permissions-service/v1/access/principals` | Check | | [CreateGroup](https://docs.perms.io/api-reference/permissions/#creategroup) | `POST /permissions-service/v1/groups` | Full | | [GetGroup](https://docs.perms.io/api-reference/permissions/#getgroup) | `GET /permissions-service/v1/groups/{name}` | Check | | [UpdateGroup](https://docs.perms.io/api-reference/permissions/#updategroup) | `PUT /permissions-service/v1/groups/{name}` | Full | | [DeleteGroup](https://docs.perms.io/api-reference/permissions/#deletegroup) | `DELETE /permissions-service/v1/groups/{name}` | Full | | [ListGroups](https://docs.perms.io/api-reference/permissions/#listgroups) | `GET /permissions-service/v1/groups` | Check | | [AddGroupMembers](https://docs.perms.io/api-reference/permissions/#addgroupmembers) | `POST /permissions-service/v1/groups/{name}/members/add` | Full | | [RemoveGroupMembers](https://docs.perms.io/api-reference/permissions/#removegroupmembers) | `POST /permissions-service/v1/groups/{name}/members/remove` | Full | | [ListGroupMembers](https://docs.perms.io/api-reference/permissions/#listgroupmembers) | `GET /permissions-service/v1/groups/{name}/members` | Check | | [ListGroupsForPrincipal](https://docs.perms.io/api-reference/permissions/#listgroupsforprincipal) | `GET /permissions-service/v1/group-memberships/{principal_id=**}` | Check | `GetTotalRolesAndPermissionsForOrganisation` (`GET /permissions-service/v1/permissions/organisation/{organisation_id}/totals`) is also part of the service. It needs an organisation-level permission that API keys don't have, and the dashboard uses it. ### Common field rules | Field | Rule | | --- | --- | | `project_name` | 1–32 characters: lowercase letters, digits and `-`. | | Permission `name` | 1–128 characters: `a-z A-Z 0-9 . _ -`. | | Role `name` | 1–128 characters, no control characters. | | Role `id` | 1–128 characters: `a-z A-Z 0-9 . _ -`. | | Group `name` | 1–64 characters: lowercase letters, digits and `-`. Its principal ID is `group:`. | | Principal IDs | Any non-empty string. IDs starting with `group:` are reserved for [groups](https://docs.perms.io/concepts/groups/): such an ID must name a group that exists in the project, or the call fails with `NOT_FOUND` (`INVALID_ARGUMENT` if it isn't a valid group name). Revoking is the exception. | | Resource URIs | See [Resources and inheritance](https://docs.perms.io/concepts/resources/#rules-for-uris). | ### Messages **Permission** | Field | Type | Description | | --- | --- | --- | | `id` | string | Unique ID. | | `organisation_id` | string | Owning organisation. | | `name` | string | The name your code checks, e.g. `document.read`. | | `description` | string | Free text. | | `project_name` | string | Owning project. | **Role** | Field | Type | Description | | --- | --- | --- | | `id` | string | Unique ID. Use it when granting and in the role endpoints. | | `organisation_id` | string | Owning organisation. | | `name` | string | Display name. | | `description` | string | Free text. | | `permissions` | Permission[] | The permissions in the role. | | `project_name` | string | Owning project. | **Assignment** | Field | Type | Description | | --- | --- | --- | | `resource_uri` | string | The resource the grant is on. | | `permissions` | Permission[] | Permissions granted directly. | | `roles` | Role[] | Roles granted. | ### Check `POST /permissions-service/v1/permissions/check` Checks whether a principal holds **every** listed permission on **every** listed resource. Grants on ancestors of a resource count, and so do grants to the [groups](https://docs.perms.io/concepts/groups/) the principal is a member of. See [how a check is decided](https://docs.perms.io/concepts/resources/#how-a-check-is-decided). To get one answer per resource, use [BatchCheck](https://docs.perms.io/api-reference/permissions/#batchcheck). **Request** (`CheckPermissionRequest`) | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. Project to check in. | | `principal_id` | string | Required. The principal being checked. | | `resource_uris` | string[] | Required. 1–100 unique resource URIs. | | `permissions` | string[] | Required. 1–100 unique permission names. | **Response** (`CheckPermissionResponse`) | Field | Type | Description | | --- | --- | --- | | `passed` | bool | `true` if every permission is held on every resource. | | `missing_permissions` | MissingPermissions[] | For each resource that failed: `resource_uri` and the `missing_permissions` not held there. | A failed check is a successful response with `passed: false`. `PERMISSION_DENIED` means the **API key** lacks `permission.check` on the resources or on the principal, not that the principal was denied. **TypeScript** ```ts const { allowed, missing } = await permio.checkDetailed({ principal: "alice", resources: ["/documents/42", "/invoices/1"], permission: "document.read", }); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/check" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "project_name": "default", "principal_id": "alice", "resource_uris": ["/documents/42", "/invoices/1"], "permissions": ["document.read"] }' ``` ```json {"missingPermissions": [{"resourceUri": "/invoices/1", "missingPermissions": ["document.read"]}]} ``` **Go** ```go res, err := client.CheckDetailed(ctx, permio.CheckRequest{ Principal: "alice", Resources: []string{"/documents/42", "/invoices/1"}, Permissions: []string{"document.read"}, }) // handle err res.Allowed // false res.Missing // [{Resource: "/invoices/1", Permissions: ["document.read"]}] ``` **Python** ```python result = permio.check_detailed("alice", "document.read", ["/documents/42", "/invoices/1"]) result.allowed # False result.missing # [MissingPermission(resource="/invoices/1", permissions=["document.read"])] ``` `check()` returns just the `bool`. See [Checking permissions](https://docs.perms.io/sdk/python/#checking-permissions). **Rust** ```rust use permio::Check; let result = permio .check_detailed( Check::new("alice") .resources(["/documents/42", "/invoices/1"]) .permission("document.read"), ) .await?; println!("{} {:?}", result.allowed, result.missing); ``` `permio.check("alice", "document.read", "/documents/42")` returns just the `bool`. See [Checking permissions](https://docs.perms.io/sdk/rust/#checking-permissions). **Java** ```java import io.perms.CheckResult; import java.util.List; CheckResult result = permio.checkDetailed( "alice", List.of("document.read"), List.of("/documents/42", "/invoices/1")); result.allowed(); // false result.missing(); // [MissingPermission{resource=/invoices/1, permissions=[document.read]}] ``` `check` returns just the boolean. See [Checking permissions](https://docs.perms.io/sdk/java/#checking-permissions). ### BatchCheck `POST /permissions-service/v1/permissions/check/batch` Checks a principal's permissions on each resource **separately** and returns one result per resource, in request order. It is evaluated exactly like [Check](https://docs.perms.io/api-reference/permissions/#check): inheritance, stored `_` wildcards, roles and the principal's [groups](https://docs.perms.io/concepts/groups/) all count, and it fails closed. A resource is allowed only if the principal holds **every** requested permission on it. **Needs:** the same as Check: `permission.check` on every resource and on the principal. **Request** (`BatchCheckRequest`) | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. Project to check in. | | `principal_id` | string | Required. The principal being checked. | | `resource_uris` | string[] | Required. 1–100 unique resource URIs, with the same rules as Check. | | `permissions` | string[] | Required. 1–100 unique permission names. | **Response** (`BatchCheckResponse`) | Field | Type | Description | | --- | --- | --- | | `results` | BatchCheckResult[] | One per requested resource, in request order. | **BatchCheckResult** | Field | Type | Description | | --- | --- | --- | | `resource_uri` | string | The resource. | | `allowed` | bool | `true` if the principal holds every requested permission on it. | | `missing_permissions` | string[] | The requested permissions not held on it, in request order. | **Errors:** `INVALID_ARGUMENT` for a malformed request; `PERMISSION_DENIED` if the API key lacks `permission.check`; `NOT_FOUND` if `principal_id` is `group:` and that group doesn't exist. **TypeScript** ```ts const allowed = await permio.batchCheck({ principal: "alice", resources: ["/documents/42", "/invoices/1"], permission: "document.read", }); allowed.get("/documents/42"); // true ``` Available from SDK 0.2.0. `batchCheckDetailed` returns the missing permissions too. See [Batch checks](https://docs.perms.io/sdk/typescript/#batch-checks). **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/check/batch" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "project_name": "default", "principal_id": "alice", "resource_uris": ["/documents/42", "/invoices/1"], "permissions": ["document.read"] }' ``` ```json {"results": [ {"resourceUri": "/documents/42", "allowed": true}, {"resourceUri": "/invoices/1", "missingPermissions": ["document.read"]} ]} ``` **Go** ```go allowed, err := client.BatchCheck(ctx, "alice", []string{"document.read"}, []string{"/documents/42", "/invoices/1"}) // handle err allowed["/documents/42"] // true ``` `BatchCheckDetailed` returns the missing permissions too. See [Batch checks](https://docs.perms.io/sdk/go/#batch-checks). **Python** ```python allowed = permio.batch_check("alice", "document.read", ["/documents/42", "/invoices/1"]) allowed["/documents/42"] # True ``` `batch_check_detailed` returns the missing permissions too. See [Batch checks](https://docs.perms.io/sdk/python/#batch-checks). **Rust** ```rust use permio::BatchCheck; let entries = permio .batch_check( BatchCheck::new("alice") .resources(["/documents/42", "/invoices/1"]) .permission("document.read"), ) .await?; for e in &entries { println!("{} {} {:?}", e.resource, e.allowed, e.missing); } ``` More than 100 resources are split into concurrent calls. See [`batch_check`](https://docs.perms.io/sdk/rust/#batch_check). **Java** ```java import java.util.List; import java.util.Map; Map allowed = permio.batchCheck("alice", "document.read", List.of("/documents/42", "/invoices/1")); allowed.get("/documents/42"); // true ``` `batchCheckDetailed` returns the missing permissions too. See [Batch checks](https://docs.perms.io/sdk/java/#batch-checks). The Go, Python, Rust and Java examples on this page use a client set up as in [Clients](https://docs.perms.io/api-reference/#clients). ### Permissions #### CreatePermission `POST /permissions-service/v1/permissions` Creates a permission. This call is idempotent: if the permission already exists, it is updated. Returns the `Permission`. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `name` | string | Required. | | `description` | string | Optional. | **TypeScript** ```ts const perm = await permio.permissions.createPermission({ projectName: "default", name: "document.read", description: "Read documents", }); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "name": "document.read", "description": "Read documents"}' ``` **Go** ```go perm, err := client.CreatePermission(ctx, "document.read", "Read documents") ``` If the permission already exists with another description, the SDK returns an error of kind `KindAlreadyExists`; use `UpdatePermission` to change it. **Python** The SDK has no helper for this RPC, so call it through the raw client: ```python from permio import raw perm = permio.permissions.CreatePermission( raw.permissions_pb2.CreatePermissionRequest( project_name="default", name="document.read", description="Read documents" ) ) ``` **Rust** There is no helper for this RPC; use the raw client. ```rust use permio::raw::permissions::v1::CreatePermissionRequest; let perm = permio .raw_permissions() .create_permission(CreatePermissionRequest { project_name: "default".into(), name: "document.read".into(), description: "Read documents".into(), }) .await? .into_inner(); ``` **Java** The SDK has no helper for this RPC, so use the generated stub from `permio.raw()`. It sends the API key and applies the client's timeout, but not its default project: set `project_name` yourself. See [Raw client](https://docs.perms.io/sdk/java/#raw-client). ```java import io.perms.proto.permissions.v1.CreatePermissionRequest; import io.perms.proto.permissions.v1.Permission; Permission perm = permio.raw().permissions().createPermission( CreatePermissionRequest.newBuilder() .setProjectName("default") .setName("document.read") .setDescription("Read documents") .build()); ``` #### GetPermission `GET /permissions-service/v1/permissions/{name}?project_name=…` Returns a `Permission` by name, or `NOT_FOUND`. **TypeScript** ```ts const perm = await permio.permissions.getPermission({ projectName: "default", name: "document.read" }); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/permissions/document.read?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### UpdatePermission `PUT /permissions-service/v1/permissions/{name}` Updates a permission, or returns `NOT_FOUND` if it doesn't exist. Returns the updated `Permission`. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `name` | string | Required (path). The current name. | | `new_name` | string | Optional. A new name; if omitted, the name stays the same. | | `description` | string | The new description. | **TypeScript** ```ts await permio.permissions.updatePermission({ projectName: "default", name: "document.read", description: "Read any document", }); ``` **cURL** ```bash curl -X PUT "$PERMIO_API/permissions-service/v1/permissions/document.read" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "description": "Read any document"}' ``` #### DeletePermission `DELETE /permissions-service/v1/permissions/{name}?project_name=…` Deletes a permission. This call is idempotent: it succeeds whether or not the permission existed. **TypeScript** ```ts await permio.permissions.deletePermission({ projectName: "default", name: "document.read" }); ``` **cURL** ```bash curl -X DELETE "$PERMIO_API/permissions-service/v1/permissions/document.read?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### ListPermissions `GET /permissions-service/v1/permissions?project_name=…` Lists permissions in a project. The response has `permissions` (Permission[]) and `pagination`. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `pagination` | [PaginationRequest](https://docs.perms.io/api-reference/pagination/) | Optional. `limit` (1–100) and `cursor`. | | `search` | string | Optional. Filters by name. | **TypeScript** ```ts for await (const p of permio.listPermissions({ search: "document." })) { console.log(p.name); } ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/permissions?project_name=default&search=document.&pagination.limit=50" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` ### Roles #### CreateRole `POST /permissions-service/v1/roles` Creates a role from existing permissions. This call is idempotent: if the role already exists, it is updated. Returns the `Role`, including its `id`. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `name` | string | Required. | | `description` | string | Optional. | | `permissions` | string[] | Permission **names**, e.g. `["document.read"]`. | **TypeScript** ```ts const role = await permio.permissions.createRole({ projectName: "default", name: "viewer", description: "Can read documents", permissions: ["document.read"], }); console.log(role.id); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/roles" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "name": "viewer", "description": "Can read documents", "permissions": ["document.read"]}' ``` #### GetRole `GET /permissions-service/v1/roles/{id}?project_name=…` Returns a `Role` by ID. **TypeScript** ```ts const role = await permio.permissions.getRole({ projectName: "default", id: roleId }); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/roles/ROLE_ID?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### UpdateRole `PUT /permissions-service/v1/roles/{id}` Replaces a role's name, description and permissions, or returns `NOT_FOUND`. Returns the updated `Role`. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `id` | string | Required (path). | | `name` | string | Required. | | `description` | string | Optional. | | `permissions` | string[] | Permission names. | **TypeScript** ```ts await permio.permissions.updateRole({ projectName: "default", id: roleId, name: "editor", permissions: ["document.read", "document.write"], }); ``` **cURL** ```bash curl -X PUT "$PERMIO_API/permissions-service/v1/roles/ROLE_ID" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "name": "editor", "permissions": ["document.read", "document.write"]}' ``` #### DeleteRole `DELETE /permissions-service/v1/roles/{id}?project_name=…` Deletes a role. This call is idempotent. **TypeScript** ```ts await permio.permissions.deleteRole({ projectName: "default", id: roleId }); ``` **cURL** ```bash curl -X DELETE "$PERMIO_API/permissions-service/v1/roles/ROLE_ID?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### ListRoles `GET /permissions-service/v1/roles?project_name=…` Lists roles in a project. The response has `roles` (Role[]) and `pagination`. It takes the same `pagination` and `search` fields as [ListPermissions](https://docs.perms.io/api-reference/permissions/#listpermissions). **TypeScript** ```ts for await (const role of permio.listRoles()) { console.log(role.id, role.name); } ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/roles?project_name=default&pagination.limit=50" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` ### Grants #### GrantUserPermissionsAndRolesOnResource `POST /permissions-service/v1/permissions/assign` Grants permissions and/or roles to a principal on a resource. Grants are additive: existing grants are kept. The grant also applies to every resource beneath `resource_uri`. Unknown permissions or roles are rejected with `INVALID_ARGUMENT`. To grant to a [group](https://docs.perms.io/concepts/groups/), set `user_id` to `group:`. The group must exist in the project, or the call fails with `NOT_FOUND`. Every member of the group then holds the grant. Grants to groups don't count towards your unique principals. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `user_id` | string | Required. The principal ID, the same value you pass as `principal_id` to Check. | | `resource_uri` | string | Required. | | `permissions` | string[] | Permission **names**. | | `roles` | string[] | Role **IDs**. | **TypeScript** ```ts await permio.permissions.grantUserPermissionsAndRolesOnResource({ projectName: "default", userId: "alice", resourceUri: "/documents", roles: [roleId], }); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/assign" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "user_id": "alice", "resource_uri": "/documents", "roles": ["ROLE_ID"]}' ``` **Go** ```go err := client.Grant(ctx, permio.GrantRequest{ Principal: "alice", Resource: "/documents", Roles: []string{roleID}, }) ``` **Python** ```python permio.grant("alice", "/documents", roles="ROLE_ID") ``` `permissions=` and `roles=` each take one value or a list. See [Granting and revoking](https://docs.perms.io/sdk/python/#granting-and-revoking). **Rust** ```rust use permio::Grant; permio .grant(Grant::new("alice", "/documents").role("ROLE_ID")) .await?; ``` Add `.permission("document.read")` to grant permissions by name. See [Granting and revoking](https://docs.perms.io/sdk/rust/#granting-and-revoking). **Java** ```java import io.perms.Grant; permio.grant(Grant.to("alice").on("/documents").roles("ROLE_ID")); ``` The client's project is used. Grants are never retried automatically. #### RevokeUserPermissionsAndRolesOnResource `POST /permissions-service/v1/permissions/revoke` Removes permissions and/or roles from a principal on a resource. It takes the same fields as Grant. Revoking from a `group:` principal works even if the group no longer exists, so stray grants can be cleaned up. **TypeScript** ```ts await permio.permissions.revokeUserPermissionsAndRolesOnResource({ projectName: "default", userId: "alice", resourceUri: "/documents", roles: [roleId], }); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/permissions/revoke" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "user_id": "alice", "resource_uri": "/documents", "roles": ["ROLE_ID"]}' ``` **Go** ```go err := client.Revoke(ctx, permio.GrantRequest{ Principal: "alice", Resource: "/documents", Roles: []string{roleID}, }) ``` **Python** ```python permio.revoke("alice", "/documents", roles="ROLE_ID") ``` **Rust** ```rust use permio::Grant; permio .revoke(Grant::new("alice", "/documents").role("ROLE_ID")) .await?; ``` **Java** ```java import io.perms.Grant; permio.revoke(Grant.to("alice").on("/documents").roles("ROLE_ID")); ``` #### GetUserPermissionsAndRolesOnResource `GET /permissions-service/v1/permissions/user/{user_id}/resource/{resource_uri=**}?project_name=…` `GET /permissions-service/v1/permissions/user/{user_id}/resource?resource_uri=…&project_name=…` Returns the permissions and roles assigned to a principal on one resource. The response has `project_name`, `user_id`, `resource_uri`, `permissions` (names) and `roles` (IDs). In the first form, send the resource URI **without** its leading slash. The second form passes the URI exactly as a query parameter. **TypeScript** ```ts const res = await permio.permissions.getUserPermissionsAndRolesOnResource({ projectName: "default", userId: "alice", resourceUri: "/documents", }); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/permissions/user/alice/resource?resource_uri=/documents&project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### ListPrincipals `GET /permissions-service/v1/permissions/principals?project_name=…` Lists the IDs of principals with grants in the project. The response has `principals` (string[]) and `pagination`. It takes `pagination` and `search`. **TypeScript** ```ts for await (const id of permio.listPrincipals()) console.log(id); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/permissions/principals?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### GetAllAssignmentsForPrincipal `GET /permissions-service/v1/permissions/assignments/principal/{principal_id=**}?project_name=…` Lists every grant a principal has in the project. The response has `assignments` (Assignment[]) and `pagination`. Principal IDs may contain slashes. **TypeScript** ```ts for await (const a of permio.listAssignments({ principal: "alice" })) { console.log(a.resourceUri, a.permissions.map((p) => p.name), a.roles.map((r) => r.name)); } ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/permissions/assignments/principal/alice?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` ### Access queries These answer "where?" and "who?" instead of "may they?", with the same rules as Check. The guide [Filtering lists and building share dialogs](https://docs.perms.io/guides/filtering-and-sharing/) shows how to use them. #### ListAccess `GET /permissions-service/v1/access?project_name=…&principal_id=…&permission=…&under_uri=…` Answers "where can this principal use this permission, at or below `under_uri`?". It counts grants made to the principal directly, through roles, and through the groups it is a member of. perms.io doesn't know your records, so the answer is a list of **scopes** in your URI tree, not a list of objects: - If the principal holds the permission on `under_uri` itself (through a grant on it, on an ancestor, or on a stored `_` wildcard URI that matches it), `covers_all` is `true` and `scopes` is a single `SUBTREE` scope for `under_uri`. There are no further pages. - Otherwise `scopes` lists the URIs of the grants below `under_uri` that confer the permission. A concrete URI is a `SUBTREE` scope, because a grant applies to its URI and everything below it. A URI with `_` wildcard segments is a `PATTERN` scope. Scopes are ordered by `uri` and paginated. **Needs:** `permission.check` on `under_uri`, and `principal.assignment.get` on the principal. **Request** (`ListAccessRequest`) | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `principal_id` | string | Required. | | `permission` | string | Required. One permission name. | | `under_uri` | string | Required. Only resources at or below this URI are considered. A `_` segment here is literal. | | `pagination` | [PaginationRequest](https://docs.perms.io/api-reference/pagination/) | Optional. | **Response** (`ListAccessResponse`) | Field | Type | Description | | --- | --- | --- | | `covers_all` | bool | `true` if the principal holds the permission on `under_uri` itself, and so on everything below it. | | `scopes` | AccessScope[] | The places at or below `under_uri` where the principal holds the permission, ordered by `uri`. | | `pagination` | PaginationResponse | `next_cursor` for the next page. | **AccessScope** | Field | Type | Description | | --- | --- | --- | | `uri` | string | A resource URI (`SUBTREE`, `EXACT`) or a URI pattern with `_` segments (`PATTERN`). For a grant below `under_uri`, it is the URI the grant was made on, which may have `_` segments in the part that matches `under_uri`. | | `scope` | AccessScopeKind | Which resources `uri` stands for. | **AccessScopeKind** | Value | Meaning | | --- | --- | | `ACCESS_SCOPE_KIND_SUBTREE` | The resource `uri` and every resource below it. | | `ACCESS_SCOPE_KIND_PATTERN` | Every resource whose leading segments match `uri`, where each `_` segment matches exactly one segment, and every resource below those. | | `ACCESS_SCOPE_KIND_EXACT` | Exactly the resource `uri`. Reserved: grants are always inherited, so it isn't returned today. | To filter a database by a `SUBTREE` scope `p`, match `path = p` **or** `path` starting with `p/`. A bare prefix match on `p` would also match siblings such as `/documents/12` for `/documents/1`. **Errors:** `INVALID_ARGUMENT` for a malformed request or an invalid cursor; `PERMISSION_DENIED` if the API key lacks either permission; `NOT_FOUND` if `principal_id` is `group:` and that group doesn't exist. **TypeScript** ```ts const access = await permio.listAccess({ principal: "alice", permission: "document.read", under: "/documents", }); access.coversAll; // false access.toPrefixes(); // ["/documents/4", "/documents/7"] ``` Available from SDK 0.2.0. It follows every page. See [`listAccess`](https://docs.perms.io/sdk/typescript/#listaccess). **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/access?project_name=default&principal_id=alice&permission=document.read&under_uri=/documents" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` ```json {"scopes": [ {"uri": "/documents/4", "scope": "ACCESS_SCOPE_KIND_SUBTREE"}, {"uri": "/documents/7", "scope": "ACCESS_SCOPE_KIND_SUBTREE"} ]} ``` **Go** ```go access, err := client.ListAccess(ctx, "alice", "document.read", "/documents") // handle err access.CoversAll // false access.Prefixes() // ["/documents/4", "/documents/7"] ``` It follows every page. See [`ListAccess`](https://docs.perms.io/sdk/go/#listaccess). **Python** ```python access = permio.list_access("alice", "document.read", "/documents") if access.covers_all: ... # no filter needed else: print(access.to_prefixes()) # ["/documents/4", "/documents/7"] ``` It follows every page. See [`list_access`](https://docs.perms.io/sdk/python/#list_access). **Rust** ```rust let access = permio.list_access("alice", "document.read", "/documents").await?; access.covers_all; // false access.to_prefixes(); // ["/documents/4", "/documents/7"] ``` It follows every page. See [`list_access`](https://docs.perms.io/sdk/rust/#list_access). **Java** ```java import io.perms.AccessScopes; AccessScopes access = permio.listAccess("alice", "document.read", "/documents"); access.coversAll(); // false access.toPrefixes(); // [/documents/4, /documents/7] ``` It follows every page. See [`listAccess`](https://docs.perms.io/sdk/java/#listaccess). #### ListPrincipalsWithAccess `GET /permissions-service/v1/access/principals?project_name=…&resource_uri=…` Answers "who has access to `resource_uri`?". It lists the principals with a grant on the resource, on an ancestor of it, or on a stored `_` wildcard URI that matches it, with the grants that give the access. - With `permission` set, only grants that confer that permission count, so a principal is listed exactly when a Check for it passes through its own grants. Without it, every grant with at least one permission or role counts. - Groups are listed as principals, with `is_group` set. With `expand_groups`, each group's members are listed too, once per group, with `via_group` set. - Entries are ordered by `principal_id`, then `via_group`, and paginated. **Needs:** `principal.list` and `principal.assignment.list` on the project. **Request** (`ListPrincipalsWithAccessRequest`) | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `resource_uri` | string | Required. The resource to look at. A `_` segment here is literal. | | `permission` | string | Optional. Only count grants that confer this permission. | | `expand_groups` | bool | Optional. Also list the members of every listed group. | | `pagination` | [PaginationRequest](https://docs.perms.io/api-reference/pagination/) | Optional. | **Response** (`ListPrincipalsWithAccessResponse`): `principals` (PrincipalAccess[]) and `pagination`. **PrincipalAccess** | Field | Type | Description | | --- | --- | --- | | `principal_id` | string | The principal. A group appears as `group:`. | | `is_group` | bool | `true` if `principal_id` is a group. | | `via_group` | string | For entries added by `expand_groups`: the slug of the group through which the principal holds the grants in `via`. Empty for grants made to the principal itself. | | `via` | AccessGrant[] | The grants that give the access, most specific first. | **AccessGrant** | Field | Type | Description | | --- | --- | --- | | `resource_uri` | string | The URI the grant was made on: the resource itself, an ancestor, or a URI with `_` segments that matches it. | | `inherited` | bool | `true` unless the grant was made on exactly the resource. | | `match` | AccessGrantMatch | `ACCESS_GRANT_MATCH_EXACT` (on the resource), `ACCESS_GRANT_MATCH_ANCESTOR` (on an ancestor, possibly with `_` segments) or `ACCESS_GRANT_MATCH_WILDCARD` (same depth, with `_` segments). | | `role` | AccessGrantRole | Set when the access comes through a role: its `id` and `name`. | | `direct` | AccessGrantDirect | Set (as an empty message) when the access comes from directly granted permissions. | `role` and `direct` are a `oneof` called `source`: exactly one is set. **Errors:** `INVALID_ARGUMENT` for a malformed request or an invalid cursor; `PERMISSION_DENIED` if the API key lacks either permission. **TypeScript** ```ts const entries = await permio.whoHasAccess({ resource: "/documents/42", permission: "document.read", expandGroups: true, }); ``` Available from SDK 0.2.0. It follows every page. See [`whoHasAccess`](https://docs.perms.io/sdk/typescript/#whohasaccess). **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/access/principals?project_name=default&resource_uri=/documents/42&permission=document.read&expand_groups=true" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` ```json {"principals": [ {"principalId": "alice", "via": [{"resourceUri": "/documents", "inherited": true, "match": "ACCESS_GRANT_MATCH_ANCESTOR", "role": {"id": "ROLE_ID", "name": "viewer"}}]}, {"principalId": "bob", "viaGroup": "editors", "via": [{"resourceUri": "/documents", "inherited": true, "match": "ACCESS_GRANT_MATCH_ANCESTOR", "direct": {}}]}, {"principalId": "group:editors", "isGroup": true, "via": [{"resourceUri": "/documents", "inherited": true, "match": "ACCESS_GRANT_MATCH_ANCESTOR", "direct": {}}]} ]} ``` **Go** ```go entries, err := client.WhoHasAccess(ctx, "/documents/42", &permio.WhoHasAccessOptions{ Permission: "document.read", ExpandGroups: true, }) ``` It follows every page. See [`WhoHasAccess`](https://docs.perms.io/sdk/go/#whohasaccess). **Python** ```python for entry in permio.who_has_access("/documents/42", permission="document.read", expand_groups=True): print(entry.principal, entry.via_group, [g.resource for g in entry.via]) ``` It follows every page. See [`who_has_access`](https://docs.perms.io/sdk/python/#who_has_access). **Rust** ```rust use permio::WhoHasAccess; let entries = permio .who_has_access( WhoHasAccess::new("/documents/42") .permission("document.read") .expand_groups(true), ) .await?; for e in &entries { println!("{} {} {:?}", e.principal, e.is_group, e.via_group); } ``` It follows every page. See [`who_has_access`](https://docs.perms.io/sdk/rust/#who_has_access). **Java** ```java import io.perms.AccessEntry; import java.util.List; List entries = permio.whoHasAccess("/documents/42", "document.read", true); ``` It follows every page. See [`whoHasAccess`](https://docs.perms.io/sdk/java/#whohasaccess). ### Groups A [group](https://docs.perms.io/concepts/groups/) is a named set of principals in a project. Grant to its principal ID, `group:`, with [GrantUserPermissionsAndRolesOnResource](https://docs.perms.io/api-reference/permissions/#grantuserpermissionsandrolesonresource), and every member holds the grant. Groups can't contain groups. In the paths below, `{name}` is the group's slug: 1–64 characters, lowercase letters, digits and `-`. **Group** | Field | Type | Description | | --- | --- | --- | | `id` | string | Unique ID. | | `organisation_id` | string | Owning organisation. | | `project_name` | string | Owning project. | | `name` | string | The slug, unique within the project. It can't be changed. | | `description` | string | Free text, up to 1024 characters. | | `principal_id` | string | The principal ID grants to the group are made to: `group:`. | | `created_at`, `updated_at` | Timestamp | | **GroupMember**: `principal_id` (string) and `created_at` (Timestamp, when the principal was added). #### CreateGroup `POST /permissions-service/v1/groups` Creates a group and returns the `Group`. Returns `ALREADY_EXISTS` if the project already has a group with this name. **Needs:** `group.create` on the project. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `name` | string | Required. The slug. | | `description` | string | Optional. Up to 1024 characters. | **TypeScript** ```ts const g = await permio.groups.create("editors", { description: "Can edit every document" }); g.principalId; // "group:editors" ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/groups" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "name": "editors", "description": "Can edit every document"}' ``` **Go** ```go g, err := client.Groups().Create(ctx, "editors", "Can edit every document") // handle err g.PrincipalID // "group:editors" ``` **Python** ```python g = permio.groups.create("editors", description="Can edit every document") g.principal_id # "group:editors" ``` **Rust** ```rust let g = permio .groups() .create("editors", Some("Can edit every document")) .await?; g.principal_id; // "group:editors" ``` **Java** ```java import io.perms.proto.permissions.v1.Group; Group g = permio.groups().create("editors", "Can edit every document"); g.getPrincipalId(); // "group:editors" ``` #### GetGroup `GET /permissions-service/v1/groups/{name}?project_name=…` Returns a `Group`, or `NOT_FOUND`. **Needs:** `group.get` on the group. **TypeScript** ```ts const g = await permio.groups.get("editors"); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/groups/editors?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### UpdateGroup `PUT /permissions-service/v1/groups/{name}` Replaces a group's description and returns the updated `Group`, or `NOT_FOUND`. The name can't be changed, because `group:` is the principal ID its grants are stored under. **Needs:** `group.update` on the group. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `name` | string | Required (path). | | `description` | string | The new description. Up to 1024 characters. | **TypeScript** ```ts await permio.groups.update("editors", { description: "Edits documents and comments" }); ``` **cURL** ```bash curl -X PUT "$PERMIO_API/permissions-service/v1/groups/editors" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "description": "Edits documents and comments"}' ``` #### DeleteGroup `DELETE /permissions-service/v1/groups/{name}?project_name=…` Deletes a group together with its memberships and every grant made to it, in one transaction. This call is idempotent: it succeeds whether or not the group existed. **Needs:** `group.delete` on the group, and `principal.assignment.delete` on the project, because it revokes the group's grants. **TypeScript** ```ts await permio.groups.delete("editors"); ``` **cURL** ```bash curl -X DELETE "$PERMIO_API/permissions-service/v1/groups/editors?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### ListGroups `GET /permissions-service/v1/groups?project_name=…` Lists the groups in a project, ordered by name. The response has `groups` (Group[]) and `pagination`. It takes `pagination` and `search` (filters by name, up to 128 characters). **Needs:** `group.list` on the project. **TypeScript** ```ts for await (const g of permio.groups.list({ search: "edit" })) console.log(g.name); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/groups?project_name=default&search=edit" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### AddGroupMembers `POST /permissions-service/v1/groups/{name}/members/add` Adds up to 100 principals to a group. Principals that are already members are reported, not added again. A principal that is new to your organisation counts towards its unique principals, exactly as if it had been granted something, and goes through the same billing check. **Needs:** `group.member.add` on the group **and** `principal.assignment.create` on the project. A member gets the group's grants anywhere in the project, so only a caller who could grant that access directly may add members. | Field | Type | Description | | --- | --- | --- | | `project_name` | string | Required. | | `name` | string | Required (path). | | `principal_ids` | string[] | Required. 1–100 unique principal IDs. They may not start with `group:`. | **Response** (`AddGroupMembersResponse`) | Field | Type | Description | | --- | --- | --- | | `added_principal_ids` | string[] | The principals that were added, in request order. | | `existing_principal_ids` | string[] | The principals that were already members, in request order. | **Errors:** `INVALID_ARGUMENT` if a principal ID starts with `group:` (groups can't be nested); `NOT_FOUND` if the group doesn't exist; `PERMISSION_DENIED` if the caller lacks either permission, or if the organisation may not add new principals. **TypeScript** ```ts const { added, existing } = await permio.groups.addMembers("editors", ["alice", "bob"]); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/groups/editors/members/add" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "principal_ids": ["alice", "bob"]}' ``` ```json {"addedPrincipalIds": ["alice", "bob"]} ``` **Go** ```go change, err := client.Groups().AddMembers(ctx, "editors", []string{"alice", "bob"}) // handle err change.Changed // added change.Unchanged // already members ``` **Python** ```python res = permio.groups.add_members("editors", ["alice", "bob"]) res.added, res.existing # (["alice", "bob"], []) ``` **Rust** ```rust let res = permio.groups().add_members("editors", ["alice", "bob"]).await?; println!("{:?} {:?}", res.added, res.existing); ``` **Java** ```java import io.perms.AddMembersResult; import java.util.List; AddMembersResult res = permio.groups().addMembers("editors", List.of("alice", "bob")); res.added(); res.existing(); ``` #### RemoveGroupMembers `POST /permissions-service/v1/groups/{name}/members/remove` Removes up to 100 principals from a group. Principals that aren't members are reported. It takes the same fields as AddGroupMembers. **Needs:** `group.member.remove` on the group **and** `principal.assignment.create` on the project. **Response** (`RemoveGroupMembersResponse`): `removed_principal_ids` and `not_member_principal_ids` (string[], in request order). **TypeScript** ```ts const { removed, notMembers } = await permio.groups.removeMembers("editors", ["bob"]); ``` **cURL** ```bash curl -X POST "$PERMIO_API/permissions-service/v1/groups/editors/members/remove" \ -H "Authorization: Bearer $PERMIO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project_name": "default", "principal_ids": ["bob"]}' ``` #### ListGroupMembers `GET /permissions-service/v1/groups/{name}/members?project_name=…` Lists a group's members, ordered by principal ID. The response has `members` (GroupMember[]) and `pagination`. It takes `pagination`. **Needs:** `group.member.list` on the group. **TypeScript** ```ts for await (const m of permio.groups.listMembers("editors")) console.log(m.principalId); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/groups/editors/members?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` #### ListGroupsForPrincipal `GET /permissions-service/v1/group-memberships/{principal_id=**}?project_name=…` Lists the groups a principal is a member of in the project, ordered by name. The response has `groups` (Group[]) and `pagination`. It takes `pagination`. Principal IDs may contain slashes. **Needs:** `group.list` on the project, and `principal.assignment.list` on the principal. **TypeScript** ```ts for await (const g of permio.groups.listForPrincipal("alice")) console.log(g.name); ``` **cURL** ```bash curl "$PERMIO_API/permissions-service/v1/group-memberships/alice?project_name=default" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` --- Source: https://docs.perms.io/api-reference/organizations/ ## Organisations & Projects `permio.organisation.v1.OrganisationService` manages organisations, projects, API keys and team members. The [dashboard](https://app.perms.io) is built on it, and it's the easiest way to do all of this. **Note: Mostly for the dashboard** These calls check organisation-level permissions such as `project.create` or `apiKeys.list`. Dashboard users get these through their role in the organisation. An API key's [project grants](https://docs.perms.io/concepts/api-keys/#access-levels) don't include them, so most calls on this page return `PERMISSION_DENIED` for an API key. (See "API host" earlier in this file.) ### Organisations | RPC | REST | Notes | | --- | --- | --- | | `CreateOrganisation` | `POST /organisation-service/v1/organisation` | Body: `name`, `user_id`. Also creates the `default` project. | | `GetOrganisation` | `GET /organisation-service/v1/organisation/{id}` | Returns `organisation`. | | `UpdateOrganisation` | `PUT /organisation-service/v1/organisation/{id}` | Body: `name`. Needs `organisation.update`. | | `GetUsersOrganisation` | `GET /organisation-service/v1/organisation/user` | Query: `user_id`. Returns the user's `organisation`. | | `GetOrganisationDashboard` | `GET /organisation-service/v1/organisation/{organisation_id}/dashboard` | `total_roles`, `total_permissions`, `total_principals`, `total_users`. Needs `organisation.dashboard.view`. | **Organisation** | Field | Type | Description | | --- | --- | --- | | `id` | string | Organisation ID. | | `name` | string | Display name. | | `users` | User[] | Members (`id`, `email`). | | `created_at`, `updated_at` | Timestamp | | | `billing_customer` | BillingCustomer | Billing contact (`id`, `organisation_id`, `name`, `email`). | ### Projects Every organisation has a `default` project, which can't be deleted. Project names are 1–32 characters: lowercase letters, digits and `-`. | RPC | REST | Notes | | --- | --- | --- | | `CreateProject` | `POST /organisation-service/v1/organisation/{organisation_id}/project` | Body: `name`. Needs `project.create`. | | `GetProject` | `GET /organisation-service/v1/organisation/{organisation_id}/project` | Query: `project_name`. Needs `project.get`. | | `GetProjectsForOrganisation` | `GET /organisation-service/v1/organisation/{organisation_id}/projects` | Query: `pagination.limit`, `pagination.cursor`, `search`. Returns `projects` and `pagination`. | | `DeleteProject` | `DELETE /organisation-service/v1/organisation/{organisation_id}/project` | Query: `project_name`. Needs `project.delete`. Invalidates the project's API keys. | **Project**: `organisation_id`, `name`, `created_at`. ### API keys See [API keys](https://docs.perms.io/concepts/api-keys/) for the key format and access levels. | RPC | REST | Notes | | --- | --- | --- | | `CreateAPIKey` | `POST /organisation-service/v1/organisation/{organisation_id}/api-key` | Body: `project_name`, `name`, `access` (`API_KEY_ACCESS_CHECK` or `API_KEY_ACCESS_FULL`; left out means check). Returns `api_key`, whose `key` is the only time the secret is returned. Needs `apiKey.create` on the project, and every permission of the chosen [access level](https://docs.perms.io/concepts/api-keys/#choosing-the-level). | | `ListAPIKeys` | `GET /organisation-service/v1/organisation/{organisation_id}/api-key` | Query: `project_name`, `pagination.*`, `search`. Returns `api_keys` and `pagination`. Needs `apiKeys.list`. | | `InvalidateAPIKey` | `DELETE /organisation-service/v1/organisation/{organisation_id}/api-key/{id}` | Query: `project_name`. The key stops working immediately. | **APIKey** | Field | Type | Description | | --- | --- | --- | | `id` | string | Key ID. This is also the key's principal ID. | | `name` | string | Display name. | | `key` | string | The secret, set only in the `CreateAPIKey` response. | | `created_at` | Timestamp | | | `invalidated` | bool | `true` once invalidated. | | `access` | APIKeyAccess | `API_KEY_ACCESS_CHECK` or `API_KEY_ACCESS_FULL` (`API_KEY_ACCESS_UNSPECIFIED` is treated as check). | **Note** `CreateAPIKey` with `access: API_KEY_ACCESS_FULL` is refused with `PERMISSION_DENIED` unless the caller holds every permission that level grants on the project. Choosing the level in the dashboard is being added; until then, keys created there get check access. ### Team members | RPC | REST | Notes | | --- | --- | --- | | `InviteUserToOrganisation` | `POST /organisation-service/v1/organisation/{organisation_id}/invite` | Body: `email`. | | `RemoveUserFromOrganisation` | `POST /organisation-service/v1/organisation/{organisation_id}/user/{user_id}` | Needs `organisation.user.remove`. | | `SetUsersOrganisation` | `POST /organisation-service/v1/organisation/{id}/user` | Body: `user_id`, `email`. Needs `user.organisation.set`. | ### Usage | RPC | REST | Notes | | --- | --- | --- | | `GetPrincipalUsageStats` | `GET /organisation-service/v1/organisation/{organisation_id}/principal-usage` | Current principal count against your plan. Needs `billing.view`. | | `GetUsageHistory` | `GET /organisation-service/v1/organisation/{organisation_id}/usage-history` | Query: `days` (default 30). Returns `history` (`date`, `count`). Needs `billing.view`. | ### Example With the [TypeScript SDK](https://docs.perms.io/sdk/typescript/), these RPCs are on `permio.organisations`: ```ts import { paginate } from "permio"; for await (const project of paginate( (pagination, signal) => permio.organisations.getProjectsForOrganisation({ organisationId: "your-org-id", pagination }, { signal }), (res) => res.projects, )) { console.log(project.name); } ``` --- Source: https://docs.perms.io/api-reference/pagination/ ## Pagination List endpoints use cursor-based pagination with two shared messages from the `pagination` proto package. ### PaginationRequest | Field | Type | Description | | --- | --- | --- | | `limit` | int32 | Optional. Items per page, 1–100. Default 10. | | `cursor` | string | Optional. The `next_cursor` from the previous page. Leave it out to start from the beginning. | Over REST, send these as query parameters: `?pagination.limit=50&pagination.cursor=…`. ### PaginationResponse | Field | Type | Description | | --- | --- | --- | | `next_cursor` | string | Pass this as `cursor` to get the next page. It's empty when there are no more pages. | Treat cursors as opaque strings. ### Paginated endpoints | RPC | Items field | | --- | --- | | [ListPermissions](https://docs.perms.io/api-reference/permissions/#listpermissions) | `permissions` | | [ListRoles](https://docs.perms.io/api-reference/permissions/#listroles) | `roles` | | [ListPrincipals](https://docs.perms.io/api-reference/permissions/#listprincipals) | `principals` | | [GetAllAssignmentsForPrincipal](https://docs.perms.io/api-reference/permissions/#getallassignmentsforprincipal) | `assignments` | | [ListAccess](https://docs.perms.io/api-reference/permissions/#listaccess) | `scopes` | | [ListPrincipalsWithAccess](https://docs.perms.io/api-reference/permissions/#listprincipalswithaccess) | `principals` | | [ListGroups](https://docs.perms.io/api-reference/permissions/#listgroups) | `groups` | | [ListGroupMembers](https://docs.perms.io/api-reference/permissions/#listgroupmembers) | `members` | | [ListGroupsForPrincipal](https://docs.perms.io/api-reference/permissions/#listgroupsforprincipal) | `groups` | | [GetProjectsForOrganisation](https://docs.perms.io/api-reference/organizations/#projects) | `projects` | | [ListAPIKeys](https://docs.perms.io/api-reference/organizations/#api-keys) | `api_keys` | ### Examples **TypeScript** The SDK's iterators follow cursors for you: ```ts for await (const p of permio.listPermissions({ pageSize: 50 })) { console.log(p.name); } ``` For other list RPCs, use `paginate` (see [Listing with iterators](https://docs.perms.io/sdk/typescript/#listing-with-iterators)). **cURL** ```bash # First page curl "$PERMIO_API/permissions-service/v1/permissions?project_name=default&pagination.limit=50" \ -H "Authorization: Bearer $PERMIO_API_KEY" # Next page: pass the nextCursor value from the previous response curl "$PERMIO_API/permissions-service/v1/permissions?project_name=default&pagination.limit=50&pagination.cursor=NEXT_CURSOR" \ -H "Authorization: Bearer $PERMIO_API_KEY" ``` Responses use camelCase JSON names, so the cursor arrives as `pagination.nextCursor`. When there are no more pages it is empty or left out.