fix(organization): allow org owner to update their own roles (#3426)

* fix: allow org owner to update their own roles

* fix(org): move the `is creator` check to the `hasPermissions` function to make it more reusable
This commit is contained in:
Fraol Lemecha
2025-07-18 10:02:24 -07:00
committed by GitHub
parent 4c9479ee2b
commit 577ad2a97f
3 changed files with 192 additions and 6 deletions
@@ -18,13 +18,22 @@ export const hasPermission = (
input: {
role: string;
options: OrganizationOptions;
allowCreatorAllPermissions?: boolean;
} & PermissionExclusive,
) => {
if (!input.permissions && !input.permission) {
return false;
}
const roles = input.role.split(",");
const acRoles = input.options.roles || defaultRoles;
const creatorRole = input.options.creatorRole || "owner";
const isCreator = roles.includes(creatorRole);
const allowCreatorsAllPermissions = input.allowCreatorAllPermissions || false;
if (isCreator && allowCreatorsAllPermissions) return true;
for (const role of roles) {
const _role = acRoles[role as keyof typeof acRoles];
const result = _role?.authorize(input.permissions ?? input.permission);
@@ -6,6 +6,8 @@ import { organizationClient } from "./client";
import { createAccessControl } from "../access";
import { ORGANIZATION_ERROR_CODES } from "./error-codes";
import { APIError } from "better-call";
import { admin } from "../admin";
import { ownerAc } from "./access";
describe("organization", async (it) => {
const { auth, signInWithTestUser, signInWithUser, cookieSetter } =
@@ -1028,6 +1030,176 @@ describe("cancel pending invitations on re-invite", async () => {
});
});
describe("owner can update roles", async () => {
const statement = {
custom: ["custom"],
} as const;
const ac = createAccessControl(statement);
const custom = ac.newRole({
custom: ["custom"],
});
const { auth } = await getTestInstance({
emailAndPassword: {
enabled: true,
},
plugins: [
admin(),
organization({
ac,
roles: {
custom,
owner: ownerAc,
},
}),
],
});
const adminEmail = "admin@email.com";
const adminPassword = "adminpassword";
await auth.api.createUser({
body: {
email: adminEmail,
password: adminPassword,
name: "Admin",
role: "admin",
},
});
const { headers } = await auth.api.signInEmail({
returnHeaders: true,
body: {
email: adminEmail,
password: adminPassword,
},
});
const adminCookie = headers.getSetCookie()[0];
const org = await auth.api.createOrganization({
headers: { cookie: adminCookie },
body: {
name: "Org",
slug: "org",
},
});
if (!org) {
throw new Error("couldn't create an organization");
}
const ownerId = org.members.at(0)?.id;
if (!ownerId) {
throw new Error("couldn't get the owner id");
}
it("allows setting custom role to a user", async () => {
const userEmail = "user@email.com";
const userPassword = "userpassword";
const { user } = await auth.api.createUser({
headers: { cookie: adminCookie },
body: {
name: "user",
email: userEmail,
password: userPassword,
},
});
const addMemberRes = await auth.api.addMember({
headers: { cookie: adminCookie },
body: {
organizationId: org.id,
userId: user.id,
role: [],
},
});
if (!addMemberRes) {
throw new Error("couldn't add user as a member to a repo");
}
await auth.api.updateMemberRole({
headers: { cookie: adminCookie },
body: {
organizationId: org.id,
memberId: addMemberRes.id,
role: ["custom"],
},
});
const signInRes = await auth.api.signInEmail({
returnHeaders: true,
body: {
email: userEmail,
password: userPassword,
},
});
const userCookie = signInRes.headers.getSetCookie()[0];
const permissionRes = await auth.api.hasPermission({
headers: { cookie: userCookie },
body: {
organizationId: org.id,
permissions: {
custom: ["custom"],
},
},
});
expect(permissionRes.success).toBe(true);
expect(permissionRes.error).toBeNull();
});
it("allows org owner to set a custom role for themselves", async () => {
await auth.api.updateMemberRole({
headers: { cookie: adminCookie },
body: {
organizationId: org.id,
memberId: ownerId,
role: ["owner", "custom"],
},
});
const permissionRes = await auth.api.hasPermission({
headers: { cookie: adminCookie },
body: {
organizationId: org.id,
permissions: {
custom: ["custom"],
},
},
});
expect(permissionRes.success).toBe(true);
expect(permissionRes.error).toBeNull();
});
// TODO: We might not want to allow this.
it("allows an org owner to remove their own creator role", async () => {
await auth.api.updateMemberRole({
headers: { cookie: adminCookie },
body: {
organizationId: org.id,
memberId: ownerId,
role: [],
},
});
const member = await auth.api.getActiveMember({
headers: { cookie: adminCookie },
});
console.log({ member });
expect(member?.role).toBe("");
});
});
describe("types", async (it) => {
const { auth } = await getTestInstance({
plugins: [organization({})],
@@ -381,15 +381,19 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
});
}
const toBeUpdatedMemberRoles = toBeUpdatedMember.role.split(",");
const updatingMemberRoles = member.role.split(",");
const creatorRole = ctx.context.orgOptions?.creatorRole || "owner";
const updatingMemberRoles = member.role.split(",");
const toBeUpdatedMemberRoles = toBeUpdatedMember.role.split(",");
const isUpdatingCreator = toBeUpdatedMemberRoles.includes(creatorRole);
const updaterIsCreator = updatingMemberRoles.includes(creatorRole);
const isSettingCreatorRole = roleToSet.includes(creatorRole);
if (
(toBeUpdatedMemberRoles.includes(creatorRole) &&
!updatingMemberRoles.includes(creatorRole)) ||
(roleToSet.includes(creatorRole) &&
!updatingMemberRoles.includes(creatorRole))
(isUpdatingCreator && !updaterIsCreator) ||
(isSettingCreatorRole && !updaterIsCreator)
) {
throw new APIError("FORBIDDEN", {
message:
@@ -403,6 +407,7 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
permissions: {
member: ["update"],
},
allowCreatorAllPermissions: true,
});
if (!canUpdateMember) {