UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

677 lines (675 loc) • 25.3 kB
import { toZodSchema } from "../../../db/to-zod.mjs"; import "../../../db/index.mjs"; import { APIError } from "../../../api/index.mjs"; import { orgSessionMiddleware } from "../call.mjs"; import { ORGANIZATION_ERROR_CODES } from "../error-codes.mjs"; import { hasPermission } from "../has-permission.mjs"; import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; //#region src/plugins/organization/routes/crud-access-control.ts const normalizeRoleName = (role) => role.toLowerCase(); const DEFAULT_MAXIMUM_ROLES_PER_ORGANIZATION = Number.POSITIVE_INFINITY; const getAdditionalFields = (options, shouldBePartial = false) => { const additionalFields = options?.schema?.organizationRole?.additionalFields || {}; if (shouldBePartial) for (const key in additionalFields) additionalFields[key].required = false; return { additionalFieldsSchema: toZodSchema({ fields: additionalFields, isClientSide: true }), $AdditionalFields: {}, $ReturnAdditionalFields: {} }; }; const baseCreateOrgRoleSchema = z.object({ organizationId: z.string().optional().meta({ description: "The id of the organization to create the role in. If not provided, the user's active organization will be used." }), role: z.string().meta({ description: "The name of the role to create" }), permission: z.record(z.string(), z.array(z.string())).meta({ description: "The permission to assign to the role" }) }); const createOrgRole = (options) => { const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = getAdditionalFields(options, false); return createAuthEndpoint("/organization/create-role", { method: "POST", body: baseCreateOrgRoleSchema.safeExtend({ additionalFields: z.object({ ...additionalFieldsSchema.shape }).optional() }), metadata: { $Infer: { body: {} } }, requireHeaders: true, use: [orgSessionMiddleware] }, async (ctx) => { const { session, user } = ctx.context.session; let roleName = ctx.body.role; const permission = ctx.body.permission; const additionalFields = ctx.body.additionalFields; const ac = options.ac; if (!ac) { ctx.context.logger.error(`[Dynamic Access Control] The organization plugin is missing a pre-defined ac instance.`, `\nPlease refer to the documentation here: https://better-auth.com/docs/plugins/organization#dynamic-access-control`); throw new APIError("NOT_IMPLEMENTED", { message: ORGANIZATION_ERROR_CODES.MISSING_AC_INSTANCE }); } const organizationId = ctx.body.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error(`[Dynamic Access Control] The session is missing an active organization id to create a role. Either set an active org id, or pass an organizationId in the request body.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.YOU_MUST_BE_IN_AN_ORGANIZATION_TO_CREATE_A_ROLE }); } roleName = normalizeRoleName(roleName); await checkIfRoleNameIsTakenByPreDefinedRole({ role: roleName, organizationId, options, ctx }); const member = await ctx.context.adapter.findOne({ model: "member", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "userId", value: user.id, operator: "eq", connector: "AND" }] }); if (!member) { ctx.context.logger.error(`[Dynamic Access Control] The user is not a member of the organization to create a role.`, { userId: user.id, organizationId }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); } if (!await hasPermission({ options, organizationId, permissions: { ac: ["create"] }, role: member.role }, ctx)) { ctx.context.logger.error(`[Dynamic Access Control] The user is not permitted to create a role. If this is unexpected, please make sure the role associated to that member has the "ac" resource with the "create" permission.`, { userId: user.id, organizationId, role: member.role }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE }); } const maximumRolesPerOrganization = typeof options.dynamicAccessControl?.maximumRolesPerOrganization === "function" ? await options.dynamicAccessControl.maximumRolesPerOrganization(organizationId) : options.dynamicAccessControl?.maximumRolesPerOrganization ?? DEFAULT_MAXIMUM_ROLES_PER_ORGANIZATION; const rolesInDB = await ctx.context.adapter.count({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }] }); if (rolesInDB >= maximumRolesPerOrganization) { ctx.context.logger.error(`[Dynamic Access Control] Failed to create a new role, the organization has too many roles. Maximum allowed roles is ${maximumRolesPerOrganization}.`, { organizationId, maximumRolesPerOrganization, rolesInDB }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.TOO_MANY_ROLES }); } await checkForInvalidResources({ ac, ctx, permission }); await checkIfMemberHasPermission({ ctx, member, options, organizationId, permissionRequired: permission, user, action: "create" }); await checkIfRoleNameIsTakenByRoleInDB({ ctx, organizationId, role: roleName }); const newRole = ac.newRole(permission); const data = { ...await ctx.context.adapter.create({ model: "organizationRole", data: { createdAt: /* @__PURE__ */ new Date(), organizationId, permission: JSON.stringify(permission), role: roleName, ...additionalFields } }), permission }; return ctx.json({ success: true, roleData: data, statements: newRole.statements }); }); }; const deleteOrgRoleBodySchema = z.object({ organizationId: z.string().optional().meta({ description: "The id of the organization to create the role in. If not provided, the user's active organization will be used." }) }).and(z.union([z.object({ roleName: z.string().nonempty().meta({ description: "The name of the role to delete" }) }), z.object({ roleId: z.string().nonempty().meta({ description: "The id of the role to delete" }) })])); const deleteOrgRole = (options) => { return createAuthEndpoint("/organization/delete-role", { method: "POST", body: deleteOrgRoleBodySchema, requireHeaders: true, use: [orgSessionMiddleware], metadata: { $Infer: { body: {} } } }, async (ctx) => { const { session, user } = ctx.context.session; const organizationId = ctx.body.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error(`[Dynamic Access Control] The session is missing an active organization id to delete a role. Either set an active org id, or pass an organizationId in the request body.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); } const member = await ctx.context.adapter.findOne({ model: "member", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "userId", value: user.id, operator: "eq", connector: "AND" }] }); if (!member) { ctx.context.logger.error(`[Dynamic Access Control] The user is not a member of the organization to delete a role.`, { userId: user.id, organizationId }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); } if (!await hasPermission({ options, organizationId, permissions: { ac: ["delete"] }, role: member.role }, ctx)) { ctx.context.logger.error(`[Dynamic Access Control] The user is not permitted to delete a role. If this is unexpected, please make sure the role associated to that member has the "ac" resource with the "delete" permission.`, { userId: user.id, organizationId, role: member.role }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE }); } if (ctx.body.roleName) { const roleName = ctx.body.roleName; const defaultRoles = options.roles ? Object.keys(options.roles) : [ "owner", "admin", "member" ]; if (defaultRoles.includes(roleName)) { ctx.context.logger.error(`[Dynamic Access Control] Cannot delete a pre-defined role.`, { roleName, organizationId, defaultRoles }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.CANNOT_DELETE_A_PRE_DEFINED_ROLE }); } } let condition; if (ctx.body.roleName) condition = { field: "role", value: ctx.body.roleName, operator: "eq", connector: "AND" }; else if (ctx.body.roleId) condition = { field: "id", value: ctx.body.roleId, operator: "eq", connector: "AND" }; else { ctx.context.logger.error(`[Dynamic Access Control] The role name/id is not provided in the request body.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND }); } const existingRoleInDB = await ctx.context.adapter.findOne({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, condition] }); if (!existingRoleInDB) { ctx.context.logger.error(`[Dynamic Access Control] The role name/id does not exist in the database.`, { ..."roleName" in ctx.body ? { roleName: ctx.body.roleName } : { roleId: ctx.body.roleId }, organizationId }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND }); } existingRoleInDB.permission = JSON.parse(existingRoleInDB.permission); const roleToDelete = existingRoleInDB.role; if ((await ctx.context.adapter.findMany({ model: "member", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "role", value: roleToDelete, operator: "contains" }] })).find((member$1) => { return member$1.role.split(",").map((r) => r.trim()).includes(roleToDelete); })) { ctx.context.logger.error(`[Dynamic Access Control] Cannot delete a role that is assigned to members.`, { role: existingRoleInDB.role, organizationId }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_IS_ASSIGNED_TO_MEMBERS }); } await ctx.context.adapter.delete({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, condition] }); return ctx.json({ success: true }); }); }; const listOrgRolesQuerySchema = z.object({ organizationId: z.string().optional().meta({ description: "The id of the organization to list roles for. If not provided, the user's active organization will be used." }) }).optional(); const listOrgRoles = (options) => { const { $ReturnAdditionalFields } = getAdditionalFields(options, false); return createAuthEndpoint("/organization/list-roles", { method: "GET", requireHeaders: true, use: [orgSessionMiddleware], query: listOrgRolesQuerySchema }, async (ctx) => { const { session, user } = ctx.context.session; const organizationId = ctx.query?.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error(`[Dynamic Access Control] The session is missing an active organization id to list roles. Either set an active org id, or pass an organizationId in the request query.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); } const member = await ctx.context.adapter.findOne({ model: "member", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "userId", value: user.id, operator: "eq", connector: "AND" }] }); if (!member) { ctx.context.logger.error(`[Dynamic Access Control] The user is not a member of the organization to list roles.`, { userId: user.id, organizationId }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); } if (!await hasPermission({ options, organizationId, permissions: { ac: ["read"] }, role: member.role }, ctx)) { ctx.context.logger.error(`[Dynamic Access Control] The user is not permitted to list roles.`, { userId: user.id, organizationId, role: member.role }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE }); } let roles = await ctx.context.adapter.findMany({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }] }); roles = roles.map((x) => ({ ...x, permission: JSON.parse(x.permission) })); return ctx.json(roles); }); }; const getOrgRoleQuerySchema = z.object({ organizationId: z.string().optional().meta({ description: "The id of the organization to read a role for. If not provided, the user's active organization will be used." }) }).and(z.union([z.object({ roleName: z.string().nonempty().meta({ description: "The name of the role to read" }) }), z.object({ roleId: z.string().nonempty().meta({ description: "The id of the role to read" }) })])).optional(); const getOrgRole = (options) => { const { $ReturnAdditionalFields } = getAdditionalFields(options, false); return createAuthEndpoint("/organization/get-role", { method: "GET", requireHeaders: true, use: [orgSessionMiddleware], query: getOrgRoleQuerySchema, metadata: { $Infer: { query: {} } } }, async (ctx) => { const { session, user } = ctx.context.session; const organizationId = ctx.query?.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error(`[Dynamic Access Control] The session is missing an active organization id to read a role. Either set an active org id, or pass an organizationId in the request query.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); } const member = await ctx.context.adapter.findOne({ model: "member", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "userId", value: user.id, operator: "eq", connector: "AND" }] }); if (!member) { ctx.context.logger.error(`[Dynamic Access Control] The user is not a member of the organization to read a role.`, { userId: user.id, organizationId }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); } if (!await hasPermission({ options, organizationId, permissions: { ac: ["read"] }, role: member.role }, ctx)) { ctx.context.logger.error(`[Dynamic Access Control] The user is not permitted to read a role.`, { userId: user.id, organizationId, role: member.role }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE }); } let condition; if (ctx.query.roleName) condition = { field: "role", value: ctx.query.roleName, operator: "eq", connector: "AND" }; else if (ctx.query.roleId) condition = { field: "id", value: ctx.query.roleId, operator: "eq", connector: "AND" }; else { ctx.context.logger.error(`[Dynamic Access Control] The role name/id is not provided in the request query.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND }); } const role = await ctx.context.adapter.findOne({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, condition] }); if (!role) { ctx.context.logger.error(`[Dynamic Access Control] The role name/id does not exist in the database.`, { ..."roleName" in ctx.query ? { roleName: ctx.query.roleName } : { roleId: ctx.query.roleId }, organizationId }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND }); } role.permission = JSON.parse(role.permission); return ctx.json(role); }); }; const roleNameOrIdSchema = z.union([z.object({ roleName: z.string().nonempty().meta({ description: "The name of the role to update" }) }), z.object({ roleId: z.string().nonempty().meta({ description: "The id of the role to update" }) })]); const updateOrgRole = (options) => { const { additionalFieldsSchema, $AdditionalFields, $ReturnAdditionalFields } = getAdditionalFields(options, true); return createAuthEndpoint("/organization/update-role", { method: "POST", body: z.object({ organizationId: z.string().optional().meta({ description: "The id of the organization to update the role in. If not provided, the user's active organization will be used." }), data: z.object({ permission: z.record(z.string(), z.array(z.string())).optional().meta({ description: "The permission to update the role with" }), roleName: z.string().optional().meta({ description: "The name of the role to update" }), ...additionalFieldsSchema.shape }) }).and(roleNameOrIdSchema), metadata: { $Infer: { body: {} } }, requireHeaders: true, use: [orgSessionMiddleware] }, async (ctx) => { const { session, user } = ctx.context.session; const ac = options.ac; if (!ac) { ctx.context.logger.error(`[Dynamic Access Control] The organization plugin is missing a pre-defined ac instance.`, `\nPlease refer to the documentation here: https://better-auth.com/docs/plugins/organization#dynamic-access-control`); throw new APIError("NOT_IMPLEMENTED", { message: ORGANIZATION_ERROR_CODES.MISSING_AC_INSTANCE }); } const organizationId = ctx.body.organizationId ?? session.activeOrganizationId; if (!organizationId) { ctx.context.logger.error(`[Dynamic Access Control] The session is missing an active organization id to update a role. Either set an active org id, or pass an organizationId in the request body.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION }); } const member = await ctx.context.adapter.findOne({ model: "member", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "userId", value: user.id, operator: "eq", connector: "AND" }] }); if (!member) { ctx.context.logger.error(`[Dynamic Access Control] The user is not a member of the organization to update a role.`, { userId: user.id, organizationId }); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION }); } if (!await hasPermission({ options, organizationId, role: member.role, permissions: { ac: ["update"] } }, ctx)) { ctx.context.logger.error(`[Dynamic Access Control] The user is not permitted to update a role.`); throw new APIError("FORBIDDEN", { message: ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE }); } let condition; if (ctx.body.roleName) condition = { field: "role", value: ctx.body.roleName, operator: "eq", connector: "AND" }; else if (ctx.body.roleId) condition = { field: "id", value: ctx.body.roleId, operator: "eq", connector: "AND" }; else { ctx.context.logger.error(`[Dynamic Access Control] The role name/id is not provided in the request body.`); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND }); } const role = await ctx.context.adapter.findOne({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, condition] }); if (!role) { ctx.context.logger.error(`[Dynamic Access Control] The role name/id does not exist in the database.`, { ..."roleName" in ctx.body ? { roleName: ctx.body.roleName } : { roleId: ctx.body.roleId }, organizationId }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NOT_FOUND }); } role.permission = role.permission ? JSON.parse(role.permission) : void 0; const { permission: _, roleName: __, ...additionalFields } = ctx.body.data; const updateData = { ...additionalFields }; if (ctx.body.data.permission) { const newPermission = ctx.body.data.permission; await checkForInvalidResources({ ac, ctx, permission: newPermission }); await checkIfMemberHasPermission({ ctx, member, options, organizationId, permissionRequired: newPermission, user, action: "update" }); updateData.permission = newPermission; } if (ctx.body.data.roleName) { let newRoleName = ctx.body.data.roleName; newRoleName = normalizeRoleName(newRoleName); await checkIfRoleNameIsTakenByPreDefinedRole({ role: newRoleName, organizationId, options, ctx }); await checkIfRoleNameIsTakenByRoleInDB({ role: newRoleName, organizationId, ctx }); updateData.role = newRoleName; } const update = { ...updateData, ...updateData.permission ? { permission: JSON.stringify(updateData.permission) } : {} }; await ctx.context.adapter.update({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, condition], update }); return ctx.json({ success: true, roleData: { ...role, ...update, permission: updateData.permission || role.permission || null } }); }); }; async function checkForInvalidResources({ ac, ctx, permission }) { const validResources = Object.keys(ac.statements); const providedResources = Object.keys(permission); if (providedResources.some((r) => !validResources.includes(r))) { ctx.context.logger.error(`[Dynamic Access Control] The provided permission includes an invalid resource.`, { providedResources, validResources }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.INVALID_RESOURCE }); } } async function checkIfMemberHasPermission({ ctx, permissionRequired: permission, options, organizationId, member, user, action }) { const hasNecessaryPermissions = []; const permissionEntries = Object.entries(permission); for await (const [resource, permissions] of permissionEntries) for await (const perm of permissions) hasNecessaryPermissions.push({ resource: { [resource]: [perm] }, hasPermission: await hasPermission({ options, organizationId, permissions: { [resource]: [perm] }, useMemoryCache: true, role: member.role }, ctx) }); const missingPermissions = hasNecessaryPermissions.filter((x) => x.hasPermission === false).map((x) => { const key = Object.keys(x.resource)[0]; return `${key}:${x.resource[key][0]}`; }); if (missingPermissions.length > 0) { ctx.context.logger.error(`[Dynamic Access Control] The user is missing permissions necessary to ${action} a role with those set of permissions.\n`, { userId: user.id, organizationId, role: member.role, missingPermissions }); let errorMessage; if (action === "create") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CREATE_A_ROLE; else if (action === "update") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_UPDATE_A_ROLE; else if (action === "delete") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_DELETE_A_ROLE; else if (action === "read") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_A_ROLE; else if (action === "list") errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_LIST_A_ROLE; else errorMessage = ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_GET_A_ROLE; throw new APIError("FORBIDDEN", { message: errorMessage, missingPermissions }); } } async function checkIfRoleNameIsTakenByPreDefinedRole({ options, organizationId, role, ctx }) { const defaultRoles = options.roles ? Object.keys(options.roles) : [ "owner", "admin", "member" ]; if (defaultRoles.includes(role)) { ctx.context.logger.error(`[Dynamic Access Control] The role name "${role}" is already taken by a pre-defined role.`, { role, organizationId, defaultRoles }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN }); } } async function checkIfRoleNameIsTakenByRoleInDB({ organizationId, role, ctx }) { if (await ctx.context.adapter.findOne({ model: "organizationRole", where: [{ field: "organizationId", value: organizationId, operator: "eq", connector: "AND" }, { field: "role", value: role, operator: "eq", connector: "AND" }] })) { ctx.context.logger.error(`[Dynamic Access Control] The role name "${role}" is already taken by a role in the database.`, { role, organizationId }); throw new APIError("BAD_REQUEST", { message: ORGANIZATION_ERROR_CODES.ROLE_NAME_IS_ALREADY_TAKEN }); } } //#endregion export { createOrgRole, deleteOrgRole, getOrgRole, listOrgRoles, updateOrgRole }; //# sourceMappingURL=crud-access-control.mjs.map