chore(medusa): middleware policies (#14521)
This commit is contained in:
committed by
GitHub
parent
cec8b8e428
commit
8426fca710
@@ -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,
|
||||
},
|
||||
]
|
||||
`)
|
||||
|
||||
@@ -130,6 +130,7 @@ export class MiddlewareFileLoader {
|
||||
handler: middleware,
|
||||
matcher: matcher,
|
||||
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 "./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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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<Body, QueryFields> {
|
||||
auth_context: AuthContext
|
||||
publishable_key_context?: PublishableKeyContext
|
||||
policies?: PolicyAction[]
|
||||
}
|
||||
|
||||
export interface MedusaStoreRequest<
|
||||
@@ -220,6 +237,7 @@ export interface MedusaStoreRequest<
|
||||
> extends MedusaRequest<Body, QueryFields> {
|
||||
auth_context?: AuthContext
|
||||
publishable_key_context: PublishableKeyContext
|
||||
policies?: PolicyAction | PolicyAction[]
|
||||
}
|
||||
|
||||
export type MedusaResponse<Body = unknown> = Response<Body>
|
||||
|
||||
151
packages/core/framework/src/utils/has-permission.ts
Normal file
151
packages/core/framework/src/utils/has-permission.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { MedusaContainer } from "@medusajs/framework/types"
|
||||
import { ContainerRegistrationKeys, useCache } from "@medusajs/framework/utils"
|
||||
import { FlagRouter } from "../feature-flags/flag-router"
|
||||
|
||||
export type PermissionAction = {
|
||||
resource: string
|
||||
operation: string
|
||||
}
|
||||
|
||||
/*
|
||||
/**
|
||||
*
|
||||
* @property roles the role(s) to check. Can be a single string or an array of strings.
|
||||
* @property actions the action(s) to check. Can be a single `PermissionAction` or an array of `PermissionAction`s.
|
||||
* @property container the Medusa container
|
||||
*/
|
||||
export type HasPermissionInput = {
|
||||
roles: string | string[]
|
||||
actions: PermissionAction | PermissionAction[]
|
||||
container: MedusaContainer
|
||||
}
|
||||
|
||||
type RolePoliciesCache = Map<string, Map<string, Set<string>>>
|
||||
|
||||
/**
|
||||
* Checks if the given role(s) have permission to perform the specified action(s).
|
||||
*
|
||||
* @param input - The input containing roles, actions, and container
|
||||
* @returns true if all actions are permitted, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const canWrite = await hasPermission({
|
||||
* roles: ['role_123'],
|
||||
* actions: { resource: 'product', operation: 'write' },
|
||||
* container
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export async function hasPermission(
|
||||
input: HasPermissionInput
|
||||
): Promise<boolean> {
|
||||
const { roles, actions, container } = input
|
||||
|
||||
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 = !ffRouter.isFeatureEnabled("rbac")
|
||||
if (isDisabled || !roleIds?.length || !actionList?.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
const rolePoliciesMap = await fetchRolePolicies(roleIds, container)
|
||||
|
||||
for (const action of actionList) {
|
||||
let hasAccess = false
|
||||
|
||||
for (const roleId of roleIds) {
|
||||
const resourceMap = rolePoliciesMap.get(roleId)
|
||||
if (!resourceMap) {
|
||||
continue
|
||||
}
|
||||
|
||||
const operations = resourceMap.get(action.resource)
|
||||
if (
|
||||
operations &&
|
||||
(operations.has(action.operation) || operations.has("*"))
|
||||
) {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a single role's policies from cache or database.
|
||||
*/
|
||||
async function fetchSingleRolePolicies(
|
||||
roleId: string,
|
||||
container: MedusaContainer
|
||||
): Promise<Map<string, Set<string>>> {
|
||||
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
const tags: string[] = []
|
||||
return await useCache<Map<string, Set<string>>>(
|
||||
async () => {
|
||||
const { data: roles } = await query.graph({
|
||||
entity: "rbac_role",
|
||||
fields: ["id", "policies.*"],
|
||||
filters: { id: roleId },
|
||||
})
|
||||
|
||||
const role = roles[0]
|
||||
const resourceMap = new Map<string, Set<string>>()
|
||||
|
||||
tags.push(`rbac_role:${roleId}`)
|
||||
if (role?.policies && Array.isArray(role.policies)) {
|
||||
const policyIds: string[] = []
|
||||
|
||||
for (const policy of role.policies) {
|
||||
policyIds.push(policy.id)
|
||||
|
||||
if (!resourceMap.has(policy.resource)) {
|
||||
resourceMap.set(policy.resource, new Set())
|
||||
}
|
||||
resourceMap.get(policy.resource)!.add(policy.operation)
|
||||
|
||||
tags.push(`rbac_policy:${policy.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
return resourceMap
|
||||
},
|
||||
{
|
||||
container,
|
||||
key: roleId,
|
||||
tags,
|
||||
ttl: 60 * 60 * 24 * 7,
|
||||
providers: ["cache-memory"],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches policies for multiple roles by composing individually cached role queries.
|
||||
*/
|
||||
async function fetchRolePolicies(
|
||||
roleIds: string[],
|
||||
container: MedusaContainer
|
||||
): Promise<RolePoliciesCache> {
|
||||
const rolePoliciesMap: RolePoliciesCache = new Map()
|
||||
|
||||
await Promise.all(
|
||||
roleIds.map(async (roleId) => {
|
||||
const resourceMap = await fetchSingleRolePolicies(roleId, container)
|
||||
rolePoliciesMap.set(roleId, resourceMap)
|
||||
})
|
||||
)
|
||||
|
||||
return rolePoliciesMap
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../types/container"
|
||||
|
||||
export * from "@medusajs/utils"
|
||||
export * from "./has-permission"
|
||||
|
||||
Reference in New Issue
Block a user