chore(medusa): middleware policies (#14521)
This commit is contained in:
committed by
GitHub
parent
cec8b8e428
commit
8426fca710
@@ -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 () => {
|
||||||
|
|||||||
@@ -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}`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export class MiddlewareFileLoader {
|
|||||||
handler: middleware,
|
handler: middleware,
|
||||||
matcher: matcher,
|
matcher: matcher,
|
||||||
methods: route.methods,
|
methods: route.methods,
|
||||||
|
policies: route.policies,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import "../types/container"
|
import "../types/container"
|
||||||
|
|
||||||
export * from "@medusajs/utils"
|
export * from "@medusajs/utils"
|
||||||
|
export * from "./has-permission"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user