Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/api/src/controllers/user/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,17 @@ const OrganizationListItemSchema = OrganizationSchema.extend({
roles: z.array(RoleSummarySchema),
});

const MemberRoleSchema = RoleSummarySchema.extend({
grantsOrgManage: z.boolean().optional(),
});

const MemberSchema = z.object({
membershipId: z.string().uuid(),
userSub: z.string(),
status: z.string(),
email: z.string().nullable().optional(),
name: z.string().nullable().optional(),
roles: z.array(RoleSummarySchema),
roles: z.array(MemberRoleSchema),
});

const AssignableRoleSchema = z.object({
Expand Down
89 changes: 88 additions & 1 deletion packages/api/src/models/organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
createOrganizationInvite,
leaveOrganization,
listOrganizationsForUser,
removeMemberRole,
removeOrganizationMember,
} from "./organizations.ts";

Expand Down Expand Up @@ -228,7 +229,7 @@ test("removeOrganizationMember rejects removing the last managing member", async
() => removeOrganizationMember(context, "manager", organization.id, managerMembership.id),
(error: unknown) => {
assert.ok(error instanceof ValidationError);
assert.equal(error.message, "Organization must retain at least one managing member");
assert.equal(error.message, "Organization must retain at least one administrator");
return true;
}
);
Expand All @@ -237,3 +238,89 @@ test("removeOrganizationMember rejects removing the last managing member", async
fs.rmSync(directory, { recursive: true, force: true });
}
});

