chore(rbac): user link and utils (#14320)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user