chore(rbac): user link and utils (#14320)

This commit is contained in:
Carlos R. L. Rodrigues
2026-01-07 10:40:15 -03:00
committed by GitHub
parent 7161cf1903
commit b2245cc672
72 changed files with 2746 additions and 703 deletions
+2 -1
View File
@@ -53,8 +53,8 @@ export * from "./medusa-container"
export * from "./merge-metadata"
export * from "./merge-plugin-modules"
export * from "./normalize-csv-value"
export * from "./normalize-locale"
export * from "./normalize-import-path-with-source"
export * from "./normalize-locale"
export * from "./object-from-string-path"
export * from "./object-to-string-path"
export * from "./omit-deep"
@@ -85,6 +85,7 @@ export * from "./to-camel-case"
export * from "./to-handle"
export * from "./to-kebab-case"
export * from "./to-pascal-case"
export * from "./to-snake-case"
export * from "./to-unix-slash"
export * from "./trim-zeros"
export * from "./try-convert-to-boolean"
@@ -0,0 +1,10 @@
/**
* Converts a string to snake_case
*/
export function toSnakeCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/[^a-zA-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase()
}
+5 -4
View File
@@ -1,11 +1,13 @@
export * from "./api-key"
export * from "./analytics"
export * from "./api-key"
export * from "./auth"
export * from "./bundles"
export * from "./caching"
export * from "./common"
export * from "./core-flows"
export * from "./dal"
export * from "./defaults"
export * from "./dev-server"
export * from "./dml"
export * from "./event-bus"
export * from "./exceptions"
@@ -21,6 +23,7 @@ export * from "./orchestration"
export * from "./order"
export * from "./payment"
export * from "./pg"
export * from "./policies"
export * from "./pricing"
export * from "./product"
export * from "./promotion"
@@ -28,10 +31,8 @@ export * from "./search"
export * from "./shipping"
export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./caching"
export * from "./translations"
export * from "./dev-server"
export * from "./user"
export const MedusaModuleType = Symbol.for("MedusaModule")
export const MedusaModuleProviderType = Symbol.for("MedusaModuleProvider")
+6
View File
@@ -128,4 +128,10 @@ export const LINKS = {
Modules.PAYMENT,
"account_holder_id"
),
UserRbacRole: composeLinkName(
Modules.USER,
"user_id",
Modules.RBAC,
"rbac_role_id"
),
}
@@ -0,0 +1,182 @@
import { getCallerFilePath, isFileDisabled, MEDUSA_SKIP_FILE } from "../common"
import { toSnakeCase } from "../common/to-snake-case"
export const MedusaPolicySymbol = Symbol.for("MedusaPolicy")
export interface PolicyDefinition {
name: string
resource: string
operation: string
description?: string
}
export interface definePoliciesExport {
[MedusaPolicySymbol]: boolean
policies: PolicyDefinition[]
}
declare global {
// eslint-disable-next-line no-var
var Resource: Record<string, string>
// eslint-disable-next-line no-var
var Operation: Record<string, string>
// eslint-disable-next-line no-var
var Policy: Record<
string,
{ resource: string; operation: string; description?: string }
>
}
/**
* 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.
*/
const defaultOperations = ["read", "write", "update", "delete", "*"]
export const PolicyOperation = global.PolicyOperation ?? {}
global.PolicyOperation ??= PolicyOperation
for (const operation of defaultOperations) {
const operationKey = operation === "*" ? "*" : toSnakeCase(operation)
PolicyOperation[operationKey] = operation
}
export const Policy = global.Policy ?? {}
global.Policy ??= Policy
/**
* Define RBAC policies that will be automatically synced to the database
* when the application starts.
*
* @param policies - Single policy or array of policy definitions
*
* @example
* ```ts
* definePolicies({
* name: "ReadBrands",
* resource: "brand",
* operation: "read"
* description: "Read brands"
* })
*
* definePolicies([
* {
* name: "ReadBrands",
* resource: "brand",
* operation: "read"
* },
* {
* name: "WriteBrands",
* resource: "brand",
* operation: "write"
* }
* ])
* ```
*/
export function definePolicies(
policies: PolicyDefinition | PolicyDefinition[]
): definePoliciesExport {
const callerFilePath = getCallerFilePath()
if (isFileDisabled(callerFilePath ?? "")) {
return { [MEDUSA_SKIP_FILE]: true } as any
}
const policiesArray = Array.isArray(policies) ? policies : [policies]
for (const policy of policiesArray) {
if (!policy.name || !policy.resource || !policy.operation) {
throw new Error(
`Policy definition must include name, resource, and operation. Received: ${JSON.stringify(
policy,
null,
2
)}`
)
}
}
for (const policy of policiesArray) {
policy.resource = policy.resource.toLowerCase()
policy.operation = policy.operation.toLowerCase()
const resourceKey = toSnakeCase(policy.resource)
PolicyResource[resourceKey] = policy.resource
const operationKey = toSnakeCase(policy.operation)
PolicyOperation[operationKey] = policy.operation
// Register in Policy object with name as key
Policy[policy.name] = { ...policy }
}
const output: definePoliciesExport = {
[MedusaPolicySymbol]: true,
policies: policiesArray,
}
return output
}
+5 -3
View File
@@ -1,7 +1,9 @@
export * from "./build-query"
export * from "./create-medusa-mikro-orm-event-subscriber"
export * from "./create-pg-connection"
export * from "./decorators"
export * from "./define-link"
export * from "./define-policies"
export * from "./definition"
export * from "./event-builder-factory"
export * from "./joiner-config-builder"
@@ -16,9 +18,9 @@ export * from "./migration-scripts"
export * from "./mikro-orm-cli-config-builder"
export * from "./module"
export * from "./module-provider"
export * from "./module-provider-registration-key"
export * from "./modules-to-container-types"
export * from "./policy-to-types"
export * from "./query-context"
export * from "./types/links-config"
export * from "./types/medusa-service"
export * from "./module-provider-registration-key"
export * from "./modules-to-container-types"
export * from "./create-medusa-mikro-orm-event-subscriber"
@@ -0,0 +1,78 @@
import { FileSystem } from "../common/file-system"
import { Policy, PolicyOperation, PolicyResource } from "./define-policies"
/**
* Generates TypeScript type definitions for RBAC Resource, Operation, and Policy.
* Creates a "policy-bindings.d.ts" file with type-safe autocomplete.
*
* @param outputDir - Directory where the type definition file should be created
*/
export async function generatePolicyTypes({
outputDir,
}: {
outputDir: string
}) {
const policyTypeEntries: string[] = []
// Generate type entries for each named policy from Policy object
for (const [name, { resource, operation }] of Object.entries(Policy)) {
policyTypeEntries.push(
` ${name}: { resource: "${resource}"; operation: "${operation}" }`
)
}
// If no policies are registered, create empty types
const policyInterface =
policyTypeEntries.length > 0
? `{\n${policyTypeEntries.join("\n")}\n}`
: "{}"
const fileSystem = new FileSystem(outputDir)
const fileName = "policy-bindings.d.ts"
const fileContents = `declare module '@medusajs/framework/utils' {
/**
* RBAC Resource registry with lowercase keys for type-safe access.
* All resource names are normalized to lowercase.
*
* @example
* import { PolicyResource } from '@medusajs/framework/utils'
*
* const productResource = PolicyResource.product // "product"
* const apiKeyResource = PolicyResource.api_key // "api-key"
*/
export const Resource: {
${Object.entries(PolicyResource)
.map(([key, val]) => ` readonly ${key}: "${val}"`)
.join("\n")}
}
/**
* RBAC Operation registry with lowercase keys for type-safe access.
* All operation names are normalized to lowercase.
*
* @example
* import { PolicyOperation } from '@medusajs/framework/utils'
*
* const readOp = PolicyOperation.read // "read"
*/
export const Operation: {
${Object.entries(PolicyOperation)
.map(([key, val]) => ` readonly ${key}: "${val}"`)
.join("\n")}
}
/**
* RBAC Policy registry with all defined policies.
* Maps policy names to their resource and operation pairs.
*
* @example
* import { Policy } from '@medusajs/framework/utils'
*
* const readProduct = Policy.ReadProduct
* // { resource: "product", operation: "read" }
*/
export const Policy: ${policyInterface}
}`
await fileSystem.create(fileName, fileContents)
}
@@ -0,0 +1,71 @@
import { readdir } from "fs/promises"
import { join, normalize } from "path"
import { dynamicImport, readDirRecursive } from "../common"
import { MedusaPolicySymbol } from "../modules-sdk"
const excludedFiles = ["index.js", "index.ts"]
const excludedExtensions = [".d.ts", ".d.ts.map", ".js.map"]
function isPolicyExport(value: unknown): boolean {
return !!value && typeof value === "object" && MedusaPolicySymbol in value
}
/**
* Discover policy definitions from a directory and subdirectories
*/
export async function discoverPoliciesFromDir(
sourcePath?: string,
maxDepth: number = 2
): Promise<void> {
if (!sourcePath) {
return
}
const root = normalize(sourcePath)
const allEntries = await readDirRecursive(root, {
ignoreMissing: true,
maxDepth,
})
const policyDirs = allEntries
.filter((e) => e.isDirectory() && e.name === "policies")
.map((e) => join((e as any).path as string, e.name))
if (!policyDirs.length) {
return
}
await Promise.all(
policyDirs.map(async (scanDir) => {
const entries = await readdir(scanDir, { withFileTypes: true })
await Promise.all(
entries.map(async (entry) => {
if (entry.isDirectory()) {
return
}
if (
excludedExtensions.some((ext) => entry.name.endsWith(ext)) ||
excludedFiles.includes(entry.name)
) {
return
}
// Import the file - this will execute definePolicies() calls
const fileExports = await dynamicImport(join(scanDir, entry.name))
// Validate that at least one export is a policy
const values = Object.values(fileExports)
const hasPolicies = values.some((value) => isPolicyExport(value))
if (!hasPolicies) {
console.warn(
`File ${entry.name} in policies directory does not export any policies`
)
}
})
)
})
)
}
@@ -0,0 +1 @@
export * from "./discover-policies"