From b2245cc67257a04d4e1fd1311bd5f3ab57153727 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:40:15 -0300 Subject: [PATCH] chore(rbac): user link and utils (#14320) --- CONTRIBUTING.md | 1 + .../rbac/admin/rbac-policies.spec.ts | 6 + .../__tests__/rbac/admin/rbac-roles.spec.ts | 125 ++- integration-tests/http/medusa-config.js | 2 + .../__tests__/rbac/rbac-workflows.spec.ts | 988 +++++++++++++++++- .../src/rbac/steps/create-rbac-policies.ts | 20 +- ...itances.ts => create-rbac-role-parents.ts} | 24 +- .../rbac/steps/create-rbac-role-policies.ts | 16 +- .../src/rbac/steps/create-rbac-roles.ts | 3 + .../src/rbac/steps/delete-rbac-policies.ts | 21 +- .../rbac/steps/delete-rbac-role-policies.ts | 19 +- .../src/rbac/steps/delete-rbac-roles.ts | 29 +- .../core/core-flows/src/rbac/steps/index.ts | 19 +- ...role-inheritance.ts => set-role-parent.ts} | 64 +- .../src/rbac/steps/update-rbac-policies.ts | 11 +- .../rbac/steps/validate-user-permissions.ts | 96 ++ .../workflows/create-rbac-role-policies.ts | 37 +- .../src/rbac/workflows/create-rbac-roles.ts | 48 +- .../workflows/delete-rbac-role-policies.ts | 18 +- .../core-flows/src/rbac/workflows/index.ts | 1 - .../src/rbac/workflows/update-rbac-roles.ts | 39 +- packages/core/framework/src/index.ts | 9 +- packages/core/framework/src/policies/index.ts | 1 + .../framework/src/policies/policy-loader.ts | 16 + packages/core/types/src/rbac/common.ts | 16 +- packages/core/types/src/rbac/mutations.ts | 13 +- packages/core/types/src/rbac/service.ts | 98 +- packages/core/utils/src/common/index.ts | 3 +- .../core/utils/src/common/to-snake-case.ts | 10 + packages/core/utils/src/index.ts | 9 +- packages/core/utils/src/link/links.ts | 6 + .../utils/src/modules-sdk/define-policies.ts | 182 ++++ packages/core/utils/src/modules-sdk/index.ts | 8 +- .../utils/src/modules-sdk/policy-to-types.ts | 78 ++ .../utils/src/policies/discover-policies.ts | 71 ++ packages/core/utils/src/policies/index.ts | 1 + .../medusa/src/api/admin/rbac/middlewares.ts | 2 - .../src/api/admin/rbac/policies/route.ts | 11 +- .../admin/rbac/role-policies/[id]/route.ts | 91 -- .../admin/rbac/role-policies/middlewares.ts | 64 -- .../admin/rbac/role-policies/query-config.ts | 20 - .../src/api/admin/rbac/role-policies/route.ts | 50 - .../admin/rbac/role-policies/validators.ts | 52 - .../roles/[id]/policies/[policy_id]/route.ts | 40 + .../admin/rbac/roles/[id]/policies/route.ts | 69 ++ .../src/api/admin/rbac/roles/[id]/route.ts | 2 + .../src/api/admin/rbac/roles/helpers.ts | 1 - .../src/api/admin/rbac/roles/middlewares.ts | 27 + .../src/api/admin/rbac/roles/query-config.ts | 21 + .../medusa/src/api/admin/rbac/roles/route.ts | 17 +- .../src/api/admin/rbac/roles/validators.ts | 12 +- .../[auth_provider]/callback/route.ts | 11 +- .../[auth_provider]/register/route.ts | 5 +- .../[actor_type]/[auth_provider]/route.ts | 5 +- .../src/api/auth/token/refresh/route.ts | 12 +- .../src/api/auth/utils/generate-jwt-token.ts | 36 +- packages/medusa/src/commands/start.ts | 44 +- packages/medusa/src/feature-flags/rbac.ts | 10 + packages/medusa/src/loaders/index.ts | 10 +- packages/medusa/src/modules/rbac.ts | 6 + .../medusa/src/utils/rbac/has-permission.ts | 152 +++ .../link-modules/src/definitions/index.ts | 5 +- .../src/definitions/user-rbac-role.ts | 74 ++ packages/modules/rbac/src/index.ts | 4 +- .../src/migrations/.snapshot-medusa-rbac.json | 104 +- ...15113723.ts => Migration20251219163509.ts} | 30 +- packages/modules/rbac/src/models/index.ts | 2 +- .../rbac/src/models/rbac-role-parent.ts | 27 + .../rbac/src/models/rbac-role-policy.ts | 6 +- packages/modules/rbac/src/models/rbac-role.ts | 8 + .../modules/rbac/src/repositories/rbac.ts | 48 +- .../rbac/src/services/rbac-module-service.ts | 263 ++++- 72 files changed, 2746 insertions(+), 703 deletions(-) rename packages/core/core-flows/src/rbac/steps/{create-rbac-role-inheritances.ts => create-rbac-role-parents.ts} (50%) rename packages/core/core-flows/src/rbac/steps/{set-role-inheritance.ts => set-role-parent.ts} (51%) create mode 100644 packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts create mode 100644 packages/core/framework/src/policies/index.ts create mode 100644 packages/core/framework/src/policies/policy-loader.ts create mode 100644 packages/core/utils/src/common/to-snake-case.ts create mode 100644 packages/core/utils/src/modules-sdk/define-policies.ts create mode 100644 packages/core/utils/src/modules-sdk/policy-to-types.ts create mode 100644 packages/core/utils/src/policies/discover-policies.ts create mode 100644 packages/core/utils/src/policies/index.ts delete mode 100644 packages/medusa/src/api/admin/rbac/role-policies/[id]/route.ts delete mode 100644 packages/medusa/src/api/admin/rbac/role-policies/middlewares.ts delete mode 100644 packages/medusa/src/api/admin/rbac/role-policies/query-config.ts delete mode 100644 packages/medusa/src/api/admin/rbac/role-policies/route.ts delete mode 100644 packages/medusa/src/api/admin/rbac/role-policies/validators.ts create mode 100644 packages/medusa/src/api/admin/rbac/roles/[id]/policies/[policy_id]/route.ts create mode 100644 packages/medusa/src/api/admin/rbac/roles/[id]/policies/route.ts delete mode 100644 packages/medusa/src/api/admin/rbac/roles/helpers.ts create mode 100644 packages/medusa/src/feature-flags/rbac.ts create mode 100644 packages/medusa/src/modules/rbac.ts create mode 100644 packages/medusa/src/utils/rbac/has-permission.ts create mode 100644 packages/modules/link-modules/src/definitions/user-rbac-role.ts rename packages/modules/rbac/src/migrations/{Migration20251215113723.ts => Migration20251219163509.ts} (53%) create mode 100644 packages/modules/rbac/src/models/rbac-role-parent.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfee1cd65e..7d26712217 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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", diff --git a/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts b/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts index 175ecdfe97..3fb453b54e 100644 --- a/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts +++ b/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts @@ -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 () => { diff --git a/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts b/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts index 2682aaf1a1..451832e662 100644 --- a/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts +++ b/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts @@ -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) }) }) }) diff --git a/integration-tests/http/medusa-config.js b/integration-tests/http/medusa-config.js index 39bb803482..60c7bc3650 100644 --- a/integration-tests/http/medusa-config.js +++ b/integration-tests/http/medusa-config.js @@ -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, }) diff --git a/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts b/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts index eca9d35bcd..7685b4667f 100644 --- a/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts +++ b/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts @@ -4,24 +4,37 @@ import { } from "@medusajs/core-flows" import { medusaIntegrationTestRunner } from "@medusajs/test-utils" import { IRbacModuleService, MedusaContainer } from "@medusajs/types" -import { Modules } from "@medusajs/utils" +import { + ContainerRegistrationKeys, + definePolicies, + Modules, + Policy, +} from "@medusajs/utils" -jest.setTimeout(50000) +jest.setTimeout(60000) + +function clearPolicies() { + for (const policy of Object.keys(Policy)) { + delete Policy[policy] + } +} medusaIntegrationTestRunner({ env: {}, testSuite: ({ getContainer }) => { describe("Workflows: RBAC", () => { let appContainer: MedusaContainer - let rbacService: IRbacModuleService + let rbacService: IRbacModuleService & { onApplicationStart: Function } beforeAll(async () => { appContainer = getContainer() - rbacService = appContainer.resolve(Modules.RBAC) + rbacService = appContainer.resolve( + Modules.RBAC + ) as IRbacModuleService & { onApplicationStart: Function } }) - describe("Role Inheritance and Policy Management", () => { - it("should create roles with inheritance and policies, then list all inherited policies", async () => { + describe("Role Parent and Policy Management", () => { + it("should create roles with parent and policies, then list all inherited policies", async () => { // Step 1: Create base policies const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) const { result: createdPolicies } = await policiesWorkflow.run({ @@ -117,7 +130,7 @@ medusaIntegrationTestRunner({ name: "Admin", description: "Inherits from Viewer and Editor, plus can delete users", - inherited_role_ids: [viewerRole.id, editorRole.id], + parent_ids: [viewerRole.id, editorRole.id], policy_ids: [ createdPolicies[4].id, // delete:users ], @@ -129,21 +142,21 @@ medusaIntegrationTestRunner({ expect(adminRoles).toHaveLength(1) const adminRole = adminRoles[0] - // Step 4: Verify role inheritance was created - const inheritances = await rbacService.listRbacRoleInheritances({ + // Step 4: Verify role parent was created + const parents = await rbacService.listRbacRoleParents({ role_id: adminRole.id, }) - expect(inheritances).toHaveLength(2) - expect(inheritances).toEqual( + expect(parents).toHaveLength(2) + expect(parents).toEqual( expect.arrayContaining([ expect.objectContaining({ role_id: adminRole.id, - inherited_role_id: viewerRole.id, + parent_id: viewerRole.id, }), expect.objectContaining({ role_id: adminRole.id, - inherited_role_id: editorRole.id, + parent_id: editorRole.id, }), ]) ) @@ -157,7 +170,7 @@ medusaIntegrationTestRunner({ expect(adminDirectPolicies[0]).toEqual( expect.objectContaining({ role_id: adminRole.id, - scope_id: createdPolicies[4].id, // delete:users + policy_id: createdPolicies[4].id, // delete:users }) ) @@ -249,7 +262,7 @@ medusaIntegrationTestRunner({ ) }) - it("should handle multi-level role inheritance", async () => { + it("should handle multi-level role parent", async () => { // Create policies const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) const { result: policies } = await policiesWorkflow.run({ @@ -301,7 +314,7 @@ medusaIntegrationTestRunner({ { name: "Manager", description: "Manager with write access", - inherited_role_ids: [basicRole.id], + parent_ids: [basicRole.id], policy_ids: [policies[1].id], // write:catalog }, ], @@ -316,7 +329,7 @@ medusaIntegrationTestRunner({ { name: "SuperAdmin", description: "Super admin with all access", - inherited_role_ids: [managerRole.id], + parent_ids: [managerRole.id], policy_ids: [policies[2].id], // admin:system }, ], @@ -324,7 +337,7 @@ medusaIntegrationTestRunner({ }) const superAdminRole = superAdminRoles[0] - // Verify SuperAdmin has all policies through inheritance chain + // Verify SuperAdmin has all policies through parent chain const superAdminPolicies = await rbacService.listPoliciesForRole( superAdminRole.id ) @@ -408,7 +421,7 @@ medusaIntegrationTestRunner({ { name: "Inventory Manager", description: "Manager with write access", - inherited_role_ids: [basicRole.id], + parent_ids: [basicRole.id], policy_ids: [policies[1].id], // write:inventory }, ], @@ -423,7 +436,7 @@ medusaIntegrationTestRunner({ { name: "Inventory SuperAdmin", description: "Super admin with all access", - inherited_role_ids: [managerRole.id], + parent_ids: [managerRole.id], policy_ids: [policies[2].id], // admin:inventory }, ], @@ -457,7 +470,7 @@ medusaIntegrationTestRunner({ role_id: basicRole.id, }) const readPolicyAssociation = basicRolePolicies.find( - (rp) => rp.scope_id === policies[0].id + (rp) => rp.policy_id === policies[0].id ) await rbacService.deleteRbacRolePolicies([readPolicyAssociation!.id]) @@ -473,7 +486,7 @@ medusaIntegrationTestRunner({ expect(managerPolicies).toHaveLength(1) expect(managerPolicies[0].key).toBe("write:inventory") - // Verify SuperAdmin role also lost the read policy through inheritance chain + // Verify SuperAdmin role also lost the read policy through parent chain superAdminPolicies = await rbacService.listPoliciesForRole( superAdminRole.id ) @@ -504,11 +517,936 @@ medusaIntegrationTestRunner({ const policies = await rbacService.listPoliciesForRole(emptyRole.id) expect(policies).toHaveLength(0) - // Verify no inheritance - const inheritances = await rbacService.listRbacRoleInheritances({ + // Verify no parent + const parents = await rbacService.listRbacRoleParents({ role_id: emptyRole.id, }) - expect(inheritances).toHaveLength(0) + expect(parents).toHaveLength(0) + }) + }) + + describe("Circular Dependency Prevention", () => { + it("should prevent creating a role parent relationship where role is its own parent", async () => { + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: roles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Test Role", + description: "Test role for self-reference check", + }, + ], + }, + }) + const testRole = roles[0] + + // Try to create a self-referencing parent relationship + await expect( + rbacService.createRbacRoleParents([ + { + role_id: testRole.id, + parent_id: testRole.id, + }, + ]) + ).rejects.toThrow(/cannot be its own parent/) + }) + + it("should prevent creating a circular dependency (A -> B -> A)", async () => { + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: roles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Role A", + description: "First role", + }, + { + name: "Role B", + description: "Second role", + }, + ], + }, + }) + const roleA = roles[0] + const roleB = roles[1] + + // Create A -> B (A inherits from B) + await rbacService.createRbacRoleParents([ + { + role_id: roleA.id, + parent_id: roleB.id, + }, + ]) + + // Try to create B -> A (would create a cycle) + + let error: Error | undefined + try { + await rbacService.createRbacRoleParents([ + { + role_id: roleB.id, + parent_id: roleA.id, + }, + ]) + } catch (e) { + error = e + } + expect(error).toBeDefined() + expect(error?.message).toContain("circular dependency") + }) + + it("should prevent creating a circular dependency (A -> B -> C -> A)", async () => { + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: roles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Role A", + description: "First role", + }, + { + name: "Role B", + description: "Second role", + }, + { + name: "Role C", + description: "Third role", + }, + ], + }, + }) + const roleA = roles[0] + const roleB = roles[1] + const roleC = roles[2] + + // Create A -> B (A inherits from B) + await rbacService.createRbacRoleParents([ + { + role_id: roleA.id, + parent_id: roleB.id, + }, + ]) + + // Create B -> C (B inherits from C) + await rbacService.createRbacRoleParents([ + { + role_id: roleB.id, + parent_id: roleC.id, + }, + ]) + + // Try to create C -> A (would create a cycle) + let error: Error | undefined + try { + await rbacService.createRbacRoleParents([ + { + role_id: roleC.id, + parent_id: roleA.id, + }, + ]) + } catch (e) { + error = e + } + expect(error).toBeDefined() + expect(error?.message).toContain("circular dependency") + }) + + it("should prevent updating a role parent to create a circular dependency", async () => { + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: roles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Role X", + description: "First role", + }, + { + name: "Role Y", + description: "Second role", + }, + { + name: "Role Z", + description: "Third role", + }, + ], + }, + }) + const roleX = roles[0] + const roleY = roles[1] + const roleZ = roles[2] + + // Create X -> Y (X inherits from Y) + const [parentRelation] = await rbacService.createRbacRoleParents([ + { + role_id: roleX.id, + parent_id: roleY.id, + }, + ]) + + // Create Y -> Z (Y inherits from Z) + await rbacService.createRbacRoleParents([ + { + role_id: roleY.id, + parent_id: roleZ.id, + }, + ]) + + // Try to update X's parent to Z -> X (would create a cycle) + let error: Error | undefined + try { + await rbacService.updateRbacRoleParents([ + { + id: parentRelation.id, + role_id: roleZ.id, + parent_id: roleX.id, + }, + ]) + } catch (e) { + error = e + } + expect(error).toBeDefined() + expect(error?.message).toContain("circular dependency") + }) + + it("should allow valid multi-level hierarchy without cycles", async () => { + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: roles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Junior", + description: "Junior role", + }, + { + name: "Senior", + description: "Senior role", + }, + { + name: "Lead", + description: "Lead role", + }, + { + name: "Manager", + description: "Manager role", + }, + ], + }, + }) + const junior = roles[0] + const senior = roles[1] + const lead = roles[2] + const manager = roles[3] + + // Create valid hierarchy: Junior -> Senior -> Lead -> Manager + await rbacService.createRbacRoleParents([ + { + role_id: junior.id, + parent_id: senior.id, + }, + ]) + + await rbacService.createRbacRoleParents([ + { + role_id: senior.id, + parent_id: lead.id, + }, + ]) + + await rbacService.createRbacRoleParents([ + { + role_id: lead.id, + parent_id: manager.id, + }, + ]) + + // Verify the hierarchy was created successfully + const juniorParents = await rbacService.listRbacRoleParents({ + role_id: junior.id, + }) + expect(juniorParents).toHaveLength(1) + expect(juniorParents[0].parent_id).toBe(senior.id) + + const seniorParents = await rbacService.listRbacRoleParents({ + role_id: senior.id, + }) + expect(seniorParents).toHaveLength(1) + expect(seniorParents[0].parent_id).toBe(lead.id) + + const leadParents = await rbacService.listRbacRoleParents({ + role_id: lead.id, + }) + expect(leadParents).toHaveLength(1) + expect(leadParents[0].parent_id).toBe(manager.id) + }) + }) + + describe("Permission Validation", () => { + it("should prevent user without roles from creating roles with policies", async () => { + const userModule = appContainer.resolve(Modules.USER) + + // Create a user with no roles + const [user] = await userModule.createUsers([ + { + email: "noroles@test.com", + first_name: "No", + last_name: "Roles", + }, + ]) + + // Create a policy + const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) + const { result: policies } = await policiesWorkflow.run({ + input: { + policies: [ + { + key: "read:test", + resource: "test", + operation: "read", + name: "Read Test", + }, + ], + }, + }) + + // Try to create a role with this policy as a user with no roles + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + + let error: any + try { + await rolesWorkflow.run({ + input: { + actor_id: user.id, + roles: [ + { + name: "Test Role", + policy_ids: [policies[0].id], + }, + ], + }, + }) + } catch (e) { + error = e + } + + expect(error).toBeDefined() + expect(error.message).toContain( + "User does not have any roles assigned and cannot create roles or assign policies" + ) + }) + + it("should prevent user from assigning policies they don't have access to", async () => { + const userModule = appContainer.resolve(Modules.USER) + const remoteLink = appContainer.resolve( + ContainerRegistrationKeys.LINK + ) + + // Create policies + const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) + const { result: policies } = await policiesWorkflow.run({ + input: { + policies: [ + { + key: "read:products", + resource: "product", + operation: "read", + name: "Read Products", + }, + { + key: "write:products", + resource: "product", + operation: "write", + name: "Write Products", + }, + { + key: "delete:products", + resource: "product", + operation: "delete", + name: "Delete Products", + }, + ], + }, + }) + + // Create a role with only read permission + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: limitedRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Limited Role", + policy_ids: [policies[0].id], // Only read:products + }, + ], + }, + }) + + // Create a user and assign the limited role + const [user] = await userModule.createUsers([ + { + email: "limited@test.com", + first_name: "Limited", + last_name: "User", + }, + ]) + + await remoteLink.create({ + [Modules.USER]: { + user_id: user.id, + }, + [Modules.RBAC]: { + rbac_role_id: limitedRoles[0].id, + }, + }) + + // Try to create a role with write permission + let error: any + try { + await rolesWorkflow.run({ + input: { + actor_id: user.id, + roles: [ + { + name: "New Role", + policy_ids: [policies[1].id], // write:products - user doesn't have this + }, + ], + }, + }) + } catch (e) { + error = e + } + + expect(error).toBeDefined() + expect(error.message).toContain( + "User does not have access to the following policies and cannot assign them" + ) + }) + + it("should allow user to create roles with policies they have access to", async () => { + const userModule = appContainer.resolve(Modules.USER) + const remoteLink = appContainer.resolve( + ContainerRegistrationKeys.LINK + ) + + // Create policies + const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) + const { result: policies } = await policiesWorkflow.run({ + input: { + policies: [ + { + key: "read:orders", + resource: "order", + operation: "read", + name: "Read Orders", + }, + { + key: "write:orders", + resource: "order", + operation: "write", + name: "Write Orders", + }, + ], + }, + }) + + // Create an admin role with both permissions + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: adminRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Order Admin", + policy_ids: [policies[0].id, policies[1].id], + }, + ], + }, + }) + + // Create a user and assign the admin role + const [user] = await userModule.createUsers([ + { + email: "admin@test.com", + first_name: "Admin", + last_name: "User", + }, + ]) + + await remoteLink.create({ + [Modules.USER]: { + user_id: user.id, + }, + [Modules.RBAC]: { + rbac_role_id: adminRoles[0].id, + }, + }) + + // User should be able to create a role with read permission (which they have) + const { result: newRoles } = await rolesWorkflow.run({ + input: { + actor_id: user.id, + roles: [ + { + name: "Order Viewer", + policy_ids: [policies[0].id], // read:orders - user has this + }, + ], + }, + }) + + expect(newRoles).toHaveLength(1) + expect(newRoles[0].name).toBe("Order Viewer") + + // Verify the role was created with the correct policy + const newRolePolicies = await rbacService.listPoliciesForRole( + newRoles[0].id + ) + expect(newRolePolicies).toHaveLength(1) + expect(newRolePolicies[0].key).toBe("read:orders") + }) + + it("should allow user with inherited permissions to create roles", async () => { + const userModule = appContainer.resolve(Modules.USER) + const remoteLink = appContainer.resolve( + ContainerRegistrationKeys.LINK + ) + + // Create policies + const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) + const { result: policies } = await policiesWorkflow.run({ + input: { + policies: [ + { + key: "read:inventory", + resource: "inventory", + operation: "read", + name: "Read Inventory", + }, + { + key: "write:inventory", + resource: "inventory", + operation: "write", + name: "Write Inventory", + }, + ], + }, + }) + + // Create base role with read permission + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: baseRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Inventory Reader", + policy_ids: [policies[0].id], + }, + ], + }, + }) + + // Create manager role that inherits from base + write permission + const { result: managerRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Inventory Manager", + parent_ids: [baseRoles[0].id], + policy_ids: [policies[1].id], + }, + ], + }, + }) + + // Create a user and assign the manager role + const [user] = await userModule.createUsers([ + { + email: "manager@test.com", + first_name: "Manager", + last_name: "User", + }, + ]) + + await remoteLink.create({ + [Modules.USER]: { + user_id: user.id, + }, + [Modules.RBAC]: { + rbac_role_id: managerRoles[0].id, + }, + }) + + // User should be able to create a role with read permission (inherited) + const { result: newRoles } = await rolesWorkflow.run({ + input: { + actor_id: user.id, + roles: [ + { + name: "New Reader", + policy_ids: [policies[0].id], // read - user has via parent + }, + ], + }, + }) + + expect(newRoles).toHaveLength(1) + expect(newRoles[0].name).toBe("New Reader") + }) + }) + + describe("Policy Registration and Synchronization", () => { + beforeEach(() => { + // Clear global policy registries before each test + clearPolicies() + }) + + it("should register policies using definePolicies", () => { + // Register policies + definePolicies([ + { + name: "ReadBrand", + resource: "brand", + operation: "read", + description: "Read brand data", + }, + { + name: "WriteBrand", + resource: "brand", + operation: "write", + description: "Write brand data", + }, + { + name: "ReadCategory", + resource: "category", + operation: "read", + description: "Read category data", + }, + ]) + + // Verify Policy object contains named policies + expect(Object.keys(Policy).length).toBe(3) + expect(Policy["ReadBrand"]).toEqual({ + resource: "brand", + name: "ReadBrand", + operation: "read", + description: "Read brand data", + }) + expect(Policy["WriteBrand"]).toEqual({ + resource: "brand", + name: "WriteBrand", + operation: "write", + description: "Write brand data", + }) + expect(Policy["ReadCategory"]).toEqual({ + resource: "category", + name: "ReadCategory", + operation: "read", + description: "Read category data", + }) + }) + + it("should sync registered policies to database on application start", async () => { + // Register policies + definePolicies([ + { + name: "ReadProduct", + resource: "product", + operation: "read", + }, + { + name: "WriteProduct", + resource: "product", + operation: "write", + }, + { + name: "DeleteProduct", + resource: "product", + operation: "delete", + }, + ]) + + // Trigger sync by calling onApplicationStart + await rbacService.onApplicationStart() + + // Verify policies were created in database + const policies = await rbacService.listRbacPolicies({ + key: ["product:read", "product:write", "product:delete"], + }) + + expect(policies).toHaveLength(3) + + const policyMap = new Map(policies.map((p) => [p.key, p])) + expect(policyMap.get("product:read")).toMatchObject({ + name: "ReadProduct", + resource: "product", + operation: "read", + }) + expect(policyMap.get("product:write")).toMatchObject({ + name: "WriteProduct", + resource: "product", + operation: "write", + }) + expect(policyMap.get("product:delete")).toMatchObject({ + name: "DeleteProduct", + resource: "product", + operation: "delete", + }) + }) + + it("should soft delete policies that are no longer registered", async () => { + // First sync: Register 3 policies + definePolicies([ + { + name: "ReadOrder", + resource: "order", + operation: "read", + }, + { + name: "WriteOrder", + resource: "order", + operation: "write", + }, + { + name: "DeleteOrder", + resource: "order", + operation: "delete", + }, + ]) + + await rbacService.onApplicationStart() + + let policies = await rbacService.listRbacPolicies({ + key: ["order:read", "order:write", "order:delete"], + }) + expect(policies).toHaveLength(3) + + // Second sync: Remove one policy from code + + clearPolicies() + definePolicies([ + { + name: "ReadOrder", + resource: "order", + operation: "read", + }, + { + name: "WriteOrder", + resource: "order", + operation: "write", + }, + ]) + + await rbacService.onApplicationStart() + + // Verify the removed policy is soft-deleted + policies = await rbacService.listRbacPolicies({ + key: ["order:read", "order:write", "order:delete"], + }) + expect(policies).toHaveLength(2) // Only active policies + + // Verify soft-deleted policy exists with deleted_at + const allPolicies = await rbacService.listRbacPolicies( + { key: "order:delete" }, + { withDeleted: true } + ) + expect(allPolicies).toHaveLength(1) + expect(allPolicies[0].deleted_at).toBeTruthy() + }) + + it("should restore soft-deleted policies when they are re-registered", async () => { + // First sync: Register policies + definePolicies([ + { + name: "ReadCustomer", + resource: "customer", + operation: "read", + }, + { + name: "WriteCustomer", + resource: "customer", + operation: "write", + }, + ]) + + await rbacService.onApplicationStart() + + let policies = await rbacService.listRbacPolicies({ + resource: "customer", + }) + expect(policies).toHaveLength(2) + const originalWritePolicy = policies.find( + (p) => p.operation === "write" + ) + + // Second sync: Remove WriteCustomer + clearPolicies() + + definePolicies({ + name: "ReadCustomer", + resource: "customer", + operation: "read", + }) + + await rbacService.onApplicationStart() + + policies = await rbacService.listRbacPolicies({ + resource: "customer", + }) + expect(policies).toHaveLength(1) + + // Third sync: Re-add WriteCustomer + clearPolicies() + + definePolicies([ + { + name: "ReadCustomer", + resource: "customer", + operation: "read", + }, + { + name: "WriteCustomer", + resource: "customer", + operation: "write", + }, + ]) + + await rbacService.onApplicationStart() + + // Verify policy was restored (same ID) + policies = await rbacService.listRbacPolicies({ + resource: "customer", + }) + expect(policies).toHaveLength(2) + + const restoredWritePolicy = policies.find( + (p) => p.operation === "write" + ) + expect(restoredWritePolicy!.id).toBe(originalWritePolicy!.id) + expect(restoredWritePolicy!.deleted_at).toBeNull() + }) + + it("should update policy name if it changes in code", async () => { + // First sync: Register with original name + definePolicies({ + name: "ReadInventory", + resource: "inventory", + operation: "read", + }) + + await rbacService.onApplicationStart() + + let policies = await rbacService.listRbacPolicies({ + key: "inventory:read", + }) + expect(policies).toHaveLength(1) + expect(policies[0].name).toBe("ReadInventory") + const policyId = policies[0].id + + // Second sync: Change the name + clearPolicies() + + definePolicies({ + name: "ViewInventory", + resource: "inventory", + operation: "read", + }) + + await rbacService.onApplicationStart() + + // Verify name was updated but ID remains the same + policies = await rbacService.listRbacPolicies({ + key: "inventory:read", + }) + expect(policies).toHaveLength(1) + expect(policies[0].id).toBe(policyId) + expect(policies[0].name).toBe("ViewInventory") + }) + + it("should preserve role associations when policy is soft-deleted and restored", async () => { + // Register and sync policies + definePolicies([ + { + name: "ReadStore", + resource: "store", + operation: "read", + }, + { + name: "WriteStore", + resource: "store", + operation: "write", + }, + ]) + + await rbacService.onApplicationStart() + + const policies = await rbacService.listRbacPolicies({ + resource: "store", + }) + expect(policies).toHaveLength(2) + + // Create a role with these policies + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + const { result: roles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Store Manager", + policy_ids: policies.map((p) => p.id), + }, + ], + }, + }) + + const storeManagerRole = roles[0] + + // Verify role has both policies + let roleWithPolicies = await rbacService.listRbacRoles( + { id: storeManagerRole.id }, + { relations: ["policies"] } + ) + expect(roleWithPolicies[0].policies).toHaveLength(2) + + // Soft delete WriteStore policy + clearPolicies() + + definePolicies({ + name: "ReadStore", + resource: "store", + operation: "read", + }) + + await rbacService.onApplicationStart() + + // Role should now have only 1 active policy + roleWithPolicies = await rbacService.listRbacRoles( + { id: storeManagerRole.id }, + { relations: ["policies"] } + ) + expect(roleWithPolicies[0].policies).toHaveLength(1) + + // Restore WriteStore policy + clearPolicies() + + definePolicies([ + { + name: "ReadStore", + resource: "store", + operation: "read", + }, + { + name: "WriteStore", + resource: "store", + operation: "write", + }, + ]) + + await rbacService.onApplicationStart() + + // Role should have both policies again (association preserved) + roleWithPolicies = await rbacService.listRbacRoles( + { id: storeManagerRole.id }, + { relations: ["policies"] } + ) + expect(roleWithPolicies[0].policies).toHaveLength(2) }) }) }) diff --git a/packages/core/core-flows/src/rbac/steps/create-rbac-policies.ts b/packages/core/core-flows/src/rbac/steps/create-rbac-policies.ts index afcd0a955d..16ac6914b9 100644 --- a/packages/core/core-flows/src/rbac/steps/create-rbac-policies.ts +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-policies.ts @@ -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 | 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(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, diff --git a/packages/core/core-flows/src/rbac/steps/create-rbac-role-inheritances.ts b/packages/core/core-flows/src/rbac/steps/create-rbac-role-parents.ts similarity index 50% rename from packages/core/core-flows/src/rbac/steps/create-rbac-role-inheritances.ts rename to packages/core/core-flows/src/rbac/steps/create-rbac-role-parents.ts index e97dbfe2dd..c92aad308b 100644 --- a/packages/core/core-flows/src/rbac/steps/create-rbac-role-inheritances.ts +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-role-parents.ts @@ -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 | 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(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(Modules.RBAC) - await service.deleteRbacRoleInheritances(createdIds) + await service.deleteRbacRoleParents(createdIds) } ) diff --git a/packages/core/core-flows/src/rbac/steps/create-rbac-role-policies.ts b/packages/core/core-flows/src/rbac/steps/create-rbac-role-policies.ts index 74054eebea..cf92b27d19 100644 --- a/packages/core/core-flows/src/rbac/steps/create-rbac-role-policies.ts +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-role-policies.ts @@ -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 | 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(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, diff --git a/packages/core/core-flows/src/rbac/steps/create-rbac-roles.ts b/packages/core/core-flows/src/rbac/steps/create-rbac-roles.ts index e64bea76ac..799592561f 100644 --- a/packages/core/core-flows/src/rbac/steps/create-rbac-roles.ts +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-roles.ts @@ -19,6 +19,9 @@ export const createRbacRolesStep = createStep( async (data: CreateRbacRolesStepInput, { container }) => { const service = container.resolve(Modules.RBAC) + if (!data.roles?.length) { + return new StepResponse([], []) + } const created = await service.createRbacRoles(data.roles) return new StepResponse( diff --git a/packages/core/core-flows/src/rbac/steps/delete-rbac-policies.ts b/packages/core/core-flows/src/rbac/steps/delete-rbac-policies.ts index a8116b2584..09275b2576 100644 --- a/packages/core/core-flows/src/rbac/steps/delete-rbac-policies.ts +++ b/packages/core/core-flows/src/rbac/steps/delete-rbac-policies.ts @@ -10,8 +10,23 @@ export const deleteRbacPoliciesStep = createStep( { name: deleteRbacPoliciesStepId, noCompensation: true }, async (ids: DeleteRbacPoliciesStepInput, { container }) => { const service = container.resolve(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(Modules.RBAC) + + // Restore the soft-deleted roles during compensation + await service.restoreRbacPolicies(deletedPoliciesIds) + } ) diff --git a/packages/core/core-flows/src/rbac/steps/delete-rbac-role-policies.ts b/packages/core/core-flows/src/rbac/steps/delete-rbac-role-policies.ts index b49ffbbc22..3da17ccd7d 100644 --- a/packages/core/core-flows/src/rbac/steps/delete-rbac-role-policies.ts +++ b/packages/core/core-flows/src/rbac/steps/delete-rbac-role-policies.ts @@ -10,8 +10,21 @@ export const deleteRbacRolePoliciesStep = createStep( { name: deleteRbacRolePoliciesStepId, noCompensation: true }, async (ids: DeleteRbacRolePoliciesStepInput, { container }) => { const service = container.resolve(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(Modules.RBAC) + await service.restoreRbacRolePolicies(deletedRolePolicyIds) + } ) diff --git a/packages/core/core-flows/src/rbac/steps/delete-rbac-roles.ts b/packages/core/core-flows/src/rbac/steps/delete-rbac-roles.ts index 9e1df44a19..5849e19e5a 100644 --- a/packages/core/core-flows/src/rbac/steps/delete-rbac-roles.ts +++ b/packages/core/core-flows/src/rbac/steps/delete-rbac-roles.ts @@ -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(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(Modules.RBAC) + + // Restore the soft-deleted roles during compensation + await service.restoreRbacRoles(deletedRoleIds) + } ) diff --git a/packages/core/core-flows/src/rbac/steps/index.ts b/packages/core/core-flows/src/rbac/steps/index.ts index cafa6c2ad3..309be2de9c 100644 --- a/packages/core/core-flows/src/rbac/steps/index.ts +++ b/packages/core/core-flows/src/rbac/steps/index.ts @@ -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" diff --git a/packages/core/core-flows/src/rbac/steps/set-role-inheritance.ts b/packages/core/core-flows/src/rbac/steps/set-role-parent.ts similarity index 51% rename from packages/core/core-flows/src/rbac/steps/set-role-inheritance.ts rename to packages/core/core-flows/src/rbac/steps/set-role-parent.ts index 97ab32f41c..2bbde442f6 100644 --- a/packages/core/core-flows/src/rbac/steps/set-role-inheritance.ts +++ b/packages/core/core-flows/src/rbac/steps/set-role-parent.ts @@ -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(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(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, + })) ) } } diff --git a/packages/core/core-flows/src/rbac/steps/update-rbac-policies.ts b/packages/core/core-flows/src/rbac/steps/update-rbac-policies.ts index c0d441940f..83b82b89ee 100644 --- a/packages/core/core-flows/src/rbac/steps/update-rbac-policies.ts +++ b/packages/core/core-flows/src/rbac/steps/update-rbac-policies.ts @@ -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) diff --git a/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts b/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts new file mode 100644 index 0000000000..12b8ff01dc --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts @@ -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}` + ) + } + } +) diff --git a/packages/core/core-flows/src/rbac/workflows/create-rbac-role-policies.ts b/packages/core/core-flows/src/rbac/workflows/create-rbac-role-policies.ts index 7704c81596..a999042bb1 100644 --- a/packages/core/core-flows/src/rbac/workflows/create-rbac-role-policies.ts +++ b/packages/core/core-flows/src/rbac/workflows/create-rbac-role-policies.ts @@ -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) => { - return new WorkflowResponse(createRbacRolePoliciesStep(input)) + const validationData = transform({ input }, ({ input }) => { + if (!input.actor_id) { + return null + } + + const policyIds = new Set() + 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) } ) diff --git a/packages/core/core-flows/src/rbac/workflows/create-rbac-roles.ts b/packages/core/core-flows/src/rbac/workflows/create-rbac-roles.ts index c3f1324a08..2e1c75085c 100644 --- a/packages/core/core-flows/src/rbac/workflows/create-rbac-roles.ts +++ b/packages/core/core-flows/src/rbac/workflows/create-rbac-roles.ts @@ -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 | 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) => { + const validationData = transform({ input }, ({ input }) => { + const allPolicyIds = new Set() + 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) diff --git a/packages/core/core-flows/src/rbac/workflows/delete-rbac-role-policies.ts b/packages/core/core-flows/src/rbac/workflows/delete-rbac-role-policies.ts index 68de512703..79004dcc73 100644 --- a/packages/core/core-flows/src/rbac/workflows/delete-rbac-role-policies.ts +++ b/packages/core/core-flows/src/rbac/workflows/delete-rbac-role-policies.ts @@ -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 - ): WorkflowData => { - deleteRbacRolePoliciesStep(input.ids) + (input: WorkflowData) => { + const deletedRolePolicies = deleteRbacRolePoliciesStep( + input.role_policy_ids + ) + + return new WorkflowResponse(deletedRolePolicies) } ) diff --git a/packages/core/core-flows/src/rbac/workflows/index.ts b/packages/core/core-flows/src/rbac/workflows/index.ts index 06f712f298..3f37f524bb 100644 --- a/packages/core/core-flows/src/rbac/workflows/index.ts +++ b/packages/core/core-flows/src/rbac/workflows/index.ts @@ -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" diff --git a/packages/core/core-flows/src/rbac/workflows/update-rbac-roles.ts b/packages/core/core-flows/src/rbac/workflows/update-rbac-roles.ts index 808661ec40..6a3d0405cf 100644 --- a/packages/core/core-flows/src/rbac/workflows/update-rbac-roles.ts +++ b/packages/core/core-flows/src/rbac/workflows/update-rbac-roles.ts @@ -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 update: Omit & { - 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) => { + 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 } } ) diff --git a/packages/core/framework/src/index.ts b/packages/core/framework/src/index.ts index 05b58590fe..72489d2be3 100644 --- a/packages/core/framework/src/index.ts +++ b/packages/core/framework/src/index.ts @@ -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") diff --git a/packages/core/framework/src/policies/index.ts b/packages/core/framework/src/policies/index.ts new file mode 100644 index 0000000000..8891477d21 --- /dev/null +++ b/packages/core/framework/src/policies/index.ts @@ -0,0 +1 @@ +export * from "./policy-loader" diff --git a/packages/core/framework/src/policies/policy-loader.ts b/packages/core/framework/src/policies/policy-loader.ts new file mode 100644 index 0000000000..6aad1c8f4e --- /dev/null +++ b/packages/core/framework/src/policies/policy-loader.ts @@ -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 { + if (!sourcePath) { + return + } + + const policyDir = normalize(sourcePath) + + await discoverPoliciesFromDir(policyDir) +} diff --git a/packages/core/types/src/rbac/common.ts b/packages/core/types/src/rbac/common.ts index 422c2f1b6c..4db2ad688c 100644 --- a/packages/core/types/src/rbac/common.ts +++ b/packages/core/types/src/rbac/common.ts @@ -3,6 +3,7 @@ export type RbacRoleDTO = { name: string description?: string | null metadata?: Record | null + policies?: RbacPolicyDTO[] } export type FilterableRbacRoleProps = { @@ -19,11 +20,12 @@ export type RbacPolicyDTO = { name?: string | null description?: string | null metadata?: Record | 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 | 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 | null } -export type FilterableRbacRoleInheritanceProps = { +export type FilterableRbacRoleParentProps = { id?: string | string[] role_id?: string | string[] - inherited_role_id?: string | string[] + parent_id?: string | string[] } diff --git a/packages/core/types/src/rbac/mutations.ts b/packages/core/types/src/rbac/mutations.ts index cc3f506c62..6bcfeaa935 100644 --- a/packages/core/types/src/rbac/mutations.ts +++ b/packages/core/types/src/rbac/mutations.ts @@ -23,7 +23,7 @@ export type UpdateRbacPolicyDTO = Partial & { export type CreateRbacRolePolicyDTO = { role_id: string - scope_id: string + policy_id: string metadata?: Record | null } @@ -31,13 +31,12 @@ export type UpdateRbacRolePolicyDTO = Partial & { id: string } -export type CreateRbacRoleInheritanceDTO = { +export type CreateRbacRoleParentDTO = { role_id: string - inherited_role_id: string + parent_id: string metadata?: Record | null } -export type UpdateRbacRoleInheritanceDTO = - Partial & { - id: string - } +export type UpdateRbacRoleParentDTO = Partial & { + id: string +} diff --git a/packages/core/types/src/rbac/service.ts b/packages/core/types/src/rbac/service.ts index 4417827ee7..e173c1b78d 100644 --- a/packages/core/types/src/rbac/service.ts +++ b/packages/core/types/src/rbac/service.ts @@ -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 - createRbacRoleInheritances( - data: CreateRbacRoleInheritanceDTO[], + ): Promise + createRbacRoleParents( + data: CreateRbacRoleParentDTO[], sharedContext?: Context - ): Promise + ): Promise - updateRbacRoleInheritances( - data: UpdateRbacRoleInheritanceDTO, + updateRbacRoleParents( + data: UpdateRbacRoleParentDTO, sharedContext?: Context - ): Promise - updateRbacRoleInheritances( - data: UpdateRbacRoleInheritanceDTO[], + ): Promise + updateRbacRoleParents( + data: UpdateRbacRoleParentDTO[], sharedContext?: Context - ): Promise + ): Promise - deleteRbacRoleInheritances( + deleteRbacRoleParents( ids: string | string[], sharedContext?: Context ): Promise - retrieveRbacRoleInheritance( + retrieveRbacRoleParent( id: string, - config?: FindConfig, + config?: FindConfig, sharedContext?: Context - ): Promise + ): Promise - listRbacRoleInheritances( - filters?: FilterableRbacRoleInheritanceProps, - config?: FindConfig, + listRbacRoleParents( + filters?: FilterableRbacRoleParentProps, + config?: FindConfig, sharedContext?: Context - ): Promise + ): Promise - listAndCountRbacRoleInheritances( - filters?: FilterableRbacRoleInheritanceProps, - config?: FindConfig, + listAndCountRbacRoleParents( + filters?: FilterableRbacRoleParentProps, + config?: FindConfig, sharedContext?: Context - ): Promise<[RbacRoleInheritanceDTO[], number]> + ): Promise<[RbacRoleParentDTO[], number]> listPoliciesForRole( roleId: string, sharedContext?: Context ): Promise + + softDeleteRbacRoles( + roleIds: string | string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + restoreRbacRoles( + roleIds: string | string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + softDeleteRbacPolicies( + policyIds: string | string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + restoreRbacPolicies( + policyIds: string | string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + softDeleteRbacRolePolicies( + rolePolicyIds: string | string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + restoreRbacRolePolicies( + rolePolicyIds: string | string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + softDeleteRbacRoleParents( + roleParentIds: string | string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + restoreRbacRoleParents( + roleParentIds: string | string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> } diff --git a/packages/core/utils/src/common/index.ts b/packages/core/utils/src/common/index.ts index c6b75b1b93..bdbd675071 100644 --- a/packages/core/utils/src/common/index.ts +++ b/packages/core/utils/src/common/index.ts @@ -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" diff --git a/packages/core/utils/src/common/to-snake-case.ts b/packages/core/utils/src/common/to-snake-case.ts new file mode 100644 index 0000000000..15c7c8274f --- /dev/null +++ b/packages/core/utils/src/common/to-snake-case.ts @@ -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() +} diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index d1268afa20..47d09bae8c 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -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") diff --git a/packages/core/utils/src/link/links.ts b/packages/core/utils/src/link/links.ts index a5c1ef7b44..b4e898ee20 100644 --- a/packages/core/utils/src/link/links.ts +++ b/packages/core/utils/src/link/links.ts @@ -128,4 +128,10 @@ export const LINKS = { Modules.PAYMENT, "account_holder_id" ), + UserRbacRole: composeLinkName( + Modules.USER, + "user_id", + Modules.RBAC, + "rbac_role_id" + ), } diff --git a/packages/core/utils/src/modules-sdk/define-policies.ts b/packages/core/utils/src/modules-sdk/define-policies.ts new file mode 100644 index 0000000000..2239f2e169 --- /dev/null +++ b/packages/core/utils/src/modules-sdk/define-policies.ts @@ -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 + // eslint-disable-next-line no-var + var Operation: Record + // 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 +} diff --git a/packages/core/utils/src/modules-sdk/index.ts b/packages/core/utils/src/modules-sdk/index.ts index d17c895743..c4ffb61eab 100644 --- a/packages/core/utils/src/modules-sdk/index.ts +++ b/packages/core/utils/src/modules-sdk/index.ts @@ -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" diff --git a/packages/core/utils/src/modules-sdk/policy-to-types.ts b/packages/core/utils/src/modules-sdk/policy-to-types.ts new file mode 100644 index 0000000000..1ad8a28a17 --- /dev/null +++ b/packages/core/utils/src/modules-sdk/policy-to-types.ts @@ -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) +} diff --git a/packages/core/utils/src/policies/discover-policies.ts b/packages/core/utils/src/policies/discover-policies.ts new file mode 100644 index 0000000000..10cbc6e423 --- /dev/null +++ b/packages/core/utils/src/policies/discover-policies.ts @@ -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 { + 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` + ) + } + }) + ) + }) + ) +} diff --git a/packages/core/utils/src/policies/index.ts b/packages/core/utils/src/policies/index.ts new file mode 100644 index 0000000000..8d7296a292 --- /dev/null +++ b/packages/core/utils/src/policies/index.ts @@ -0,0 +1 @@ +export * from "./discover-policies" diff --git a/packages/medusa/src/api/admin/rbac/middlewares.ts b/packages/medusa/src/api/admin/rbac/middlewares.ts index f9b7705aa9..c8eb986d6f 100644 --- a/packages/medusa/src/api/admin/rbac/middlewares.ts +++ b/packages/medusa/src/api/admin/rbac/middlewares.ts @@ -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, ] diff --git a/packages/medusa/src/api/admin/rbac/policies/route.ts b/packages/medusa/src/api/admin/rbac/policies/route.ts index e62237105a..5d73ade81b 100644 --- a/packages/medusa/src/api/admin/rbac/policies/route.ts +++ b/packages/medusa/src/api/admin/rbac/policies/route.ts @@ -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), +}) diff --git a/packages/medusa/src/api/admin/rbac/role-policies/[id]/route.ts b/packages/medusa/src/api/admin/rbac/role-policies/[id]/route.ts deleted file mode 100644 index 2dbe90a0c6..0000000000 --- a/packages/medusa/src/api/admin/rbac/role-policies/[id]/route.ts +++ /dev/null @@ -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, - 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, - }) -} diff --git a/packages/medusa/src/api/admin/rbac/role-policies/middlewares.ts b/packages/medusa/src/api/admin/rbac/role-policies/middlewares.ts deleted file mode 100644 index 3f6664d313..0000000000 --- a/packages/medusa/src/api/admin/rbac/role-policies/middlewares.ts +++ /dev/null @@ -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: [], - }, -] diff --git a/packages/medusa/src/api/admin/rbac/role-policies/query-config.ts b/packages/medusa/src/api/admin/rbac/role-policies/query-config.ts deleted file mode 100644 index d3f910d348..0000000000 --- a/packages/medusa/src/api/admin/rbac/role-policies/query-config.ts +++ /dev/null @@ -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, -} diff --git a/packages/medusa/src/api/admin/rbac/role-policies/route.ts b/packages/medusa/src/api/admin/rbac/role-policies/route.ts deleted file mode 100644 index 1a11ce1b7a..0000000000 --- a/packages/medusa/src/api/admin/rbac/role-policies/route.ts +++ /dev/null @@ -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, - 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 }) -} diff --git a/packages/medusa/src/api/admin/rbac/role-policies/validators.ts b/packages/medusa/src/api/admin/rbac/role-policies/validators.ts deleted file mode 100644 index 797c940e0a..0000000000 --- a/packages/medusa/src/api/admin/rbac/role-policies/validators.ts +++ /dev/null @@ -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() diff --git a/packages/medusa/src/api/admin/rbac/roles/[id]/policies/[policy_id]/route.ts b/packages/medusa/src/api/admin/rbac/roles/[id]/policies/[policy_id]/route.ts new file mode 100644 index 0000000000..0a5ddcbb3f --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/[id]/policies/[policy_id]/route.ts @@ -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), +}) diff --git a/packages/medusa/src/api/admin/rbac/roles/[id]/policies/route.ts b/packages/medusa/src/api/admin/rbac/roles/[id]/policies/route.ts new file mode 100644 index 0000000000..e3406f0386 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/[id]/policies/route.ts @@ -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, + 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), +}) diff --git a/packages/medusa/src/api/admin/rbac/roles/[id]/route.ts b/packages/medusa/src/api/admin/rbac/roles/[id]/route.ts index 7d2a02b119..d1fbce5b37 100644 --- a/packages/medusa/src/api/admin/rbac/roles/[id]/route.ts +++ b/packages/medusa/src/api/admin/rbac/roles/[id]/route.ts @@ -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, }, diff --git a/packages/medusa/src/api/admin/rbac/roles/helpers.ts b/packages/medusa/src/api/admin/rbac/roles/helpers.ts deleted file mode 100644 index 336ce12bb9..0000000000 --- a/packages/medusa/src/api/admin/rbac/roles/helpers.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/packages/medusa/src/api/admin/rbac/roles/middlewares.ts b/packages/medusa/src/api/admin/rbac/roles/middlewares.ts index 8c8ae23765..d9db1364ed 100644 --- a/packages/medusa/src/api/admin/rbac/roles/middlewares.ts +++ b/packages/medusa/src/api/admin/rbac/roles/middlewares.ts @@ -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", diff --git a/packages/medusa/src/api/admin/rbac/roles/query-config.ts b/packages/medusa/src/api/admin/rbac/roles/query-config.ts index 972294c4a1..b9f85a6766 100644 --- a/packages/medusa/src/api/admin/rbac/roles/query-config.ts +++ b/packages/medusa/src/api/admin/rbac/roles/query-config.ts @@ -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, +} diff --git a/packages/medusa/src/api/admin/rbac/roles/route.ts b/packages/medusa/src/api/admin/rbac/roles/route.ts index 88a5291738..135d45524e 100644 --- a/packages/medusa/src/api/admin/rbac/roles/route.ts +++ b/packages/medusa/src/api/admin/rbac/roles/route.ts @@ -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), +}) diff --git a/packages/medusa/src/api/admin/rbac/roles/validators.ts b/packages/medusa/src/api/admin/rbac/roles/validators.ts index ca446d052d..93abf028c3 100644 --- a/packages/medusa/src/api/admin/rbac/roles/validators.ts +++ b/packages/medusa/src/api/admin/rbac/roles/validators.ts @@ -7,7 +7,11 @@ import { } from "../../../utils/validators" export type AdminGetRbacRoleParamsType = z.infer -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 diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts index 224cc9c685..77d72df79a 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/callback/route.ts @@ -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, diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts index 9990831528..820bea6759 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/register/route.ts @@ -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!, diff --git a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts index 3722bc056b..69f281cf07 100644 --- a/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts +++ b/packages/medusa/src/api/auth/[actor_type]/[auth_provider]/route.ts @@ -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!, diff --git a/packages/medusa/src/api/auth/token/refresh/route.ts b/packages/medusa/src/api/auth/token/refresh/route.ts index 20c09e7fa2..2ef3754a5d 100644 --- a/packages/medusa/src/api/auth/token/refresh/route.ts +++ b/packages/medusa/src/api/auth/token/refresh/route.ts @@ -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, diff --git a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts index e6a7250ebf..646b23a30d 100644 --- a/packages/medusa/src/api/auth/utils/generate-jwt-token.ts +++ b/packages/medusa/src/api/auth/utils/generate-jwt-token.ts @@ -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 ?? {}, }, diff --git a/packages/medusa/src/commands/start.ts b/packages/medusa/src/commands/start.ts index 77ff230a24..c699e3e334 100644 --- a/packages/medusa/src/commands/start.ts +++ b/packages/medusa/src/commands/start.ts @@ -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[] = [] - 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. diff --git a/packages/medusa/src/feature-flags/rbac.ts b/packages/medusa/src/feature-flags/rbac.ts new file mode 100644 index 0000000000..b155a0a10e --- /dev/null +++ b/packages/medusa/src/feature-flags/rbac.ts @@ -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 diff --git a/packages/medusa/src/loaders/index.ts b/packages/medusa/src/loaders/index.ts index ade6353f68..53d6f2225f 100644 --- a/packages/medusa/src/loaders/index.ts +++ b/packages/medusa/src/loaders/index.ts @@ -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, diff --git a/packages/medusa/src/modules/rbac.ts b/packages/medusa/src/modules/rbac.ts new file mode 100644 index 0000000000..a96994ca3f --- /dev/null +++ b/packages/medusa/src/modules/rbac.ts @@ -0,0 +1,6 @@ +import RbacModule from "@medusajs/rbac" + +export * from "@medusajs/rbac" + +export default RbacModule +export const discoveryPath = require.resolve("@medusajs/rbac") diff --git a/packages/medusa/src/utils/rbac/has-permission.ts b/packages/medusa/src/utils/rbac/has-permission.ts new file mode 100644 index 0000000000..97ebc5b875 --- /dev/null +++ b/packages/medusa/src/utils/rbac/has-permission.ts @@ -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>> + +/** + * 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 { + 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>> { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + + const tags: string[] = [] + return await useCache>>( + async () => { + const { data: roles } = await query.graph({ + entity: "rbac_role", + fields: ["id", "policies.*"], + filters: { id: roleId }, + }) + + const role = roles[0] + const resourceMap = new Map>() + + 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 { + const rolePoliciesMap: RolePoliciesCache = new Map() + + await Promise.all( + roleIds.map(async (roleId) => { + const resourceMap = await fetchSingleRolePolicies(roleId, container) + rolePoliciesMap.set(roleId, resourceMap) + }) + ) + + return rolePoliciesMap +} diff --git a/packages/modules/link-modules/src/definitions/index.ts b/packages/modules/link-modules/src/definitions/index.ts index 6323e17250..82746b74a4 100644 --- a/packages/modules/link-modules/src/definitions/index.ts +++ b/packages/modules/link-modules/src/definitions/index.ts @@ -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" diff --git a/packages/modules/link-modules/src/definitions/user-rbac-role.ts b/packages/modules/link-modules/src/definitions/user-rbac-role.ts new file mode 100644 index 0000000000..1b484c7899 --- /dev/null +++ b/packages/modules/link-modules/src/definitions/user-rbac-role.ts @@ -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, + }, + }, + ], +} diff --git a/packages/modules/rbac/src/index.ts b/packages/modules/rbac/src/index.ts index 30c1bc5289..2b703f404d 100644 --- a/packages/modules/rbac/src/index.ts +++ b/packages/modules/rbac/src/index.ts @@ -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, }) diff --git a/packages/modules/rbac/src/migrations/.snapshot-medusa-rbac.json b/packages/modules/rbac/src/migrations/.snapshot-medusa-rbac.json index e5a2919b4c..7327c890d1 100644 --- a/packages/modules/rbac/src/migrations/.snapshot-medusa-rbac.json +++ b/packages/modules/rbac/src/migrations/.snapshot-medusa-rbac.json @@ -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" } diff --git a/packages/modules/rbac/src/migrations/Migration20251215113723.ts b/packages/modules/rbac/src/migrations/Migration20251219163509.ts similarity index 53% rename from packages/modules/rbac/src/migrations/Migration20251215113723.ts rename to packages/modules/rbac/src/migrations/Migration20251219163509.ts index b3284bf275..4de2856bfd 100644 --- a/packages/modules/rbac/src/migrations/Migration20251215113723.ts +++ b/packages/modules/rbac/src/migrations/Migration20251219163509.ts @@ -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 { - 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;`); } } diff --git a/packages/modules/rbac/src/models/index.ts b/packages/modules/rbac/src/models/index.ts index ecdfa59956..23c04feab3 100644 --- a/packages/modules/rbac/src/models/index.ts +++ b/packages/modules/rbac/src/models/index.ts @@ -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" diff --git a/packages/modules/rbac/src/models/rbac-role-parent.ts b/packages/modules/rbac/src/models/rbac-role-parent.ts new file mode 100644 index 0000000000..301b999293 --- /dev/null +++ b/packages/modules/rbac/src/models/rbac-role-parent.ts @@ -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 diff --git a/packages/modules/rbac/src/models/rbac-role-policy.ts b/packages/modules/rbac/src/models/rbac-role-policy.ts index 87c0c92e57..f27f7ae3c5 100644 --- a/packages/modules/rbac/src/models/rbac-role-policy.ts +++ b/packages/modules/rbac/src/models/rbac-role-policy.ts @@ -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", }, diff --git a/packages/modules/rbac/src/models/rbac-role.ts b/packages/modules/rbac/src/models/rbac-role.ts index eca0d19c32..c018b1a919 100644 --- a/packages/modules/rbac/src/models/rbac-role.ts +++ b/packages/modules/rbac/src/models/rbac-role.ts @@ -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([ { diff --git a/packages/modules/rbac/src/repositories/rbac.ts b/packages/modules/rbac/src/repositories/rbac.ts index 0b78121e68..8d98a3d997 100644 --- a/packages/modules/rbac/src/repositories/rbac.ts +++ b/packages/modules/rbac/src/repositories/rbac.ts @@ -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 { + const manager = this.getActiveManager(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 + } } diff --git a/packages/modules/rbac/src/services/rbac-module-service.ts b/packages/modules/rbac/src/services/rbac-module-service.ts index 0f0a39d7f5..485833d004 100644 --- a/packages/modules/rbac/src/services/rbac-module-service.ts +++ b/packages/modules/rbac/src/services/rbac-module-service.ts @@ -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 { + await this.syncRegisteredPolicies() + } + + @InjectManager() + private async syncRegisteredPolicies( + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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 = {}, + filters: FilterableRbacRoleProps = {}, + config: FindConfig = {}, @MedusaContext() sharedContext: Context = {} - ): Promise { - 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 = {}, - @MedusaContext() sharedContext: Context = {} - ): Promise<[any[], number]> { - const [roles, count] = await super.listAndCountRbacRoles( + ): Promise { + 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 = {}, + @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 { + 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 { + 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) } }