test("removeMemberRole rejects removing the last administrator role", async () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-org-role-remove-test-"));
const { db, close } = await createPglite(directory);
const context = { db, logger: createLogger() } as Context;

try {
await db.insert(users).values([
{ sub: "admin", email: "admin@example.com", name: "Admin" },
{ sub: "member", email: "member@example.com", name: "Member" },
]);
const [organization] = await db
.insert(organizations)
.values({ slug: "org-1", name: "Org One", createdByUserSub: "admin" })
.returning();
assert.ok(organization);

const [adminMembership] = await db
.insert(organizationMembers)
.values({ organizationId: organization.id, userSub: "admin", status: "active" })
.returning();
const [memberMembership] = await db
.insert(organizationMembers)
.values({ organizationId: organization.id, userSub: "member", status: "active" })
.returning();
assert.ok(adminMembership);
assert.ok(memberMembership);

const [adminRole] = await db
.insert(roles)
.values({ key: "org-admin", name: "Organization Admin", assignable: true })
.returning();
const [memberRole] = await db
.insert(roles)
.values({ key: "org-member", name: "Member", assignable: true })
.returning();
assert.ok(adminRole);
assert.ok(memberRole);

await db
.insert(permissions)
.values({ key: "darkauth.org:manage", description: "Manage org" })
.onConflictDoNothing();
await db
.insert(rolePermissions)
.values({ roleId: adminRole.id, permissionKey: "darkauth.org:manage" });
await db
.insert(organizationMemberRoles)
.values({ organizationMemberId: adminMembership.id, roleId: adminRole.id });
await db
.insert(organizationMemberRoles)
.values({ organizationMemberId: memberMembership.id, roleId: memberRole.id });

await assert.rejects(
() => removeMemberRole(context, "admin", organization.id, adminMembership.id, adminRole.id),
(error: unknown) => {
assert.ok(error instanceof ValidationError);
assert.equal(error.message, "Organization must retain at least one administrator");
return true;
}
);

// Granting a second administrator allows the role to be removed.
await db
.insert(organizationMemberRoles)
.values({ organizationMemberId: memberMembership.id, roleId: adminRole.id });

const result = await removeMemberRole(
context,
"admin",
organization.id,
adminMembership.id,
adminRole.id
);
assert.deepEqual(result, { success: true });

const remaining = await db
.select({ roleId: organizationMemberRoles.roleId })
.from(organizationMemberRoles)
.where(eq(organizationMemberRoles.organizationMemberId, adminMembership.id));
assert.equal(remaining.length, 0);
} finally {
await close();
fs.rmSync(directory, { recursive: true, force: true });
}
});
77 changes: 69 additions & 8 deletions packages/api/src/models/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
organizationMemberRoles,
organizationMembers,
organizations,
rolePermissions,
roles,
users,
} from "../db/schema.ts";
Expand Down Expand Up @@ -35,6 +36,8 @@ const personalSlugWords = [

type DbLike = Pick<Context["db"], "delete" | "insert" | "query" | "select" | "update">;

const ORG_MANAGE_PERMISSION = "darkauth.org:manage";

function cleanSlug(value: string) {
return value
.trim()
Expand Down Expand Up @@ -413,11 +416,21 @@ export async function listOrganizationMembers(
.innerJoin(roles, eq(organizationMemberRoles.roleId, roles.id))
.where(inArray(organizationMemberRoles.organizationMemberId, membershipIds));

const rolesByMembership = new Map<string, Array<{ id: string; key: string; name: string }>>();
const manageRoleIds = await getOrgManageRoleIds(context);

const rolesByMembership = new Map<
string,
Array<{ id: string; key: string; name: string; grantsOrgManage: boolean }>
>();

for (const row of roleRows) {
const list = rolesByMembership.get(row.membershipId) || [];
list.push({ id: row.roleId, key: row.roleKey, name: row.roleName });
list.push({
id: row.roleId,
key: row.roleKey,
name: row.roleName,
grantsOrgManage: manageRoleIds.has(row.roleId),
});
rolesByMembership.set(row.membershipId, list);
}

Expand Down Expand Up @@ -515,6 +528,13 @@ export async function removeMemberRole(
});
if (!member) throw new NotFoundError("Organization member not found");

await assertRoleRemovalKeepsOrganizationManageAuthority(
context,
organizationId,
memberId,
roleId
);

await context.db
.delete(organizationMemberRoles)
.where(
Expand Down Expand Up @@ -553,20 +573,61 @@ async function userHasOrganizationManagePermission(
organizationId: string
) {
const access = await getUserOrgAccess(context, userSub, organizationId);
return access.permissions.includes("darkauth.org:manage");
return access.permissions.includes(ORG_MANAGE_PERMISSION);
}

async function assertRemovalKeepsOrganizationManageAuthority(
async function getOrgManageRoleIds(context: Context) {
const rows = await context.db
.select({ roleId: rolePermissions.roleId })
.from(rolePermissions)
.where(eq(rolePermissions.permissionKey, ORG_MANAGE_PERMISSION));
return new Set(rows.map((row) => row.roleId));
}

async function organizationHasOtherManagingMember(
context: Context,
organizationId: string,
removedMemberId: string
excludeMembershipId: string
) {
const members = await activeMembersForOrganization(context, organizationId);
for (const member of members) {
if (member.membershipId === removedMemberId) continue;
if (await userHasOrganizationManagePermission(context, member.userSub, organizationId)) return;
if (member.membershipId === excludeMembershipId) continue;
if (await userHasOrganizationManagePermission(context, member.userSub, organizationId))
return true;
}
throw new ValidationError("Organization must retain at least one managing member");
return false;
}

async function assertRoleRemovalKeepsOrganizationManageAuthority(
context: Context,
organizationId: string,
memberId: string,
roleId: string
) {
const manageRoleIds = await getOrgManageRoleIds(context);
if (!manageRoleIds.has(roleId)) return;

const memberRoleRows = await context.db
.select({ roleId: organizationMemberRoles.roleId })
.from(organizationMemberRoles)
.where(eq(organizationMemberRoles.organizationMemberId, memberId));
const retainsManageItself = memberRoleRows.some(
(row) => row.roleId !== roleId && manageRoleIds.has(row.roleId)
);
if (retainsManageItself) return;

if (await organizationHasOtherManagingMember(context, organizationId, memberId)) return;

throw new ValidationError("Organization must retain at least one administrator");
}

async function assertRemovalKeepsOrganizationManageAuthority(
context: Context,
organizationId: string,
removedMemberId: string
) {
if (await organizationHasOtherManagingMember(context, organizationId, removedMemberId)) return;
throw new ValidationError("Organization must retain at least one administrator");
}

async function assertMemberCanBeRemoved(
Expand Down
Loading
Loading