chore(medusa): middleware policies (#14521)

This commit is contained in:
Carlos R. L. Rodrigues
2026-01-13 16:46:53 -03:00
committed by GitHub
parent cec8b8e428
commit 8426fca710
13 changed files with 146 additions and 99 deletions

View File

@@ -829,9 +829,7 @@ medusaIntegrationTestRunner({
} }
expect(error).toBeDefined() expect(error).toBeDefined()
expect(error.message).toContain( expect(error.message).toContain("Unauthorized")
"User does not have any roles assigned and cannot create roles or assign policies"
)
}) })
it("should prevent user from assigning policies they don't have access to", async () => { it("should prevent user from assigning policies they don't have access to", async () => {
@@ -917,9 +915,7 @@ medusaIntegrationTestRunner({
} }
expect(error).toBeDefined() expect(error).toBeDefined()
expect(error.message).toContain( expect(error.message).toContain("Unauthorized")
"User does not have access to the following policies and cannot assign them"
)
}) })
it("should allow user to create roles with policies they have access to", async () => { it("should allow user to create roles with policies they have access to", async () => {

View File

@@ -38,10 +38,7 @@ export const validateUserPermissionsStep = createStep(
}) })
if (!users?.[0]?.rbac_roles || users[0].rbac_roles.length === 0) { if (!users?.[0]?.rbac_roles || users[0].rbac_roles.length === 0) {
throw new MedusaError( throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Unauthorized")
MedusaError.Types.UNAUTHORIZED,
`User does not have any roles assigned and cannot create roles or assign policies`
)
} }
const operationMap = new Map() const operationMap = new Map()
@@ -79,18 +76,7 @@ export const validateUserPermissionsStep = createStep(
} }
if (unauthorizedPolicies.length) { if (unauthorizedPolicies.length) {
const policyMap = new Map( throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Unauthorized")
allUserPolicies.map((p) => [p.id, p.name || p.key])
)
const unauthorizedNames = unauthorizedPolicies
.map((id) => policyMap.get(id) || id)
.join(", ")
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
`User does not have access to the following policies and cannot assign them: ${unauthorizedNames}`
)
} }
} }
) )

View File

@@ -54,6 +54,7 @@ describe("Middleware file loader", () => {
"handler": [Function], "handler": [Function],
"matcher": "/customers", "matcher": "/customers",
"methods": undefined, "methods": undefined,
"policies": undefined,
}, },
{ {
"handler": [Function], "handler": [Function],
@@ -61,11 +62,13 @@ describe("Middleware file loader", () => {
"methods": [ "methods": [
"POST", "POST",
], ],
"policies": undefined,
}, },
{ {
"handler": [Function], "handler": [Function],
"matcher": "/store/*", "matcher": "/store/*",
"methods": undefined, "methods": undefined,
"policies": undefined,
}, },
{ {
"handler": [Function], "handler": [Function],
@@ -73,6 +76,7 @@ describe("Middleware file loader", () => {
"methods": [ "methods": [
"POST", "POST",
], ],
"policies": undefined,
}, },
] ]
`) `)

View File

@@ -130,6 +130,7 @@ export class MiddlewareFileLoader {
handler: middleware, handler: middleware,
matcher: matcher, matcher: matcher,
methods: route.methods, methods: route.methods,
policies: route.policies,
}) })
}) })
} }

View File

@@ -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<void> {
// 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)
}
}
}

View File

@@ -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 "./authenticate-middleware"
export * from "./check-permissions"
export * from "./clear-filters-by-key"
export * from "./error-handler" export * from "./error-handler"
export * from "./exception-formatter" 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" export * from "./set-context"

View File

