diff --git a/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts b/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts index 7685b4667f..1ee171ad67 100644 --- a/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts +++ b/integration-tests/modules/__tests__/rbac/rbac-workflows.spec.ts @@ -829,9 +829,7 @@ medusaIntegrationTestRunner({ } expect(error).toBeDefined() - expect(error.message).toContain( - "User does not have any roles assigned and cannot create roles or assign policies" - ) + expect(error.message).toContain("Unauthorized") }) it("should prevent user from assigning policies they don't have access to", async () => { @@ -917,9 +915,7 @@ medusaIntegrationTestRunner({ } expect(error).toBeDefined() - expect(error.message).toContain( - "User does not have access to the following policies and cannot assign them" - ) + expect(error.message).toContain("Unauthorized") }) it("should allow user to create roles with policies they have access to", async () => { diff --git a/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts b/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts index 12b8ff01dc..dfb3b098e2 100644 --- a/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts +++ b/packages/core/core-flows/src/rbac/steps/validate-user-permissions.ts @@ -38,10 +38,7 @@ export const validateUserPermissionsStep = createStep( }) if (!users?.[0]?.rbac_roles || users[0].rbac_roles.length === 0) { - throw new MedusaError( - MedusaError.Types.UNAUTHORIZED, - `User does not have any roles assigned and cannot create roles or assign policies` - ) + throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Unauthorized") } const operationMap = new Map() @@ -79,18 +76,7 @@ export const validateUserPermissionsStep = createStep( } if (unauthorizedPolicies.length) { - const policyMap = new Map( - allUserPolicies.map((p) => [p.id, p.name || p.key]) - ) - - const unauthorizedNames = unauthorizedPolicies - .map((id) => policyMap.get(id) || id) - .join(", ") - - throw new MedusaError( - MedusaError.Types.UNAUTHORIZED, - `User does not have access to the following policies and cannot assign them: ${unauthorizedNames}` - ) + throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Unauthorized") } } ) diff --git a/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts b/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts index 36670b26fe..c4800d3769 100644 --- a/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts +++ b/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts @@ -54,6 +54,7 @@ describe("Middleware file loader", () => { "handler": [Function], "matcher": "/customers", "methods": undefined, + "policies": undefined, }, { "handler": [Function], @@ -61,11 +62,13 @@ describe("Middleware file loader", () => { "methods": [ "POST", ], + "policies": undefined, }, { "handler": [Function], "matcher": "/store/*", "methods": undefined, + "policies": undefined, }, { "handler": [Function], @@ -73,6 +76,7 @@ describe("Middleware file loader", () => { "methods": [ "POST", ], + "policies": undefined, }, ] `) diff --git a/packages/core/framework/src/http/middleware-file-loader.ts b/packages/core/framework/src/http/middleware-file-loader.ts index ec70f28c55..2860fc5668 100644 --- a/packages/core/framework/src/http/middleware-file-loader.ts +++ b/packages/core/framework/src/http/middleware-file-loader.ts @@ -130,6 +130,7 @@ export class MiddlewareFileLoader { handler: middleware, matcher: matcher, methods: route.methods, + policies: route.policies, }) }) } diff --git a/packages/core/framework/src/http/middlewares/check-permissions.ts b/packages/core/framework/src/http/middlewares/check-permissions.ts new file mode 100644 index 0000000000..d219c35a71 --- /dev/null +++ b/packages/core/framework/src/http/middlewares/check-permissions.ts @@ -0,0 +1,82 @@ +import { MedusaError } from "@medusajs/utils" +import { hasPermission } from "../../utils/has-permission" +import type { + AuthenticatedMedusaRequest, + MedusaNextFunction, + MedusaResponse, + MiddlewareFunction, +} from "../types" + +export type PolicyAction = { + resource: string + operation: string +} + +/** + * Core permission checking logic for middleware and routes + */ +async function checkPermissions( + policies: PolicyAction | PolicyAction[], + req: AuthenticatedMedusaRequest +): Promise { + // Normalize policies to array + const policyList = Array.isArray(policies) ? policies : [policies] + + if (!policyList.length) { + return + } + + const authContext = req.auth_context + // Get roles from JWT token's app_metadata + const roleIds = (authContext.app_metadata?.roles as string[]) || [] + + if (!roleIds.length) { + throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Unauthorized") + } + + const hasAccess = await hasPermission({ + roles: roleIds, + actions: policyList, + container: req.scope, + }) + + if (!hasAccess) { + const policyKeys = policyList + .map((p) => `${p.resource}:${p.operation}`) + .join(", ") + + throw new MedusaError( + MedusaError.Types.UNAUTHORIZED, + `Insufficient permissions. Required policies: ${policyKeys}` + ) + } +} + +/** + * Wraps a middleware or route handler with RBAC permission checking. + * Checks if the authenticated user has the required policies before executing the handler. + * + * @param handler - The original middleware or route handler to wrap + * @param policies - Single policy or array of policies to check + * @returns Wrapped middleware or route function that checks permissions first + */ +export function wrapWithPoliciesCheck( + handler: MiddlewareFunction, + policies: PolicyAction | PolicyAction[] +): MiddlewareFunction { + return async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction + ) => { + try { + req.policies ??= [] + req.policies.push(...(Array.isArray(policies) ? policies : [policies])) + + await checkPermissions(policies, req) + return handler(req, res, next) + } catch (error) { + return next(error) + } + } +} diff --git a/packages/core/framework/src/http/middlewares/index.ts b/packages/core/framework/src/http/middlewares/index.ts index 55983af8b4..be188477a0 100644 --- a/packages/core/framework/src/http/middlewares/index.ts +++ b/packages/core/framework/src/http/middlewares/index.ts @@ -1,8 +1,9 @@ +export * from "./apply-default-filters" +export * from "./apply-locale" +export * from "./apply-params-as-filters" export * from "./authenticate-middleware" +export * from "./check-permissions" +export * from "./clear-filters-by-key" export * from "./error-handler" export * from "./exception-formatter" -export * from "./apply-default-filters" -export * from "./apply-params-as-filters" -export * from "./apply-locale" -export * from "./clear-filters-by-key" export * from "./set-context" diff --git a/packages/core/framework/src/http/router.ts b/packages/core/framework/src/http/router.ts index c4b649c009..511222e736 100644 --- a/packages/core/framework/src/http/router.ts +++ b/packages/core/framework/src/http/router.ts @@ -30,6 +30,7 @@ import { configManager } from "../config" import { MiddlewareFileLoader } from "./middleware-file-loader" import { applyLocale, authenticate, AuthType } from "./middlewares" import { createBodyParserMiddlewaresStack } from "./middlewares/bodyparser" +import { wrapWithPoliciesCheck } from "./middlewares/check-permissions" import { ensurePublishableApiKeyMiddleware } from "./middlewares/ensure-publishable-api-key" import { errorHandler } from "./middlewares/error-handler" import { RoutesFinder } from "./routes-finder" @@ -169,11 +170,18 @@ export class ApiLoader { if (!route.methods) { this.#logger.debug(`registering global middleware for ${route.matcher}`) + + // Wrap with permission check if policies are defined + let handlerToUse = route.handler + if (route.policies) { + handlerToUse = wrapWithPoliciesCheck(route.handler, route.policies) + } + const handler = ApiLoader.traceMiddleware - ? (ApiLoader.traceMiddleware(route.handler, { + ? (ApiLoader.traceMiddleware(handlerToUse, { route: route.matcher, }) as RequestHandler) - : (route.handler as RequestHandler) + : (handlerToUse as RequestHandler) this.#app.use(route.matcher, wrapHandler(handler)) return @@ -194,12 +202,18 @@ export class ApiLoader { this.#logger.debug( `registering route middleware ${method} ${route.matcher}` ) + + let handlerToUse = route.handler + if (route.policies) { + handlerToUse = wrapWithPoliciesCheck(route.handler, route.policies) + } + const handler = ApiLoader.traceMiddleware - ? (ApiLoader.traceMiddleware(wrapHandler(route.handler), { + ? (ApiLoader.traceMiddleware(wrapHandler(handlerToUse), { route: route.matcher, method: method, }) as RequestHandler) - : wrapHandler(route.handler) + : wrapHandler(handlerToUse) this.#app[method.toLowerCase()](route.matcher, handler) }) diff --git a/packages/core/framework/src/http/types.ts b/packages/core/framework/src/http/types.ts index 602ce7fa52..48aaa400af 100644 --- a/packages/core/framework/src/http/types.ts +++ b/packages/core/framework/src/http/types.ts @@ -1,5 +1,10 @@ +import type { + ZodNullable, + ZodObject, + ZodOptional, + ZodRawShape, +} from "@medusajs/deps/zod" import type { NextFunction, Request, Response } from "express" -import type { ZodNullable, ZodObject, ZodOptional, ZodRawShape } from "@medusajs/deps/zod" import { FindConfig, @@ -7,6 +12,7 @@ import { RequestQueryFields, } from "@medusajs/types" import { MedusaContainer } from "../container" +import { PolicyAction } from "./middlewares/check-permissions" import { RestrictedFields } from "./utils/restricted-fields" /** @@ -62,6 +68,10 @@ export type MiddlewareRoute = { bodyParser?: ParserConfig additionalDataValidator?: ZodRawShape middlewares?: MiddlewareFunction[] + /** @ignore */ + policies?: + | { resource: string; operation: string } + | Array<{ resource: string; operation: string }> } export type MiddlewaresConfig = { @@ -78,6 +88,9 @@ export type RouteDescriptor = { matcher: string method: RouteVerb handler: RouteHandler + policies?: + | { resource: string; operation: string } + | Array<{ resource: string; operation: string }> optedOutOfAuth: boolean isRoute: true routeType?: "admin" | "store" | "auth" @@ -95,6 +108,9 @@ export type MiddlewareDescriptor = { matcher: string methods?: MiddlewareVerb | MiddlewareVerb[] handler: MiddlewareFunction + policies?: + | { resource: string; operation: string } + | Array<{ resource: string; operation: string }> } export type BodyParserConfigRoute = { @@ -212,6 +228,7 @@ export interface AuthenticatedMedusaRequest< > extends MedusaRequest { auth_context: AuthContext publishable_key_context?: PublishableKeyContext + policies?: PolicyAction[] } export interface MedusaStoreRequest< @@ -220,6 +237,7 @@ export interface MedusaStoreRequest< > extends MedusaRequest { auth_context?: AuthContext publishable_key_context: PublishableKeyContext + policies?: PolicyAction | PolicyAction[] } export type MedusaResponse = Response diff --git a/packages/medusa/src/utils/rbac/has-permission.ts b/packages/core/framework/src/utils/has-permission.ts similarity index 92% rename from packages/medusa/src/utils/rbac/has-permission.ts rename to packages/core/framework/src/utils/has-permission.ts index 97ebc5b875..cb0da56f94 100644 --- a/packages/medusa/src/utils/rbac/has-permission.ts +++ b/packages/core/framework/src/utils/has-permission.ts @@ -1,10 +1,6 @@ import { MedusaContainer } from "@medusajs/framework/types" -import { - ContainerRegistrationKeys, - FeatureFlag, - useCache, -} from "@medusajs/framework/utils" -import RbacFeatureFlag from "../../feature-flags/rbac" +import { ContainerRegistrationKeys, useCache } from "@medusajs/framework/utils" +import { FlagRouter } from "../feature-flags/flag-router" export type PermissionAction = { resource: string @@ -48,8 +44,11 @@ export async function hasPermission( const roleIds = Array.isArray(roles) ? roles : [roles] const actionList = Array.isArray(actions) ? actions : [actions] + const ffRouter = container.resolve( + ContainerRegistrationKeys.FEATURE_FLAG_ROUTER + ) as FlagRouter - const isDisabled = !FeatureFlag.isFeatureEnabled(RbacFeatureFlag.key) + const isDisabled = !ffRouter.isFeatureEnabled("rbac") if (isDisabled || !roleIds?.length || !actionList?.length) { return true } diff --git a/packages/core/framework/src/utils/index.ts b/packages/core/framework/src/utils/index.ts index 4f1278757f..02ef2b162e 100644 --- a/packages/core/framework/src/utils/index.ts +++ b/packages/core/framework/src/utils/index.ts @@ -1,3 +1,4 @@ import "../types/container" export * from "@medusajs/utils" +export * from "./has-permission" diff --git a/packages/core/types/src/common/common.ts b/packages/core/types/src/common/common.ts index 16a1185d7f..797e06648c 100644 --- a/packages/core/types/src/common/common.ts +++ b/packages/core/types/src/common/common.ts @@ -437,6 +437,10 @@ export type RawRounding = { * @ignore */ export type QueryConfig = { + /** + * The main entity to retrieve. For example, `product`. + */ + entity?: TEntity | string /** * Default fields and relations to return. * use `*` or `.*` to select all fields from a relations (e.g '*products' or 'products.*' will select all products properties) diff --git a/packages/core/utils/src/modules-sdk/define-policies.ts b/packages/core/utils/src/modules-sdk/define-policies.ts index 2239f2e169..d0182c0b8b 100644 --- a/packages/core/utils/src/modules-sdk/define-policies.ts +++ b/packages/core/utils/src/modules-sdk/define-policies.ts @@ -30,68 +30,9 @@ declare global { /** * Global registry for all unique resources. */ -const defaultResources = [ - "api-key", - "campaign", - "claim", - "collection", - "currency", - "customer", - "customer-group", - "draft-order", - "exchange", - "fulfillment", - "fulfillment-provider", - "fulfillment-set", - "inventory", - "inventory-item", - "invite", - "locale", - "notification", - "order", - "order-change", - "order-edit", - "payment", - "payment-collection", - "payment-provider", - "price-list", - "price-preference", - "product", - "product-category", - "product-tag", - "product-type", - "product-variant", - "promotion", - "rbac", - "refund-reason", - "region", - "reservation", - "return", - "return-reason", - "sales-channel", - "shipping-option", - "shipping-option-type", - "shipping-profile", - "stock-location", - "store", - "tax", - "tax-provider", - "tax-rate", - "tax-region", - "translation", - "upload", - "user", - "workflow-execution", -] - export const PolicyResource = global.PolicyResource ?? {} global.PolicyResource ??= PolicyResource -for (const resource of defaultResources) { - const resourceKey = toSnakeCase(resource) - PolicyResource[resourceKey] = resource -} - /** * Global registry for all unique operations. */ diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts index c6f93f8a02..209426f3c1 100644 --- a/packages/medusa/src/utils/index.ts +++ b/packages/medusa/src/utils/index.ts @@ -1,5 +1,5 @@ +export * from "./admin-consts" export * from "./clean-response-data" +export * from "./define-middlewares" export * from "./exception-formatter" export * from "./middlewares" -export * from "./define-middlewares" -export * from "./admin-consts"