chore(rbac): user link and utils (#14320)

This commit is contained in:
Carlos R. L. Rodrigues
2026-01-07 10:40:15 -03:00
committed by GitHub
parent 7161cf1903
commit b2245cc672
72 changed files with 2746 additions and 703 deletions

View File

@@ -83,6 +83,7 @@ The code snippets in this section assume that your forked Medusa project and the
"@medusajs/pricing": "file:../medusa/packages/modules/pricing",
"@medusajs/product": "file:../medusa/packages/modules/product",
"@medusajs/promotion": "file:../medusa/packages/modules/promotion",
"@medusajs/rbac": "file:../medusa/packages/modules/rbac",
"@medusajs/region": "file:../medusa/packages/modules/region",
"@medusajs/sales-channel": "file:../medusa/packages/modules/sales-channel",
"@medusajs/stock-location": "file:../medusa/packages/modules/stock-location",

View File

@@ -6,6 +6,8 @@ import {
jest.setTimeout(60000)
process.env.MEDUSA_FF_RBAC = "true"
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, api, getContainer }) => {
let container
@@ -15,6 +17,10 @@ medusaIntegrationTestRunner({
await createAdminUser(dbConnection, adminHeaders, container)
})
afterAll(async () => {
delete process.env.MEDUSA_FF_RBAC
})
describe("RBAC Policies - Admin API", () => {
describe("POST /admin/rbac/policies", () => {
it("should create a policy", async () => {

View File

@@ -1,3 +1,4 @@
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import {
adminHeaders,
@@ -6,6 +7,8 @@ import {
jest.setTimeout(60000)
process.env.MEDUSA_FF_RBAC = "true"
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, api, getContainer }) => {
let container
@@ -15,6 +18,10 @@ medusaIntegrationTestRunner({
await createAdminUser(dbConnection, adminHeaders, container)
})
afterAll(async () => {
delete process.env.MEDUSA_FF_RBAC
})
describe("RBAC Roles - Admin API", () => {
describe("POST /admin/rbac/roles", () => {
it("should create a role", async () => {
@@ -225,8 +232,13 @@ medusaIntegrationTestRunner({
let policies
let viewerRole
let editorRole
let adminUser
beforeEach(async () => {
const userModule = container.resolve(Modules.USER)
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK)
// Create policies
const policy1 = await api.post(
"/admin/rbac/policies",
{
@@ -266,6 +278,40 @@ medusaIntegrationTestRunner({
policy3.data.policy,
]
// Create an admin role with all policies
const adminRoleResponse = await api.post(
"/admin/rbac/roles",
{
name: "Admin Role",
description: "Has all permissions",
},
adminHeaders
)
const adminRole = adminRoleResponse.data.role
// Associate all policies with the admin role using the module directly
const rbacModule = container.resolve(Modules.RBAC)
await rbacModule.createRbacRolePolicies([
{ role_id: adminRole.id, policy_id: policies[0].id },
{ role_id: adminRole.id, policy_id: policies[1].id },
{ role_id: adminRole.id, policy_id: policies[2].id },
])
// Get the admin user
const users = await userModule.listUsers({ email: "admin@medusa.js" })
adminUser = users[0]
// Link the admin user to the admin role
await remoteLink.create({
[Modules.USER]: {
user_id: adminUser.id,
},
[Modules.RBAC]: {
rbac_role_id: adminRole.id,
},
})
// Create viewer and editor roles for the tests
const viewer = await api.post(
"/admin/rbac/roles",
{
@@ -287,96 +333,91 @@ medusaIntegrationTestRunner({
editorRole = editor.data.role
})
it("should create role-policy associations", async () => {
it("should add policies to a role", async () => {
const response = await api.post(
"/admin/rbac/role-policies",
`/admin/rbac/roles/${viewerRole.id}/policies`,
{
role_id: viewerRole.id,
scope_id: policies[0].id,
policies: [policies[0].id],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.role_policy).toEqual(
expect.objectContaining({
role_id: viewerRole.id,
scope_id: policies[0].id,
})
)
expect(response.data.policies).toHaveLength(1)
expect(response.data.policies[0]).toMatchObject({
role_id: viewerRole.id,
policy_id: policies[0].id,
})
})
it("should list role-policies for a specific role", async () => {
// Add multiple policies to the role
await api.post(
"/admin/rbac/role-policies",
`/admin/rbac/roles/${viewerRole.id}/policies`,
{
role_id: viewerRole.id,
scope_id: policies[0].id,
},
adminHeaders
)
await api.post(
"/admin/rbac/role-policies",
{
role_id: viewerRole.id,
scope_id: policies[1].id,
policies: [policies[0].id, policies[1].id],
},
adminHeaders
)
// List the role to get its policies
const response = await api.get(
`/admin/rbac/role-policies?role_id=${viewerRole.id}`,
`/admin/rbac/roles/${viewerRole.id}/?fields=policies`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(2)
expect(response.data.role_policies).toEqual(
expect(Array.isArray(response.data.role.policies)).toBe(true)
expect(response.data.role.policies).toHaveLength(2)
expect(response.data.role.policies).toEqual(
expect.arrayContaining([
expect.objectContaining({
role_id: viewerRole.id,
scope_id: policies[0].id,
id: policies[0].id,
}),
expect.objectContaining({
role_id: viewerRole.id,
scope_id: policies[1].id,
id: policies[1].id,
}),
])
)
})
it("should delete a role-policy association", async () => {
const createResponse = await api.post(
"/admin/rbac/role-policies",
it("should remove a policy from a role", async () => {
// First add a policy to the role
await api.post(
`/admin/rbac/roles/${editorRole.id}/policies`,
{
role_id: editorRole.id,
scope_id: policies[2].id,
policies: [policies[2].id],
},
adminHeaders
)
const rolePolicyId = createResponse.data.role_policy.id
// Verify the policy was added
const initialResponse = await api.get(
`/admin/rbac/roles/${editorRole.id}?fields=policies`,
adminHeaders
)
expect(initialResponse.data.role.policies).toHaveLength(1)
// Remove the policy from the role
const deleteResponse = await api.delete(
`/admin/rbac/role-policies/${rolePolicyId}`,
`/admin/rbac/roles/${editorRole.id}/policies/${policies[2].id}`,
adminHeaders
)
expect(deleteResponse.status).toEqual(200)
expect(deleteResponse.data).toEqual({
id: rolePolicyId,
id: expect.stringContaining("rlpl_"),
object: "rbac_role_policy",
deleted: true,
})
const listResponse = await api.get(
`/admin/rbac/role-policies?role_id=${editorRole.id}`,
// Verify the policy was removed
const finalResponse = await api.get(
`/admin/rbac/roles/${editorRole.id}?fields=policies`,
adminHeaders
)
expect(
listResponse.data.role_policies.find((rp) => rp.id === rolePolicyId)
).toBeUndefined()
expect(finalResponse.data.role.policies).toHaveLength(0)
})
})
})

View File

@@ -68,6 +68,7 @@ const modules = {
},
[Modules.RBAC]: {
resolve: "@medusajs/rbac",
disable: process.env.MEDUSA_FF_RBAC !== "true",
},
}
@@ -89,6 +90,7 @@ module.exports = defineConfig({
featureFlags: {
index_engine: process.env.ENABLE_INDEX_MODULE === "true",
translation: process.env.MEDUSA_FF_TRANSLATION === "true",
rbac: process.env.MEDUSA_FF_RBAC === "true",
},
modules,
})

View File

@@ -1,15 +1,6 @@
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { IRbacModuleService } from "@medusajs/types"
export type CreateRbacPolicyDTO = {
key: string
resource: string
operation: string
name?: string | null
description?: string | null
metadata?: Record<string, unknown> | null
}
import { CreateRbacPolicyDTO, IRbacModuleService } from "@medusajs/types"
export type CreateRbacPoliciesStepInput = {
policies: CreateRbacPolicyDTO[]
@@ -22,7 +13,14 @@ export const createRbacPoliciesStep = createStep(
async (data: CreateRbacPoliciesStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
const created = await service.createRbacPolicies(data.policies)
// Normalize resource and operation to lowercase
const normalizedPolicies = data.policies.map((policy) => ({
...policy,
resource: policy.resource.toLowerCase(),
operation: policy.operation.toLowerCase(),
}))
const created = await service.createRbacPolicies(normalizedPolicies)
return new StepResponse(
created,

View File

@@ -2,30 +2,28 @@ import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { IRbacModuleService } from "@medusajs/types"
export type CreateRbacRoleInheritanceDTO = {
export type CreateRbacRoleParentDTO = {
role_id: string
inherited_role_id: string
parent_id: string
metadata?: Record<string, unknown> | null
}
export type CreateRbacRoleInheritancesStepInput = {
role_inheritances: CreateRbacRoleInheritanceDTO[]
export type CreateRbacRoleParentsStepInput = {
role_parents: CreateRbacRoleParentDTO[]
}
export const createRbacRoleInheritancesStepId = "create-rbac-role-inheritances"
export const createRbacRoleParentsStepId = "create-rbac-role-parents"
export const createRbacRoleInheritancesStep = createStep(
createRbacRoleInheritancesStepId,
async (data: CreateRbacRoleInheritancesStepInput, { container }) => {
export const createRbacRoleParentsStep = createStep(
createRbacRoleParentsStepId,
async (data: CreateRbacRoleParentsStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
if (!data.role_inheritances || data.role_inheritances.length === 0) {
if (!data.role_parents?.length) {
return new StepResponse([], [])
}
const created = await service.createRbacRoleInheritances(
data.role_inheritances
)
const created = await service.createRbacRoleParents(data.role_parents)
return new StepResponse(
created,
@@ -38,6 +36,6 @@ export const createRbacRoleInheritancesStep = createStep(
}
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
await service.deleteRbacRoleInheritances(createdIds)
await service.deleteRbacRoleParents(createdIds)
}
)

View File

@@ -1,15 +1,9 @@
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { IRbacModuleService } from "@medusajs/types"
export type CreateRbacRolePolicyDTO = {
role_id: string
scope_id: string
metadata?: Record<string, unknown> | null
}
import { CreateRbacRolePolicyDTO, IRbacModuleService } from "@medusajs/types"
export type CreateRbacRolePoliciesStepInput = {
role_policies: CreateRbacRolePolicyDTO[]
policies: CreateRbacRolePolicyDTO[]
}
export const createRbacRolePoliciesStepId = "create-rbac-role-policies"
@@ -19,7 +13,11 @@ export const createRbacRolePoliciesStep = createStep(
async (data: CreateRbacRolePoliciesStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
const created = await service.createRbacRolePolicies(data.role_policies)
if (!data.policies?.length) {
return new StepResponse([], [])
}
const created = await service.createRbacRolePolicies(data.policies)
return new StepResponse(
created,

View File

@@ -19,6 +19,9 @@ export const createRbacRolesStep = createStep(
async (data: CreateRbacRolesStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
if (!data.roles?.length) {
return new StepResponse([], [])
}
const created = await service.createRbacRoles(data.roles)
return new StepResponse(

View File

@@ -10,8 +10,23 @@ export const deleteRbacPoliciesStep = createStep(
{ name: deleteRbacPoliciesStepId, noCompensation: true },
async (ids: DeleteRbacPoliciesStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
await service.deleteRbacPolicies(ids)
return new StepResponse(void 0)
if (!ids?.length) {
return new StepResponse([] as any, [])
}
const deleted = await service.deleteRbacPolicies(ids)
return new StepResponse(deleted, ids)
},
async () => {}
async (deletedPoliciesIds, { container }) => {
if (!deletedPoliciesIds?.length) {
return
}
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
// Restore the soft-deleted roles during compensation
await service.restoreRbacPolicies(deletedPoliciesIds)
}
)

View File

@@ -10,8 +10,21 @@ export const deleteRbacRolePoliciesStep = createStep(
{ name: deleteRbacRolePoliciesStepId, noCompensation: true },
async (ids: DeleteRbacRolePoliciesStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
await service.deleteRbacRolePolicies(ids)
return new StepResponse(void 0)
if (!ids?.length) {
return new StepResponse([] as any, [])
}
const deleted = await service.deleteRbacRolePolicies(ids)
return new StepResponse(deleted, ids)
},
async () => {}
async (deletedRolePolicyIds, { container }) => {
if (!deletedRolePolicyIds?.length) {
return
}
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
await service.restoreRbacRolePolicies(deletedRolePolicyIds)
}
)

View File

@@ -6,12 +6,33 @@ export type DeleteRbacRolesStepInput = string[]
export const deleteRbacRolesStepId = "delete-rbac-roles"
/**
* This step deletes one or more RBAC roles.
* @param ids - The IDs of the roles to delete
* @param container - The workflow container
* @returns A step response with the deleted role IDs
*/
export const deleteRbacRolesStep = createStep(
{ name: deleteRbacRolesStepId, noCompensation: true },
deleteRbacRolesStepId,
async (ids: DeleteRbacRolesStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
await service.deleteRbacRoles(ids)
return new StepResponse(void 0)
if (!ids?.length) {
return new StepResponse([] as any, [])
}
const deleted = await service.deleteRbacRoles(ids)
return new StepResponse(deleted, ids)
},
async () => {}
async (deletedRoleIds, { container }) => {
if (!deletedRoleIds?.length) {
return
}
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
// Restore the soft-deleted roles during compensation
await service.restoreRbacRoles(deletedRoleIds)
}
)

View File

@@ -1,14 +1,11 @@
export * from "./create-rbac-roles"
export * from "./delete-rbac-roles"
export * from "./update-rbac-roles"
export * from "./create-rbac-policies"
export * from "./delete-rbac-policies"
export * from "./update-rbac-policies"
export * from "./create-rbac-role-parents"
export * from "./create-rbac-role-policies"
export * from "./create-rbac-roles"
export * from "./delete-rbac-policies"
export * from "./delete-rbac-role-policies"
export * from "./update-rbac-role-policies"
export * from "./create-rbac-role-inheritances"
export * from "./set-role-inheritance"
export * from "./delete-rbac-roles"
export * from "./set-role-parent"
export * from "./update-rbac-policies"
export * from "./update-rbac-roles"
export * from "./validate-user-permissions"

View File

@@ -2,21 +2,21 @@ import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
import { IRbacModuleService } from "@medusajs/types"
export type SetRoleInheritanceStepInput = Array<{
export type SetRoleParentStepInput = Array<{
role_id: string
inherited_role_ids: string[]
parent_ids: string[]
}>
export const setRoleInheritanceStepId = "set-role-inheritance"
export const setRoleParentStepId = "set-role-parent"
export const setRoleInheritanceStep = createStep(
setRoleInheritanceStepId,
async (data: SetRoleInheritanceStepInput, { container }) => {
export const setRoleParentStep = createStep(
setRoleParentStepId,
async (data: SetRoleParentStepInput, { container }) => {
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
const allCompensationData: Array<{
role_id: string
previousInheritedRoleIds: string[]
previous_inherited_role_ids: string[]
}> = []
if (!data || data.length === 0) {
@@ -29,54 +29,52 @@ export const setRoleInheritanceStep = createStep(
const allToRemoveIds: string[] = []
const allToCreate: Array<{
role_id: string
inherited_role_id: string
parent_id: string
}> = []
for (const roleData of data) {
const existingInheritance = await service.listRbacRoleInheritances({
const existingParent = await service.listRbacRoleParents({
role_id: roleData.role_id,
})
const existingInheritedRoleIds = existingInheritance.map(
(ri) => ri.inherited_role_id
)
const existingInheritedRoleIds = existingParent.map((ri) => ri.parent_id)
allCompensationData.push({
role_id: roleData.role_id,
previousInheritedRoleIds: existingInheritedRoleIds,
previous_inherited_role_ids: existingInheritedRoleIds,
})
const toAdd = roleData.inherited_role_ids.filter(
const toAdd = roleData.parent_ids.filter(
(id) => !existingInheritedRoleIds.includes(id)
)
const toRemove = existingInheritedRoleIds.filter(
(id) => !roleData.inherited_role_ids.includes(id)
(id) => !roleData.parent_ids.includes(id)
)
if (toRemove.length > 0) {
const toRemoveRecords = existingInheritance.filter((ri) =>
toRemove.includes(ri.inherited_role_id)
const toRemoveRecords = existingParent.filter((ri) =>
toRemove.includes(ri.parent_id)
)
allToRemoveIds.push(...toRemoveRecords.map((ri) => ri.id))
}
if (toAdd.length > 0) {
allToCreate.push(
...toAdd.map((inherited_role_id) => ({
...toAdd.map((parent_id) => ({
role_id: roleData.role_id,
inherited_role_id,
parent_id,
}))
)
}
}
if (allToRemoveIds.length > 0) {
await service.deleteRbacRoleInheritances(allToRemoveIds)
await service.deleteRbacRoleParents(allToRemoveIds)
}
let created: any[] = []
if (allToCreate.length > 0) {
created = await service.createRbacRoleInheritances(allToCreate)
created = await service.createRbacRoleParents(allToCreate)
}
return new StepResponse(
@@ -86,7 +84,7 @@ export const setRoleInheritanceStep = createStep(
},
async (
compensationData:
| Array<{ role_id: string; previousInheritedRoleIds: string[] }>
| Array<{ role_id: string; previous_inherited_role_ids: string[] }>
| undefined,
{ container }
) => {
@@ -97,24 +95,20 @@ export const setRoleInheritanceStep = createStep(
const service = container.resolve<IRbacModuleService>(Modules.RBAC)
for (const roleCompensation of compensationData) {
const currentInheritance = await service.listRbacRoleInheritances({
const currentParent = await service.listRbacRoleParents({
role_id: roleCompensation.role_id,
})
if (currentInheritance.length > 0) {
await service.deleteRbacRoleInheritances(
currentInheritance.map((ri) => ri.id)
)
if (currentParent.length > 0) {
await service.deleteRbacRoleParents(currentParent.map((ri) => ri.id))
}
if (roleCompensation.previousInheritedRoleIds.length > 0) {
await service.createRbacRoleInheritances(
roleCompensation.previousInheritedRoleIds.map(
(inherited_role_id) => ({
role_id: roleCompensation.role_id,
inherited_role_id,
})
)
if (roleCompensation.previous_inherited_role_ids.length > 0) {
await service.createRbacRoleParents(
roleCompensation.previous_inherited_role_ids.map((parent_id) => ({
role_id: roleCompensation.role_id,
parent_id,
}))
)
}
}

View File

@@ -26,9 +26,18 @@ export const updateRbacPoliciesStep = createStep(
relations,
})
// Normalize resource and operation to lowercase if present
const normalizedUpdate = { ...data.update }
if (normalizedUpdate.resource) {
normalizedUpdate.resource = normalizedUpdate.resource.toLowerCase()
}
if (normalizedUpdate.operation) {
normalizedUpdate.operation = normalizedUpdate.operation.toLowerCase()
}
const updates = (prevData ?? []).map((p) => ({
id: p.id,
...data.update,
...normalizedUpdate,
})) as UpdateRbacPolicyDTO[]
const updated = await service.updateRbacPolicies(updates)

View File

@@ -0,0 +1,96 @@
import {
ContainerRegistrationKeys,
MedusaError,
toSnakeCase,
} from "@medusajs/framework/utils"
import { createStep } from "@medusajs/framework/workflows-sdk"
export type ValidateUserPermissionsStepInput = {
actor_id: string
actor?: string
policy_ids?: string[]
actions?: {
resource: string
operation: string
}[]
}
export const validateUserPermissionsStepId = "validate-user-permissions"
/**
* Validates that a user has access to all the policies they are trying to assign.
* A user can only create roles and add policies that they themselves have access to.
*/
export const validateUserPermissionsStep = createStep(
validateUserPermissionsStepId,
async (data: ValidateUserPermissionsStepInput, { container }) => {
const { actor_id, actor, policy_ids, actions } = data
if (!policy_ids?.length && !actions?.length) {
return
}
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: users } = await query.graph({
entity: actor ?? "user",
fields: ["rbac_roles.id", "rbac_roles.policies.*"],
filters: { id: actor_id },
})
if (!users?.[0]?.rbac_roles || users[0].rbac_roles.length === 0) {
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
`User does not have any roles assigned and cannot create roles or assign policies`
)
}
const operationMap = new Map()
users[0].rbac_roles.forEach((role) => {
role.policies.forEach((policy) => {
const op =
policy.operation === "*" ? "*" : toSnakeCase(policy.operation)
operationMap.set(`${policy.resource}:${op}`, policy.id)
})
})
const allUserPolicies = users[0].rbac_roles.flatMap(
(role) => role.policies || []
)
const userPolicyIds = new Set(allUserPolicies.map((p) => p.id))
let unauthorizedPolicies: string[] = []
if (policy_ids?.length) {
unauthorizedPolicies = policy_ids.filter(
(policyId) => !userPolicyIds.has(policyId)
)
} else if (actions?.length) {
unauthorizedPolicies = actions
.filter((action) => {
const op =
action.operation === "*" ? "*" : toSnakeCase(action.operation)
return (
!operationMap.has(`${action.resource}:${op}`) &&
!operationMap.has(`${action.resource}:*`)
)
})
.map((action) => `${action.resource}:${action.operation}`)
}
if (unauthorizedPolicies.length) {
const policyMap = new Map(
allUserPolicies.map((p) => [p.id, p.name || p.key])
)
const unauthorizedNames = unauthorizedPolicies
.map((id) => policyMap.get(id) || id)
.join(", ")
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
`User does not have access to the following policies and cannot assign them: ${unauthorizedNames}`
)
}
}
)

View File

@@ -2,11 +2,19 @@ import {
WorkflowData,
WorkflowResponse,
createWorkflow,
transform,
when,
} from "@medusajs/framework/workflows-sdk"
import { createRbacRolePoliciesStep } from "../steps"
import { validateUserPermissionsStep } from "../steps/validate-user-permissions"
export type CreateRbacRolePoliciesWorkflowInput = {
role_policies: any[]
actor_id?: string
actor?: string
policies: {
role_id: string
policy_id: string
}[]
}
export const createRbacRolePoliciesWorkflowId = "create-rbac-role-policies"
@@ -14,6 +22,31 @@ export const createRbacRolePoliciesWorkflowId = "create-rbac-role-policies"
export const createRbacRolePoliciesWorkflow = createWorkflow(
createRbacRolePoliciesWorkflowId,
(input: WorkflowData<CreateRbacRolePoliciesWorkflowInput>) => {
return new WorkflowResponse(createRbacRolePoliciesStep(input))
const validationData = transform({ input }, ({ input }) => {
if (!input.actor_id) {
return null
}
const policyIds = new Set<string>()
input.policies.forEach((rp) => policyIds.add(rp.policy_id))
return {
actor_id: input.actor_id,
actor: input.actor,
policy_ids: Array.from(policyIds),
}
})
when({ validationData }, ({ validationData }) => {
return !!validationData?.actor_id && !!validationData?.policy_ids?.length
}).then(() => {
validateUserPermissionsStep(validationData)
})
const rolePolicies = createRbacRolePoliciesStep({
policies: input.policies,
})
return new WorkflowResponse(rolePolicies)
}
)

View File

@@ -1,21 +1,25 @@
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
transform,
when,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
createRbacRoleInheritancesStep,
createRbacRoleParentsStep,
createRbacRolePoliciesStep,
createRbacRolesStep,
} from "../steps"
import { validateUserPermissionsStep } from "../steps/validate-user-permissions"
export type CreateRbacRolesWorkflowInput = {
actor_id?: string
actor?: string
roles: {
name: string
description?: string | null
metadata?: Record<string, unknown> | null
inherited_role_ids?: string[]
parent_ids?: string[]
policy_ids?: string[]
}[]
}
@@ -25,6 +29,24 @@ export const createRbacRolesWorkflowId = "create-rbac-roles"
export const createRbacRolesWorkflow = createWorkflow(
createRbacRolesWorkflowId,
(input: WorkflowData<CreateRbacRolesWorkflowInput>) => {
const validationData = transform({ input }, ({ input }) => {
const allPolicyIds = new Set<string>()
input.roles.forEach((role) => {
role.policy_ids?.forEach((policyId) => allPolicyIds.add(policyId))
})
return {
actor_id: input.actor_id!,
actor: input.actor,
policy_ids: Array.from(allPolicyIds),
}
})
when({ validationData }, ({ validationData }) => {
return !!validationData?.actor_id && !!validationData?.policy_ids?.length
}).then(() => {
validateUserPermissionsStep(validationData)
})
const roleData = transform({ input }, ({ input }) => ({
roles: input.roles.map((r) => ({
name: r.name,
@@ -35,22 +57,22 @@ export const createRbacRolesWorkflow = createWorkflow(
const createdRoles = createRbacRolesStep(roleData)
const inheritanceData = transform(
const parentData = transform(
{ input, createdRoles },
({ input, createdRoles }) => {
const inheritances: any[] = []
const parents: any[] = []
createdRoles.forEach((role, index) => {
const inheritedRoleIds = input.roles[index].inherited_role_ids || []
const inheritedRoleIds = input.roles[index].parent_ids || []
inheritedRoleIds.forEach((inheritedRoleId) => {
inheritances.push({
parents.push({
role_id: role.id,
inherited_role_id: inheritedRoleId,
parent_id: inheritedRoleId,
})
})
})
return { role_inheritances: inheritances }
return { role_parents: parents }
}
)
@@ -63,15 +85,15 @@ export const createRbacRolesWorkflow = createWorkflow(
policyIds.forEach((policy_id) => {
allPolicies.push({
role_id: role.id,
scope_id: policy_id,
policy_id: policy_id,
})
})
})
return { role_policies: allPolicies }
return { policies: allPolicies }
}
)
createRbacRoleInheritancesStep(inheritanceData)
createRbacRoleParentsStep(parentData)
createRbacRolePoliciesStep(policiesData)

View File

@@ -1,17 +1,23 @@
import { WorkflowData, createWorkflow } from "@medusajs/framework/workflows-sdk"
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
} from "@medusajs/framework/workflows-sdk"
import { deleteRbacRolePoliciesStep } from "../steps"
export type DeleteRbacRolePoliciesWorkflowInput = {
ids: string[]
role_policy_ids: string[]
}
export const deleteRbacRolePoliciesWorkflowId = "delete-rbac-role-policies"
export const deleteRbacRolePoliciesWorkflow = createWorkflow(
deleteRbacRolePoliciesWorkflowId,
(
input: WorkflowData<DeleteRbacRolePoliciesWorkflowInput>
): WorkflowData<void> => {
deleteRbacRolePoliciesStep(input.ids)
(input: WorkflowData<DeleteRbacRolePoliciesWorkflowInput>) => {
const deletedRolePolicies = deleteRbacRolePoliciesStep(
input.role_policy_ids
)
return new WorkflowResponse(deletedRolePolicies)
}
)

View File

@@ -8,4 +8,3 @@ export * from "./update-rbac-policies"
export * from "./create-rbac-role-policies"
export * from "./delete-rbac-role-policies"
export * from "./update-rbac-role-policies"

View File

@@ -4,15 +4,19 @@ import {
WorkflowResponse,
createWorkflow,
transform,
when,
} from "@medusajs/framework/workflows-sdk"
import { UpdateRbacRoleDTO } from "@medusajs/types"
import { createRbacRolePoliciesStep, setRoleInheritanceStep } from "../steps"
import { createRbacRolePoliciesStep, setRoleParentStep } from "../steps"
import { updateRbacRolesStep } from "../steps/update-rbac-roles"
import { validateUserPermissionsStep } from "../steps/validate-user-permissions"
export type UpdateRbacRolesWorkflowInput = {
actor_id?: string
actor?: string
selector: Record<string, any>
update: Omit<UpdateRbacRoleDTO, "id"> & {
inherited_role_ids?: string[]
parent_ids?: string[]
policy_ids?: string[]
}
}
@@ -22,6 +26,21 @@ export const updateRbacRolesWorkflowId = "update-rbac-roles"
export const updateRbacRolesWorkflow = createWorkflow(
updateRbacRolesWorkflowId,
(input: WorkflowData<UpdateRbacRolesWorkflowInput>) => {
const validationData = transform({ input }, ({ input }) => {
const policyIds = input.update.policy_ids || []
return {
actor_id: input.actor_id!,
policy_ids: policyIds,
actor: input.actor,
}
})
when({ validationData }, ({ validationData }) => {
return !!validationData?.actor_id && !!validationData?.policy_ids?.length
}).then(() => {
validateUserPermissionsStep(validationData)
})
const roleUpdateData = transform({ input }, ({ input }) => ({
selector: input.selector,
update: {
@@ -33,40 +52,40 @@ export const updateRbacRolesWorkflow = createWorkflow(
const updatedRoles = updateRbacRolesStep(roleUpdateData)
const inheritanceUpdateData = transform(
const parentUpdateData = transform(
{ input, updatedRoles },
({ input, updatedRoles }) => {
if (!isDefined(input.update.inherited_role_ids)) {
if (!isDefined(input.update.parent_ids)) {
return []
}
return updatedRoles.map((role) => ({
role_id: role.id,
inherited_role_ids: input.update.inherited_role_ids || [],
parent_ids: input.update.parent_ids || [],
}))
}
)
setRoleInheritanceStep(inheritanceUpdateData)
setRoleParentStep(parentUpdateData)
const policiesUpdateData = transform(
{ input, updatedRoles },
({ input, updatedRoles }) => {
if (!isDefined(input.update.policy_ids)) {
return { role_policies: [] }
return { policies: [] }
}
const allPolicies: any[] = []
updatedRoles.forEach((role) => {
const policyIds = input.update.policy_ids || []
policyIds.forEach((policy_id) => {
policyIds.forEach((policyId) => {
allPolicies.push({
role_id: role.id,
scope_id: policy_id,
policy_id: policyId,
})
})
})
return { role_policies: allPolicies }
return { policies: allPolicies }
}
)

View File

@@ -7,11 +7,12 @@ export * from "./jobs"
export * from "./links"
export * from "./logger"
export * from "./medusa-app-loader"
export * from "./subscribers"
export * from "./workflows"
export * from "./telemetry"
export * from "./zod"
export * from "./migrations"
export * from "./policies"
export * from "./subscribers"
export * from "./telemetry"
export * from "./workflows"
export * from "./zod"
export const MEDUSA_CLI_PATH = require.resolve("@medusajs/cli")

View File

@@ -0,0 +1 @@
export * from "./policy-loader"

View File

@@ -0,0 +1,16 @@
import { discoverPoliciesFromDir } from "@medusajs/utils"
import { normalize } from "path"
/**
* Load RBAC policies from a directory
* @param sourcePath - Path to scan for policies directories
*/
export async function policiesLoader(sourcePath?: string): Promise<void> {
if (!sourcePath) {
return
}
const policyDir = normalize(sourcePath)
await discoverPoliciesFromDir(policyDir)
}

View File

@@ -3,6 +3,7 @@ export type RbacRoleDTO = {
name: string
description?: string | null
metadata?: Record<string, unknown> | null
policies?: RbacPolicyDTO[]
}
export type FilterableRbacRoleProps = {
@@ -19,11 +20,12 @@ export type RbacPolicyDTO = {
name?: string | null
description?: string | null
metadata?: Record<string, unknown> | null
deleted_at?: Date | string | null
}
export type FilterableRbacPolicyProps = {
id?: string | string[]
key?: string
key?: string | string[]
resource?: string
operation?: string
q?: string
@@ -32,25 +34,25 @@ export type FilterableRbacPolicyProps = {
export type RbacRolePolicyDTO = {
id: string
role_id: string
scope_id: string
policy_id: string
metadata?: Record<string, unknown> | null
}
export type FilterableRbacRolePolicyProps = {
id?: string | string[]
role_id?: string | string[]
scope_id?: string | string[]
policy_id?: string | string[]
}
export type RbacRoleInheritanceDTO = {
export type RbacRoleParentDTO = {
id: string
role_id: string
inherited_role_id: string
parent_id: string
metadata?: Record<string, unknown> | null
}
export type FilterableRbacRoleInheritanceProps = {
export type FilterableRbacRoleParentProps = {
id?: string | string[]
role_id?: string | string[]
inherited_role_id?: string | string[]
parent_id?: string | string[]
}

View File

@@ -23,7 +23,7 @@ export type UpdateRbacPolicyDTO = Partial<CreateRbacPolicyDTO> & {
export type CreateRbacRolePolicyDTO = {
role_id: string
scope_id: string
policy_id: string
metadata?: Record<string, unknown> | null
}
@@ -31,13 +31,12 @@ export type UpdateRbacRolePolicyDTO = Partial<CreateRbacRolePolicyDTO> & {
id: string
}
export type CreateRbacRoleInheritanceDTO = {
export type CreateRbacRoleParentDTO = {
role_id: string
inherited_role_id: string
parent_id: string
metadata?: Record<string, unknown> | null
}
export type UpdateRbacRoleInheritanceDTO =
Partial<CreateRbacRoleInheritanceDTO> & {
id: string
}
export type UpdateRbacRoleParentDTO = Partial<CreateRbacRoleParentDTO> & {
id: string
}

View File

@@ -1,24 +1,25 @@
import { FindConfig } from "../common"
import { RestoreReturn, SoftDeleteReturn } from "../dal"
import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context"
import {
FilterableRbacPolicyProps,
FilterableRbacRoleInheritanceProps,
FilterableRbacRoleParentProps,
FilterableRbacRolePolicyProps,
FilterableRbacRoleProps,
RbacPolicyDTO,
RbacRoleDTO,
RbacRoleInheritanceDTO,
RbacRoleParentDTO,
RbacRolePolicyDTO,
} from "./common"
import {
CreateRbacPolicyDTO,
CreateRbacRoleDTO,
CreateRbacRoleInheritanceDTO,
CreateRbacRoleParentDTO,
CreateRbacRolePolicyDTO,
UpdateRbacPolicyDTO,
UpdateRbacRoleDTO,
UpdateRbacRoleInheritanceDTO,
UpdateRbacRoleParentDTO,
UpdateRbacRolePolicyDTO,
} from "./mutations"
@@ -146,49 +147,90 @@ export interface IRbacModuleService extends IModuleService {
sharedContext?: Context
): Promise<[RbacRolePolicyDTO[], number]>
createRbacRoleInheritances(
data: CreateRbacRoleInheritanceDTO,
createRbacRoleParents(
data: CreateRbacRoleParentDTO,
sharedContext?: Context
): Promise<RbacRoleInheritanceDTO>
createRbacRoleInheritances(
data: CreateRbacRoleInheritanceDTO[],
): Promise<RbacRoleParentDTO>
createRbacRoleParents(
data: CreateRbacRoleParentDTO[],
sharedContext?: Context
): Promise<RbacRoleInheritanceDTO[]>
): Promise<RbacRoleParentDTO[]>
updateRbacRoleInheritances(
data: UpdateRbacRoleInheritanceDTO,
updateRbacRoleParents(
data: UpdateRbacRoleParentDTO,
sharedContext?: Context
): Promise<RbacRoleInheritanceDTO>
updateRbacRoleInheritances(
data: UpdateRbacRoleInheritanceDTO[],
): Promise<RbacRoleParentDTO>
updateRbacRoleParents(
data: UpdateRbacRoleParentDTO[],
sharedContext?: Context
): Promise<RbacRoleInheritanceDTO[]>
): Promise<RbacRoleParentDTO[]>
deleteRbacRoleInheritances(
deleteRbacRoleParents(
ids: string | string[],
sharedContext?: Context
): Promise<void>
retrieveRbacRoleInheritance(
retrieveRbacRoleParent(
id: string,
config?: FindConfig<RbacRoleInheritanceDTO>,
config?: FindConfig<RbacRoleParentDTO>,
sharedContext?: Context
): Promise<RbacRoleInheritanceDTO>
): Promise<RbacRoleParentDTO>
listRbacRoleInheritances(
filters?: FilterableRbacRoleInheritanceProps,
config?: FindConfig<RbacRoleInheritanceDTO>,
listRbacRoleParents(
filters?: FilterableRbacRoleParentProps,
config?: FindConfig<RbacRoleParentDTO>,
sharedContext?: Context
): Promise<RbacRoleInheritanceDTO[]>
): Promise<RbacRoleParentDTO[]>
listAndCountRbacRoleInheritances(
filters?: FilterableRbacRoleInheritanceProps,
config?: FindConfig<RbacRoleInheritanceDTO>,
listAndCountRbacRoleParents(
filters?: FilterableRbacRoleParentProps,
config?: FindConfig<RbacRoleParentDTO>,
sharedContext?: Context
): Promise<[RbacRoleInheritanceDTO[], number]>
): Promise<[RbacRoleParentDTO[], number]>
listPoliciesForRole(
roleId: string,
sharedContext?: Context
): Promise<RbacPolicyDTO[]>
softDeleteRbacRoles<TReturnableLinkableKeys extends string = string>(
roleIds: string | string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restoreRbacRoles<TReturnableLinkableKeys extends string = string>(
roleIds: string | string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
softDeleteRbacPolicies<TReturnableLinkableKeys extends string = string>(
policyIds: string | string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restoreRbacPolicies<TReturnableLinkableKeys extends string = string>(
policyIds: string | string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
softDeleteRbacRolePolicies<TReturnableLinkableKeys extends string = string>(
rolePolicyIds: string | string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restoreRbacRolePolicies<TReturnableLinkableKeys extends string = string>(
rolePolicyIds: string | string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
softDeleteRbacRoleParents<TReturnableLinkableKeys extends string = string>(
roleParentIds: string | string[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
restoreRbacRoleParents<TReturnableLinkableKeys extends string = string>(
roleParentIds: string | string[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}

View File

@@ -53,8 +53,8 @@ export * from "./medusa-container"
export * from "./merge-metadata"
export * from "./merge-plugin-modules"
export * from "./normalize-csv-value"
export * from "./normalize-locale"
export * from "./normalize-import-path-with-source"
export * from "./normalize-locale"
export * from "./object-from-string-path"
export * from "./object-to-string-path"
export * from "./omit-deep"
@@ -85,6 +85,7 @@ export * from "./to-camel-case"
export * from "./to-handle"
export * from "./to-kebab-case"
export * from "./to-pascal-case"
export * from "./to-snake-case"
export * from "./to-unix-slash"
export * from "./trim-zeros"
export * from "./try-convert-to-boolean"

View File

@@ -0,0 +1,10 @@
/**
* Converts a string to snake_case
*/
export function toSnakeCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase()
}

View File

@@ -1,11 +1,13 @@
export * from "./api-key"
export * from "./analytics"
export * from "./api-key"
export * from "./auth"
export * from "./bundles"
export * from "./caching"
export * from "./common"
export * from "./core-flows"
export * from "./dal"
export * from "./defaults"
export * from "./dev-server"
export * from "./dml"
export * from "./event-bus"
export * from "./exceptions"
@@ -21,6 +23,7 @@ export * from "./orchestration"
export * from "./order"
export * from "./payment"
export * from "./pg"
export * from "./policies"
export * from "./pricing"
export * from "./product"
export * from "./promotion"
@@ -28,10 +31,8 @@ export * from "./search"
export * from "./shipping"
export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./caching"
export * from "./translations"
export * from "./dev-server"
export * from "./user"
export const MedusaModuleType = Symbol.for("MedusaModule")
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")

View File

@@ -128,4 +128,10 @@ export const LINKS = {
Modules.PAYMENT,
"account_holder_id"
),
UserRbacRole: composeLinkName(
Modules.USER,
"user_id",
Modules.RBAC,
"rbac_role_id"
),
}

View File

@@ -0,0 +1,182 @@
import { getCallerFilePath, isFileDisabled, MEDUSA_SKIP_FILE } from "../common"
import { toSnakeCase } from "../common/to-snake-case"
export const MedusaPolicySymbol = Symbol.for("MedusaPolicy")
export interface PolicyDefinition {
name: string
resource: string
operation: string
description?: string
}
export interface definePoliciesExport {
[MedusaPolicySymbol]: boolean
policies: PolicyDefinition[]
}
declare global {
// eslint-disable-next-line no-var
var Resource: Record<string, string>
// eslint-disable-next-line no-var
var Operation: Record<string, string>
// eslint-disable-next-line no-var
var Policy: Record<
string,
{ resource: string; operation: string; description?: string }
>
}
/**
* Global registry for all unique resources.
*/
const defaultResources = [
"api-key",
"campaign",
"claim",
"collection",
"currency",
"customer",
"customer-group",
"draft-order",
"exchange",
"fulfillment",
"fulfillment-provider",
"fulfillment-set",
"inventory",
"inventory-item",
"invite",
"locale",
"notification",
"order",
"order-change",
"order-edit",
"payment",
"payment-collection",
"payment-provider",
"price-list",
"price-preference",
"product",
"product-category",
"product-tag",
"product-type",
"product-variant",
"promotion",
"rbac",
"refund-reason",
"region",
"reservation",
"return",
"return-reason",
"sales-channel",
"shipping-option",
"shipping-option-type",
"shipping-profile",
"stock-location",
"store",
"tax",
"tax-provider",
"tax-rate",
"tax-region",
"translation",
"upload",
"user",
"workflow-execution",
]
export const PolicyResource = global.PolicyResource ?? {}
global.PolicyResource ??= PolicyResource
for (const resource of defaultResources) {
const resourceKey = toSnakeCase(resource)
PolicyResource[resourceKey] = resource
}
/**
* Global registry for all unique operations.
*/
const defaultOperations = ["read", "write", "update", "delete", "*"]
export const PolicyOperation = global.PolicyOperation ?? {}
global.PolicyOperation ??= PolicyOperation
for (const operation of defaultOperations) {
const operationKey = operation === "*" ? "*" : toSnakeCase(operation)
PolicyOperation[operationKey] = operation
}
export const Policy = global.Policy ?? {}
global.Policy ??= Policy
/**
* Define RBAC policies that will be automatically synced to the database
* when the application starts.
*
* @param policies - Single policy or array of policy definitions
*
* @example
* ```ts
* definePolicies({
* name: "ReadBrands",
* resource: "brand",
* operation: "read"
* description: "Read brands"
* })
*
* definePolicies([
* {
* name: "ReadBrands",
* resource: "brand",
* operation: "read"
* },
* {
* name: "WriteBrands",
* resource: "brand",
* operation: "write"
* }
* ])
* ```
*/
export function definePolicies(
policies: PolicyDefinition | PolicyDefinition[]
): definePoliciesExport {
const callerFilePath = getCallerFilePath()
if (isFileDisabled(callerFilePath ?? "")) {
return { [MEDUSA_SKIP_FILE]: true } as any
}
const policiesArray = Array.isArray(policies) ? policies : [policies]
for (const policy of policiesArray) {
if (!policy.name || !policy.resource || !policy.operation) {
throw new Error(
`Policy definition must include name, resource, and operation. Received: ${JSON.stringify(
policy,
null,
2
)}`
)
}
}
for (const policy of policiesArray) {
policy.resource = policy.resource.toLowerCase()
policy.operation = policy.operation.toLowerCase()
const resourceKey = toSnakeCase(policy.resource)
PolicyResource[resourceKey] = policy.resource
const operationKey = toSnakeCase(policy.operation)
PolicyOperation[operationKey] = policy.operation
// Register in Policy object with name as key
Policy[policy.name] = { ...policy }
}
const output: definePoliciesExport = {
[MedusaPolicySymbol]: true,
policies: policiesArray,
}
return output
}

View File

@@ -1,7 +1,9 @@
export * from "./build-query"
export * from "./create-medusa-mikro-orm-event-subscriber"
export * from "./create-pg-connection"
export * from "./decorators"
export * from "./define-link"
export * from "./define-policies"
export * from "./definition"
export * from "./event-builder-factory"
export * from "./joiner-config-builder"
@@ -16,9 +18,9 @@ export * from "./migration-scripts"
export * from "./mikro-orm-cli-config-builder"
export * from "./module"
export * from "./module-provider"
export * from "./module-provider-registration-key"
export * from "./modules-to-container-types"
export * from "./policy-to-types"
export * from "./query-context"
export * from "./types/links-config"
export * from "./types/medusa-service"
export * from "./module-provider-registration-key"
export * from "./modules-to-container-types"
export * from "./create-medusa-mikro-orm-event-subscriber"

View File

@@ -0,0 +1,78 @@
import { FileSystem } from "../common/file-system"
import { Policy, PolicyOperation, PolicyResource } from "./define-policies"
/**
* Generates TypeScript type definitions for RBAC Resource, Operation, and Policy.
* Creates a "policy-bindings.d.ts" file with type-safe autocomplete.
*
* @param outputDir - Directory where the type definition file should be created
*/
export async function generatePolicyTypes({
outputDir,
}: {
outputDir: string
}) {
const policyTypeEntries: string[] = []
// Generate type entries for each named policy from Policy object
for (const [name, { resource, operation }] of Object.entries(Policy)) {
policyTypeEntries.push(
` ${name}: { resource: "${resource}"; operation: "${operation}" }`
)
}
// If no policies are registered, create empty types
const policyInterface =
policyTypeEntries.length > 0
? `{\n${policyTypeEntries.join("\n")}\n}`
: "{}"
const fileSystem = new FileSystem(outputDir)
const fileName = "policy-bindings.d.ts"
const fileContents = `declare module '@medusajs/framework/utils' {
/**
* RBAC Resource registry with lowercase keys for type-safe access.
* All resource names are normalized to lowercase.
*
* @example
* import { PolicyResource } from '@medusajs/framework/utils'
*
* const productResource = PolicyResource.product // "product"
* const apiKeyResource = PolicyResource.api_key // "api-key"
*/
export const Resource: {
${Object.entries(PolicyResource)
.map(([key, val]) => ` readonly ${key}: "${val}"`)
.join("\n")}
}
/**
* RBAC Operation registry with lowercase keys for type-safe access.
* All operation names are normalized to lowercase.
*
* @example
* import { PolicyOperation } from '@medusajs/framework/utils'
*
* const readOp = PolicyOperation.read // "read"
*/
export const Operation: {
${Object.entries(PolicyOperation)
.map(([key, val]) => ` readonly ${key}: "${val}"`)
.join("\n")}
}
/**
* RBAC Policy registry with all defined policies.
* Maps policy names to their resource and operation pairs.
*
* @example
* import { Policy } from '@medusajs/framework/utils'
*
* const readProduct = Policy.ReadProduct
* // { resource: "product", operation: "read" }
*/
export const Policy: ${policyInterface}
}`
await fileSystem.create(fileName, fileContents)
}

View File

@@ -0,0 +1,71 @@
import { readdir } from "fs/promises"
import { join, normalize } from "path"
import { dynamicImport, readDirRecursive } from "../common"
import { MedusaPolicySymbol } from "../modules-sdk"
const excludedFiles = ["index.js", "index.ts"]
const excludedExtensions = [".d.ts", ".d.ts.map", ".js.map"]
function isPolicyExport(value: unknown): boolean {
return !!value && typeof value === "object" && MedusaPolicySymbol in value
}
/**
* Discover policy definitions from a directory and subdirectories
*/
export async function discoverPoliciesFromDir(
sourcePath?: string,
maxDepth: number = 2
): Promise<void> {
if (!sourcePath) {
return
}
const root = normalize(sourcePath)
const allEntries = await readDirRecursive(root, {
ignoreMissing: true,
maxDepth,
})
const policyDirs = allEntries
.filter((e) => e.isDirectory() && e.name === "policies")
.map((e) => join((e as any).path as string, e.name))
if (!policyDirs.length) {
return
}
await Promise.all(
policyDirs.map(async (scanDir) => {
const entries = await readdir(scanDir, { withFileTypes: true })
await Promise.all(
entries.map(async (entry) => {
if (entry.isDirectory()) {
return
}
if (
excludedExtensions.some((ext) => entry.name.endsWith(ext)) ||
excludedFiles.includes(entry.name)
) {
return
}
// Import the file - this will execute definePolicies() calls
const fileExports = await dynamicImport(join(scanDir, entry.name))
// Validate that at least one export is a policy
const values = Object.values(fileExports)
const hasPolicies = values.some((value) => isPolicyExport(value))
if (!hasPolicies) {
console.warn(
`File ${entry.name} in policies directory does not export any policies`
)
}
})
)
})
)
}

View File

@@ -0,0 +1 @@
export * from "./discover-policies"

View File

@@ -1,11 +1,9 @@
import { MiddlewareRoute } from "@medusajs/framework/http"
import { adminRbacPolicyRoutesMiddlewares } from "./policies/middlewares"
import { adminRbacRolePolicyRoutesMiddlewares } from "./role-policies/middlewares"
import { adminRbacRoleRoutesMiddlewares } from "./roles/middlewares"
export const adminRbacRoutesMiddlewares: MiddlewareRoute[] = [
...adminRbacRoleRoutesMiddlewares,
...adminRbacPolicyRoutesMiddlewares,
...adminRbacRolePolicyRoutesMiddlewares,
]

View File

@@ -3,7 +3,12 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import RbacFeatureFlag from "../../../../feature-flags/rbac"
import { AdminCreateRbacPolicyType } from "./validators"
export const GET = async (
@@ -48,3 +53,7 @@ export const POST = async (
res.status(200).json({ policy })
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key),
})

View File

@@ -1,91 +0,0 @@
import {
deleteRbacRolePoliciesWorkflow,
updateRbacRolePoliciesWorkflow,
} from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
ContainerRegistrationKeys,
MedusaError,
} from "@medusajs/framework/utils"
import { AdminUpdateRbacRolePolicyType } from "../validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: rolePolicies } = await query.graph({
entity: "rbac_role_policy",
filters: { id: req.params.id },
fields: req.queryConfig.fields,
})
const role_policy = rolePolicies[0]
if (!role_policy) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Role policy with id: ${req.params.id} not found`
)
}
res.status(200).json({ role_policy })
}
export const POST = async (
req: AuthenticatedMedusaRequest<AdminUpdateRbacRolePolicyType>,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: existing } = await query.graph({
entity: "rbac_role_policy",
filters: { id: req.params.id },
fields: ["id"],
})
const existingRolePolicy = existing[0]
if (!existingRolePolicy) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Role policy with id "${req.params.id}" not found`
)
}
const { result } = await updateRbacRolePoliciesWorkflow(req.scope).run({
input: {
selector: { id: req.params.id },
update: req.validatedBody,
},
})
const { data: rolePolicies } = await query.graph({
entity: "rbac_role_policy",
filters: { id: result[0].id },
fields: req.queryConfig.fields,
})
const role_policy = rolePolicies[0]
res.status(200).json({ role_policy })
}
export const DELETE = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const id = req.params.id
await deleteRbacRolePoliciesWorkflow(req.scope).run({
input: { ids: [id] },
})
res.status(200).json({
id,
object: "rbac_role_policy",
deleted: true,
})
}

View File

@@ -1,64 +0,0 @@
import * as QueryConfig from "./query-config"
import {
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework"
import { MiddlewareRoute } from "@medusajs/framework/http"
import {
AdminCreateRbacRolePolicy,
AdminGetRbacRolePoliciesParams,
AdminGetRbacRolePolicyParams,
AdminUpdateRbacRolePolicy,
} from "./validators"
export const adminRbacRolePolicyRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/rbac/role-policies",
middlewares: [
validateAndTransformQuery(
AdminGetRbacRolePoliciesParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["GET"],
matcher: "/admin/rbac/role-policies/:id",
middlewares: [
validateAndTransformQuery(
AdminGetRbacRolePolicyParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/rbac/role-policies",
middlewares: [
validateAndTransformBody(AdminCreateRbacRolePolicy),
validateAndTransformQuery(
AdminGetRbacRolePolicyParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/rbac/role-policies/:id",
middlewares: [
validateAndTransformBody(AdminUpdateRbacRolePolicy),
validateAndTransformQuery(
AdminGetRbacRolePolicyParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
{
method: ["DELETE"],
matcher: "/admin/rbac/role-policies/:id",
middlewares: [],
},
]

View File

@@ -1,20 +0,0 @@
export const defaultAdminRbacRolePolicyFields = [
"id",
"role_id",
"scope_id",
"metadata",
"created_at",
"updated_at",
"deleted_at",
]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminRbacRolePolicyFields,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
defaultLimit: 20,
isList: true,
}

View File

@@ -1,50 +0,0 @@
import { createRbacRolePoliciesWorkflow } from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { AdminCreateRbacRolePolicyType } from "./validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: role_policies, metadata } = await query.graph({
entity: "rbac_role_policy",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
})
res.status(200).json({
role_policies,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}
export const POST = async (
req: AuthenticatedMedusaRequest<AdminCreateRbacRolePolicyType>,
res: MedusaResponse
) => {
const input = [req.validatedBody]
const { result } = await createRbacRolePoliciesWorkflow(req.scope).run({
input: { role_policies: input },
})
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: rolePolicies } = await query.graph({
entity: "rbac_role_policy",
fields: req.queryConfig.fields,
filters: { id: result[0].id },
})
const role_policy = rolePolicies[0]
res.status(200).json({ role_policy })
}

View File

@@ -1,52 +0,0 @@
import { z } from "zod"
import { applyAndAndOrOperators } from "../../../utils/common-validators"
import {
createFindParams,
createOperatorMap,
createSelectParams,
} from "../../../utils/validators"
export type AdminGetRbacRolePolicyParamsType = z.infer<
typeof AdminGetRbacRolePolicyParams
>
export const AdminGetRbacRolePolicyParams = createSelectParams()
export const AdminGetRbacRolePoliciesParamsFields = z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
role_id: z.union([z.string(), z.array(z.string())]).optional(),
scope_id: z.union([z.string(), z.array(z.string())]).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
})
export type AdminGetRbacRolePoliciesParamsType = z.infer<
typeof AdminGetRbacRolePoliciesParams
>
export const AdminGetRbacRolePoliciesParams = createFindParams({
limit: 50,
offset: 0,
})
.merge(AdminGetRbacRolePoliciesParamsFields)
.merge(applyAndAndOrOperators(AdminGetRbacRolePoliciesParamsFields))
export type AdminCreateRbacRolePolicyType = z.infer<
typeof AdminCreateRbacRolePolicy
>
export const AdminCreateRbacRolePolicy = z
.object({
role_id: z.string(),
scope_id: z.string(),
metadata: z.record(z.unknown()).nullish(),
})
.strict()
export type AdminUpdateRbacRolePolicyType = z.infer<
typeof AdminUpdateRbacRolePolicy
>
export const AdminUpdateRbacRolePolicy = z
.object({
metadata: z.record(z.unknown()).nullish(),
})
.strict()

View File

@@ -0,0 +1,40 @@
import { deleteRbacRolePoliciesWorkflow } from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { defineFileConfig, FeatureFlag } from "@medusajs/framework/utils"
import RbacFeatureFlag from "../../../../../../../feature-flags/rbac"
export const DELETE = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const { policy_id, id: role_id } = req.params
// First, we need to find the role_policy_id that connects this role and policy
const query = req.scope.resolve("query")
const { data: rolePolicies } = await query.graph({
entity: "rbac_role_policy",
fields: ["id"],
filters: { role_id, policy_id },
})
const rolePolicyId = rolePolicies[0]?.id
await deleteRbacRolePoliciesWorkflow(req.scope).run({
input: {
role_policy_ids: rolePolicyId ? [rolePolicyId] : [],
},
})
res.status(200).json({
id: rolePolicyId,
object: "rbac_role_policy",
deleted: true,
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key),
})

View File

@@ -0,0 +1,69 @@
import { createRbacRolePoliciesWorkflow } from "@medusajs/core-flows"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import RbacFeatureFlag from "../../../../../../feature-flags/rbac"
import { AdminAddRolePoliciesType } from "../../validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const roleId = req.params.id
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: policies, metadata } = await query.graph({
entity: "rbac_role_policy",
fields: req.queryConfig?.fields,
filters: { ...req.filterableFields, role_id: roleId },
pagination: req.queryConfig?.pagination || {},
})
res.status(200).json({
policies,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}
export const POST = async (
req: AuthenticatedMedusaRequest<AdminAddRolePoliciesType>,
res: MedusaResponse
) => {
const roleId = req.params.id
const { policies } = req.validatedBody
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const rolePolicies = policies.map((policyId) => ({
role_id: roleId,
policy_id: policyId,
}))
const { result } = await createRbacRolePoliciesWorkflow(req.scope).run({
input: {
actor_id: req.auth_context.actor_id,
actor: req.auth_context.actor_type,
policies: rolePolicies,
},
})
// Get the created role-policy association
const { data } = await query.graph({
entity: "rbac_role_policy",
fields: req.queryConfig?.fields,
filters: { id: result[0].id },
})
res.status(200).json({ policies: data })
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key),
})

View File

@@ -57,6 +57,8 @@ export const POST = async (
const { result } = await updateRbacRolesWorkflow(req.scope).run({
input: {
actor_id: req.auth_context.actor_id,
actor: req.auth_context.actor_type,
selector: { id: req.params.id },
update: req.validatedBody,
},

View File

@@ -1 +0,0 @@
export {}

View File

@@ -7,6 +7,7 @@ import {
import { MiddlewareRoute } from "@medusajs/framework/http"
import {
AdminAddRolePoliciesType,
AdminCreateRbacRole,
AdminGetRbacRoleParams,
AdminGetRbacRolesParams,
@@ -56,6 +57,32 @@ export const adminRbacRoleRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["GET"],
matcher: "/admin/rbac/roles/:id/policies",
middlewares: [
validateAndTransformQuery(
AdminGetRbacRoleParams,
QueryConfig.retrieveRolePoliciesTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/rbac/roles/:id/policies",
middlewares: [
validateAndTransformBody(AdminAddRolePoliciesType),
validateAndTransformQuery(
AdminGetRbacRoleParams,
QueryConfig.retrieveRolePoliciesTransformQueryConfig
),
],
},
{
method: ["DELETE"],
matcher: "/admin/rbac/roles/:id/policies/:policy_id",
middlewares: [],
},
{
method: ["DELETE"],
matcher: "/admin/rbac/roles/:id",

View File

@@ -19,3 +19,24 @@ export const listTransformQueryConfig = {
defaultLimit: 20,
isList: true,
}
export const defaultAdminRolePoliciesFields = [
"id",
"role_id",
"policy_id",
"policy",
"metadata",
"created_at",
"updated_at",
"deleted_at",
]
export const retrieveRolePoliciesTransformQueryConfig = {
defaults: defaultAdminRolePoliciesFields,
isList: false,
}
export const listRolePoliciesTransformQueryConfig = {
...retrieveRolePoliciesTransformQueryConfig,
isList: true,
}

View File

@@ -3,7 +3,12 @@ import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import RbacFeatureFlag from "../../../../feature-flags/rbac"
import { AdminCreateRbacRoleType } from "./validators"
export const GET = async (
@@ -34,7 +39,11 @@ export const POST = async (
const input = [req.validatedBody]
const { result } = await createRbacRolesWorkflow(req.scope).run({
input: { roles: input },
input: {
actor_id: req.auth_context.actor_id,
actor: req.auth_context.actor_type,
roles: input,
},
})
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
@@ -48,3 +57,7 @@ export const POST = async (
res.status(200).json({ role })
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key),
})

View File

@@ -7,7 +7,11 @@ import {
} from "../../../utils/validators"
export type AdminGetRbacRoleParamsType = z.infer<typeof AdminGetRbacRoleParams>
export const AdminGetRbacRoleParams = createSelectParams()
export const AdminGetRbacRoleParams = createSelectParams().merge(
z.object({
policies: z.union([z.string(), z.array(z.string())]).optional(),
})
)
export const AdminGetRbacRolesParamsFields = z.object({
q: z.string().optional(),
@@ -48,3 +52,9 @@ export const AdminUpdateRbacRole = z
metadata: z.record(z.unknown()).nullish(),
})
.strict()
export const AdminAddRolePoliciesType = z.object({
policies: z.array(z.string().min(1)).min(1),
})
export type AdminAddRolePoliciesType = z.infer<typeof AdminAddRolePoliciesType>

View File

@@ -1,3 +1,4 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import {
AuthenticationInput,
ConfigModule,
@@ -8,7 +9,6 @@ import {
MedusaError,
Modules,
} from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { generateJwtTokenForAuthIdentity } from "../../../utils/generate-jwt-token"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
@@ -35,8 +35,13 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
if (success && authIdentity) {
const { http } = config.projectConfig
const token = generateJwtTokenForAuthIdentity(
{ authIdentity, actorType: actor_type, authProvider: auth_provider },
const token = await generateJwtTokenForAuthIdentity(
{
authIdentity,
actorType: actor_type,
authProvider: auth_provider,
container: req.scope,
},
{
secret: http.jwtSecret!,
expiresIn: http.jwtExpiresIn,

View File

@@ -1,3 +1,4 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import {
AuthenticationInput,
ConfigModule,
@@ -8,7 +9,6 @@ import {
MedusaError,
Modules,
} from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { generateJwtTokenForAuthIdentity } from "../../../utils/generate-jwt-token"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
@@ -35,11 +35,12 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
if (success && authIdentity) {
const { http } = config.projectConfig
const token = generateJwtTokenForAuthIdentity(
const token = await generateJwtTokenForAuthIdentity(
{
authIdentity,
actorType: actor_type,
authProvider: auth_provider,
container: req.scope,
},
{
secret: http.jwtSecret!,

View File

@@ -1,3 +1,4 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import {
AuthenticationInput,
ConfigModule,
@@ -8,7 +9,6 @@ import {
MedusaError,
Modules,
} from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { generateJwtTokenForAuthIdentity } from "../../utils/generate-jwt-token"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
@@ -39,11 +39,12 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
if (success && authIdentity) {
const { http } = config.projectConfig
const token = generateJwtTokenForAuthIdentity(
const token = await generateJwtTokenForAuthIdentity(
{
authIdentity,
actorType: actor_type,
authProvider: auth_provider,
container: req.scope,
},
{
secret: http.jwtSecret!,

View File

@@ -1,9 +1,9 @@
import { IAuthModuleService } from "@medusajs/framework/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { IAuthModuleService } from "@medusajs/framework/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
import { generateJwtTokenForAuthIdentity } from "../../utils/generate-jwt-token"
// Retrieve a newly generated JWT token. All checks that the existing token is valid already happen in the auth middleware.
@@ -23,8 +23,12 @@ export const POST = async (
ContainerRegistrationKeys.CONFIG_MODULE
).projectConfig
const token = generateJwtTokenForAuthIdentity(
{ authIdentity, actorType: req.auth_context.actor_type },
const token = await generateJwtTokenForAuthIdentity(
{
authIdentity,
actorType: req.auth_context.actor_type,
container: req.scope,
},
{
secret: http.jwtSecret!,
expiresIn: http.jwtExpiresIn,

View File

@@ -1,19 +1,27 @@
import {
AuthIdentityDTO,
MedusaContainer,
ProjectConfigOptions,
} from "@medusajs/framework/types"
import { generateJwtToken } from "@medusajs/framework/utils"
import {
ContainerRegistrationKeys,
FeatureFlag,
generateJwtToken,
} from "@medusajs/framework/utils"
import { type Secret } from "jsonwebtoken"
import RbacFeatureFlag from "../../../feature-flags/rbac"
export function generateJwtTokenForAuthIdentity(
export async function generateJwtTokenForAuthIdentity(
{
authIdentity,
actorType,
authProvider,
container,
}: {
authIdentity: AuthIdentityDTO
actorType: string
authProvider?: string
container?: MedusaContainer
},
{
secret,
@@ -37,6 +45,29 @@ export function generateJwtTokenForAuthIdentity(
(identity) => identity.provider === authProvider
)[0]
let roles: string[] = []
if (FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key)) {
if (container && entityId) {
try {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const { data: userRoles } = await query.graph({
entity: actorType,
fields: ["rbac_roles.id"],
filters: {
id: entityId,
},
})
if (userRoles?.[0]?.rbac_roles) {
roles = userRoles[0].rbac_roles.map((role) => role.id)
}
} catch {
// ignore
}
}
}
return generateJwtToken(
{
actor_id: entityId ?? "",
@@ -44,6 +75,7 @@ export function generateJwtTokenForAuthIdentity(
auth_identity_id: authIdentity?.id ?? "",
app_metadata: {
[entityIdKey]: entityId,
roles,
},
user_metadata: providerIdentity?.user_metadata ?? {},
},

View File

@@ -11,10 +11,12 @@ import {
dynamicImport,
FileSystem,
generateContainerTypes,
generatePolicyTypes,
gqlSchemaToTypes,
GracefulShutdownServer,
isFileSkipped,
isPresent,
promiseAll,
} from "@medusajs/framework/utils"
import { MedusaModule } from "@medusajs/framework/modules-sdk"
@@ -274,27 +276,35 @@ async function start(args: {
if (generateTypes) {
const typesDirectory = path.join(directory, ".medusa/types")
/**
* Cleanup existing types directory before creating new artifacts
*/
await new FileSystem(typesDirectory).cleanup({ recursive: true })
const fileGenPromises: Promise<void>[] = []
await generateContainerTypes(modules, {
outputDir: typesDirectory,
interfaceName: "ModuleImplementations",
})
logger.debug("Generated container types")
fileGenPromises.push(
generateContainerTypes(modules, {
outputDir: typesDirectory,
interfaceName: "ModuleImplementations",
})
)
if (gqlSchema) {
await gqlSchemaToTypes({
outputDir: typesDirectory,
filename: "query-entry-points",
interfaceName: "RemoteQueryEntryPoints",
schema: gqlSchema,
joinerConfigs: MedusaModule.getAllJoinerConfigs(),
})
logger.debug("Generated modules types")
fileGenPromises.push(
gqlSchemaToTypes({
outputDir: typesDirectory,
filename: "query-entry-points",
interfaceName: "RemoteQueryEntryPoints",
schema: gqlSchema,
joinerConfigs: MedusaModule.getAllJoinerConfigs(),
})
)
}
fileGenPromises.push(
generatePolicyTypes({
outputDir: typesDirectory,
})
)
await promiseAll(fileGenPromises)
logger.debug("Generated policy types")
}
// Register a health check endpoint. Ideally this also checks the readiness of the service, rather than just returning a static response.

View File

@@ -0,0 +1,10 @@
import { FlagSettings } from "@medusajs/framework/feature-flags"
const RbacFeatureFlag: FlagSettings = {
key: "rbac",
default_val: false,
env_key: "MEDUSA_FF_RBAC",
description: "Enable role based access control",
}
export default RbacFeatureFlag

View File

@@ -1,4 +1,5 @@
import { container, MedusaAppLoader } from "@medusajs/framework"
import { container, MedusaAppLoader, policiesLoader } from "@medusajs/framework"
import { asValue } from "@medusajs/framework/awilix"
import { configLoader } from "@medusajs/framework/config"
import { pgConnectionLoader } from "@medusajs/framework/database"
import { featureFlagsLoader } from "@medusajs/framework/feature-flags"
@@ -22,7 +23,6 @@ import {
validateModuleName,
} from "@medusajs/framework/utils"
import { WorkflowLoader } from "@medusajs/framework/workflows"
import { asValue } from "@medusajs/framework/awilix"
import { Express, NextFunction, Request, Response } from "express"
import { join } from "path"
import requestIp from "request-ip"
@@ -182,6 +182,12 @@ export default async ({
)
await new LinkLoader(linksSourcePaths, logger).load()
// Load policies from project root and all plugins
await policiesLoader(rootDirectory)
for (const plugin of plugins) {
await policiesLoader(plugin.resolve)
}
const {
onApplicationStart,
onApplicationShutdown,

View File

@@ -0,0 +1,6 @@
import RbacModule from "@medusajs/rbac"
export * from "@medusajs/rbac"
export default RbacModule
export const discoveryPath = require.resolve("@medusajs/rbac")

View File

@@ -0,0 +1,152 @@
import { MedusaContainer } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
FeatureFlag,
useCache,
} from "@medusajs/framework/utils"
import RbacFeatureFlag from "../../feature-flags/rbac"
export type PermissionAction = {
resource: string
operation: string
}
/*
/**
*
* @property roles the role(s) to check. Can be a single string or an array of strings.
* @property actions the action(s) to check. Can be a single `PermissionAction` or an array of `PermissionAction`s.
* @property container the Medusa container
*/
export type HasPermissionInput = {
roles: string | string[]
actions: PermissionAction | PermissionAction[]
container: MedusaContainer
}
type RolePoliciesCache = Map<string, Map<string, Set<string>>>
/**
* Checks if the given role(s) have permission to perform the specified action(s).
*
* @param input - The input containing roles, actions, and container
* @returns true if all actions are permitted, false otherwise
*
* @example
* ```ts
* const canWrite = await hasPermission({
* roles: ['role_123'],
* actions: { resource: 'product', operation: 'write' },
* container
* })
* ```
*/
export async function hasPermission(
input: HasPermissionInput
): Promise<boolean> {
const { roles, actions, container } = input
const roleIds = Array.isArray(roles) ? roles : [roles]
const actionList = Array.isArray(actions) ? actions : [actions]
const isDisabled = !FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key)
if (isDisabled || !roleIds?.length || !actionList?.length) {
return true
}
const rolePoliciesMap = await fetchRolePolicies(roleIds, container)
for (const action of actionList) {
let hasAccess = false
for (const roleId of roleIds) {
const resourceMap = rolePoliciesMap.get(roleId)
if (!resourceMap) {
continue
}
const operations = resourceMap.get(action.resource)
if (
operations &&
(operations.has(action.operation) || operations.has("*"))
) {
hasAccess = true
break
}
}
if (!hasAccess) {
return false
}
}
return true
}
/**
* Fetches a single role's policies from cache or database.
*/
async function fetchSingleRolePolicies(
roleId: string,
container: MedusaContainer
): Promise<Map<string, Set<string>>> {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const tags: string[] = []
return await useCache<Map<string, Set<string>>>(
async () => {
const { data: roles } = await query.graph({
entity: "rbac_role",
fields: ["id", "policies.*"],
filters: { id: roleId },
})
const role = roles[0]
const resourceMap = new Map<string, Set<string>>()
tags.push(`rbac_role:${roleId}`)
if (role?.policies && Array.isArray(role.policies)) {
const policyIds: string[] = []
for (const policy of role.policies) {
policyIds.push(policy.id)
if (!resourceMap.has(policy.resource)) {
resourceMap.set(policy.resource, new Set())
}
resourceMap.get(policy.resource)!.add(policy.operation)
tags.push(`rbac_policy:${policy.id}`)
}
}
return resourceMap
},
{
container,
key: roleId,
tags,
ttl: 60 * 60 * 24 * 7,
providers: ["cache-memory"],
}
)
}
/**
* Fetches policies for multiple roles by composing individually cached role queries.
*/
async function fetchRolePolicies(
roleIds: string[],
container: MedusaContainer
): Promise<RolePoliciesCache> {
const rolePoliciesMap: RolePoliciesCache = new Map()
await Promise.all(
roleIds.map(async (roleId) => {
const resourceMap = await fetchSingleRolePolicies(roleId, container)
rolePoliciesMap.set(roleId, resourceMap)
})
)
return rolePoliciesMap
}

View File

@@ -1,5 +1,6 @@
export * from "./cart-payment-collection"
export * from "./cart-promotion"
export * from "./customer-account-holder"
export * from "./fulfillment-provider-location"
export * from "./fulfillment-set-location"
export * from "./order-cart"
@@ -8,6 +9,7 @@ export * from "./order-payment-collection"
export * from "./order-promotion"
export * from "./order-return-fulfillment"
export * from "./product-sales-channel"
export * from "./product-shipping-profile"
export * from "./product-variant-inventory-item"
export * from "./product-variant-price-set"
export * from "./publishable-api-key-sales-channel"
@@ -15,5 +17,4 @@ export * from "./readonly"
export * from "./region-payment-provider"
export * from "./sales-channel-location"
export * from "./shipping-option-price-set"
export * from "./product-shipping-profile"
export * from "./customer-account-holder"
export * from "./user-rbac-role"

View File

@@ -0,0 +1,74 @@
import { ModuleJoinerConfig } from "@medusajs/framework/types"
import { LINKS, Modules } from "@medusajs/framework/utils"
export const UserRbacRole: ModuleJoinerConfig = {
serviceName: LINKS.UserRbacRole,
isLink: true,
databaseConfig: {
tableName: "user_rbac_role",
idPrefix: "userrole",
},
alias: [
{
name: "user_rbac_role",
},
{
name: "user_rbac_roles",
},
],
primaryKeys: ["id", "user_id", "rbac_role_id"],
relationships: [
{
serviceName: Modules.USER,
entity: "User",
primaryKey: "id",
foreignKey: "user_id",
alias: "user",
args: {
methodSuffix: "Users",
},
hasMany: true,
},
{
serviceName: Modules.RBAC,
entity: "RbacRole",
primaryKey: "id",
foreignKey: "rbac_role_id",
alias: "rbac_role",
args: {
methodSuffix: "RbacRoles",
},
hasMany: true,
},
],
extends: [
{
serviceName: Modules.USER,
entity: "User",
fieldAlias: {
rbac_roles: {
path: "rbac_roles_link.rbac_role",
isList: true,
},
},
relationship: {
serviceName: LINKS.UserRbacRole,
primaryKey: "user_id",
foreignKey: "id",
alias: "rbac_roles_link",
isList: true,
},
},
{
serviceName: Modules.RBAC,
entity: "RbacRole",
relationship: {
serviceName: LINKS.UserRbacRole,
primaryKey: "rbac_role_id",
foreignKey: "id",
alias: "users_link",
isList: true,
},
},
],
}

View File

@@ -1,6 +1,6 @@
import { Module } from "@medusajs/framework/utils"
import { Module, Modules } from "@medusajs/framework/utils"
import { RbacModuleService } from "@services"
export default Module("rbac", {
export default Module(Modules.RBAC, {
service: RbacModuleService,
})

View File

@@ -1,7 +1,5 @@
{
"namespaces": [
"public"
],
"namespaces": ["public"],
"name": "public",
"tables": [
{
@@ -143,9 +141,7 @@
},
{
"keyName": "rbac_policy_pkey",
"columnNames": [
"id"
],
"columnNames": ["id"],
"composite": false,
"constraint": true,
"primary": true,
@@ -250,9 +246,7 @@
},
{
"keyName": "rbac_role_pkey",
"columnNames": [
"id"
],
"columnNames": ["id"],
"composite": false,
"constraint": true,
"primary": true,
@@ -283,8 +277,8 @@
"nullable": false,
"mappedType": "text"
},
"inherited_role_id": {
"name": "inherited_role_id",
"parent_id": {
"name": "parent_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
@@ -334,50 +328,48 @@
"mappedType": "datetime"
}
},
"name": "rbac_role_inheritance",
"name": "rbac_role_parent",
"schema": "public",
"indexes": [
{
"keyName": "IDX_rbac_role_inheritance_role_id",
"keyName": "IDX_rbac_role_parent_role_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_inheritance_role_id\" ON \"rbac_role_inheritance\" (\"role_id\") WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_parent_role_id\" ON \"rbac_role_parent\" (\"role_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_rbac_role_inheritance_inherited_role_id",
"keyName": "IDX_rbac_role_parent_parent_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_inheritance_inherited_role_id\" ON \"rbac_role_inheritance\" (\"inherited_role_id\") WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_parent_parent_id\" ON \"rbac_role_parent\" (\"parent_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_rbac_role_inheritance_deleted_at",
"keyName": "IDX_rbac_role_parent_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_inheritance_deleted_at\" ON \"rbac_role_inheritance\" (\"deleted_at\") WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_parent_deleted_at\" ON \"rbac_role_parent\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_rbac_role_inheritance_role_id_inherited_role_id_unique",
"keyName": "IDX_rbac_role_parent_role_id_parent_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_rbac_role_inheritance_role_id_inherited_role_id_unique\" ON \"rbac_role_inheritance\" (\"role_id\", \"inherited_role_id\") WHERE deleted_at IS NULL"
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_rbac_role_parent_role_id_parent_id_unique\" ON \"rbac_role_parent\" (\"role_id\", \"parent_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "rbac_role_inheritance_pkey",
"columnNames": [
"id"
],
"keyName": "rbac_role_parent_pkey",
"columnNames": ["id"],
"composite": false,
"constraint": true,
"primary": true,
@@ -386,27 +378,19 @@
],
"checks": [],
"foreignKeys": {
"rbac_role_inheritance_role_id_foreign": {
"constraintName": "rbac_role_inheritance_role_id_foreign",
"columnNames": [
"role_id"
],
"localTableName": "public.rbac_role_inheritance",
"referencedColumnNames": [
"id"
],
"rbac_role_parent_role_id_foreign": {
"constraintName": "rbac_role_parent_role_id_foreign",
"columnNames": ["role_id"],
"localTableName": "public.rbac_role_parent",
"referencedColumnNames": ["id"],
"referencedTableName": "public.rbac_role",
"updateRule": "cascade"
},
"rbac_role_inheritance_inherited_role_id_foreign": {
"constraintName": "rbac_role_inheritance_inherited_role_id_foreign",
"columnNames": [
"inherited_role_id"
],
"localTableName": "public.rbac_role_inheritance",
"referencedColumnNames": [
"id"
],
"rbac_role_parent_parent_id_foreign": {
"constraintName": "rbac_role_parent_parent_id_foreign",
"columnNames": ["parent_id"],
"localTableName": "public.rbac_role_parent",
"referencedColumnNames": ["id"],
"referencedTableName": "public.rbac_role",
"updateRule": "cascade"
}
@@ -433,8 +417,8 @@
"nullable": false,
"mappedType": "text"
},
"scope_id": {
"name": "scope_id",
"policy_id": {
"name": "policy_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
@@ -497,13 +481,13 @@
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_policy_role_id\" ON \"rbac_role_policy\" (\"role_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_rbac_role_policy_scope_id",
"keyName": "IDX_rbac_role_policy_policy_id",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_policy_scope_id\" ON \"rbac_role_policy\" (\"scope_id\") WHERE deleted_at IS NULL"
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_policy_policy_id\" ON \"rbac_role_policy\" (\"policy_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_rbac_role_policy_deleted_at",
@@ -515,19 +499,17 @@
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_policy_deleted_at\" ON \"rbac_role_policy\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_rbac_role_policy_role_id_scope_id_unique",
"keyName": "IDX_rbac_role_policy_role_id_policy_id_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_rbac_role_policy_role_id_scope_id_unique\" ON \"rbac_role_policy\" (\"role_id\", \"scope_id\") WHERE deleted_at IS NULL"
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_rbac_role_policy_role_id_policy_id_unique\" ON \"rbac_role_policy\" (\"role_id\", \"policy_id\") WHERE deleted_at IS NULL"
},
{
"keyName": "rbac_role_policy_pkey",
"columnNames": [
"id"
],
"columnNames": ["id"],
"composite": false,
"constraint": true,
"primary": true,
@@ -538,25 +520,17 @@
"foreignKeys": {
"rbac_role_policy_role_id_foreign": {
"constraintName": "rbac_role_policy_role_id_foreign",
"columnNames": [
"role_id"
],
"columnNames": ["role_id"],
"localTableName": "public.rbac_role_policy",
"referencedColumnNames": [
"id"
],
"referencedColumnNames": ["id"],
"referencedTableName": "public.rbac_role",
"updateRule": "cascade"
},
"rbac_role_policy_scope_id_foreign": {
"constraintName": "rbac_role_policy_scope_id_foreign",
"columnNames": [
"scope_id"
],
"rbac_role_policy_policy_id_foreign": {
"constraintName": "rbac_role_policy_policy_id_foreign",
"columnNames": ["policy_id"],
"localTableName": "public.rbac_role_policy",
"referencedColumnNames": [
"id"
],
"referencedColumnNames": ["id"],
"referencedTableName": "public.rbac_policy",
"updateRule": "cascade"
}

View File

@@ -1,10 +1,10 @@
import { Migration } from '@mikro-orm/migrations';
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20251215113723 extends Migration {
export class Migration20251219163509 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "rbac_role_policy" drop constraint if exists "rbac_role_policy_role_id_scope_id_unique";`);
this.addSql(`alter table if exists "rbac_role_inheritance" drop constraint if exists "rbac_role_inheritance_role_id_inherited_role_id_unique";`);
this.addSql(`alter table if exists "rbac_role_policy" drop constraint if exists "rbac_role_policy_role_id_policy_id_unique";`);
this.addSql(`alter table if exists "rbac_role_parent" drop constraint if exists "rbac_role_parent_role_id_parent_id_unique";`);
this.addSql(`alter table if exists "rbac_role" drop constraint if exists "rbac_role_name_unique";`);
this.addSql(`alter table if exists "rbac_policy" drop constraint if exists "rbac_policy_key_unique";`);
this.addSql(`create table if not exists "rbac_policy" ("id" text not null, "key" text not null, "resource" text not null, "operation" text not null, "name" text null, "description" text null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "rbac_policy_pkey" primary key ("id"));`);
@@ -17,23 +17,23 @@ export class Migration20251215113723 extends Migration {
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_deleted_at" ON "rbac_role" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_rbac_role_name_unique" ON "rbac_role" ("name") WHERE deleted_at IS NULL;`);
this.addSql(`create table if not exists "rbac_role_inheritance" ("id" text not null, "role_id" text not null, "inherited_role_id" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "rbac_role_inheritance_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_inheritance_role_id" ON "rbac_role_inheritance" ("role_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_inheritance_inherited_role_id" ON "rbac_role_inheritance" ("inherited_role_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_inheritance_deleted_at" ON "rbac_role_inheritance" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_rbac_role_inheritance_role_id_inherited_role_id_unique" ON "rbac_role_inheritance" ("role_id", "inherited_role_id") WHERE deleted_at IS NULL;`);
this.addSql(`create table if not exists "rbac_role_parent" ("id" text not null, "role_id" text not null, "parent_id" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "rbac_role_parent_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_parent_role_id" ON "rbac_role_parent" ("role_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_parent_parent_id" ON "rbac_role_parent" ("parent_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_parent_deleted_at" ON "rbac_role_parent" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_rbac_role_parent_role_id_parent_id_unique" ON "rbac_role_parent" ("role_id", "parent_id") WHERE deleted_at IS NULL;`);
this.addSql(`create table if not exists "rbac_role_policy" ("id" text not null, "role_id" text not null, "scope_id" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "rbac_role_policy_pkey" primary key ("id"));`);
this.addSql(`create table if not exists "rbac_role_policy" ("id" text not null, "role_id" text not null, "policy_id" text not null, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "rbac_role_policy_pkey" primary key ("id"));`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_policy_role_id" ON "rbac_role_policy" ("role_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_policy_scope_id" ON "rbac_role_policy" ("scope_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_policy_policy_id" ON "rbac_role_policy" ("policy_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_role_policy_deleted_at" ON "rbac_role_policy" ("deleted_at") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_rbac_role_policy_role_id_scope_id_unique" ON "rbac_role_policy" ("role_id", "scope_id") WHERE deleted_at IS NULL;`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_rbac_role_policy_role_id_policy_id_unique" ON "rbac_role_policy" ("role_id", "policy_id") WHERE deleted_at IS NULL;`);
this.addSql(`alter table if exists "rbac_role_inheritance" add constraint "rbac_role_inheritance_role_id_foreign" foreign key ("role_id") references "rbac_role" ("id") on update cascade;`);
this.addSql(`alter table if exists "rbac_role_inheritance" add constraint "rbac_role_inheritance_inherited_role_id_foreign" foreign key ("inherited_role_id") references "rbac_role" ("id") on update cascade;`);
this.addSql(`alter table if exists "rbac_role_parent" add constraint "rbac_role_parent_role_id_foreign" foreign key ("role_id") references "rbac_role" ("id") on update cascade;`);
this.addSql(`alter table if exists "rbac_role_parent" add constraint "rbac_role_parent_parent_id_foreign" foreign key ("parent_id") references "rbac_role" ("id") on update cascade;`);
this.addSql(`alter table if exists "rbac_role_policy" add constraint "rbac_role_policy_role_id_foreign" foreign key ("role_id") references "rbac_role" ("id") on update cascade;`);
this.addSql(`alter table if exists "rbac_role_policy" add constraint "rbac_role_policy_scope_id_foreign" foreign key ("scope_id") references "rbac_policy" ("id") on update cascade;`);
this.addSql(`alter table if exists "rbac_role_policy" add constraint "rbac_role_policy_policy_id_foreign" foreign key ("policy_id") references "rbac_policy" ("id") on update cascade;`);
}
}

View File

@@ -1,4 +1,4 @@
export { default as RbacPolicy } from "./rbac-policy"
export { default as RbacRole } from "./rbac-role"
export { default as RbacRoleInheritance } from "./rbac-role-inheritance"
export { default as RbacRoleParent } from "./rbac-role-parent"
export { default as RbacRolePolicy } from "./rbac-role-policy"

View File

@@ -0,0 +1,27 @@
import { model } from "@medusajs/framework/utils"
import RbacRole from "./rbac-role"
const RbacRoleParent = model
.define("rbac_role_parent", {
id: model.id({ prefix: "rlin" }).primaryKey(),
role: model.belongsTo(() => RbacRole, { mappedBy: "parents" }),
parent: model.belongsTo(() => RbacRole),
metadata: model.json().nullable(),
})
.indexes([
{
on: ["role_id"],
where: "deleted_at IS NULL",
},
{
on: ["parent_id"],
where: "deleted_at IS NULL",
},
{
on: ["role_id", "parent_id"],
unique: true,
where: "deleted_at IS NULL",
},
])
export default RbacRoleParent

View File

@@ -6,7 +6,7 @@ const RbacRolePolicy = model
.define("rbac_role_policy", {
id: model.id({ prefix: "rlpl" }).primaryKey(),
role: model.belongsTo(() => RbacRole),
scope: model.belongsTo(() => RbacPolicy),
policy: model.belongsTo(() => RbacPolicy),
metadata: model.json().nullable(),
})
.indexes([
@@ -15,11 +15,11 @@ const RbacRolePolicy = model
where: "deleted_at IS NULL",
},
{
on: ["scope_id"],
on: ["policy_id"],
where: "deleted_at IS NULL",
},
{
on: ["role_id", "scope_id"],
on: ["role_id", "policy_id"],
unique: true,
where: "deleted_at IS NULL",
},

View File

@@ -1,4 +1,6 @@
import { model } from "@medusajs/framework/utils"
import RbacRoleParent from "./rbac-role-parent"
import RbacRolePolicy from "./rbac-role-policy"
const RbacRole = model
.define("rbac_role", {
@@ -6,6 +8,12 @@ const RbacRole = model
name: model.text().searchable(),
description: model.text().nullable(),
metadata: model.json().nullable(),
policies: model.hasMany(() => RbacRolePolicy, {
mappedBy: "role",
}),
parents: model.hasMany(() => RbacRoleParent, {
mappedBy: "role",
}),
})
.indexes([
{

View File

@@ -35,17 +35,19 @@ export class RbacRepository extends MikroOrmBase {
const query = `
WITH RECURSIVE role_hierarchy AS (
SELECT id, name, id as original_role_id
SELECT id, name, id as original_role_id, ARRAY[id] as path
FROM rbac_role
WHERE id IN (${placeholders}) AND deleted_at IS NULL
UNION ALL
SELECT r.id, r.name, rh.original_role_id
SELECT r.id, r.name, rh.original_role_id, rh.path || r.id
FROM rbac_role r
INNER JOIN rbac_role_inheritance ri ON ri.inherited_role_id = r.id
INNER JOIN rbac_role_parent ri ON ri.parent_id = r.id
INNER JOIN role_hierarchy rh ON rh.id = ri.role_id
WHERE r.deleted_at IS NULL AND ri.deleted_at IS NULL
WHERE r.deleted_at IS NULL
AND ri.deleted_at IS NULL
AND NOT (r.id = ANY(rh.path))
)
SELECT DISTINCT
rh.original_role_id,
@@ -60,7 +62,7 @@ export class RbacRepository extends MikroOrmBase {
p.updated_at,
CASE WHEN rp.role_id = rh.original_role_id THEN NULL ELSE rp.role_id END as inherited_from_role_id
FROM rbac_policy p
INNER JOIN rbac_role_policy rp ON rp.scope_id = p.id
INNER JOIN rbac_role_policy rp ON rp.policy_id = p.id
INNER JOIN role_hierarchy rh ON rh.id = rp.role_id
WHERE p.deleted_at IS NULL AND rp.deleted_at IS NULL
ORDER BY rh.original_role_id, p.resource, p.operation, p.key
@@ -85,4 +87,40 @@ export class RbacRepository extends MikroOrmBase {
return policiesByRole
}
async checkForCycle(
roleId: string,
parentId: string,
sharedContext: Context = {}
): Promise<boolean> {
const manager = this.getActiveManager<SqlEntityManager>(sharedContext)
const knex = manager.getKnex()
// Check if adding this parent would create a circular dependency
// A cycle exists if role_id is already an ancestor of parent_id
// (i.e., if we traverse up from parent_id, we reach role_id)
const query = `
WITH RECURSIVE role_hierarchy AS (
SELECT id, ARRAY[id] as path
FROM rbac_role
WHERE id = ? AND deleted_at IS NULL
UNION ALL
SELECT r.id, rh.path || r.id
FROM role_hierarchy rh
INNER JOIN rbac_role_parent ri ON ri.role_id = rh.id
INNER JOIN rbac_role r ON r.id = ri.parent_id
WHERE r.deleted_at IS NULL
AND ri.deleted_at IS NULL
AND NOT (r.id = ANY(rh.path))
)
SELECT EXISTS(
SELECT 1 FROM role_hierarchy WHERE id = ?
) as has_cycle
`
const result = await knex.raw(query, [parentId, roleId])
return result.rows[0]?.has_cycle || false
}
}

View File

@@ -1,32 +1,38 @@
import { Context, FindConfig } from "@medusajs/framework/types"
import {
Context,
FilterableRbacRoleProps,
FindConfig,
RbacRoleDTO,
} from "@medusajs/framework/types"
import {
InjectManager,
MedusaContext,
MedusaService,
Policy,
promiseAll,
} from "@medusajs/framework/utils"
import {
RbacPolicy,
RbacRole,
RbacRoleInheritance,
RbacRolePolicy,
} from "@models"
CreateRbacRoleParentDTO,
IRbacModuleService,
RbacRoleParentDTO,
UpdateRbacRoleParentDTO,
} from "@medusajs/types"
import { RbacPolicy, RbacRole, RbacRoleParent, RbacRolePolicy } from "@models"
import { RbacRepository } from "../repositories"
type InjectedDependencies = {
rbacRepository: RbacRepository
}
export default class RbacModuleService extends MedusaService<{
RbacRole: { dto: any }
RbacPolicy: { dto: any }
RbacRoleInheritance: { dto: any }
RbacRolePolicy: { dto: any }
}>({
RbacRole,
RbacPolicy,
RbacRoleInheritance,
RbacRolePolicy,
}) {
export default class RbacModuleService
extends MedusaService({
RbacRole,
RbacPolicy,
RbacRoleParent,
RbacRolePolicy,
})
implements IRbacModuleService
{
protected readonly rbacRepository_: RbacRepository
constructor({ rbacRepository }: InjectedDependencies) {
@@ -35,6 +41,93 @@ export default class RbacModuleService extends MedusaService<{
this.rbacRepository_ = rbacRepository
}
__hooks = {
onApplicationStart: async () => {
this.onApplicationStart()
},
}
async onApplicationStart(): Promise<void> {
await this.syncRegisteredPolicies()
}
@InjectManager()
private async syncRegisteredPolicies(
@MedusaContext() sharedContext: Context = {}
): Promise<void> {
const registeredPolicies = Object.entries(Policy).map(
([name, { resource, operation, description }]) => ({
key: `${resource}:${operation}`,
name,
resource,
operation,
description,
})
)
const registeredKeys = registeredPolicies.map((p) => p.key)
// Fetch all existing policies (including soft-deleted ones)
const existingPolicies = await this.listRbacPolicies(
{},
{ withDeleted: true },
sharedContext
)
const existingPoliciesMap = new Map(existingPolicies.map((p) => [p.key, p]))
const policiesToCreate: any[] = []
const policiesToUpdate: any[] = []
const policiesToRestore: string[] = []
// Process registered policies
for (const registeredPolicy of registeredPolicies) {
const existing = existingPoliciesMap.get(registeredPolicy.key)
const hasChanges =
existing &&
(existing.name !== registeredPolicy.name ||
existing.description !== registeredPolicy.description)
if (!existing) {
policiesToCreate.push(registeredPolicy)
} else if (existing.deleted_at) {
policiesToRestore.push(existing.id)
if (hasChanges) {
policiesToUpdate.push({
id: existing.id,
name: registeredPolicy.name,
description: registeredPolicy.description,
})
}
} else if (hasChanges) {
policiesToUpdate.push({
id: existing.id,
name: registeredPolicy.name,
description: registeredPolicy.description,
})
}
}
const policiesToSoftDelete = existingPolicies
.filter((p) => !p.deleted_at && !registeredKeys.includes(p.key))
.map((p) => p.id)
// First restore any soft-deleted policies
if (policiesToRestore.length > 0) {
await this.restoreRbacPolicies(policiesToRestore, {}, sharedContext)
}
await promiseAll([
policiesToCreate.length > 0 &&
this.createRbacPolicies(policiesToCreate, sharedContext),
policiesToUpdate.length > 0 &&
this.updateRbacPolicies(policiesToUpdate, sharedContext),
policiesToSoftDelete.length > 0 &&
this.softDeleteRbacPolicies(policiesToSoftDelete, {}, sharedContext),
])
}
@InjectManager()
async listPoliciesForRole(
roleId: string,
@@ -46,41 +139,13 @@ export default class RbacModuleService extends MedusaService<{
@InjectManager()
// @ts-expect-error
async listRbacRoles(
filters: any = {},
config: FindConfig<any> = {},
filters: FilterableRbacRoleProps = {},
config: FindConfig<RbacRoleDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<any[]> {
const roles = await super.listRbacRoles(filters, config, sharedContext)
const shouldIncludePolicies =
config.relations?.includes("policies") ||
config.select?.includes("policies")
if (shouldIncludePolicies && roles.length > 0) {
const roleIds = roles.map((role) => role.id)
const policiesByRole = await this.rbacRepository_.listPoliciesForRoles(
roleIds,
sharedContext
)
for (const role of roles) {
role.policies = policiesByRole.get(role.id) || []
}
}
return roles
}
@InjectManager()
// @ts-expect-error
async listAndCountRbacRoles(
filters: any = {},
config: FindConfig<any> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[any[], number]> {
const [roles, count] = await super.listAndCountRbacRoles(
): Promise<RbacRoleDTO[]> {
const roles = await super.listRbacRoles(
filters,
config,
config as any,
sharedContext
)
@@ -100,6 +165,102 @@ export default class RbacModuleService extends MedusaService<{
}
}
return [roles, count]
return roles as unknown as RbacRoleDTO[]
}
@InjectManager()
// @ts-expect-error
async listAndCountRbacRoles(
filters: FilterableRbacRoleProps = {},
config: FindConfig<RbacRoleDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<[RbacRoleDTO[], number]> {
const [roles, count] = await super.listAndCountRbacRoles(
filters,
config as any,
sharedContext
)
const shouldIncludePolicies =
config.relations?.includes("policies") ||
config.select?.includes("policies")
if (shouldIncludePolicies && roles.length > 0) {
const roleIds = roles.map((role) => role.id)
const policiesByRole = await this.rbacRepository_.listPoliciesForRoles(
roleIds,
sharedContext
)
for (const role of roles) {
role.policies = policiesByRole.get(role.id) || []
}
}
return [roles as unknown as RbacRoleDTO[], count]
}
@InjectManager()
// @ts-expect-error
async createRbacRoleParents(
data: CreateRbacRoleParentDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<RbacRoleParentDTO[]> {
for (const parent of data) {
const { role_id, parent_id } = parent
if (role_id === parent_id) {
throw new Error(
`Cannot create role parent relationship: a role cannot be its own parent (role_id: ${role_id})`
)
}
const wouldCreateCycle = await this.rbacRepository_.checkForCycle(
role_id,
parent_id,
sharedContext
)
if (wouldCreateCycle) {
throw new Error(
`Cannot create role parent relationship: this would create a circular dependency (role_id: ${role_id}, parent_id: ${parent_id})`
)
}
}
return await super.createRbacRoleParents(data, sharedContext)
}
@InjectManager()
// @ts-expect-error
async updateRbacRoleParents(
data: UpdateRbacRoleParentDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<RbacRoleParentDTO[]> {
for (const parent of data) {
const { role_id, parent_id } = parent
if (parent_id) {
if (role_id === parent_id) {
throw new Error(
`Cannot update role parent relationship: a role cannot be its own parent (role_id: ${role_id})`
)
}
const wouldCreateCycle = await this.rbacRepository_.checkForCycle(
role_id!,
parent_id,
sharedContext
)
if (wouldCreateCycle) {
throw new Error(
`Cannot update role parent relationship: this would create a circular dependency (role_id: ${role_id}, parent_id: ${parent_id})`
)
}
}
}
return await super.updateRbacRoleParents(data, sharedContext)
}
}