@@ -30,6 +30,7 @@ import { configManager } from "../config"
import { MiddlewareFileLoader } from "./middleware-file-loader" import { MiddlewareFileLoader } from "./middleware-file-loader"
import { applyLocale, authenticate, AuthType } from "./middlewares" import { applyLocale, authenticate, AuthType } from "./middlewares"
import { createBodyParserMiddlewaresStack } from "./middlewares/bodyparser" import { createBodyParserMiddlewaresStack } from "./middlewares/bodyparser"
import { wrapWithPoliciesCheck } from "./middlewares/check-permissions"
import { ensurePublishableApiKeyMiddleware } from "./middlewares/ensure-publishable-api-key" import { ensurePublishableApiKeyMiddleware } from "./middlewares/ensure-publishable-api-key"
import { errorHandler } from "./middlewares/error-handler" import { errorHandler } from "./middlewares/error-handler"
import { RoutesFinder } from "./routes-finder" import { RoutesFinder } from "./routes-finder"
@@ -169,11 +170,18 @@ export class ApiLoader {
if (!route.methods) { if (!route.methods) {
this.#logger.debug(`registering global middleware for ${route.matcher}`) 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 const handler = ApiLoader.traceMiddleware
? (ApiLoader.traceMiddleware(route.handler, { ? (ApiLoader.traceMiddleware(handlerToUse, {
route: route.matcher, route: route.matcher,
}) as RequestHandler) }) as RequestHandler)
: (route.handler as RequestHandler) : (handlerToUse as RequestHandler)
this.#app.use(route.matcher, wrapHandler(handler)) this.#app.use(route.matcher, wrapHandler(handler))
return return
@@ -194,12 +202,18 @@ export class ApiLoader {
this.#logger.debug( this.#logger.debug(
`registering route middleware ${method} ${route.matcher}` `registering route middleware ${method} ${route.matcher}`
) )
let handlerToUse = route.handler
if (route.policies) {
handlerToUse = wrapWithPoliciesCheck(route.handler, route.policies)
}
const handler = ApiLoader.traceMiddleware const handler = ApiLoader.traceMiddleware
? (ApiLoader.traceMiddleware(wrapHandler(route.handler), { ? (ApiLoader.traceMiddleware(wrapHandler(handlerToUse), {
route: route.matcher, route: route.matcher,
method: method, method: method,
}) as RequestHandler) }) as RequestHandler)
: wrapHandler(route.handler) : wrapHandler(handlerToUse)
this.#app[method.toLowerCase()](route.matcher, handler) this.#app[method.toLowerCase()](route.matcher, handler)
}) })

View File

