diff --git a/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts b/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts new file mode 100644 index 0000000000..175ecdfe97 --- /dev/null +++ b/integration-tests/http/__tests__/rbac/admin/rbac-policies.spec.ts @@ -0,0 +1,285 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(60000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, api, getContainer }) => { + let container + + beforeEach(async () => { + container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + }) + + describe("RBAC Policies - Admin API", () => { + describe("POST /admin/rbac/policies", () => { + it("should create a policy", async () => { + const response = await api.post( + "/admin/rbac/policies", + { + key: "read:products", + resource: "product", + operation: "read", + name: "Read Products", + description: "Permission to read products", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.policy).toEqual( + expect.objectContaining({ + id: expect.any(String), + key: "read:products", + resource: "product", + operation: "read", + name: "Read Products", + description: "Permission to read products", + }) + ) + }) + + it("should create a policy with metadata", async () => { + const response = await api.post( + "/admin/rbac/policies", + { + key: "write:orders", + resource: "order", + operation: "write", + name: "Write Orders", + metadata: { category: "order_management" }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.policy).toEqual( + expect.objectContaining({ + key: "write:orders", + resource: "order", + operation: "write", + metadata: { category: "order_management" }, + }) + ) + }) + }) + + describe("GET /admin/rbac/policies", () => { + beforeEach(async () => { + await api.post( + "/admin/rbac/policies", + { + key: "read:products", + resource: "product", + operation: "read", + name: "Read Products", + }, + adminHeaders + ) + + await api.post( + "/admin/rbac/policies", + { + key: "write:products", + resource: "product", + operation: "write", + name: "Write Products", + }, + adminHeaders + ) + + await api.post( + "/admin/rbac/policies", + { + key: "read:orders", + resource: "order", + operation: "read", + name: "Read Orders", + }, + adminHeaders + ) + }) + + it("should list all policies", async () => { + const response = await api.get("/admin/rbac/policies", adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) + expect(response.data.policies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "read:products", + resource: "product", + operation: "read", + }), + expect.objectContaining({ + key: "write:products", + resource: "product", + operation: "write", + }), + expect.objectContaining({ + key: "read:orders", + resource: "order", + operation: "read", + }), + ]) + ) + }) + + it("should filter policies by resource", async () => { + const response = await api.get( + "/admin/rbac/policies?resource=product", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.policies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "read:products", + resource: "product", + }), + expect.objectContaining({ + key: "write:products", + resource: "product", + }), + ]) + ) + }) + + it("should filter policies by operation", async () => { + const response = await api.get( + "/admin/rbac/policies?operation=read", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.policies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "read:products", + operation: "read", + }), + expect.objectContaining({ + key: "read:orders", + operation: "read", + }), + ]) + ) + }) + }) + + describe("GET /admin/rbac/policies/:id", () => { + it("should retrieve a policy by id", async () => { + const createResponse = await api.post( + "/admin/rbac/policies", + { + key: "delete:users", + resource: "user", + operation: "delete", + name: "Delete Users", + }, + adminHeaders + ) + + const policyId = createResponse.data.policy.id + + const response = await api.get( + `/admin/rbac/policies/${policyId}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.policy).toEqual( + expect.objectContaining({ + id: policyId, + key: "delete:users", + resource: "user", + operation: "delete", + name: "Delete Users", + }) + ) + }) + }) + + describe("POST /admin/rbac/policies/:id", () => { + it("should update a policy", async () => { + const createResponse = await api.post( + "/admin/rbac/policies", + { + key: "admin:system", + resource: "system", + operation: "admin", + name: "System Admin", + }, + adminHeaders + ) + + const policyId = createResponse.data.policy.id + + const response = await api.post( + `/admin/rbac/policies/${policyId}`, + { + name: "System Administrator", + description: "Full system access", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.policy).toEqual( + expect.objectContaining({ + id: policyId, + key: "admin:system", + name: "System Administrator", + description: "Full system access", + }) + ) + }) + }) + + describe("DELETE /admin/rbac/policies/:id", () => { + it("should delete a policy", async () => { + const createResponse = await api.post( + "/admin/rbac/policies", + { + key: "test:delete", + resource: "test", + operation: "delete", + name: "Test Delete", + }, + adminHeaders + ) + + const policyId = createResponse.data.policy.id + + const deleteResponse = await api.delete( + `/admin/rbac/policies/${policyId}`, + adminHeaders + ) + + expect(deleteResponse.status).toEqual(200) + expect(deleteResponse.data).toEqual({ + id: policyId, + object: "rbac_policy", + deleted: true, + }) + + const listResponse = await api.get( + "/admin/rbac/policies", + adminHeaders + ) + expect( + listResponse.data.policies.find((p) => p.id === policyId) + ).toBeUndefined() + }) + }) + }) + }, +}) diff --git a/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts b/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts new file mode 100644 index 0000000000..2682aaf1a1 --- /dev/null +++ b/integration-tests/http/__tests__/rbac/admin/rbac-roles.spec.ts @@ -0,0 +1,384 @@ +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" + +jest.setTimeout(60000) + +medusaIntegrationTestRunner({ + testSuite: ({ dbConnection, api, getContainer }) => { + let container + + beforeEach(async () => { + container = getContainer() + await createAdminUser(dbConnection, adminHeaders, container) + }) + + describe("RBAC Roles - Admin API", () => { + describe("POST /admin/rbac/roles", () => { + it("should create a role", async () => { + const response = await api.post( + "/admin/rbac/roles", + { + name: "Viewer", + description: "Can view resources", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.role).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "Viewer", + description: "Can view resources", + }) + ) + }) + + it("should create a role with metadata", async () => { + const response = await api.post( + "/admin/rbac/roles", + { + name: "Editor", + description: "Can edit resources", + metadata: { department: "sales" }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.role).toEqual( + expect.objectContaining({ + name: "Editor", + metadata: { department: "sales" }, + }) + ) + }) + }) + + describe("GET /admin/rbac/roles", () => { + beforeEach(async () => { + await api.post( + "/admin/rbac/roles", + { + name: "Viewer", + description: "Can view resources", + }, + adminHeaders + ) + + await api.post( + "/admin/rbac/roles", + { + name: "Editor", + description: "Can edit resources", + }, + adminHeaders + ) + + await api.post( + "/admin/rbac/roles", + { + name: "Admin", + description: "Full access", + }, + adminHeaders + ) + }) + + it("should list all roles", async () => { + const response = await api.get("/admin/rbac/roles", adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) + expect(response.data.roles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Viewer", + description: "Can view resources", + }), + expect.objectContaining({ + name: "Editor", + description: "Can edit resources", + }), + expect.objectContaining({ + name: "Admin", + description: "Full access", + }), + ]) + ) + }) + + it("should filter roles by name", async () => { + const response = await api.get( + "/admin/rbac/roles?name=Viewer", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(1) + expect(response.data.roles[0]).toEqual( + expect.objectContaining({ + name: "Viewer", + }) + ) + }) + }) + + describe("GET /admin/rbac/roles/:id", () => { + it("should retrieve a role by id", async () => { + const createResponse = await api.post( + "/admin/rbac/roles", + { + name: "Manager", + description: "Can manage resources", + }, + adminHeaders + ) + + const roleId = createResponse.data.role.id + + const response = await api.get( + `/admin/rbac/roles/${roleId}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.role).toEqual( + expect.objectContaining({ + id: roleId, + name: "Manager", + description: "Can manage resources", + }) + ) + }) + }) + + describe("POST /admin/rbac/roles/:id", () => { + it("should update a role", async () => { + const createResponse = await api.post( + "/admin/rbac/roles", + { + name: "Support", + description: "Support team", + }, + adminHeaders + ) + + const roleId = createResponse.data.role.id + + const response = await api.post( + `/admin/rbac/roles/${roleId}`, + { + name: "Customer Support", + description: "Customer support team with limited access", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.role).toEqual( + expect.objectContaining({ + id: roleId, + name: "Customer Support", + description: "Customer support team with limited access", + }) + ) + }) + }) + + describe("DELETE /admin/rbac/roles/:id", () => { + it("should delete a role", async () => { + const createResponse = await api.post( + "/admin/rbac/roles", + { + name: "Temporary", + description: "Temporary role", + }, + adminHeaders + ) + + const roleId = createResponse.data.role.id + + const deleteResponse = await api.delete( + `/admin/rbac/roles/${roleId}`, + adminHeaders + ) + + expect(deleteResponse.status).toEqual(200) + expect(deleteResponse.data).toEqual({ + id: roleId, + object: "rbac_role", + deleted: true, + }) + + const listResponse = await api.get("/admin/rbac/roles", adminHeaders) + expect( + listResponse.data.roles.find((r) => r.id === roleId) + ).toBeUndefined() + }) + }) + + describe("Role Policies", () => { + let policies + let viewerRole + let editorRole + + beforeEach(async () => { + const policy1 = await api.post( + "/admin/rbac/policies", + { + key: "read:products", + resource: "product", + operation: "read", + name: "Read Products", + }, + adminHeaders + ) + + const policy2 = await api.post( + "/admin/rbac/policies", + { + key: "write:products", + resource: "product", + operation: "write", + name: "Write Products", + }, + adminHeaders + ) + + const policy3 = await api.post( + "/admin/rbac/policies", + { + key: "delete:products", + resource: "product", + operation: "delete", + name: "Delete Products", + }, + adminHeaders + ) + + policies = [ + policy1.data.policy, + policy2.data.policy, + policy3.data.policy, + ] + + const viewer = await api.post( + "/admin/rbac/roles", + { + name: "Product Viewer", + description: "Can view products", + }, + adminHeaders + ) + viewerRole = viewer.data.role + + const editor = await api.post( + "/admin/rbac/roles", + { + name: "Product Editor", + description: "Can edit products", + }, + adminHeaders + ) + editorRole = editor.data.role + }) + + it("should create role-policy associations", async () => { + const response = await api.post( + "/admin/rbac/role-policies", + { + role_id: viewerRole.id, + scope_id: 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, + }) + ) + }) + + it("should list role-policies for a specific role", async () => { + await api.post( + "/admin/rbac/role-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, + }, + adminHeaders + ) + + const response = await api.get( + `/admin/rbac/role-policies?role_id=${viewerRole.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.role_policies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role_id: viewerRole.id, + scope_id: policies[0].id, + }), + expect.objectContaining({ + role_id: viewerRole.id, + scope_id: policies[1].id, + }), + ]) + ) + }) + + it("should delete a role-policy association", async () => { + const createResponse = await api.post( + "/admin/rbac/role-policies", + { + role_id: editorRole.id, + scope_id: policies[2].id, + }, + adminHeaders + ) + + const rolePolicyId = createResponse.data.role_policy.id + + const deleteResponse = await api.delete( + `/admin/rbac/role-policies/${rolePolicyId}`, + adminHeaders + ) + + expect(deleteResponse.status).toEqual(200) + expect(deleteResponse.data).toEqual({ + id: rolePolicyId, + object: "rbac_role_policy", + deleted: true, + }) + + const listResponse = await api.get( + `/admin/rbac/role-policies?role_id=${editorRole.id}`, + adminHeaders + ) + expect( + listResponse.data.role_policies.find((rp) => rp.id === rolePolicyId) + ).toBeUndefined() + }) + }) + }) + }, +}) diff --git a/integration-tests/http/medusa-config.js b/integration-tests/http/medusa-config.js index 6ffd3cfe8e..39bb803482 100644 --- a/integration-tests/http/medusa-config.js +++ b/integration-tests/http/medusa-config.js @@ -66,6 +66,9 @@ const modules = { resolve: "@medusajs/index", disable: process.env.ENABLE_INDEX_MODULE !== "true", }, + [Modules.RBAC]: { + resolve: "@medusajs/rbac", + }, } if (process.env.MEDUSA_FF_TRANSLATION === "true") { diff --git a/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts b/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts new file mode 100644 index 0000000000..eca9d35bcd --- /dev/null +++ b/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts @@ -0,0 +1,516 @@ +import { + createRbacPoliciesWorkflow, + createRbacRolesWorkflow, +} from "@medusajs/core-flows" +import { medusaIntegrationTestRunner } from "@medusajs/test-utils" +import { IRbacModuleService, MedusaContainer } from "@medusajs/types" +import { Modules } from "@medusajs/utils" + +jest.setTimeout(50000) + +medusaIntegrationTestRunner({ + env: {}, + testSuite: ({ getContainer }) => { + describe("Workflows: RBAC", () => { + let appContainer: MedusaContainer + let rbacService: IRbacModuleService + + beforeAll(async () => { + appContainer = getContainer() + rbacService = appContainer.resolve(Modules.RBAC) + }) + + describe("Role Inheritance and Policy Management", () => { + it("should create roles with inheritance and policies, then list all inherited policies", async () => { + // Step 1: Create base policies + const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) + const { result: createdPolicies } = await policiesWorkflow.run({ + input: { + policies: [ + { + key: "read:products", + resource: "product", + operation: "read", + name: "Read Products", + description: "Permission to read products", + }, + { + key: "write:products", + resource: "product", + operation: "write", + name: "Write Products", + description: "Permission to write products", + }, + { + key: "read:orders", + resource: "order", + operation: "read", + name: "Read Orders", + description: "Permission to read orders", + }, + { + key: "write:orders", + resource: "order", + operation: "write", + name: "Write Orders", + description: "Permission to write orders", + }, + { + key: "delete:users", + resource: "user", + operation: "delete", + name: "Delete Users", + description: "Permission to delete users", + }, + ], + }, + }) + + expect(createdPolicies).toHaveLength(5) + + // Step 2: Create base roles with their policies + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + + // Create "Viewer" role with read permissions + const { result: viewerRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Viewer", + description: "Can view products and orders", + policy_ids: [ + createdPolicies[0].id, // read:products + createdPolicies[2].id, // read:orders + ], + }, + ], + }, + }) + + expect(viewerRoles).toHaveLength(1) + const viewerRole = viewerRoles[0] + + // Create "Editor" role with write permissions + const { result: editorRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Editor", + description: "Can write products and orders", + policy_ids: [ + createdPolicies[1].id, // write:products + createdPolicies[3].id, // write:orders + ], + }, + ], + }, + }) + + expect(editorRoles).toHaveLength(1) + const editorRole = editorRoles[0] + + // Step 3: Create "Admin" role that inherits from both Viewer and Editor, plus additional permissions + const { result: adminRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Admin", + description: + "Inherits from Viewer and Editor, plus can delete users", + inherited_role_ids: [viewerRole.id, editorRole.id], + policy_ids: [ + createdPolicies[4].id, // delete:users + ], + }, + ], + }, + }) + + expect(adminRoles).toHaveLength(1) + const adminRole = adminRoles[0] + + // Step 4: Verify role inheritance was created + const inheritances = await rbacService.listRbacRoleInheritances({ + role_id: adminRole.id, + }) + + expect(inheritances).toHaveLength(2) + expect(inheritances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role_id: adminRole.id, + inherited_role_id: viewerRole.id, + }), + expect.objectContaining({ + role_id: adminRole.id, + inherited_role_id: editorRole.id, + }), + ]) + ) + + // Step 5: Verify direct policies for Admin role + const adminDirectPolicies = await rbacService.listRbacRolePolicies({ + role_id: adminRole.id, + }) + + expect(adminDirectPolicies).toHaveLength(1) + expect(adminDirectPolicies[0]).toEqual( + expect.objectContaining({ + role_id: adminRole.id, + scope_id: createdPolicies[4].id, // delete:users + }) + ) + + // Step 6: List ALL policies for Admin role (including inherited) + const allAdminPolicies = await rbacService.listPoliciesForRole( + adminRole.id + ) + + // Admin should have: + // - read:products (from Viewer) + // - read:orders (from Viewer) + // - write:products (from Editor) + // - write:orders (from Editor) + // - delete:users (direct) + expect(allAdminPolicies).toHaveLength(5) + + const policyKeys = allAdminPolicies.map((p) => p.key).sort() + expect(policyKeys).toEqual([ + "delete:users", + "read:orders", + "read:products", + "write:orders", + "write:products", + ]) + + // Verify each policy has correct details + expect(allAdminPolicies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "read:products", + resource: "product", + operation: "read", + }), + expect.objectContaining({ + key: "write:products", + resource: "product", + operation: "write", + }), + expect.objectContaining({ + key: "read:orders", + resource: "order", + operation: "read", + }), + expect.objectContaining({ + key: "write:orders", + resource: "order", + operation: "write", + }), + expect.objectContaining({ + key: "delete:users", + resource: "user", + operation: "delete", + }), + ]) + ) + + // Step 7: Verify Viewer role only has its direct policies + const viewerPolicies = await rbacService.listPoliciesForRole( + viewerRole.id + ) + + expect(viewerPolicies).toHaveLength(2) + expect(viewerPolicies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "read:products", + }), + expect.objectContaining({ + key: "read:orders", + }), + ]) + ) + + // Step 8: Verify Editor role only has its direct policies + const editorPolicies = await rbacService.listPoliciesForRole( + editorRole.id + ) + + expect(editorPolicies).toHaveLength(2) + expect(editorPolicies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "write:products", + }), + expect.objectContaining({ + key: "write:orders", + }), + ]) + ) + }) + + it("should handle multi-level role inheritance", async () => { + // Create policies + const policiesWorkflow = createRbacPoliciesWorkflow(appContainer) + const { result: policies } = await policiesWorkflow.run({ + input: { + policies: [ + { + key: "read:catalog", + resource: "catalog", + operation: "read", + name: "Read Catalog", + }, + { + key: "write:catalog", + resource: "catalog", + operation: "write", + name: "Write Catalog", + }, + { + key: "admin:system", + resource: "system", + operation: "admin", + name: "System Admin", + }, + ], + }, + }) + + // Create role hierarchy: Basic -> Manager -> SuperAdmin + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + + // Level 1: Basic role + const { result: basicRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Basic User", + description: "Basic read access", + policy_ids: [policies[0].id], // read:catalog + }, + ], + }, + }) + const basicRole = basicRoles[0] + + // Level 2: Manager inherits from Basic + const { result: managerRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Manager", + description: "Manager with write access", + inherited_role_ids: [basicRole.id], + policy_ids: [policies[1].id], // write:catalog + }, + ], + }, + }) + const managerRole = managerRoles[0] + + // Level 3: SuperAdmin inherits from Manager + const { result: superAdminRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "SuperAdmin", + description: "Super admin with all access", + inherited_role_ids: [managerRole.id], + policy_ids: [policies[2].id], // admin:system + }, + ], + }, + }) + const superAdminRole = superAdminRoles[0] + + // Verify SuperAdmin has all policies through inheritance chain + const superAdminPolicies = await rbacService.listPoliciesForRole( + superAdminRole.id + ) + + expect(superAdminPolicies).toHaveLength(3) + expect(superAdminPolicies.map((p) => p.key).sort()).toEqual([ + "admin:system", + "read:catalog", + "write:catalog", + ]) + + // Verify Manager has policies from Basic + its own + const managerPolicies = await rbacService.listPoliciesForRole( + managerRole.id + ) + + expect(managerPolicies).toHaveLength(2) + expect(managerPolicies.map((p) => p.key).sort()).toEqual([ + "read:catalog", + "write:catalog", + ]) + + // Verify Basic only has its own policy + const basicPolicies = await rbacService.listPoliciesForRole( + basicRole.id + ) + + expect(basicPolicies).toHaveLength(1) + expect(basicPolicies[0].key).toBe("read:catalog") + }) + + it("should propagate policy deletion from inherited role", async () => { + // 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", + }, + { + key: "admin:inventory", + resource: "inventory", + operation: "admin", + name: "Admin Inventory", + }, + ], + }, + }) + + // Create role hierarchy: Basic -> Manager -> SuperAdmin + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + + // Level 1: Basic role with read permission + const { result: basicRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Basic Inventory User", + description: "Basic inventory read access", + policy_ids: [policies[0].id], // read:inventory + }, + ], + }, + }) + const basicRole = basicRoles[0] + + // Level 2: Manager inherits from Basic + write permission + const { result: managerRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Inventory Manager", + description: "Manager with write access", + inherited_role_ids: [basicRole.id], + policy_ids: [policies[1].id], // write:inventory + }, + ], + }, + }) + const managerRole = managerRoles[0] + + // Level 3: SuperAdmin inherits from Manager + admin permission + const { result: superAdminRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Inventory SuperAdmin", + description: "Super admin with all access", + inherited_role_ids: [managerRole.id], + policy_ids: [policies[2].id], // admin:inventory + }, + ], + }, + }) + const superAdminRole = superAdminRoles[0] + + // Verify initial state: SuperAdmin has all 3 policies + let superAdminPolicies = await rbacService.listPoliciesForRole( + superAdminRole.id + ) + expect(superAdminPolicies).toHaveLength(3) + expect(superAdminPolicies.map((p) => p.key).sort()).toEqual([ + "admin:inventory", + "read:inventory", + "write:inventory", + ]) + + // Verify Manager has 2 policies (Basic's + its own) + let managerPolicies = await rbacService.listPoliciesForRole( + managerRole.id + ) + expect(managerPolicies).toHaveLength(2) + expect(managerPolicies.map((p) => p.key).sort()).toEqual([ + "read:inventory", + "write:inventory", + ]) + + // Delete the read:inventory policy from Basic role + const basicRolePolicies = await rbacService.listRbacRolePolicies({ + role_id: basicRole.id, + }) + const readPolicyAssociation = basicRolePolicies.find( + (rp) => rp.scope_id === policies[0].id + ) + await rbacService.deleteRbacRolePolicies([readPolicyAssociation!.id]) + + // Verify Basic role no longer has the read policy + const basicPoliciesAfterDelete = + await rbacService.listPoliciesForRole(basicRole.id) + expect(basicPoliciesAfterDelete).toHaveLength(0) + + // Verify Manager role no longer inherits the read policy + managerPolicies = await rbacService.listPoliciesForRole( + managerRole.id + ) + expect(managerPolicies).toHaveLength(1) + expect(managerPolicies[0].key).toBe("write:inventory") + + // Verify SuperAdmin role also lost the read policy through inheritance chain + superAdminPolicies = await rbacService.listPoliciesForRole( + superAdminRole.id + ) + expect(superAdminPolicies).toHaveLength(2) + expect(superAdminPolicies.map((p) => p.key).sort()).toEqual([ + "admin:inventory", + "write:inventory", + ]) + }) + + it("should handle role with no inherited roles or policies", async () => { + const rolesWorkflow = createRbacRolesWorkflow(appContainer) + + const { result: emptyRoles } = await rolesWorkflow.run({ + input: { + roles: [ + { + name: "Empty Role", + description: "Role with no permissions", + }, + ], + }, + }) + + const emptyRole = emptyRoles[0] + + // Verify no policies + const policies = await rbacService.listPoliciesForRole(emptyRole.id) + expect(policies).toHaveLength(0) + + // Verify no inheritance + const inheritances = await rbacService.listRbacRoleInheritances({ + role_id: emptyRole.id, + }) + expect(inheritances).toHaveLength(0) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/medusa-config.ts b/integration-tests/modules/medusa-config.ts index ef37e72194..e7fe3446c0 100644 --- a/integration-tests/modules/medusa-config.ts +++ b/integration-tests/modules/medusa-config.ts @@ -189,5 +189,9 @@ module.exports = defineConfig({ key: "brand", resolve: "src/modules/brand", }, + { + key: Modules.RBAC, + resolve: "@medusajs/rbac", + }, ], }) diff --git a/packages/core/core-flows/src/index.ts b/packages/core/core-flows/src/index.ts index 719238bda3..faaa0d435f 100644 --- a/packages/core/core-flows/src/index.ts +++ b/packages/core/core-flows/src/index.ts @@ -21,6 +21,7 @@ export * from "./pricing" export * from "./product" export * from "./product-category" export * from "./promotion" +export * from "./rbac" export * from "./region" export * from "./reservation" export * from "./return-reason" diff --git a/packages/core/core-flows/src/rbac/index.ts b/packages/core/core-flows/src/rbac/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core/core-flows/src/rbac/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" 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 new file mode 100644 index 0000000000..afcd0a955d --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-policies.ts @@ -0,0 +1,40 @@ +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 +} + +export type CreateRbacPoliciesStepInput = { + policies: CreateRbacPolicyDTO[] +} + +export const createRbacPoliciesStepId = "create-rbac-policies" + +export const createRbacPoliciesStep = createStep( + createRbacPoliciesStepId, + async (data: CreateRbacPoliciesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const created = await service.createRbacPolicies(data.policies) + + return new StepResponse( + created, + (created ?? []).map((p) => p.id) + ) + }, + async (createdIds: string[] | undefined, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + await service.deleteRbacPolicies(createdIds) + } +) 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-inheritances.ts new file mode 100644 index 0000000000..e97dbfe2dd --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-role-inheritances.ts @@ -0,0 +1,43 @@ +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService } from "@medusajs/types" + +export type CreateRbacRoleInheritanceDTO = { + role_id: string + inherited_role_id: string + metadata?: Record | null +} + +export type CreateRbacRoleInheritancesStepInput = { + role_inheritances: CreateRbacRoleInheritanceDTO[] +} + +export const createRbacRoleInheritancesStepId = "create-rbac-role-inheritances" + +export const createRbacRoleInheritancesStep = createStep( + createRbacRoleInheritancesStepId, + async (data: CreateRbacRoleInheritancesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + if (!data.role_inheritances || data.role_inheritances.length === 0) { + return new StepResponse([], []) + } + + const created = await service.createRbacRoleInheritances( + data.role_inheritances + ) + + return new StepResponse( + created, + (created ?? []).map((ri) => ri.id) + ) + }, + async (createdIds: string[] | undefined, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + await service.deleteRbacRoleInheritances(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 new file mode 100644 index 0000000000..74054eebea --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-role-policies.ts @@ -0,0 +1,37 @@ +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 +} + +export type CreateRbacRolePoliciesStepInput = { + role_policies: CreateRbacRolePolicyDTO[] +} + +export const createRbacRolePoliciesStepId = "create-rbac-role-policies" + +export const createRbacRolePoliciesStep = createStep( + createRbacRolePoliciesStepId, + async (data: CreateRbacRolePoliciesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const created = await service.createRbacRolePolicies(data.role_policies) + + return new StepResponse( + created, + (created ?? []).map((rp) => rp.id) + ) + }, + async (createdIds: string[] | undefined, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + await service.deleteRbacRolePolicies(createdIds) + } +) 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 new file mode 100644 index 0000000000..e64bea76ac --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/create-rbac-roles.ts @@ -0,0 +1,37 @@ +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService } from "@medusajs/types" + +export type CreateRbacRoleDTO = { + name: string + description?: string | null + metadata?: Record | null +} + +export type CreateRbacRolesStepInput = { + roles: CreateRbacRoleDTO[] +} + +export const createRbacRolesStepId = "create-rbac-roles" + +export const createRbacRolesStep = createStep( + createRbacRolesStepId, + async (data: CreateRbacRolesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const created = await service.createRbacRoles(data.roles) + + return new StepResponse( + created, + (created ?? []).map((r) => r.id) + ) + }, + async (createdIds: string[] | undefined, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + await service.deleteRbacRoles(createdIds) + } +) 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 new file mode 100644 index 0000000000..a8116b2584 --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/delete-rbac-policies.ts @@ -0,0 +1,17 @@ +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService } from "@medusajs/types" + +export type DeleteRbacPoliciesStepInput = string[] + +export const deleteRbacPoliciesStepId = "delete-rbac-policies" + +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) + }, + async () => {} +) 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 new file mode 100644 index 0000000000..b49ffbbc22 --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/delete-rbac-role-policies.ts @@ -0,0 +1,17 @@ +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService } from "@medusajs/types" + +export type DeleteRbacRolePoliciesStepInput = string[] + +export const deleteRbacRolePoliciesStepId = "delete-rbac-role-policies" + +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) + }, + async () => {} +) 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 new file mode 100644 index 0000000000..9e1df44a19 --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/delete-rbac-roles.ts @@ -0,0 +1,17 @@ +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService } from "@medusajs/types" + +export type DeleteRbacRolesStepInput = string[] + +export const deleteRbacRolesStepId = "delete-rbac-roles" + +export const deleteRbacRolesStep = createStep( + { name: deleteRbacRolesStepId, noCompensation: true }, + async (ids: DeleteRbacRolesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + await service.deleteRbacRoles(ids) + return new StepResponse(void 0) + }, + async () => {} +) diff --git a/packages/core/core-flows/src/rbac/steps/index.ts b/packages/core/core-flows/src/rbac/steps/index.ts new file mode 100644 index 0000000000..cafa6c2ad3 --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/index.ts @@ -0,0 +1,14 @@ +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-policies" +export * from "./delete-rbac-role-policies" +export * from "./update-rbac-role-policies" + +export * from "./create-rbac-role-inheritances" +export * from "./set-role-inheritance" diff --git a/packages/core/core-flows/src/rbac/steps/set-role-inheritance.ts b/packages/core/core-flows/src/rbac/steps/set-role-inheritance.ts new file mode 100644 index 0000000000..97ab32f41c --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/set-role-inheritance.ts @@ -0,0 +1,122 @@ +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService } from "@medusajs/types" + +export type SetRoleInheritanceStepInput = Array<{ + role_id: string + inherited_role_ids: string[] +}> + +export const setRoleInheritanceStepId = "set-role-inheritance" + +export const setRoleInheritanceStep = createStep( + setRoleInheritanceStepId, + async (data: SetRoleInheritanceStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const allCompensationData: Array<{ + role_id: string + previousInheritedRoleIds: string[] + }> = [] + + if (!data || data.length === 0) { + return new StepResponse( + { created: [], removedCount: 0 }, + allCompensationData + ) + } + + const allToRemoveIds: string[] = [] + const allToCreate: Array<{ + role_id: string + inherited_role_id: string + }> = [] + + for (const roleData of data) { + const existingInheritance = await service.listRbacRoleInheritances({ + role_id: roleData.role_id, + }) + + const existingInheritedRoleIds = existingInheritance.map( + (ri) => ri.inherited_role_id + ) + + allCompensationData.push({ + role_id: roleData.role_id, + previousInheritedRoleIds: existingInheritedRoleIds, + }) + + const toAdd = roleData.inherited_role_ids.filter( + (id) => !existingInheritedRoleIds.includes(id) + ) + const toRemove = existingInheritedRoleIds.filter( + (id) => !roleData.inherited_role_ids.includes(id) + ) + + if (toRemove.length > 0) { + const toRemoveRecords = existingInheritance.filter((ri) => + toRemove.includes(ri.inherited_role_id) + ) + allToRemoveIds.push(...toRemoveRecords.map((ri) => ri.id)) + } + + if (toAdd.length > 0) { + allToCreate.push( + ...toAdd.map((inherited_role_id) => ({ + role_id: roleData.role_id, + inherited_role_id, + })) + ) + } + } + + if (allToRemoveIds.length > 0) { + await service.deleteRbacRoleInheritances(allToRemoveIds) + } + + let created: any[] = [] + if (allToCreate.length > 0) { + created = await service.createRbacRoleInheritances(allToCreate) + } + + return new StepResponse( + { created, removedCount: allToRemoveIds.length }, + allCompensationData + ) + }, + async ( + compensationData: + | Array<{ role_id: string; previousInheritedRoleIds: string[] }> + | undefined, + { container } + ) => { + if (!compensationData || compensationData.length === 0) { + return + } + + const service = container.resolve(Modules.RBAC) + + for (const roleCompensation of compensationData) { + const currentInheritance = await service.listRbacRoleInheritances({ + role_id: roleCompensation.role_id, + }) + + if (currentInheritance.length > 0) { + await service.deleteRbacRoleInheritances( + currentInheritance.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, + }) + ) + ) + } + } + } +) 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 new file mode 100644 index 0000000000..c0d441940f --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/update-rbac-policies.ts @@ -0,0 +1,61 @@ +import { + getSelectsAndRelationsFromObjectArray, + Modules, +} from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService, UpdateRbacPolicyDTO } from "@medusajs/types" + +export type UpdateRbacPoliciesStepInput = { + selector: Record + update: Omit +} + +export const updateRbacPoliciesStepId = "update-rbac-policies" + +export const updateRbacPoliciesStep = createStep( + updateRbacPoliciesStepId, + async (data: UpdateRbacPoliciesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + + const prevData = await service.listRbacPolicies(data.selector, { + select: selects, + relations, + }) + + const updates = (prevData ?? []).map((p) => ({ + id: p.id, + ...data.update, + })) as UpdateRbacPolicyDTO[] + + const updated = await service.updateRbacPolicies(updates) + + return new StepResponse(updated, { + prevData, + updateKeys: Object.keys(data.update ?? {}), + }) + }, + async ( + compensationData: { prevData: any[]; updateKeys: string[] } | undefined, + { container } + ) => { + if (!compensationData?.prevData?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + + const updates = compensationData.prevData.map((p) => { + const payload: Record = { id: p.id } + for (const key of compensationData.updateKeys) { + payload[key] = p[key] + } + return payload + }) as UpdateRbacPolicyDTO[] + + await service.updateRbacPolicies(updates) + } +) diff --git a/packages/core/core-flows/src/rbac/steps/update-rbac-role-policies.ts b/packages/core/core-flows/src/rbac/steps/update-rbac-role-policies.ts new file mode 100644 index 0000000000..6435f40355 --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/update-rbac-role-policies.ts @@ -0,0 +1,61 @@ +import { + getSelectsAndRelationsFromObjectArray, + Modules, +} from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService, UpdateRbacRolePolicyDTO } from "@medusajs/types" + +export type UpdateRbacRolePoliciesStepInput = { + selector: Record + update: Omit +} + +export const updateRbacRolePoliciesStepId = "update-rbac-role-policies" + +export const updateRbacRolePoliciesStep = createStep( + updateRbacRolePoliciesStepId, + async (data: UpdateRbacRolePoliciesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + + const prevData = await service.listRbacRolePolicies(data.selector, { + select: selects, + relations, + }) + + const updates = (prevData ?? []).map((rp) => ({ + id: rp.id, + ...data.update, + })) as UpdateRbacRolePolicyDTO[] + + const updated = await service.updateRbacRolePolicies(updates) + + return new StepResponse(updated, { + prevData, + updateKeys: Object.keys(data.update ?? {}), + }) + }, + async ( + compensationData: { prevData: any[]; updateKeys: string[] } | undefined, + { container } + ) => { + if (!compensationData?.prevData?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + + const updates = compensationData.prevData.map((rp) => { + const payload: Record = { id: rp.id } + for (const key of compensationData.updateKeys) { + payload[key] = rp[key] + } + return payload + }) as UpdateRbacRolePolicyDTO[] + + await service.updateRbacRolePolicies(updates) + } +) diff --git a/packages/core/core-flows/src/rbac/steps/update-rbac-roles.ts b/packages/core/core-flows/src/rbac/steps/update-rbac-roles.ts new file mode 100644 index 0000000000..6154c82da3 --- /dev/null +++ b/packages/core/core-flows/src/rbac/steps/update-rbac-roles.ts @@ -0,0 +1,61 @@ +import { + getSelectsAndRelationsFromObjectArray, + Modules, +} from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { IRbacModuleService, UpdateRbacRoleDTO } from "@medusajs/types" + +export type UpdateRbacRolesStepInput = { + selector: Record + update: Omit +} + +export const updateRbacRolesStepId = "update-rbac-roles" + +export const updateRbacRolesStep = createStep( + updateRbacRolesStepId, + async (data: UpdateRbacRolesStepInput, { container }) => { + const service = container.resolve(Modules.RBAC) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + + const prevData = await service.listRbacRoles(data.selector, { + select: selects, + relations, + }) + + const updates = (prevData ?? []).map((r) => ({ + id: r.id, + ...data.update, + })) as UpdateRbacRoleDTO[] + + const updated = await service.updateRbacRoles(updates) + + return new StepResponse(updated, { + prevData, + updateKeys: Object.keys(data.update ?? {}), + }) + }, + async ( + compensationData: { prevData: any[]; updateKeys: string[] } | undefined, + { container } + ) => { + if (!compensationData?.prevData?.length) { + return + } + + const service = container.resolve(Modules.RBAC) + + const updates = compensationData.prevData.map((r) => { + const payload: Record = { id: r.id } + for (const key of compensationData.updateKeys) { + payload[key] = r[key] + } + return payload + }) as UpdateRbacRoleDTO[] + + await service.updateRbacRoles(updates) + } +) diff --git a/packages/core/core-flows/src/rbac/workflows/create-rbac-policies.ts b/packages/core/core-flows/src/rbac/workflows/create-rbac-policies.ts new file mode 100644 index 0000000000..3529be4d30 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/create-rbac-policies.ts @@ -0,0 +1,19 @@ +import { + WorkflowData, + WorkflowResponse, + createWorkflow, +} from "@medusajs/framework/workflows-sdk" +import { createRbacPoliciesStep } from "../steps" + +export type CreateRbacPoliciesWorkflowInput = { + policies: any[] +} + +export const createRbacPoliciesWorkflowId = "create-rbac-policies" + +export const createRbacPoliciesWorkflow = createWorkflow( + createRbacPoliciesWorkflowId, + (input: WorkflowData) => { + return new WorkflowResponse(createRbacPoliciesStep(input)) + } +) 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 new file mode 100644 index 0000000000..7704c81596 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/create-rbac-role-policies.ts @@ -0,0 +1,19 @@ +import { + WorkflowData, + WorkflowResponse, + createWorkflow, +} from "@medusajs/framework/workflows-sdk" +import { createRbacRolePoliciesStep } from "../steps" + +export type CreateRbacRolePoliciesWorkflowInput = { + role_policies: any[] +} + +export const createRbacRolePoliciesWorkflowId = "create-rbac-role-policies" + +export const createRbacRolePoliciesWorkflow = createWorkflow( + createRbacRolePoliciesWorkflowId, + (input: WorkflowData) => { + return new WorkflowResponse(createRbacRolePoliciesStep(input)) + } +) 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 new file mode 100644 index 0000000000..c3f1324a08 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/create-rbac-roles.ts @@ -0,0 +1,80 @@ +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { + createRbacRoleInheritancesStep, + createRbacRolePoliciesStep, + createRbacRolesStep, +} from "../steps" + +export type CreateRbacRolesWorkflowInput = { + roles: { + name: string + description?: string | null + metadata?: Record | null + inherited_role_ids?: string[] + policy_ids?: string[] + }[] +} + +export const createRbacRolesWorkflowId = "create-rbac-roles" + +export const createRbacRolesWorkflow = createWorkflow( + createRbacRolesWorkflowId, + (input: WorkflowData) => { + const roleData = transform({ input }, ({ input }) => ({ + roles: input.roles.map((r) => ({ + name: r.name, + description: r.description, + metadata: r.metadata, + })), + })) + + const createdRoles = createRbacRolesStep(roleData) + + const inheritanceData = transform( + { input, createdRoles }, + ({ input, createdRoles }) => { + const inheritances: any[] = [] + + createdRoles.forEach((role, index) => { + const inheritedRoleIds = input.roles[index].inherited_role_ids || [] + inheritedRoleIds.forEach((inheritedRoleId) => { + inheritances.push({ + role_id: role.id, + inherited_role_id: inheritedRoleId, + }) + }) + }) + + return { role_inheritances: inheritances } + } + ) + + const policiesData = transform( + { input, createdRoles }, + ({ input, createdRoles }) => { + const allPolicies: any[] = [] + createdRoles.forEach((role, index) => { + const policyIds = input.roles[index].policy_ids || [] + policyIds.forEach((policy_id) => { + allPolicies.push({ + role_id: role.id, + scope_id: policy_id, + }) + }) + }) + return { role_policies: allPolicies } + } + ) + + createRbacRoleInheritancesStep(inheritanceData) + + createRbacRolePoliciesStep(policiesData) + + return new WorkflowResponse(createdRoles) + } +) diff --git a/packages/core/core-flows/src/rbac/workflows/delete-rbac-policies.ts b/packages/core/core-flows/src/rbac/workflows/delete-rbac-policies.ts new file mode 100644 index 0000000000..07006af445 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/delete-rbac-policies.ts @@ -0,0 +1,17 @@ +import { WorkflowData, createWorkflow } from "@medusajs/framework/workflows-sdk" +import { deleteRbacPoliciesStep } from "../steps" + +export type DeleteRbacPoliciesWorkflowInput = { + ids: string[] +} + +export const deleteRbacPoliciesWorkflowId = "delete-rbac-policies" + +export const deleteRbacPoliciesWorkflow = createWorkflow( + deleteRbacPoliciesWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + deleteRbacPoliciesStep(input.ids) + } +) 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 new file mode 100644 index 0000000000..68de512703 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/delete-rbac-role-policies.ts @@ -0,0 +1,17 @@ +import { WorkflowData, createWorkflow } from "@medusajs/framework/workflows-sdk" +import { deleteRbacRolePoliciesStep } from "../steps" + +export type DeleteRbacRolePoliciesWorkflowInput = { + ids: string[] +} + +export const deleteRbacRolePoliciesWorkflowId = "delete-rbac-role-policies" + +export const deleteRbacRolePoliciesWorkflow = createWorkflow( + deleteRbacRolePoliciesWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + deleteRbacRolePoliciesStep(input.ids) + } +) diff --git a/packages/core/core-flows/src/rbac/workflows/delete-rbac-roles.ts b/packages/core/core-flows/src/rbac/workflows/delete-rbac-roles.ts new file mode 100644 index 0000000000..e1990077de --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/delete-rbac-roles.ts @@ -0,0 +1,15 @@ +import { WorkflowData, createWorkflow } from "@medusajs/framework/workflows-sdk" +import { deleteRbacRolesStep } from "../steps" + +export type DeleteRbacRolesWorkflowInput = { + ids: string[] +} + +export const deleteRbacRolesWorkflowId = "delete-rbac-roles" + +export const deleteRbacRolesWorkflow = createWorkflow( + deleteRbacRolesWorkflowId, + (input: WorkflowData): WorkflowData => { + deleteRbacRolesStep(input.ids) + } +) diff --git a/packages/core/core-flows/src/rbac/workflows/index.ts b/packages/core/core-flows/src/rbac/workflows/index.ts new file mode 100644 index 0000000000..06f712f298 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/index.ts @@ -0,0 +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-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-policies.ts b/packages/core/core-flows/src/rbac/workflows/update-rbac-policies.ts new file mode 100644 index 0000000000..6289b5ce57 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/update-rbac-policies.ts @@ -0,0 +1,21 @@ +import { + WorkflowData, + WorkflowResponse, + createWorkflow, +} from "@medusajs/framework/workflows-sdk" +import { UpdateRbacPolicyDTO } from "@medusajs/types" +import { updateRbacPoliciesStep } from "../steps/update-rbac-policies" + +export type UpdateRbacPoliciesWorkflowInput = { + selector: Record + update: Omit +} + +export const updateRbacPoliciesWorkflowId = "update-rbac-policies" + +export const updateRbacPoliciesWorkflow = createWorkflow( + updateRbacPoliciesWorkflowId, + (input: WorkflowData) => { + return new WorkflowResponse(updateRbacPoliciesStep(input)) + } +) diff --git a/packages/core/core-flows/src/rbac/workflows/update-rbac-role-policies.ts b/packages/core/core-flows/src/rbac/workflows/update-rbac-role-policies.ts new file mode 100644 index 0000000000..aea77a9b60 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/update-rbac-role-policies.ts @@ -0,0 +1,21 @@ +import { + WorkflowData, + WorkflowResponse, + createWorkflow, +} from "@medusajs/framework/workflows-sdk" +import { UpdateRbacRolePolicyDTO } from "@medusajs/types" +import { updateRbacRolePoliciesStep } from "../steps/update-rbac-role-policies" + +export type UpdateRbacRolePoliciesWorkflowInput = { + selector: Record + update: Omit +} + +export const updateRbacRolePoliciesWorkflowId = "update-rbac-role-policies" + +export const updateRbacRolePoliciesWorkflow = createWorkflow( + updateRbacRolePoliciesWorkflowId, + (input: WorkflowData) => { + return new WorkflowResponse(updateRbacRolePoliciesStep(input)) + } +) 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 new file mode 100644 index 0000000000..808661ec40 --- /dev/null +++ b/packages/core/core-flows/src/rbac/workflows/update-rbac-roles.ts @@ -0,0 +1,77 @@ +import { isDefined } from "@medusajs/framework/utils" +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { UpdateRbacRoleDTO } from "@medusajs/types" +import { createRbacRolePoliciesStep, setRoleInheritanceStep } from "../steps" +import { updateRbacRolesStep } from "../steps/update-rbac-roles" + +export type UpdateRbacRolesWorkflowInput = { + selector: Record + update: Omit & { + inherited_role_ids?: string[] + policy_ids?: string[] + } +} + +export const updateRbacRolesWorkflowId = "update-rbac-roles" + +export const updateRbacRolesWorkflow = createWorkflow( + updateRbacRolesWorkflowId, + (input: WorkflowData) => { + const roleUpdateData = transform({ input }, ({ input }) => ({ + selector: input.selector, + update: { + name: input.update.name, + description: input.update.description, + metadata: input.update.metadata, + }, + })) + + const updatedRoles = updateRbacRolesStep(roleUpdateData) + + const inheritanceUpdateData = transform( + { input, updatedRoles }, + ({ input, updatedRoles }) => { + if (!isDefined(input.update.inherited_role_ids)) { + return [] + } + + return updatedRoles.map((role) => ({ + role_id: role.id, + inherited_role_ids: input.update.inherited_role_ids || [], + })) + } + ) + + setRoleInheritanceStep(inheritanceUpdateData) + + const policiesUpdateData = transform( + { input, updatedRoles }, + ({ input, updatedRoles }) => { + if (!isDefined(input.update.policy_ids)) { + return { role_policies: [] } + } + + const allPolicies: any[] = [] + updatedRoles.forEach((role) => { + const policyIds = input.update.policy_ids || [] + policyIds.forEach((policy_id) => { + allPolicies.push({ + role_id: role.id, + scope_id: policy_id, + }) + }) + }) + return { role_policies: allPolicies } + } + ) + + createRbacRolePoliciesStep(policiesUpdateData) + + return new WorkflowResponse(updatedRoles) + } +) diff --git a/packages/core/framework/src/types/container.ts b/packages/core/framework/src/types/container.ts index aa5569af55..33587682c6 100644 --- a/packages/core/framework/src/types/container.ts +++ b/packages/core/framework/src/types/container.ts @@ -21,6 +21,7 @@ import { IPricingModuleService, IProductModuleService, IPromotionModuleService, + IRbacModuleService, IRegionModuleService, ISalesChannelModuleService, ISettingsModuleService, @@ -82,6 +83,7 @@ declare module "@medusajs/types" { [Modules.CACHING]: ICachingModuleService [Modules.INDEX]: IIndexService [Modules.TRANSLATION]: ITranslationModuleService + [Modules.RBAC]: IRbacModuleService } } diff --git a/packages/core/types/src/index.ts b/packages/core/types/src/index.ts index 0d4f4357f6..9eecb4bd45 100644 --- a/packages/core/types/src/index.ts +++ b/packages/core/types/src/index.ts @@ -32,6 +32,7 @@ export * from "./pricing" export * from "./product" export * from "./product-category" export * from "./promotion" +export * from "./rbac" export * from "./region" export * from "./sales-channel" export * from "./search" diff --git a/packages/core/types/src/rbac/common.ts b/packages/core/types/src/rbac/common.ts new file mode 100644 index 0000000000..422c2f1b6c --- /dev/null +++ b/packages/core/types/src/rbac/common.ts @@ -0,0 +1,56 @@ +export type RbacRoleDTO = { + id: string + name: string + description?: string | null + metadata?: Record | null +} + +export type FilterableRbacRoleProps = { + id?: string | string[] + name?: string + q?: string +} + +export type RbacPolicyDTO = { + id: string + key: string + resource: string + operation: string + name?: string | null + description?: string | null + metadata?: Record | null +} + +export type FilterableRbacPolicyProps = { + id?: string | string[] + key?: string + resource?: string + operation?: string + q?: string +} + +export type RbacRolePolicyDTO = { + id: string + role_id: string + scope_id: string + metadata?: Record | null +} + +export type FilterableRbacRolePolicyProps = { + id?: string | string[] + role_id?: string | string[] + scope_id?: string | string[] +} + +export type RbacRoleInheritanceDTO = { + id: string + role_id: string + inherited_role_id: string + metadata?: Record | null +} + +export type FilterableRbacRoleInheritanceProps = { + id?: string | string[] + role_id?: string | string[] + inherited_role_id?: string | string[] +} diff --git a/packages/core/types/src/rbac/index.ts b/packages/core/types/src/rbac/index.ts new file mode 100644 index 0000000000..0c73656566 --- /dev/null +++ b/packages/core/types/src/rbac/index.ts @@ -0,0 +1,3 @@ +export * from "./common" +export * from "./mutations" +export * from "./service" diff --git a/packages/core/types/src/rbac/mutations.ts b/packages/core/types/src/rbac/mutations.ts new file mode 100644 index 0000000000..cc3f506c62 --- /dev/null +++ b/packages/core/types/src/rbac/mutations.ts @@ -0,0 +1,43 @@ +export type CreateRbacRoleDTO = { + name: string + description?: string | null + metadata?: Record | null +} + +export type UpdateRbacRoleDTO = Partial & { + id: string +} + +export type CreateRbacPolicyDTO = { + key: string + resource: string + operation: string + name?: string | null + description?: string | null + metadata?: Record | null +} + +export type UpdateRbacPolicyDTO = Partial & { + id: string +} + +export type CreateRbacRolePolicyDTO = { + role_id: string + scope_id: string + metadata?: Record | null +} + +export type UpdateRbacRolePolicyDTO = Partial & { + id: string +} + +export type CreateRbacRoleInheritanceDTO = { + role_id: string + inherited_role_id: string + metadata?: Record | null +} + +export type UpdateRbacRoleInheritanceDTO = + Partial & { + id: string + } diff --git a/packages/core/types/src/rbac/service.ts b/packages/core/types/src/rbac/service.ts new file mode 100644 index 0000000000..4417827ee7 --- /dev/null +++ b/packages/core/types/src/rbac/service.ts @@ -0,0 +1,194 @@ +import { FindConfig } from "../common" +import { IModuleService } from "../modules-sdk" +import { Context } from "../shared-context" +import { + FilterableRbacPolicyProps, + FilterableRbacRoleInheritanceProps, + FilterableRbacRolePolicyProps, + FilterableRbacRoleProps, + RbacPolicyDTO, + RbacRoleDTO, + RbacRoleInheritanceDTO, + RbacRolePolicyDTO, +} from "./common" +import { + CreateRbacPolicyDTO, + CreateRbacRoleDTO, + CreateRbacRoleInheritanceDTO, + CreateRbacRolePolicyDTO, + UpdateRbacPolicyDTO, + UpdateRbacRoleDTO, + UpdateRbacRoleInheritanceDTO, + UpdateRbacRolePolicyDTO, +} from "./mutations" + +export interface IRbacModuleService extends IModuleService { + createRbacRoles( + data: CreateRbacRoleDTO, + sharedContext?: Context + ): Promise + createRbacRoles( + data: CreateRbacRoleDTO[], + sharedContext?: Context + ): Promise + + updateRbacRoles( + data: UpdateRbacRoleDTO, + sharedContext?: Context + ): Promise + updateRbacRoles( + data: UpdateRbacRoleDTO[], + sharedContext?: Context + ): Promise + + deleteRbacRoles( + ids: string | string[], + sharedContext?: Context + ): Promise + + retrieveRbacRole( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listRbacRoles( + filters?: FilterableRbacRoleProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountRbacRoles( + filters?: FilterableRbacRoleProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[RbacRoleDTO[], number]> + + createRbacPolicies( + data: CreateRbacPolicyDTO, + sharedContext?: Context + ): Promise + createRbacPolicies( + data: CreateRbacPolicyDTO[], + sharedContext?: Context + ): Promise + + updateRbacPolicies( + data: UpdateRbacPolicyDTO, + sharedContext?: Context + ): Promise + updateRbacPolicies( + data: UpdateRbacPolicyDTO[], + sharedContext?: Context + ): Promise + + deleteRbacPolicies( + ids: string | string[], + sharedContext?: Context + ): Promise + + retrieveRbacPolicy( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listRbacPolicies( + filters?: FilterableRbacPolicyProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountRbacPolicies( + filters?: FilterableRbacPolicyProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[RbacPolicyDTO[], number]> + + createRbacRolePolicies( + data: CreateRbacRolePolicyDTO, + sharedContext?: Context + ): Promise + createRbacRolePolicies( + data: CreateRbacRolePolicyDTO[], + sharedContext?: Context + ): Promise + + updateRbacRolePolicies( + data: UpdateRbacRolePolicyDTO, + sharedContext?: Context + ): Promise + updateRbacRolePolicies( + data: UpdateRbacRolePolicyDTO[], + sharedContext?: Context + ): Promise + + deleteRbacRolePolicies( + ids: string | string[], + sharedContext?: Context + ): Promise + + retrieveRbacRolePolicy( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listRbacRolePolicies( + filters?: FilterableRbacRolePolicyProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountRbacRolePolicies( + filters?: FilterableRbacRolePolicyProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[RbacRolePolicyDTO[], number]> + + createRbacRoleInheritances( + data: CreateRbacRoleInheritanceDTO, + sharedContext?: Context + ): Promise + createRbacRoleInheritances( + data: CreateRbacRoleInheritanceDTO[], + sharedContext?: Context + ): Promise + + updateRbacRoleInheritances( + data: UpdateRbacRoleInheritanceDTO, + sharedContext?: Context + ): Promise + updateRbacRoleInheritances( + data: UpdateRbacRoleInheritanceDTO[], + sharedContext?: Context + ): Promise + + deleteRbacRoleInheritances( + ids: string | string[], + sharedContext?: Context + ): Promise + + retrieveRbacRoleInheritance( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listRbacRoleInheritances( + filters?: FilterableRbacRoleInheritanceProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + listAndCountRbacRoleInheritances( + filters?: FilterableRbacRoleInheritanceProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[RbacRoleInheritanceDTO[], number]> + + listPoliciesForRole( + roleId: string, + sharedContext?: Context + ): Promise +} diff --git a/packages/core/utils/src/modules-sdk/definition.ts b/packages/core/utils/src/modules-sdk/definition.ts index 164eb12158..a52cf2ddef 100644 --- a/packages/core/utils/src/modules-sdk/definition.ts +++ b/packages/core/utils/src/modules-sdk/definition.ts @@ -29,6 +29,7 @@ export const Modules = { SETTINGS: "settings", CACHING: "caching", TRANSLATION: "translation", + RBAC: "rbac", } as const export const MODULE_PACKAGE_NAMES = { @@ -62,6 +63,7 @@ export const MODULE_PACKAGE_NAMES = { [Modules.SETTINGS]: "@medusajs/medusa/settings", [Modules.CACHING]: "@medusajs/caching", [Modules.TRANSLATION]: "@medusajs/translation", + [Modules.RBAC]: "@medusajs/medusa/rbac", } export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries( diff --git a/packages/core/utils/src/modules-sdk/modules-to-container-types.ts b/packages/core/utils/src/modules-sdk/modules-to-container-types.ts index b74d4e6589..7ea7549c09 100644 --- a/packages/core/utils/src/modules-sdk/modules-to-container-types.ts +++ b/packages/core/utils/src/modules-sdk/modules-to-container-types.ts @@ -1,10 +1,10 @@ -import { join } from "path" -import { Modules } from "./definition" import type { LoadedModule } from "@medusajs/types" +import { join } from "path" import { FileSystem } from "../common/file-system" -import { toUnixSlash } from "../common/to-unix-slash" import { toCamelCase } from "../common/to-camel-case" +import { toUnixSlash } from "../common/to-unix-slash" import { upperCaseFirst } from "../common/upper-case-first" +import { Modules } from "./definition" /** * For known services that has interfaces, we will set the container @@ -37,6 +37,10 @@ const SERVICES_INTERFACES = { [Modules.FILE]: "IFileModuleService", [Modules.NOTIFICATION]: "INotificationModuleService", [Modules.LOCKING]: "ILockingModule", + [Modules.SETTINGS]: "ISettingsModuleService", + [Modules.CACHING]: "ICachingModuleService", + [Modules.TRANSLATION]: "ITranslationModuleService", + [Modules.RBAC]: "IRbacModuleService", } /** diff --git a/packages/medusa/src/api/admin/rbac/middlewares.ts b/packages/medusa/src/api/admin/rbac/middlewares.ts new file mode 100644 index 0000000000..f9b7705aa9 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/middlewares.ts @@ -0,0 +1,11 @@ +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/[id]/route.ts b/packages/medusa/src/api/admin/rbac/policies/[id]/route.ts new file mode 100644 index 0000000000..3c99d4cd88 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/policies/[id]/route.ts @@ -0,0 +1,91 @@ +import { + deleteRbacPoliciesWorkflow, + updateRbacPoliciesWorkflow, +} from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" + +import { AdminUpdateRbacPolicyType } from "../validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: policies } = await query.graph({ + entity: "rbac_policy", + filters: { id: req.params.id }, + fields: req.queryConfig.fields, + }) + + const policy = policies[0] + + if (!policy) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Policy with id: ${req.params.id} not found` + ) + } + + res.status(200).json({ policy }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: existing } = await query.graph({ + entity: "rbac_policy", + filters: { id: req.params.id }, + fields: ["id"], + }) + + const existingPolicy = existing[0] + if (!existingPolicy) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Policy with id "${req.params.id}" not found` + ) + } + + const { result } = await updateRbacPoliciesWorkflow(req.scope).run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody, + }, + }) + + const { data: policies } = await query.graph({ + entity: "rbac_policy", + filters: { id: result[0].id }, + fields: req.queryConfig.fields, + }) + + const policy = policies[0] + + res.status(200).json({ policy }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + await deleteRbacPoliciesWorkflow(req.scope).run({ + input: { ids: [id] }, + }) + + res.status(200).json({ + id, + object: "rbac_policy", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/admin/rbac/policies/middlewares.ts b/packages/medusa/src/api/admin/rbac/policies/middlewares.ts new file mode 100644 index 0000000000..84f1aea21c --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/policies/middlewares.ts @@ -0,0 +1,64 @@ +import * as QueryConfig from "./query-config" + +import { + validateAndTransformBody, + validateAndTransformQuery, +} from "@medusajs/framework" +import { MiddlewareRoute } from "@medusajs/framework/http" + +import { + AdminCreateRbacPolicy, + AdminGetRbacPoliciesParams, + AdminGetRbacPolicyParams, + AdminUpdateRbacPolicy, +} from "./validators" + +export const adminRbacPolicyRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/rbac/policies", + middlewares: [ + validateAndTransformQuery( + AdminGetRbacPoliciesParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/rbac/policies/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetRbacPolicyParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/rbac/policies", + middlewares: [ + validateAndTransformBody(AdminCreateRbacPolicy), + validateAndTransformQuery( + AdminGetRbacPolicyParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/rbac/policies/:id", + middlewares: [ + validateAndTransformBody(AdminUpdateRbacPolicy), + validateAndTransformQuery( + AdminGetRbacPolicyParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["DELETE"], + matcher: "/admin/rbac/policies/:id", + middlewares: [], + }, +] diff --git a/packages/medusa/src/api/admin/rbac/policies/query-config.ts b/packages/medusa/src/api/admin/rbac/policies/query-config.ts new file mode 100644 index 0000000000..73a9f41cf0 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/policies/query-config.ts @@ -0,0 +1,23 @@ +export const defaultAdminRbacPolicyFields = [ + "id", + "key", + "resource", + "operation", + "name", + "description", + "metadata", + "created_at", + "updated_at", + "deleted_at", +] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminRbacPolicyFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + defaultLimit: 20, + isList: true, +} diff --git a/packages/medusa/src/api/admin/rbac/policies/route.ts b/packages/medusa/src/api/admin/rbac/policies/route.ts new file mode 100644 index 0000000000..e62237105a --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/policies/route.ts @@ -0,0 +1,50 @@ +import { createRbacPoliciesWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { AdminCreateRbacPolicyType } from "./validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: policies, metadata } = await query.graph({ + entity: "rbac_policy", + fields: req.queryConfig.fields, + filters: req.filterableFields, + 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 input = [req.validatedBody] + + const { result } = await createRbacPoliciesWorkflow(req.scope).run({ + input: { policies: input }, + }) + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: policies } = await query.graph({ + entity: "rbac_policy", + fields: req.queryConfig.fields, + filters: { id: result[0].id }, + }) + + const policy = policies[0] + + res.status(200).json({ policy }) +} diff --git a/packages/medusa/src/api/admin/rbac/policies/validators.ts b/packages/medusa/src/api/admin/rbac/policies/validators.ts new file mode 100644 index 0000000000..e573f7c7c5 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/policies/validators.ts @@ -0,0 +1,57 @@ +import { z } from "zod" +import { applyAndAndOrOperators } from "../../../utils/common-validators" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../../utils/validators" + +export type AdminGetRbacPolicyParamsType = z.infer< + typeof AdminGetRbacPolicyParams +> +export const AdminGetRbacPolicyParams = createSelectParams() + +export const AdminGetRbacPoliciesParamsFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + key: z.union([z.string(), z.array(z.string())]).optional(), + resource: z.union([z.string(), z.array(z.string())]).optional(), + operation: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export type AdminGetRbacPoliciesParamsType = z.infer< + typeof AdminGetRbacPoliciesParams +> +export const AdminGetRbacPoliciesParams = createFindParams({ + limit: 50, + offset: 0, +}) + .merge(AdminGetRbacPoliciesParamsFields) + .merge(applyAndAndOrOperators(AdminGetRbacPoliciesParamsFields)) + +export type AdminCreateRbacPolicyType = z.infer +export const AdminCreateRbacPolicy = z + .object({ + key: z.string(), + resource: z.string(), + operation: z.string(), + name: z.string().nullish(), + description: z.string().nullish(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() + +export type AdminUpdateRbacPolicyType = z.infer +export const AdminUpdateRbacPolicy = z + .object({ + key: z.string().optional(), + resource: z.string().optional(), + operation: z.string().optional(), + name: z.string().nullish(), + description: z.string().nullish(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() 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 new file mode 100644 index 0000000000..2dbe90a0c6 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/role-policies/[id]/route.ts @@ -0,0 +1,91 @@ +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 new file mode 100644 index 0000000000..3f6664d313 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/role-policies/middlewares.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000000..d3f910d348 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/role-policies/query-config.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000000..1a11ce1b7a --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/role-policies/route.ts @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000000..797c940e0a --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/role-policies/validators.ts @@ -0,0 +1,52 @@ +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]/route.ts b/packages/medusa/src/api/admin/rbac/roles/[id]/route.ts new file mode 100644 index 0000000000..7d2a02b119 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/[id]/route.ts @@ -0,0 +1,91 @@ +import { + deleteRbacRolesWorkflow, + updateRbacRolesWorkflow, +} from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { + ContainerRegistrationKeys, + MedusaError, +} from "@medusajs/framework/utils" + +import { AdminUpdateRbacRoleType } from "../validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: roles } = await query.graph({ + entity: "rbac_role", + filters: { id: req.params.id }, + fields: req.queryConfig.fields, + }) + + const role = roles[0] + + if (!role) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Role with id: ${req.params.id} not found` + ) + } + + res.status(200).json({ role }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: existing } = await query.graph({ + entity: "rbac_role", + filters: { id: req.params.id }, + fields: ["id"], + }) + + const existingRole = existing[0] + if (!existingRole) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Role with id "${req.params.id}" not found` + ) + } + + const { result } = await updateRbacRolesWorkflow(req.scope).run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody, + }, + }) + + const { data: roles } = await query.graph({ + entity: "rbac_role", + filters: { id: result[0].id }, + fields: req.queryConfig.fields, + }) + + const role = roles[0] + + res.status(200).json({ role }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + await deleteRbacRolesWorkflow(req.scope).run({ + input: { ids: [id] }, + }) + + res.status(200).json({ + id, + object: "rbac_role", + deleted: true, + }) +} diff --git a/packages/medusa/src/api/admin/rbac/roles/helpers.ts b/packages/medusa/src/api/admin/rbac/roles/helpers.ts new file mode 100644 index 0000000000..336ce12bb9 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/helpers.ts @@ -0,0 +1 @@ +export {} diff --git a/packages/medusa/src/api/admin/rbac/roles/middlewares.ts b/packages/medusa/src/api/admin/rbac/roles/middlewares.ts new file mode 100644 index 0000000000..8c8ae23765 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/middlewares.ts @@ -0,0 +1,64 @@ +import * as QueryConfig from "./query-config" + +import { + validateAndTransformBody, + validateAndTransformQuery, +} from "@medusajs/framework" +import { MiddlewareRoute } from "@medusajs/framework/http" + +import { + AdminCreateRbacRole, + AdminGetRbacRoleParams, + AdminGetRbacRolesParams, + AdminUpdateRbacRole, +} from "./validators" + +export const adminRbacRoleRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/rbac/roles", + middlewares: [ + validateAndTransformQuery( + AdminGetRbacRolesParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/rbac/roles/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetRbacRoleParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/rbac/roles", + middlewares: [ + validateAndTransformBody(AdminCreateRbacRole), + validateAndTransformQuery( + AdminGetRbacRoleParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/rbac/roles/:id", + middlewares: [ + validateAndTransformBody(AdminUpdateRbacRole), + validateAndTransformQuery( + AdminGetRbacRoleParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["DELETE"], + matcher: "/admin/rbac/roles/:id", + middlewares: [], + }, +] diff --git a/packages/medusa/src/api/admin/rbac/roles/query-config.ts b/packages/medusa/src/api/admin/rbac/roles/query-config.ts new file mode 100644 index 0000000000..972294c4a1 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/query-config.ts @@ -0,0 +1,21 @@ +export const defaultAdminRbacRoleFields = [ + "id", + "name", + "parent_id", + "description", + "metadata", + "created_at", + "updated_at", + "deleted_at", +] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminRbacRoleFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + defaultLimit: 20, + isList: true, +} diff --git a/packages/medusa/src/api/admin/rbac/roles/route.ts b/packages/medusa/src/api/admin/rbac/roles/route.ts new file mode 100644 index 0000000000..88a5291738 --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/route.ts @@ -0,0 +1,50 @@ +import { createRbacRolesWorkflow } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { AdminCreateRbacRoleType } from "./validators" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: roles, metadata } = await query.graph({ + entity: "rbac_role", + fields: req.queryConfig.fields, + filters: req.filterableFields, + pagination: req.queryConfig.pagination, + }) + + res.status(200).json({ + roles, + 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 createRbacRolesWorkflow(req.scope).run({ + input: { roles: input }, + }) + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: roles } = await query.graph({ + entity: "rbac_role", + fields: req.queryConfig.fields, + filters: { id: result[0].id }, + }) + + const role = roles[0] + + res.status(200).json({ role }) +} diff --git a/packages/medusa/src/api/admin/rbac/roles/validators.ts b/packages/medusa/src/api/admin/rbac/roles/validators.ts new file mode 100644 index 0000000000..ca446d052d --- /dev/null +++ b/packages/medusa/src/api/admin/rbac/roles/validators.ts @@ -0,0 +1,50 @@ +import { z } from "zod" +import { applyAndAndOrOperators } from "../../../utils/common-validators" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../../utils/validators" + +export type AdminGetRbacRoleParamsType = z.infer +export const AdminGetRbacRoleParams = createSelectParams() + +export const AdminGetRbacRolesParamsFields = z.object({ + q: z.string().optional(), + id: z.union([z.string(), z.array(z.string())]).optional(), + name: z.union([z.string(), z.array(z.string())]).optional(), + parent_id: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), +}) + +export type AdminGetRbacRolesParamsType = z.infer< + typeof AdminGetRbacRolesParams +> +export const AdminGetRbacRolesParams = createFindParams({ + limit: 50, + offset: 0, +}) + .merge(AdminGetRbacRolesParamsFields) + .merge(applyAndAndOrOperators(AdminGetRbacRolesParamsFields)) + +export type AdminCreateRbacRoleType = z.infer +export const AdminCreateRbacRole = z + .object({ + name: z.string(), + parent_id: z.string().nullish(), + description: z.string().nullish(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() + +export type AdminUpdateRbacRoleType = z.infer +export const AdminUpdateRbacRole = z + .object({ + name: z.string().optional(), + parent_id: z.string().nullish(), + description: z.string().nullish(), + metadata: z.record(z.unknown()).nullish(), + }) + .strict() diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index ab621ff228..2d7fde520f 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -27,6 +27,7 @@ import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlew import { adminProductVariantRoutesMiddlewares } from "./admin/product-variants/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" +import { adminRbacRoutesMiddlewares } from "./admin/rbac/middlewares" import { adminRefundReasonsRoutesMiddlewares } from "./admin/refund-reasons/middlewares" import { adminRegionRoutesMiddlewares } from "./admin/regions/middlewares" import { adminReservationRoutesMiddlewares } from "./admin/reservations/middlewares" @@ -92,6 +93,7 @@ export default defineMiddlewares([ ...adminReturnRoutesMiddlewares, ...storeRegionRoutesMiddlewares, ...adminRegionRoutesMiddlewares, + ...adminRbacRoutesMiddlewares, ...adminReturnRoutesMiddlewares, ...adminUserRoutesMiddlewares, ...adminInviteRoutesMiddlewares, diff --git a/packages/modules/rbac/.gitignore b/packages/modules/rbac/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/modules/rbac/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/modules/rbac/jest.config.js b/packages/modules/rbac/jest.config.js new file mode 100644 index 0000000000..3aab9b7072 --- /dev/null +++ b/packages/modules/rbac/jest.config.js @@ -0,0 +1,10 @@ +const defineJestConfig = require("../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + "^@utils": "/src/utils", + }, +}) diff --git a/packages/modules/rbac/mikro-orm.config.dev.ts b/packages/modules/rbac/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..91d83b37b8 --- /dev/null +++ b/packages/modules/rbac/mikro-orm.config.dev.ts @@ -0,0 +1,6 @@ +import { defineMikroOrmCliConfig } from "@medusajs/framework/utils" +import * as entities from "./src/models" + +export default defineMikroOrmCliConfig("rbac", { + entities: Object.values(entities), +}) diff --git a/packages/modules/rbac/package.json b/packages/modules/rbac/package.json new file mode 100644 index 0000000000..c6b6be6818 --- /dev/null +++ b/packages/modules/rbac/package.json @@ -0,0 +1,45 @@ +{ + "name": "@medusajs/rbac", + "version": "2.12.4", + "description": "Medusa RBAC module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist", + "!dist/**/__tests__", + "!dist/**/__mocks__", + "!dist/**/__fixtures__" + ], + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/rbac" + }, + "publishConfig": { + "access": "public" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "watch": "yarn run -T tsc --build --watch", + "watch:test": "yarn run -T tsc --build tsconfig.spec.json --watch", + "resolve:aliases": "yarn run -T tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && yarn run -T tsc-alias -p tsconfig.resolved.json && yarn run -T rimraf tsconfig.resolved.json", + "build": "yarn run -T rimraf dist && yarn run -T tsc --build && npm run resolve:aliases", + "test": "../../../node_modules/.bin/jest --passWithNoTests --bail --forceExit --testPathPattern=src", + "test:integration": "../../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"integration-tests/__tests__/.*\\.ts\"", + "migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create --initial", + "migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create", + "migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:up", + "orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm cache:clear" + }, + "devDependencies": { + "@medusajs/framework": "2.12.4", + "@medusajs/test-utils": "2.12.4" + }, + "peerDependencies": { + "@medusajs/framework": "2.12.4" + } +} diff --git a/packages/modules/rbac/src/index.ts b/packages/modules/rbac/src/index.ts new file mode 100644 index 0000000000..30c1bc5289 --- /dev/null +++ b/packages/modules/rbac/src/index.ts @@ -0,0 +1,6 @@ +import { Module } from "@medusajs/framework/utils" +import { RbacModuleService } from "@services" + +export default Module("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 new file mode 100644 index 0000000000..e5a2919b4c --- /dev/null +++ b/packages/modules/rbac/src/migrations/.snapshot-medusa-rbac.json @@ -0,0 +1,568 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "key": { + "name": "key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "resource": { + "name": "resource", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "operation": { + "name": "operation", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "rbac_policy", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_rbac_policy_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_policy_deleted_at\" ON \"rbac_policy\" (\"deleted_at\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_rbac_policy_key_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_rbac_policy_key_unique\" ON \"rbac_policy\" (\"key\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_rbac_policy_resource", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_policy_resource\" ON \"rbac_policy\" (\"resource\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_rbac_policy_operation", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_policy_operation\" ON \"rbac_policy\" (\"operation\") WHERE deleted_at IS NULL" + }, + { + "keyName": "rbac_policy_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "description": { + "name": "description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "rbac_role", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_rbac_role_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_rbac_role_deleted_at\" ON \"rbac_role\" (\"deleted_at\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_rbac_role_name_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_rbac_role_name_unique\" ON \"rbac_role\" (\"name\") WHERE deleted_at IS NULL" + }, + { + "keyName": "rbac_role_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "role_id": { + "name": "role_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "inherited_role_id": { + "name": "inherited_role_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "rbac_role_inheritance", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_rbac_role_inheritance_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" + }, + { + "keyName": "IDX_rbac_role_inheritance_inherited_role_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" + }, + { + "keyName": "IDX_rbac_role_inheritance_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" + }, + { + "keyName": "IDX_rbac_role_inheritance_role_id_inherited_role_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" + }, + { + "keyName": "rbac_role_inheritance_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "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" + ], + "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" + ], + "referencedTableName": "public.rbac_role", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "role_id": { + "name": "role_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "rbac_role_policy", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_rbac_role_policy_role_id", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "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", + "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" + }, + { + "keyName": "IDX_rbac_role_policy_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "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", + "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" + }, + { + "keyName": "rbac_role_policy_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "rbac_role_policy_role_id_foreign": { + "constraintName": "rbac_role_policy_role_id_foreign", + "columnNames": [ + "role_id" + ], + "localTableName": "public.rbac_role_policy", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.rbac_role", + "updateRule": "cascade" + }, + "rbac_role_policy_scope_id_foreign": { + "constraintName": "rbac_role_policy_scope_id_foreign", + "columnNames": [ + "scope_id" + ], + "localTableName": "public.rbac_role_policy", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.rbac_policy", + "updateRule": "cascade" + } + }, + "nativeEnums": {} + } + ], + "nativeEnums": {} +} diff --git a/packages/modules/rbac/src/migrations/Migration20251215113723.ts b/packages/modules/rbac/src/migrations/Migration20251215113723.ts new file mode 100644 index 0000000000..b3284bf275 --- /dev/null +++ b/packages/modules/rbac/src/migrations/Migration20251215113723.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251215113723 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" 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"));`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_policy_deleted_at" ON "rbac_policy" ("deleted_at") WHERE deleted_at IS NULL;`); + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_rbac_policy_key_unique" ON "rbac_policy" ("key") WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_policy_resource" ON "rbac_policy" ("resource") WHERE deleted_at IS NULL;`); + this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_rbac_policy_operation" ON "rbac_policy" ("operation") WHERE deleted_at IS NULL;`); + + this.addSql(`create table if not exists "rbac_role" ("id" text not null, "name" text not 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_role_pkey" primary key ("id"));`); + 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_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 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_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(`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_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;`); + } + +} diff --git a/packages/modules/rbac/src/models/index.ts b/packages/modules/rbac/src/models/index.ts new file mode 100644 index 0000000000..ecdfa59956 --- /dev/null +++ b/packages/modules/rbac/src/models/index.ts @@ -0,0 +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 RbacRolePolicy } from "./rbac-role-policy" diff --git a/packages/modules/rbac/src/models/rbac-policy.ts b/packages/modules/rbac/src/models/rbac-policy.ts new file mode 100644 index 0000000000..afdc9a252e --- /dev/null +++ b/packages/modules/rbac/src/models/rbac-policy.ts @@ -0,0 +1,29 @@ +import { model } from "@medusajs/framework/utils" + +const RbacPolicy = model + .define("rbac_policy", { + id: model.id({ prefix: "rpol" }).primaryKey(), + key: model.text().searchable(), + resource: model.text().searchable(), + operation: model.text().searchable(), + name: model.text().searchable().nullable(), + description: model.text().nullable(), + metadata: model.json().nullable(), + }) + .indexes([ + { + on: ["key"], + unique: true, + where: "deleted_at IS NULL", + }, + { + on: ["resource"], + where: "deleted_at IS NULL", + }, + { + on: ["operation"], + where: "deleted_at IS NULL", + }, + ]) + +export default RbacPolicy diff --git a/packages/modules/rbac/src/models/rbac-role-inheritance.ts b/packages/modules/rbac/src/models/rbac-role-inheritance.ts new file mode 100644 index 0000000000..d1b4852b22 --- /dev/null +++ b/packages/modules/rbac/src/models/rbac-role-inheritance.ts @@ -0,0 +1,29 @@ +import { model } from "@medusajs/framework/utils" +import RbacRole from "./rbac-role" + +const RbacRoleInheritance = model + .define("rbac_role_inheritance", { + id: model.id({ prefix: "rlin" }).primaryKey(), + role: model.belongsTo(() => RbacRole, { mappedBy: "inherited_roles" }), + inherited_role: model.belongsTo(() => RbacRole, { + mappedBy: "inheritedBy", + }), + metadata: model.json().nullable(), + }) + .indexes([ + { + on: ["role_id"], + where: "deleted_at IS NULL", + }, + { + on: ["inherited_role_id"], + where: "deleted_at IS NULL", + }, + { + on: ["role_id", "inherited_role_id"], + unique: true, + where: "deleted_at IS NULL", + }, + ]) + +export default RbacRoleInheritance diff --git a/packages/modules/rbac/src/models/rbac-role-policy.ts b/packages/modules/rbac/src/models/rbac-role-policy.ts new file mode 100644 index 0000000000..87c0c92e57 --- /dev/null +++ b/packages/modules/rbac/src/models/rbac-role-policy.ts @@ -0,0 +1,28 @@ +import { model } from "@medusajs/framework/utils" +import RbacPolicy from "./rbac-policy" +import RbacRole from "./rbac-role" + +const RbacRolePolicy = model + .define("rbac_role_policy", { + id: model.id({ prefix: "rlpl" }).primaryKey(), + role: model.belongsTo(() => RbacRole), + scope: model.belongsTo(() => RbacPolicy), + metadata: model.json().nullable(), + }) + .indexes([ + { + on: ["role_id"], + where: "deleted_at IS NULL", + }, + { + on: ["scope_id"], + where: "deleted_at IS NULL", + }, + { + on: ["role_id", "scope_id"], + unique: true, + where: "deleted_at IS NULL", + }, + ]) + +export default RbacRolePolicy diff --git a/packages/modules/rbac/src/models/rbac-role.ts b/packages/modules/rbac/src/models/rbac-role.ts new file mode 100644 index 0000000000..eca0d19c32 --- /dev/null +++ b/packages/modules/rbac/src/models/rbac-role.ts @@ -0,0 +1,18 @@ +import { model } from "@medusajs/framework/utils" + +const RbacRole = model + .define("rbac_role", { + id: model.id({ prefix: "role" }).primaryKey(), + name: model.text().searchable(), + description: model.text().nullable(), + metadata: model.json().nullable(), + }) + .indexes([ + { + on: ["name"], + unique: true, + where: "deleted_at IS NULL", + }, + ]) + +export default RbacRole diff --git a/packages/modules/rbac/src/repositories/index.ts b/packages/modules/rbac/src/repositories/index.ts new file mode 100644 index 0000000000..8b37f0b5a3 --- /dev/null +++ b/packages/modules/rbac/src/repositories/index.ts @@ -0,0 +1 @@ +export * from "./rbac" diff --git a/packages/modules/rbac/src/repositories/rbac.ts b/packages/modules/rbac/src/repositories/rbac.ts new file mode 100644 index 0000000000..0b78121e68 --- /dev/null +++ b/packages/modules/rbac/src/repositories/rbac.ts @@ -0,0 +1,88 @@ +import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql" +import { Context } from "@medusajs/framework/types" +import { MikroOrmBase } from "@medusajs/framework/utils" + +export class RbacRepository extends MikroOrmBase { + constructor() { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + super(...arguments) + } + + async listPoliciesForRole( + roleId: string, + sharedContext: Context = {} + ): Promise { + const policiesByRole = await this.listPoliciesForRoles( + [roleId], + sharedContext + ) + return policiesByRole.get(roleId) || [] + } + + async listPoliciesForRoles( + roleIds: string[], + sharedContext: Context = {} + ): Promise> { + const manager = this.getActiveManager(sharedContext) + const knex = manager.getKnex() + + if (!roleIds?.length) { + return new Map() + } + + const placeholders = roleIds.map(() => "?").join(",") + + const query = ` + WITH RECURSIVE role_hierarchy AS ( + SELECT id, name, id as original_role_id + FROM rbac_role + WHERE id IN (${placeholders}) AND deleted_at IS NULL + + UNION ALL + + SELECT r.id, r.name, rh.original_role_id + FROM rbac_role r + INNER JOIN rbac_role_inheritance ri ON ri.inherited_role_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 + ) + SELECT DISTINCT + rh.original_role_id, + p.id, + p.key, + p.resource, + p.operation, + p.name, + p.description, + p.metadata, + p.created_at, + 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 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 + ` + + const result = await knex.raw(query, roleIds) + const rows = result.rows || [] + + // Group policies by role_id + const policiesByRole = new Map() + + for (const row of rows) { + const roleId = row.original_role_id + delete row.original_role_id + + if (!policiesByRole.has(roleId)) { + policiesByRole.set(roleId, []) + } + + policiesByRole.get(roleId)!.push(row) + } + + return policiesByRole + } +} diff --git a/packages/modules/rbac/src/services/index.ts b/packages/modules/rbac/src/services/index.ts new file mode 100644 index 0000000000..6771f71091 --- /dev/null +++ b/packages/modules/rbac/src/services/index.ts @@ -0,0 +1 @@ +export { default as RbacModuleService } from "./rbac-module-service" diff --git a/packages/modules/rbac/src/services/rbac-module-service.ts b/packages/modules/rbac/src/services/rbac-module-service.ts new file mode 100644 index 0000000000..0f0a39d7f5 --- /dev/null +++ b/packages/modules/rbac/src/services/rbac-module-service.ts @@ -0,0 +1,105 @@ +import { Context, FindConfig } from "@medusajs/framework/types" +import { + InjectManager, + MedusaContext, + MedusaService, +} from "@medusajs/framework/utils" +import { + RbacPolicy, + RbacRole, + RbacRoleInheritance, + 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, +}) { + protected readonly rbacRepository_: RbacRepository + + constructor({ rbacRepository }: InjectedDependencies) { + // @ts-ignore + super(...arguments) + this.rbacRepository_ = rbacRepository + } + + @InjectManager() + async listPoliciesForRole( + roleId: string, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.rbacRepository_.listPoliciesForRole(roleId, sharedContext) + } + + @InjectManager() + // @ts-expect-error + async listRbacRoles( + filters: any = {}, + 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( + 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, count] + } +} diff --git a/packages/modules/rbac/src/types/index.ts b/packages/modules/rbac/src/types/index.ts new file mode 100644 index 0000000000..2ab22c18c6 --- /dev/null +++ b/packages/modules/rbac/src/types/index.ts @@ -0,0 +1 @@ +export type RbacModuleOptions = Record diff --git a/packages/modules/rbac/tsconfig.json b/packages/modules/rbac/tsconfig.json new file mode 100644 index 0000000000..748a37ffa8 --- /dev/null +++ b/packages/modules/rbac/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../_tsconfig.base.json", + "compilerOptions": { + "paths": { + "@models": ["./src/models"], + "@services": ["./src/services"], + "@repositories": ["./src/repositories"], + "@types": ["./src/types"], + "@utils": ["./src/utils"] + } + } +} diff --git a/yarn.lock b/yarn.lock index fe623c6fbb..1b4823f179 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4013,6 +4013,17 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/rbac@workspace:packages/modules/rbac": + version: 0.0.0-use.local + resolution: "@medusajs/rbac@workspace:packages/modules/rbac" + dependencies: + "@medusajs/framework": 2.12.4 + "@medusajs/test-utils": 2.12.4 + peerDependencies: + "@medusajs/framework": 2.12.4 + languageName: unknown + linkType: soft + "@medusajs/region@2.12.4, @medusajs/region@workspace:^, @medusajs/region@workspace:packages/modules/region": version: 0.0.0-use.local resolution: "@medusajs/region@workspace:packages/modules/region"