feat(organization): support multiple permissions check (#2227)

* feat: remove the artificial resource limit so that code can check

Also change `permission` to `permissions` (clearer for end user). `permission` is left for backwards compatibility.

* docs: add examples for multiple perms checking

* refactor: check `permissions` first, then legacy one

* feat: use union types for `permission` & `permissions`

* fix: properly use union types

* fix: remove accidental `@deprecated` comment

* chore: lint

* fix test

* chore: add oneTimeToken plugin to client barrel exports (#2224)

* docs(expo): add id token usage

* feat(oauth2): override user info on provider sign-in (#2148)

* feat(oauth2): override user info on provider sign-in

* improve email verification handling

* resolve mrge

* fix(sso): update overrideUserInfo handling to use provider configuration

* fix param

* chore: change plugin interface middleware type (#2195)

* fix: delete from session table when stopImpersonate called (#2230)

* chore: fix active organization inferred type

* chore: fix admin test

---------

Co-authored-by: Bereket Engida <bekacru@gmail.com>
Co-authored-by: Wade Fletcher <3798059+wadefletch@users.noreply.github.com>
Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
Co-authored-by: KinfeMichael Tariku <65047246+Kinfe123@users.noreply.github.com>
This commit is contained in:
ririxi
2025-04-12 21:00:58 +02:00
committed by GitHub
parent de91c26708
commit cb900f9594
15 changed files with 551 additions and 288 deletions

View File

@@ -19,6 +19,7 @@ import { MysqlDialect } from "kysely";
import { createPool } from "mysql2/promise"; import { createPool } from "mysql2/promise";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { passkey } from "better-auth/plugins/passkey"; import { passkey } from "better-auth/plugins/passkey";
import { expo } from "@better-auth/expo";
import { stripe } from "@better-auth/stripe"; import { stripe } from "@better-auth/stripe";
import { Stripe } from "stripe"; import { Stripe } from "stripe";
@@ -197,5 +198,7 @@ export const auth = betterAuth({
], ],
}, },
}), }),
expo(),
], ],
trustedOrigins: ["exp://"],
}); });

View File

@@ -130,7 +130,7 @@ const users = await authClient.admin.listUsers({
#### Pagination #### Pagination
The `listUsers` function supports pagination by returning metadata alongside the user list. The response includes the following fields: The `listUsers` function supports pagination by returning metadata alongside the user list. The response includes the following fields:
```ts ```ts
{ {
@@ -141,18 +141,18 @@ The `listUsers` function supports pagination by returning metadata alongside the
} }
``` ```
##### How to Implement Pagination ##### How to Implement Pagination
To paginate results, use the `total`, `limit`, and `offset` values to calculate: To paginate results, use the `total`, `limit`, and `offset` values to calculate:
- **Total pages:** `Math.ceil(total / limit)` - **Total pages:** `Math.ceil(total / limit)`
- **Current page:** `(offset / limit) + 1` - **Current page:** `(offset / limit) + 1`
- **Next page offset:** `Math.min(offset + limit, (total - 1))` The value to use as `offset` for the next page, ensuring it does not exceed the total number of pages. - **Next page offset:** `Math.min(offset + limit, (total - 1))` The value to use as `offset` for the next page, ensuring it does not exceed the total number of pages.
- **Previous page offset:** `Math.max(0, offset - limit)` The value to use as `offset` for the previous page (ensuring it doesnt go below zero). - **Previous page offset:** `Math.max(0, offset - limit)` The value to use as `offset` for the previous page (ensuring it doesnt go below zero).
##### Example Usage ##### Example Usage
Fetching the second page with 10 users per page: Fetching the second page with 10 users per page:
```ts title="admin.ts" ```ts title="admin.ts"
const pageSize = 10; const pageSize = 10;
@@ -416,10 +416,18 @@ To check a user's permissions, you can use the `hasPermission` function provided
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = await authClient.admin.hasPermission({ const canCreateProject = await authClient.admin.hasPermission({
permission: { permissions: {
project: ["create"], project: ["create"],
}, },
}); });
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = await authClient.admin.hasPermission({
permissions: {
project: ["create"],
sale: ["create"]
},
});
``` ```
If you want to check a user's permissions server-side, you can use the `userHasPermission` action provided by the `api` to check the user's permissions. If you want to check a user's permissions server-side, you can use the `userHasPermission` action provided by the `api` to check the user's permissions.
@@ -429,21 +437,32 @@ import { auth } from "@/auth";
auth.api.userHasPermission({ auth.api.userHasPermission({
body: { body: {
userId: 'id', //the user id userId: 'id', //the user id
permission: { permissions: {
project: ["create"], // This must match the structure in your access control project: ["create"], // This must match the structure in your access control
}, },
}, },
}); });
//you can also just pass the role directly // You can also just pass the role directly
auth.api.userHasPermission({ auth.api.userHasPermission({
body: { body: {
role: "admin", role: "admin",
permission: { permissions: {
project: ["create"], // This must match the structure in your access control project: ["create"], // This must match the structure in your access control
}, },
}, },
}); });
// You can also check multiple resource permissions at the same time
auth.api.userHasPermission({
body: {
role: "admin",
permissions: {
project: ["create"], // This must match the structure in your access control
sale: ["create"]
},
},
});
``` ```
@@ -453,11 +472,20 @@ Once you have defined the roles and permissions to avoid checking the permission
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = client.admin.checkRolePermission({ const canCreateProject = client.admin.checkRolePermission({
permission: { permissions: {
user: ["delete"], user: ["delete"],
}, },
role: "admin", role: "admin",
}); });
// You can also check multiple resource permissions at the same time
const canCreateProjectAndRevokeSession = client.admin.checkRolePermission({
permissions: {
user: ["delete"],
session: ["revoke"]
},
role: "admin",
});
``` ```
## Schema ## Schema
@@ -584,4 +612,3 @@ admin({
bannedUserMessage: "Custom banned user message", bannedUserMessage: "Custom banned user message",
}); });
``` ```