@@ -1,5 +1,10 @@
import type {
ZodNullable,
ZodObject,
ZodOptional,
ZodRawShape,
} from "@medusajs/deps/zod"
import type { NextFunction, Request, Response } from "express" import type { NextFunction, Request, Response } from "express"
import type { ZodNullable, ZodObject, ZodOptional, ZodRawShape } from "@medusajs/deps/zod"
import { import {
FindConfig, FindConfig,
@@ -7,6 +12,7 @@ import {
RequestQueryFields, RequestQueryFields,
} from "@medusajs/types" } from "@medusajs/types"
import { MedusaContainer } from "../container" import { MedusaContainer } from "../container"
import { PolicyAction } from "./middlewares/check-permissions"
import { RestrictedFields } from "./utils/restricted-fields" import { RestrictedFields } from "./utils/restricted-fields"
/** /**
@@ -62,6 +68,10 @@ export type MiddlewareRoute = {
bodyParser?: ParserConfig bodyParser?: ParserConfig
additionalDataValidator?: ZodRawShape additionalDataValidator?: ZodRawShape
middlewares?: MiddlewareFunction[] middlewares?: MiddlewareFunction[]
/** @ignore */
policies?:
| { resource: string; operation: string }
| Array<{ resource: string; operation: string }>
} }
export type MiddlewaresConfig = { export type MiddlewaresConfig = {
@@ -78,6 +88,9 @@ export type RouteDescriptor = {
matcher: string matcher: string
method: RouteVerb method: RouteVerb
handler: RouteHandler handler: RouteHandler
policies?:
| { resource: string; operation: string }
| Array<{ resource: string; operation: string }>
optedOutOfAuth: boolean optedOutOfAuth: boolean
isRoute: true isRoute: true
routeType?: "admin" | "store" | "auth" routeType?: "admin" | "store" | "auth"
@@ -95,6 +108,9 @@ export type MiddlewareDescriptor = {
matcher: string matcher: string
methods?: MiddlewareVerb | MiddlewareVerb[] methods?: MiddlewareVerb | MiddlewareVerb[]
handler: MiddlewareFunction handler: MiddlewareFunction
policies?:
| { resource: string; operation: string }
| Array<{ resource: string; operation: string }>
} }
export type BodyParserConfigRoute = { export type BodyParserConfigRoute = {
@@ -212,6 +228,7 @@ export interface AuthenticatedMedusaRequest<
> extends MedusaRequest<Body, QueryFields> { > extends MedusaRequest<Body, QueryFields> {
auth_context: AuthContext auth_context: AuthContext
publishable_key_context?: PublishableKeyContext publishable_key_context?: PublishableKeyContext
policies?: PolicyAction[]
} }
export interface MedusaStoreRequest< export interface MedusaStoreRequest<
@@ -220,6 +237,7 @@ export interface MedusaStoreRequest<
> extends MedusaRequest<Body, QueryFields> { > extends MedusaRequest<Body, QueryFields> {
auth_context?: AuthContext auth_context?: AuthContext
publishable_key_context: PublishableKeyContext publishable_key_context: PublishableKeyContext
policies?: PolicyAction | PolicyAction[]
} }
export type MedusaResponse<Body = unknown> = Response<Body> export type MedusaResponse<Body = unknown> = Response<Body>

View File

@@ -1,10 +1,6 @@
import { MedusaContainer } from "@medusajs/framework/types" import { MedusaContainer } from "@medusajs/framework/types"
import { import { ContainerRegistrationKeys, useCache } from "@medusajs/framework/utils"
ContainerRegistrationKeys, import { FlagRouter } from "../feature-flags/flag-router"
FeatureFlag,
useCache,
} from "@medusajs/framework/utils"
import RbacFeatureFlag from "../../feature-flags/rbac"
export type PermissionAction = { export type PermissionAction = {
resource: string resource: string
@@ -48,8 +44,11 @@ export async function hasPermission(
const roleIds = Array.isArray(roles) ? roles : [roles] const roleIds = Array.isArray(roles) ? roles : [roles]
const actionList = Array.isArray(actions) ? actions : [actions] 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) { if (isDisabled || !roleIds?.length || !actionList?.length) {
return true return true
} }

View File

@@ -1,3 +1,4 @@
import "../types/container" import "../types/container"
export * from "@medusajs/utils" export * from "@medusajs/utils"
export * from "./has-permission"

View File

@@ -437,6 +437,10 @@ export type RawRounding = {
* @ignore * @ignore
*/ */
export type QueryConfig<TEntity> = { export type QueryConfig<TEntity> = {
/**
* The main entity to retrieve. For example, `product`.
*/
entity?: TEntity | string
/** /**
* Default fields and relations to return. * 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) * use `*` or `.*` to select all fields from a relations (e.g '*products' or 'products.*' will select all products properties)

View File

@@ -30,68 +30,9 @@ declare global {
/** /**
* Global registry for all unique resources. * 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 ?? {} export const PolicyResource = global.PolicyResource ?? {}
global.PolicyResource ??= PolicyResource global.PolicyResource ??= PolicyResource
for (const resource of defaultResources) {
const resourceKey = toSnakeCase(resource)
PolicyResource[resourceKey] = resource
}
/** /**
* Global registry for all unique operations. * Global registry for all unique operations.
*/ */

View File

@@ -1,5 +1,5 @@
export * from "./admin-consts"
export * from "./clean-response-data" export * from "./clean-response-data"
export * from "./define-middlewares"
export * from "./exception-formatter" export * from "./exception-formatter"
export * from "./middlewares" export * from "./middlewares"
export * from "./define-middlewares"
export * from "./admin-consts"