View File

@@ -44,8 +44,8 @@ export const auth = betterAuth({
<Step> <Step>
### Add the client plugin ### Add the client plugin
```ts title="auth-client.ts" ```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client" import { createAuthClient } from "better-auth/client"
import { organizationClient } from "better-auth/client/plugins" import { organizationClient } from "better-auth/client/plugins"
@@ -56,7 +56,7 @@ export const auth = betterAuth({
}) })
``` ```
</Step> </Step>
</Steps> </Steps>
## Usage ## Usage
@@ -184,7 +184,7 @@ function App(){
{organizations.map(org => <p>{org.name}</p>)} {organizations.map(org => <p>{org.name}</p>)}
</div> </div>
) )
} }
``` ```
</Tab> </Tab>
@@ -315,8 +315,8 @@ export const auth = betterAuth({
To retrieve the active organization for the user, you can call the `useActiveOrganization` hook. It returns the active organization for the user. Whenever the active organization changes, the hook will re-evaluate and return the new active organization. To retrieve the active organization for the user, you can call the `useActiveOrganization` hook. It returns the active organization for the user. Whenever the active organization changes, the hook will re-evaluate and return the new active organization.
<Tabs items={['React', 'Vue', 'Svelte']}> <Tabs items={['React', 'Vue', 'Svelte']}>
<Tab value="React"> <Tab value="React">
```tsx title="client.tsx" ```tsx title="client.tsx"
import { client } from "@/auth/client" import { client } from "@/auth/client"
function App(){ function App(){
@@ -326,11 +326,11 @@ To retrieve the active organization for the user, you can call the `useActiveOrg
{activeOrganization ? <p>{activeOrganization.name}</p> : null} {activeOrganization ? <p>{activeOrganization.name}</p> : null}
</div> </div>
) )
} }
``` ```
</Tab> </Tab>
<Tab value="Svelte"> <Tab value="Svelte">
```tsx title="client.tsx" ```tsx title="client.tsx"
<script lang="ts"> <script lang="ts">
import { client } from "$lib/client"; import { client } from "$lib/client";
const activeOrganization = client.useActiveOrganization(); const activeOrganization = client.useActiveOrganization();
@@ -345,10 +345,10 @@ To retrieve the active organization for the user, you can call the `useActiveOrg
{:else} {:else}
<p>{$activeOrganization.data.name}</p> <p>{$activeOrganization.data.name}</p>
{/if} {/if}
``` ```
</Tab> </Tab>
<Tab value="Vue"> <Tab value="Vue">
```vue title="organization.vue" ```vue title="organization.vue"
<script lang="ts">; <script lang="ts">;
export default { export default {
setup() { setup() {
@@ -368,7 +368,7 @@ To retrieve the active organization for the user, you can call the `useActiveOrg
</div> </div>
</div> </div>
</template> </template>
``` ```
</Tab> </Tab>
</Tabs> </Tabs>
@@ -418,7 +418,7 @@ To get the full details of an organization, you can use the `getFullOrganization
To update organization info, you can use `organization.update` To update organization info, you can use `organization.update`
```ts ```ts
await client.organization.update({ await client.organization.update({
data: { data: {
name: "updated-name", name: "updated-name",
@@ -521,7 +521,7 @@ await authClient.organization.inviteMember({
When a user receives an invitation email, they can click on the invitation link to accept the invitation. The invitation link should include the invitation ID, which will be used to accept the invitation. When a user receives an invitation email, they can click on the invitation link to accept the invitation. The invitation link should include the invitation ID, which will be used to accept the invitation.
Make sure to call the `acceptInvitation` function after the user is logged in. Make sure to call the `acceptInvitation` function after the user is logged in.
```ts title="auth-client.ts" ```ts title="auth-client.ts"
await authClient.organization.acceptInvitation({ await authClient.organization.acceptInvitation({
@@ -530,7 +530,7 @@ await authClient.organization.acceptInvitation({
``` ```
### Update Invitation Status ### Update Invitation Status
To update the status of invitation you can use the `acceptInvitation`, `cancelInvitation`, `rejectInvitation` functions provided by the client. The functions take the invitation id as an argument. To update the status of invitation you can use the `acceptInvitation`, `cancelInvitation`, `rejectInvitation` functions provided by the client. The functions take the invitation id as an argument.
```ts title="auth-client.ts" ```ts title="auth-client.ts"
@@ -572,7 +572,7 @@ const invitations = await authClient.organization.listInvitations({
## Members ## Members
### Remove Member ### Remove Member
To remove you can use `organization.removeMember` To remove you can use `organization.removeMember`
@@ -628,7 +628,7 @@ To leave organization you can use `organization.leave` function. This function w
await authClient.organization.leave({ await authClient.organization.leave({
organizationId: "organization-id" organizationId: "organization-id"
}) })
``` ```
## Access Control ## Access Control
@@ -652,16 +652,16 @@ By default, there are three roles in the organization:
By default, there are three resources, and these have two to three actions. By default, there are three resources, and these have two to three actions.
**organization**: **organization**:
`update` `delete` `update` `delete`
**member**: **member**:
`create` `update` `delete` `create` `update` `delete`
**invitation**: **invitation**:
`create` `cancel` `create` `cancel`
The owner have full control over all the resources and actions. The admin have full control over all the resources except for deleting the organization or changing the owner. The member have no control over any of those action other than reading the data. The owner have full control over all the resources and actions. The admin have full control over all the resources except for deleting the organization or changing the owner. The member have no control over any of those action other than reading the data.
@@ -697,17 +697,17 @@ The plugin provides an easy way to define your own set of permissions for each r
```ts title="permissions.ts" ```ts title="permissions.ts"
import { createAccessControl } from "better-auth/plugins/access"; import { createAccessControl } from "better-auth/plugins/access";
const statement = { const statement = {
project: ["create", "share", "update", "delete"], project: ["create", "share", "update", "delete"],
} as const; } as const;
const ac = createAccessControl(statement); const ac = createAccessControl(statement);
const member = ac.newRole({ // [!code highlight] const member = ac.newRole({ // [!code highlight]
project: ["create"], // [!code highlight] project: ["create"], // [!code highlight]
}); // [!code highlight] }); // [!code highlight]
const admin = ac.newRole({ // [!code highlight] const admin = ac.newRole({ // [!code highlight]
project: ["create", "update"], // [!code highlight] project: ["create", "update"], // [!code highlight]
}); // [!code highlight] }); // [!code highlight]
@@ -727,11 +727,11 @@ The plugin provides an easy way to define your own set of permissions for each r
import { createAccessControl } from "better-auth/plugins/access"; import { createAccessControl } from "better-auth/plugins/access";
import { defaultStatements, adminAc } from 'better-auth/plugins/organization/access' import { defaultStatements, adminAc } from 'better-auth/plugins/organization/access'
const statement = { const statement = {
...defaultStatements, // [!code highlight] ...defaultStatements, // [!code highlight]
project: ["create", "share", "update", "delete"], project: ["create", "share", "update", "delete"],
} as const; } as const;
const ac = createAccessControl(statement); const ac = createAccessControl(statement);
const admin = ac.newRole({ const admin = ac.newRole({
@@ -800,24 +800,43 @@ You can use the `hasPermission` action provided by the `api` to check the permis
```ts title="api.ts" ```ts title="api.ts"
import { auth } from "@/auth"; import { auth } from "@/auth";
auth.api.hasPermission({ auth.api.hasPermission({
headers: await headers(), headers: await headers(),
body: { body: {
permission: { permissions: {
project: ["create"] // This must match the structure in your access control project: ["create"] // This must match the structure in your access control
} }
} }
}); });
// You can also check multiple resource permissions at the same time
auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
project: ["create"], // This must match the structure in your access control
sale: ["create"]
}
}
});
``` ```
If you want to check the permission of the user on the client from the server you can use the `hasPermission` function provided by the client. If you want to check the permission of the user on the client from the server you can use the `hasPermission` function provided by the client.
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = await authClient.organization.hasPermission({ const canCreateProject = await authClient.organization.hasPermission({
permission: { permissions: {
project: ["create"] project: ["create"]
} }
}) })
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = await authClient.organization.hasPermission({
permissions: {
project: ["create"],
sale: ["create"]
}
})
``` ```
**Check Role Permission**: **Check Role Permission**:
@@ -826,11 +845,20 @@ Once you have defined the roles and permissions to avoid checking the permission
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = client.organization.checkRolePermission({ const canCreateProject = client.organization.checkRolePermission({
permission: { permissions: {
organization: ["delete"], organization: ["delete"],
}, },
role: "admin", role: "admin",
}); });
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = client.organization.checkRolePermission({
permissions: {
organization: ["delete"],
member: ["delete"]
},
role: "admin",
});
``` ```
## Teams ## Teams
@@ -976,32 +1004,32 @@ When teams are enabled, a new `team` table is added with the following structure
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "id", name: "id",
type: "string", type: "string",
description: "Unique identifier for each team", description: "Unique identifier for each team",
isPrimaryKey: true isPrimaryKey: true
}, },
{ {
name: "name", name: "name",
type: "string", type: "string",
description: "The name of the team" description: "The name of the team"
}, },
{ {
name: "organizationId", name: "organizationId",
type: "string", type: "string",
description: "The id of the organization", description: "The id of the organization",
isForeignKey: true isForeignKey: true
}, },
{ {
name: "createdAt", name: "createdAt",
type: "Date", type: "Date",
description: "Timestamp of when the team was created" description: "Timestamp of when the team was created"
}, },
{ {
name: "updatedAt", name: "updatedAt",
type: "Date", type: "Date",
description: "Timestamp of when the team was last updated" description: "Timestamp of when the team was last updated"
} }
]} ]}
/> />
@@ -1016,38 +1044,38 @@ Table Name: `organization`
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "id", name: "id",
type: "string", type: "string",
description: "Unique identifier for each organization", description: "Unique identifier for each organization",
isPrimaryKey: true isPrimaryKey: true
}, },
{ {
name: "name", name: "name",
type: "string", type: "string",
description: "The name of the organization" description: "The name of the organization"
}, },
{ {
name: "slug", name: "slug",
type: "string", type: "string",
description: "The slug of the organization" description: "The slug of the organization"
}, },
{ {
name: "logo", name: "logo",
type: "string", type: "string",
description: "The logo of the organization", description: "The logo of the organization",
isOptional: true isOptional: true
}, },
{ {
name: "metadata", name: "metadata",
type: "string", type: "string",
description: "Additional metadata for the organization", description: "Additional metadata for the organization",
isOptional: true isOptional: true
}, },
{ {
name: "createdAt", name: "createdAt",
type: "Date", type: "Date",
description: "Timestamp of when the organization was created" description: "Timestamp of when the organization was created"
}, },
]} ]}
/> />
@@ -1058,33 +1086,33 @@ Table Name: `member`
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "id", name: "id",
type: "string", type: "string",
description: "Unique identifier for each member", description: "Unique identifier for each member",
isPrimaryKey: true isPrimaryKey: true
}, },
{ {
name: "userId", name: "userId",
type: "string", type: "string",
description: "The id of the user", description: "The id of the user",
isForeignKey: true isForeignKey: true
}, },
{ {
name: "organizationId", name: "organizationId",
type: "string", type: "string",
description: "The id of the organization", description: "The id of the organization",
isForeignKey: true isForeignKey: true
}, },
{ {
name: "role", name: "role",
type: "string", type: "string",
description: "The role of the user in the organization" description: "The role of the user in the organization"
}, },
{ {
name: "createdAt", name: "createdAt",
type: "Date", type: "Date",
description: "Timestamp of when the member was added to the organization" description: "Timestamp of when the member was added to the organization"
}, },
]} ]}
/> />
@@ -1095,63 +1123,63 @@ Table Name: `invitation`
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "id", name: "id",
type: "string", type: "string",
description: "Unique identifier for each invitation", description: "Unique identifier for each invitation",
isPrimaryKey: true isPrimaryKey: true
}, },
{ {
name: "email", name: "email",
type: "string", type: "string",
description: "The email address of the user" description: "The email address of the user"
}, },
{ {
name: "inviterId", name: "inviterId",
type: "string", type: "string",
description: "The id of the inviter", description: "The id of the inviter",
isForeignKey: true isForeignKey: true
}, },
{ {
name: "organizationId", name: "organizationId",
type: "string", type: "string",
description: "The id of the organization", description: "The id of the organization",
isForeignKey: true isForeignKey: true
}, },
{ {
name: "role", name: "role",
type: "string", type: "string",
description: "The role of the user in the organization" description: "The role of the user in the organization"
}, },
{ {
name: "status", name: "status",
type: "string", type: "string",
description: "The status of the invitation" description: "The status of the invitation"
}, },
{ {
name: "expiresAt", name: "expiresAt",
type: "Date", type: "Date",
description: "Timestamp of when the invitation expires" description: "Timestamp of when the invitation expires"
}, },
{ {
name: "createdAt", name: "createdAt",
type: "Date", type: "Date",
description: "Timestamp of when the invitation was created" description: "Timestamp of when the invitation was created"
}, },
]} ]}
/> />
### Session ### Session
Table Name: `session` Table Name: `session`
You need to add one more field to the session table to store the active organization id. You need to add one more field to the session table to store the active organization id.
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "activeOrganizationId", name: "activeOrganizationId",
type: "string", type: "string",
description: "The id of the active organization", description: "The id of the active organization",
isOptional: true isOptional: true
}, },
@@ -1164,33 +1192,33 @@ Table Name: `team`
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "id", name: "id",
type: "string", type: "string",
description: "Unique identifier for each team", description: "Unique identifier for each team",
isPrimaryKey: true isPrimaryKey: true
}, },
{ {
name: "name", name: "name",
type: "string", type: "string",
description: "The name of the team" description: "The name of the team"
}, },
{ {
name: "organizationId", name: "organizationId",
type: "string", type: "string",
description: "The id of the organization", description: "The id of the organization",
isForeignKey: true isForeignKey: true
}, },
{ {
name: "createdAt", name: "createdAt",
type: "Date", type: "Date",
description: "Timestamp of when the team was created" description: "Timestamp of when the team was created"
}, },
{ {
name: "updatedAt", name: "updatedAt",
type: "Date", type: "Date",
isOptional: true, isOptional: true,
description: "Timestamp of when the team was created" description: "Timestamp of when the team was created"
}, },
]} ]}
/> />
@@ -1199,9 +1227,9 @@ Table Name: `team`
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "teamId", name: "teamId",
type: "string", type: "string",
description: "The id of the team", description: "The id of the team",
isOptional: true isOptional: true
}, },
@@ -1212,9 +1240,9 @@ Table Name: `team`
<DatabaseTable <DatabaseTable
fields={[ fields={[
{ {
name: "teamId", name: "teamId",
type: "string", type: "string",
description: "The id of the team", description: "The id of the team",
isOptional: true isOptional: true
}, },
@@ -1223,7 +1251,7 @@ Table Name: `team`
### Customizing the Schema ### Customizing the Schema
To change the schema table name or fields, you can pass `schema` option to the organization plugin. To change the schema table name or fields, you can pass `schema` option to the organization plugin.
```ts title="auth.ts" ```ts title="auth.ts"
const auth = betterAuth({ const auth = betterAuth({
@@ -1231,7 +1259,7 @@ const auth = betterAuth({
schema: { schema: {
organization: { organization: {
modelName: "organizations", //map the organization table to organizations modelName: "organizations", //map the organization table to organizations
fields: { fields: {
name: "title" //map the name field to title name: "title" //map the name field to title
} }
} }

View File

@@ -624,7 +624,7 @@ describe("Admin plugin", async () => {
describe("access control", async (it) => { describe("access control", async (it) => {
const ac = createAccessControl({ const ac = createAccessControl({
user: ["create", "read", "update", "delete", "list"], user: ["create", "read", "update", "delete", "list", "bulk-delete"],
order: ["create", "read", "update", "delete", "update-many"], order: ["create", "read", "update", "delete", "update-many"],
}); });
@@ -699,61 +699,136 @@ describe("access control", async (it) => {
it("should validate on the client", async () => { it("should validate on the client", async () => {
const canCreateOrder = client.admin.checkRolePermission({ const canCreateOrder = client.admin.checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
order: ["create"], order: ["create"],
}, },
}); });
expect(canCreateOrder).toBe(true); expect(canCreateOrder).toBe(true);
// To be removed when `permission` will be removed entirely
const canCreateOrderLegacy = client.admin.checkRolePermission({
role: "admin",
permission: {
order: ["create"],
user: ["read"],
},
});
expect(canCreateOrderLegacy).toBe(true);
const canCreateOrderAndReadUser = client.admin.checkRolePermission({
role: "admin",
permissions: {
order: ["create"],
user: ["read"],
},
});
expect(canCreateOrderAndReadUser).toBe(true);
const canCreateUser = client.admin.checkRolePermission({ const canCreateUser = client.admin.checkRolePermission({
role: "user", role: "user",
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}); });
expect(canCreateUser).toBe(false); expect(canCreateUser).toBe(false);
const canCreateOrderAndCreateUser = client.admin.checkRolePermission({
role: "user",
permissions: {
order: ["create"],
user: ["create"],
},
});
expect(canCreateOrderAndCreateUser).toBe(false);
}); });
it("should validate using userId", async () => { it("should validate using userId", async () => {
const canCreateUser = await auth.api.userHasPermission({ const canCreateUser = await auth.api.userHasPermission({
body: { body: {
userId: user.id, userId: user.id,
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}, },
}); });
expect(canCreateUser.success).toBe(true); expect(canCreateUser.success).toBe(true);
const canCreateUserAndCreateOrder = await auth.api.userHasPermission({
body: {
userId: user.id,
permissions: {
user: ["create"],
order: ["create"],
},
},
});
expect(canCreateUserAndCreateOrder.success).toBe(true);
const canUpdateManyOrder = await auth.api.userHasPermission({ const canUpdateManyOrder = await auth.api.userHasPermission({
body: { body: {
userId: user.id, userId: user.id,
permission: { permissions: {
order: ["update-many"], order: ["update-many"],
}, },
}, },
}); });
expect(canUpdateManyOrder.success).toBe(false); expect(canUpdateManyOrder.success).toBe(false);
const canUpdateManyOrderAndBulkDeleteUser =
await auth.api.userHasPermission({
body: {
userId: user.id,
permissions: {
user: ["bulk-delete"],
order: ["update-many"],
},
},
});
expect(canUpdateManyOrderAndBulkDeleteUser.success).toBe(false);
}); });
it("should validate using role", async () => { it("should validate using role", async () => {
const canCreateUser = await auth.api.userHasPermission({ const canCreateUser = await auth.api.userHasPermission({
body: { body: {
role: "admin", role: "admin",
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}, },
}); });
expect(canCreateUser.success).toBe(true); expect(canCreateUser.success).toBe(true);
const canCreateUserAndCreateOrder = await auth.api.userHasPermission({
body: {
role: "admin",
permissions: {
user: ["create"],
order: ["create"],
},
},
});
expect(canCreateUserAndCreateOrder.success).toBe(true);
const canUpdateOrder = await auth.api.userHasPermission({ const canUpdateOrder = await auth.api.userHasPermission({
body: { body: {
role: "user", role: "user",
permission: { permissions: {
order: ["update"], order: ["update"],
}, },
}, },
}); });
expect(canUpdateOrder.success).toBe(false); expect(canUpdateOrder.success).toBe(false);
const canUpdateOrderAndUpdateUser = await auth.api.userHasPermission({
body: {
role: "user",
permissions: {
order: ["update"],
user: ["update"],
},
},
});
expect(canUpdateOrderAndUpdateUser.success).toBe(false);
}); });
it("shouldn't allow to list users", async () => { it("shouldn't allow to list users", async () => {

View File

@@ -119,6 +119,26 @@ export const admin = <O extends AdminOptions>(options?: O) => {
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
const adminMiddleware = createAuthMiddleware(async (ctx) => { const adminMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx); const session = await getSessionFromCtx(ctx);
if (!session) { if (!session) {
@@ -265,7 +285,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: ctx.context.session.user.role, role: ctx.context.session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["set-role"], user: ["set-role"],
}, },
}); });
@@ -370,7 +390,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: session.user.id, userId: session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}); });
@@ -528,7 +548,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["list"], user: ["list"],
}, },
}); });
@@ -629,7 +649,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
session: ["list"], session: ["list"],
}, },
}); });
@@ -689,7 +709,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["ban"], user: ["ban"],
}, },
}); });
@@ -769,7 +789,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["ban"], user: ["ban"],
}, },
}); });
@@ -848,7 +868,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: ctx.context.session.user.role, role: ctx.context.session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["impersonate"], user: ["impersonate"],
}, },
}); });
@@ -1001,7 +1021,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
session: ["revoke"], session: ["revoke"],
}, },
}); });
@@ -1061,7 +1081,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
session: ["revoke"], session: ["revoke"],
}, },
}); });
@@ -1120,7 +1140,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["delete"], user: ["delete"],
}, },
}); });
@@ -1178,7 +1198,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: ctx.context.session.user.role, role: ctx.context.session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["set-password"], user: ["set-password"],
}, },
}); });
@@ -1204,11 +1224,23 @@ export const admin = <O extends AdminOptions>(options?: O) => {
"/admin/has-permission", "/admin/has-permission",
{ {
method: "POST", method: "POST",
body: z.object({ body: z
permission: z.record(z.string(), z.array(z.string())), .object({
userId: z.coerce.string().optional(), userId: z.coerce.string().optional(),
role: z.string().optional(), role: z.string().optional(),
}), })
.and(
z.union([
z.object({
permission: z.record(z.string(), z.array(z.string())),
permissions: z.undefined(),
}),
z.object({
permission: z.undefined(),
permissions: z.record(z.string(), z.array(z.string())),
}),
]),
),
metadata: { metadata: {
openapi: { openapi: {
description: "Check if the user has permission", description: "Check if the user has permission",
@@ -1221,9 +1253,14 @@ export const admin = <O extends AdminOptions>(options?: O) => {
permission: { permission: {
type: "object", type: "object",
description: "The permission to check", description: "The permission to check",
deprecated: true,
},
permissions: {
type: "object",
description: "The permission to check",
}, },
}, },
required: ["permission"], required: ["permissions"],
}, },
}, },
}, },
@@ -1251,11 +1288,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
}, },
}, },
$Infer: { $Infer: {
body: {} as { body: {} as PermissionExclusive & {
permission: {
//@ts-expect-error
[key in keyof Statements]?: Array<Statements[key][number]>;
};
userId?: string; userId?: string;
role?: InferAdminRolesFromOption<O>; role?: InferAdminRolesFromOption<O>;
}, },
@@ -1263,13 +1296,10 @@ export const admin = <O extends AdminOptions>(options?: O) => {
}, },
}, },
async (ctx) => { async (ctx) => {
if ( if (!ctx.body?.permission && !ctx.body?.permissions) {
!ctx.body.permission ||
Object.keys(ctx.body.permission).length > 1
) {
throw new APIError("BAD_REQUEST", { throw new APIError("BAD_REQUEST", {
message: message:
"invalid permission check. you can only check one resource permission at a time.", "invalid permission check. no permission(s) were passed.",
}); });
} }
const session = await getSessionFromCtx(ctx); const session = await getSessionFromCtx(ctx);
@@ -1297,7 +1327,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: user.id, userId: user.id,
role: user.role, role: user.role,
options: options as AdminOptions, options: options as AdminOptions,
permission: ctx.body.permission as any, permissions: (ctx.body.permissions ?? ctx.body.permission) as any,
}); });
return ctx.json({ return ctx.json({
error: null, error: null,

View File

@@ -1,4 +1,3 @@
import { BetterAuthError } from "../../error";
import type { BetterAuthClientPlugin } from "../../types"; import type { BetterAuthClientPlugin } from "../../types";
import { type AccessControl, type Role } from "../access"; import { type AccessControl, type Role } from "../access";
import { adminAc, defaultStatements, userAc } from "./access"; import { adminAc, defaultStatements, userAc } from "./access";
@@ -17,6 +16,26 @@ export const adminClient = <O extends AdminClientOptions>(options?: O) => {
type Statements = O["ac"] extends AccessControl<infer S> type Statements = O["ac"] extends AccessControl<infer S>
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
const roles = { const roles = {
admin: adminAc, admin: adminAc,
user: userAc, user: userAc,
@@ -44,25 +63,18 @@ export const adminClient = <O extends AdminClientOptions>(options?: O) => {
R extends O extends { roles: any } R extends O extends { roles: any }
? keyof O["roles"] ? keyof O["roles"]
: "admin" | "user", : "admin" | "user",
>(data: { >(
role: R; data: PermissionExclusive & {
permission: { role: R;
//@ts-expect-error fix this later },
[key in keyof Statements]?: Statements[key][number][]; ) => {
};
}) => {
if (Object.keys(data.permission).length > 1) {
throw new BetterAuthError(
"you can only check one resource permission at a time.",
);
}
const isAuthorized = hasPermission({ const isAuthorized = hasPermission({
role: data.role as string, role: data.role as string,
options: { options: {
ac: options?.ac, ac: options?.ac,
roles: roles, roles: roles,
}, },
permission: data.permission as any, permissions: (data.permissions ?? data.permission) as any,
}); });
return isAuthorized; return isAuthorized;
}, },

View File

@@ -1,22 +1,37 @@
import { defaultRoles } from "./access"; import { defaultRoles } from "./access";
import type { AdminOptions } from "./admin"; import type { AdminOptions } from "./admin";
export const hasPermission = (input: { type PermissionExclusive =
userId?: string; | {
role?: string; /**
options?: AdminOptions; * @deprecated Use `permissions` instead
permission: { */
[key: string]: string[]; permission: { [key: string]: string[] };
}; permissions?: never;
}) => { }
| {
permissions: { [key: string]: string[] };
permission?: never;
};
export const hasPermission = (
input: {
userId?: string;
role?: string;
options?: AdminOptions;
} & PermissionExclusive,
) => {
if (input.userId && input.options?.adminUserIds?.includes(input.userId)) { if (input.userId && input.options?.adminUserIds?.includes(input.userId)) {
return true; return true;
} }
if (!input.permissions && !input.permission) {
return false;
}
const roles = (input.role || input.options?.defaultRole || "user").split(","); const roles = (input.role || input.options?.defaultRole || "user").split(",");
const acRoles = input.options?.roles || defaultRoles; const acRoles = input.options?.roles || defaultRoles;
for (const role of roles) { for (const role of roles) {
const _role = acRoles[role as keyof typeof acRoles]; const _role = acRoles[role as keyof typeof acRoles];
const result = _role?.authorize(input.permission); const result = _role?.authorize(input.permission ?? input.permissions);
if (result?.success) { if (result?.success) {
return true; return true;
} }

View File

@@ -12,7 +12,6 @@ import { type AccessControl, type Role } from "../access";
import type { BetterAuthClientPlugin } from "../../client/types"; import type { BetterAuthClientPlugin } from "../../client/types";
import type { organization } from "./organization"; import type { organization } from "./organization";
import { useAuthQuery } from "../../client"; import { useAuthQuery } from "../../client";
import { BetterAuthError } from "../../error";
import { defaultStatements, adminAc, memberAc, ownerAc } from "./access"; import { defaultStatements, adminAc, memberAc, ownerAc } from "./access";
import { hasPermission } from "./has-permission"; import { hasPermission } from "./has-permission";
@@ -37,6 +36,26 @@ export const organizationClient = <O extends OrganizationClientOptions>(
type Statements = O["ac"] extends AccessControl<infer S> type Statements = O["ac"] extends AccessControl<infer S>
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
const roles = { const roles = {
admin: adminAc, admin: adminAc,
member: memberAc, member: memberAc,
@@ -86,25 +105,18 @@ export const organizationClient = <O extends OrganizationClientOptions>(
R extends O extends { roles: any } R extends O extends { roles: any }
? keyof O["roles"] ? keyof O["roles"]
: "admin" | "member" | "owner", : "admin" | "member" | "owner",
>(data: { >(
role: R; data: PermissionExclusive & {
permission: { role: R;
//@ts-expect-error fix this later },
[key in keyof Statements]?: Statements[key][number][]; ) => {
};
}) => {
if (Object.keys(data.permission).length > 1) {
throw new BetterAuthError(
"you can only check one resource permission at a time.",
);
}
const isAuthorized = hasPermission({ const isAuthorized = hasPermission({
role: data.role as string, role: data.role as string,
options: { options: {
ac: options?.ac, ac: options?.ac,
roles: roles, roles: roles,
}, },
permission: data.permission as any, permissions: (data.permissions ?? data.permission) as any,
}); });
return isAuthorized; return isAuthorized;
}, },

View File

@@ -1,18 +1,33 @@
import { defaultRoles } from "./access"; import { defaultRoles } from "./access";
import type { OrganizationOptions } from "./organization"; import type { OrganizationOptions } from "./organization";
export const hasPermission = (input: { type PermissionExclusive =
role: string; | {
options: OrganizationOptions; /**
permission: { * @deprecated Use `permissions` instead
[key: string]: string[]; */
}; permission: { [key: string]: string[] };
}) => { permissions?: never;
}
| {
permissions: { [key: string]: string[] };
permission?: never;
};
export const hasPermission = (
input: {
role: string;
options: OrganizationOptions;
} & PermissionExclusive,
) => {
if (!input.permissions && !input.permission) {
return false;
}
const roles = input.role.split(","); const roles = input.role.split(",");
const acRoles = input.options.roles || defaultRoles; const acRoles = input.options.roles || defaultRoles;
for (const role of roles) { for (const role of roles) {
const _role = acRoles[role as keyof typeof acRoles]; const _role = acRoles[role as keyof typeof acRoles];
const result = _role?.authorize(input.permission); const result = _role?.authorize(input.permissions ?? input.permission);
if (result?.success) { if (result?.success) {
return true; return true;
} }

View File

@@ -5,7 +5,6 @@ import { createAuthClient } from "../../client";
import { organizationClient } from "./client"; import { organizationClient } from "./client";
import { createAccessControl } from "../access"; import { createAccessControl } from "../access";
import { ORGANIZATION_ERROR_CODES } from "./error-codes"; import { ORGANIZATION_ERROR_CODES } from "./error-codes";
import { BetterAuthError } from "../../error";
import { APIError } from "better-call"; import { APIError } from "better-call";
describe("organization", async (it) => { describe("organization", async (it) => {
@@ -500,7 +499,7 @@ describe("organization", async (it) => {
}, },
}); });
const hasPermission = await client.organization.hasPermission({ const hasPermission = await client.organization.hasPermission({
permission: { permissions: {
member: ["update"], member: ["update"],
}, },
fetchOptions: { fetchOptions: {
@@ -508,6 +507,17 @@ describe("organization", async (it) => {
}, },
}); });
expect(hasPermission.data?.success).toBe(true); expect(hasPermission.data?.success).toBe(true);
const hasMultiplePermissions = await client.organization.hasPermission({
permissions: {
member: ["update"],
invitation: ["create"],
},
fetchOptions: {
headers,
},
});
expect(hasMultiplePermissions.data?.success).toBe(true);
}); });
it("should allow deleting organization", async () => { it("should allow deleting organization", async () => {
@@ -795,15 +805,25 @@ describe("access control", async (it) => {
it("should return success", async () => { it("should return success", async () => {
const canCreateProject = checkRolePermission({ const canCreateProject = checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
project: ["create"], project: ["create"],
}, },
}); });
expect(canCreateProject).toBe(true); expect(canCreateProject).toBe(true);
const canCreateProjectServer = await hasPermission({
// To be removed when `permission` will be removed entirely
const canCreateProjectLegacy = checkRolePermission({
role: "admin",
permission: { permission: {
project: ["create"], project: ["create"],
}, },
});
expect(canCreateProjectLegacy).toBe(true);
const canCreateProjectServer = await hasPermission({
permissions: {
project: ["create"],
},
fetchOptions: { fetchOptions: {
headers, headers,
}, },
@@ -814,7 +834,7 @@ describe("access control", async (it) => {
it("should return not success", async () => { it("should return not success", async () => {
const canCreateProject = checkRolePermission({ const canCreateProject = checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
project: ["delete"], project: ["delete"],
}, },
}); });
@@ -822,21 +842,14 @@ describe("access control", async (it) => {
}); });
it("should return not success", async () => { it("should return not success", async () => {
let error: BetterAuthError | null = null; const res = checkRolePermission({
try { role: "admin",
checkRolePermission({ permissions: {
role: "admin", project: ["read"],
permission: { sales: ["delete"],
project: ["read"], },
sales: ["delete"], });
}, expect(res).toBe(false);
});
} catch (e) {
if (e instanceof BetterAuthError) {
error = e;
}
}
expect(error).toBeInstanceOf(BetterAuthError);
}); });
}); });

View File

@@ -443,6 +443,26 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
type Statements = O["ac"] extends AccessControl<infer S> type Statements = O["ac"] extends AccessControl<infer S>
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
return { return {
id: "organization", id: "organization",
endpoints: { endpoints: {
@@ -454,18 +474,26 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
{ {
method: "POST", method: "POST",
requireHeaders: true, requireHeaders: true,
body: z.object({ body: z
organizationId: z.string().optional(), .object({
permission: z.record(z.string(), z.array(z.string())), organizationId: z.string().optional(),
}), })
.and(
z.union([
z.object({
permission: z.record(z.string(), z.array(z.string())),
permissions: z.undefined(),
}),
z.object({
permission: z.undefined(),
permissions: z.record(z.string(), z.array(z.string())),
}),
]),
),
use: [orgSessionMiddleware], use: [orgSessionMiddleware],
metadata: { metadata: {
$Infer: { $Infer: {
body: {} as { body: {} as PermissionExclusive & {
permission: {
//@ts-expect-error
[key in keyof Statements]?: Array<Statements[key][number]>;
};
organizationId?: string; organizationId?: string;
}, },
}, },
@@ -480,9 +508,14 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
permission: { permission: {
type: "object", type: "object",
description: "The permission to check", description: "The permission to check",
deprecated: true,
},
permissions: {
type: "object",
description: "The permission to check",
}, },
}, },
required: ["permission"], required: ["permissions"],
}, },
}, },
}, },
@@ -534,7 +567,7 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
const result = hasPermission({ const result = hasPermission({
role: member.role, role: member.role,
options: options as OrganizationOptions, options: options as OrganizationOptions,
permission: ctx.body.permission as any, permissions: (ctx.body.permissions ?? ctx.body.permission) as any,
}); });
return ctx.json({ return ctx.json({
error: null, error: null,

View File

@@ -153,7 +153,7 @@ export const createInvitation = <O extends OrganizationOptions | undefined>(
const canInvite = hasPermission({ const canInvite = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
invitation: ["create"], invitation: ["create"],
}, },
}); });
@@ -489,7 +489,7 @@ export const cancelInvitation = createAuthEndpoint(
const canCancel = hasPermission({ const canCancel = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
invitation: ["cancel"], invitation: ["cancel"],
}, },
}); });

View File

@@ -244,7 +244,7 @@ export const removeMember = createAuthEndpoint(
const canDeleteMember = hasPermission({ const canDeleteMember = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
member: ["delete"], member: ["delete"],
}, },
}); });
@@ -399,7 +399,7 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
const canUpdateMember = hasPermission({ const canUpdateMember = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
member: ["update"], member: ["update"],
}, },
}); });

View File

@@ -321,7 +321,7 @@ export const updateOrganization = createAuthEndpoint(
}); });
} }
const canUpdateOrg = hasPermission({ const canUpdateOrg = hasPermission({
permission: { permissions: {
organization: ["update"], organization: ["update"],
}, },
role: member.role, role: member.role,
@@ -403,7 +403,7 @@ export const deleteOrganization = createAuthEndpoint(
} }
const canDeleteOrg = hasPermission({ const canDeleteOrg = hasPermission({
role: member.role, role: member.role,
permission: { permissions: {
organization: ["delete"], organization: ["delete"],
}, },
options: ctx.context.orgOptions, options: ctx.context.orgOptions,

View File

@@ -99,7 +99,7 @@ export const createTeam = <O extends OrganizationOptions | undefined>(
const canCreate = hasPermission({ const canCreate = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
team: ["create"], team: ["create"],
}, },
}); });
@@ -208,7 +208,7 @@ export const removeTeam = createAuthEndpoint(
const canRemove = hasPermission({ const canRemove = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
team: ["delete"], team: ["delete"],
}, },
}); });
@@ -329,7 +329,7 @@ export const updateTeam = createAuthEndpoint(
const canUpdate = hasPermission({ const canUpdate = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
team: ["update"], team: ["update"],
}, },
}); });