@zap-studio/permit 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +21 -0
- package/dist/index.js +1 -1
- package/dist/policy-BvQ4lr0v.js +230 -0
- package/dist/policy-BvQ4lr0v.js.map +1 -0
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +2 -171
- package/package.json +9 -4
- package/dist/policy.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [2.0.0]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
Native OpenTelemetry support. Every `can(...)` call gets an `INTERNAL` span (`permit.check {resourceType}:{action}`) with the decision (`"allow"`/`"deny"`) set as a span attribute, plus a `permit.checks` counter tagged the same way. `mergePoliciesAnd`/`mergePoliciesOr` get their own wrapping span around the composite check. See [OpenTelemetry](https://www.zapstudio.dev/permit/opentelemetry).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
**Breaking:** `@opentelemetry/api` is now a required peer dependency. It's tiny, side-effect-free, and a no-op until an app registers a real SDK, so nothing changes at runtime for consumers who don't set one up — but the package won't resolve without it installed: `npm install @opentelemetry/api`.
|
|
16
|
+
|
|
7
17
|
## [1.1.1]
|
|
8
18
|
|
|
9
19
|
### Changed
|
package/README.md
CHANGED
|
@@ -160,6 +160,27 @@ const policy = createPolicy({ resources, actions, rules, logger });
|
|
|
160
160
|
|
|
161
161
|
Allow decisions log at `debug`, deny decisions log at `info`. Resource validation and policy evaluation errors log at `warn` through the logger when one is provided, instead of `console.warn`.
|
|
162
162
|
|
|
163
|
+
## OpenTelemetry
|
|
164
|
+
|
|
165
|
+
`@opentelemetry/api` is a required peer dependency — tiny, side-effect-free, and a no-op until an app registers a real SDK, so installing it costs nothing at runtime for consumers who never set one up.
|
|
166
|
+
|
|
167
|
+
Every `can(...)` check gets an `INTERNAL` span named `permit.check {resourceType}:{action}`, with the decision (`"allow"` or `"deny"`) set as a span attribute, plus a `permit.checks` counter tagged the same way. `mergePoliciesAnd`/`mergePoliciesOr` get their own span around the composite check, on top of the spans each underlying policy already produces:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm install @opentelemetry/api
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { createPolicy } from "@zap-studio/permit";
|
|
175
|
+
|
|
176
|
+
const policy = createPolicy({ resources, actions, rules });
|
|
177
|
+
|
|
178
|
+
// If your app has registered an OpenTelemetry SDK, this call now produces a
|
|
179
|
+
// span attributed with the allow/deny decision. If not, it's a no-op — no
|
|
180
|
+
// wiring required either way.
|
|
181
|
+
await policy.can(ctx, "post:write", post);
|
|
182
|
+
```
|
|
183
|
+
|
|
163
184
|
## Runtime Support
|
|
164
185
|
|
|
165
186
|
| Runtime | Minimum version |
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { allow, and, collectInheritedRoles, deny, has, hasRole, not, or, when } from "./conditions.js";
|
|
2
2
|
import { PolicyError } from "./errors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { n as mergePoliciesAnd, r as mergePoliciesOr, t as createPolicy } from "./policy-BvQ4lr0v.js";
|
|
4
4
|
export { PolicyError, allow, and, collectInheritedRoles, createPolicy, deny, has, hasRole, mergePoliciesAnd, mergePoliciesOr, not, or, when };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { PolicyError } from "./errors.js";
|
|
2
|
+
import { createStandardValidator } from "@zap-studio/validation";
|
|
3
|
+
import { SpanKind, context, metrics, trace } from "@opentelemetry/api";
|
|
4
|
+
//#region package.json
|
|
5
|
+
var name = "@zap-studio/permit";
|
|
6
|
+
var version = "2.0.0";
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/_otel.ts
|
|
9
|
+
/**
|
|
10
|
+
* Internal OpenTelemetry wiring for the permit package: tracer resolution,
|
|
11
|
+
* the per-check span, and the checks counter. Kept out of `policy.ts` so
|
|
12
|
+
* authorization logic doesn't get tangled with tracing/metrics concerns.
|
|
13
|
+
*
|
|
14
|
+
* @module @zap-studio/permit/otel
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* OpenTelemetry tracer for this package. Resolved once against the global
|
|
18
|
+
* `TracerProvider`; a no-op provider (the default until an app registers an
|
|
19
|
+
* SDK) makes every span call below a no-op too.
|
|
20
|
+
*/
|
|
21
|
+
const tracer = trace.getTracer(name, version);
|
|
22
|
+
/**
|
|
23
|
+
* Records one authorization check, tagged with `permit.decision: "allow" |
|
|
24
|
+
* "deny"`.
|
|
25
|
+
*
|
|
26
|
+
* Resolves the meter and counter fresh on every call instead of caching
|
|
27
|
+
* them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`
|
|
28
|
+
* has no proxy indirection — a reference grabbed before an app registers
|
|
29
|
+
* its `MeterProvider` (the common case, since ESM imports resolve before
|
|
30
|
+
* the importing module's own SDK-bootstrap code runs) would stay a no-op
|
|
31
|
+
* forever. Repeated `createCounter` calls with the same name are cheap and
|
|
32
|
+
* idempotent, so this costs nothing meaningful.
|
|
33
|
+
*/
|
|
34
|
+
const recordPermitCheck = (decision) => {
|
|
35
|
+
metrics.getMeter(name, version).createCounter("permit.checks", { description: "Number of authorization checks made, tagged by decision." }).add(1, { "permit.decision": decision });
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Wraps one authorization check in an `INTERNAL` span named
|
|
39
|
+
* `permit.check {permission}`, setting `permit.decision` and recording the
|
|
40
|
+
* `permit.checks` counter from `run`'s boolean result.
|
|
41
|
+
*/
|
|
42
|
+
const withCheckSpan = async (permission, run) => {
|
|
43
|
+
const span = tracer.startSpan(`permit.check ${permission}`, { kind: SpanKind.INTERNAL });
|
|
44
|
+
try {
|
|
45
|
+
return await context.with(trace.setSpan(context.active(), span), async () => {
|
|
46
|
+
const allowed = await run();
|
|
47
|
+
const decision = allowed ? "allow" : "deny";
|
|
48
|
+
span.setAttribute("permit.decision", decision);
|
|
49
|
+
recordPermitCheck(decision);
|
|
50
|
+
return allowed;
|
|
51
|
+
});
|
|
52
|
+
} finally {
|
|
53
|
+
span.end();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/policy.ts
|
|
58
|
+
/**
|
|
59
|
+
* Splits a typed `resource:action` permission string into its parts.
|
|
60
|
+
* Returns `null` when the string is malformed (missing/empty part or extra
|
|
61
|
+
* segments) or `resourceType` is not one of `actions`' keys.
|
|
62
|
+
*/
|
|
63
|
+
const parsePermission = (permission, actions) => {
|
|
64
|
+
const isValidResourceKey = (value) => Object.keys(actions).includes(value);
|
|
65
|
+
const [resourceTypeValue, actionValue, ...rest] = permission.split(":");
|
|
66
|
+
if (resourceTypeValue === void 0 || resourceTypeValue.length === 0 || actionValue === void 0 || actionValue.length === 0 || rest.length > 0 || !isValidResourceKey(resourceTypeValue)) return null;
|
|
67
|
+
return {
|
|
68
|
+
action: actionValue,
|
|
69
|
+
resourceType: resourceTypeValue
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Creates a type-safe policy from resource schemas, actions, and rules.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* import { z } from "zod";
|
|
78
|
+
* import { createPolicy, allow, deny, when } from "@zap-studio/permit";
|
|
79
|
+
* import type { Resources, Actions } from "@zap-studio/permit/types";
|
|
80
|
+
*
|
|
81
|
+
* // Define resource schemas
|
|
82
|
+
* const resources = {
|
|
83
|
+
* post: z.object({
|
|
84
|
+
* id: z.string(),
|
|
85
|
+
* authorId: z.string(),
|
|
86
|
+
* visibility: z.enum(["public", "private"]),
|
|
87
|
+
* }),
|
|
88
|
+
* comment: z.object({
|
|
89
|
+
* id: z.string(),
|
|
90
|
+
* postId: z.string(),
|
|
91
|
+
* authorId: z.string(),
|
|
92
|
+
* }),
|
|
93
|
+
* } satisfies Resources;
|
|
94
|
+
*
|
|
95
|
+
* // Define actions per resource
|
|
96
|
+
* const actions = {
|
|
97
|
+
* post: ["read", "write", "delete"],
|
|
98
|
+
* comment: ["read", "write"],
|
|
99
|
+
* } as const satisfies Actions<typeof resources>;
|
|
100
|
+
*
|
|
101
|
+
* // Define context type
|
|
102
|
+
* type AppContext = { user: { id: string; role: string } };
|
|
103
|
+
*
|
|
104
|
+
* // Create the policy
|
|
105
|
+
* const policy = createPolicy<AppContext>({
|
|
106
|
+
* resources,
|
|
107
|
+
* actions,
|
|
108
|
+
* rules: {
|
|
109
|
+
* post: {
|
|
110
|
+
* read: when((ctx, action, resource) => resource.visibility === "public"),
|
|
111
|
+
* write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
|
|
112
|
+
* delete: deny(),
|
|
113
|
+
* },
|
|
114
|
+
* comment: {
|
|
115
|
+
* read: allow(),
|
|
116
|
+
* write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
|
|
117
|
+
* },
|
|
118
|
+
* },
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* // Check permissions
|
|
122
|
+
* const post = { id: "1", authorId: "user-1", visibility: "public" as const };
|
|
123
|
+
* await policy.can(ctx, "post:read", post); // true
|
|
124
|
+
* await policy.can(ctx, "post:write", post); // depends on ctx.user.id
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
const createPolicy = (config) => {
|
|
128
|
+
const { rules, resources, actions, logger } = config;
|
|
129
|
+
const validators = /* @__PURE__ */ new Map();
|
|
130
|
+
const getValidatedResource = async (resourceType, resource) => {
|
|
131
|
+
const validator = validators.get(resourceType);
|
|
132
|
+
if (validator === void 0) return null;
|
|
133
|
+
try {
|
|
134
|
+
const result = await validator(resource);
|
|
135
|
+
if (result.issues) return null;
|
|
136
|
+
return result.value;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
logger?.warn(`Resource validation failed for ${String(resourceType)}: ${String(error)}`, {
|
|
139
|
+
error,
|
|
140
|
+
resourceType: String(resourceType)
|
|
141
|
+
});
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const hasAllowedAction = (resourceType, action) => actions[resourceType]?.includes(action) ?? false;
|
|
146
|
+
const evaluatePolicy = (context, resourceType, action, resource) => {
|
|
147
|
+
const policyFn = rules[resourceType]?.[action];
|
|
148
|
+
if (policyFn === void 0) return false;
|
|
149
|
+
try {
|
|
150
|
+
const allowed = policyFn(context, action, resource) === "allow";
|
|
151
|
+
if (allowed) logger?.debug("permission allowed", {
|
|
152
|
+
action,
|
|
153
|
+
resourceType: String(resourceType)
|
|
154
|
+
});
|
|
155
|
+
else logger?.info("permission denied", {
|
|
156
|
+
action,
|
|
157
|
+
resourceType: String(resourceType)
|
|
158
|
+
});
|
|
159
|
+
return allowed;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger?.warn(`Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`, {
|
|
162
|
+
action,
|
|
163
|
+
error,
|
|
164
|
+
resourceType: String(resourceType)
|
|
165
|
+
});
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
for (const key of Object.keys(resources)) {
|
|
170
|
+
const schema = resources[key];
|
|
171
|
+
if (schema === void 0) throw new PolicyError(`Missing schema for resource: ${String(key)}`);
|
|
172
|
+
const validator = createStandardValidator(schema);
|
|
173
|
+
validators.set(key, async (input) => await validator(input));
|
|
174
|
+
}
|
|
175
|
+
return { async can(context, permission, resource) {
|
|
176
|
+
return await withCheckSpan(permission, async () => {
|
|
177
|
+
const parsedPermission = parsePermission(permission, actions);
|
|
178
|
+
if (parsedPermission === null) return false;
|
|
179
|
+
const { action, resourceType } = parsedPermission;
|
|
180
|
+
if (!hasAllowedAction(resourceType, action)) return false;
|
|
181
|
+
const validatedResource = await getValidatedResource(resourceType, resource);
|
|
182
|
+
if (validatedResource === null) return false;
|
|
183
|
+
return evaluatePolicy(context, resourceType, action, validatedResource);
|
|
184
|
+
});
|
|
185
|
+
} };
|
|
186
|
+
};
|
|
187
|
+
const mergePoliciesWithStrategy = (policies, strategy) => ({ async can(context, permission, resource) {
|
|
188
|
+
return await withCheckSpan(permission, async () => {
|
|
189
|
+
if (policies.length === 0) return false;
|
|
190
|
+
const results = (await Promise.allSettled(policies.map(async (policy) => await policy.can(context, permission, resource)))).map((result) => {
|
|
191
|
+
if (result.status === "fulfilled") return result.value;
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
return strategy === "and" ? results.every(Boolean) : results.some(Boolean);
|
|
195
|
+
});
|
|
196
|
+
} });
|
|
197
|
+
/**
|
|
198
|
+
* Merges multiple policies into one, requiring every policy to allow.
|
|
199
|
+
* If any policy denies, the merged policy denies. Policies are evaluated
|
|
200
|
+
* in parallel; every policy is invoked regardless of outcome.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* const basePolicy = createPolicy({ ... });
|
|
205
|
+
* const adminPolicy = createPolicy({ ... });
|
|
206
|
+
*
|
|
207
|
+
* const merged = mergePoliciesAnd(basePolicy, adminPolicy);
|
|
208
|
+
* // Both policies must allow for the action to be permitted
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
const mergePoliciesAnd = (...policies) => mergePoliciesWithStrategy(policies, "and");
|
|
212
|
+
/**
|
|
213
|
+
* Merges multiple policies into one, requiring at least one policy to allow.
|
|
214
|
+
* If every policy denies, the merged policy denies. Policies are evaluated
|
|
215
|
+
* in parallel; every policy is invoked regardless of outcome.
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* ```ts
|
|
219
|
+
* const guestPolicy = createPolicy({ ... });
|
|
220
|
+
* const memberPolicy = createPolicy({ ... });
|
|
221
|
+
*
|
|
222
|
+
* const merged = mergePoliciesOr(guestPolicy, memberPolicy);
|
|
223
|
+
* // If either policy allows, the action is permitted
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
const mergePoliciesOr = (...policies) => mergePoliciesWithStrategy(policies, "or");
|
|
227
|
+
//#endregion
|
|
228
|
+
export { mergePoliciesAnd as n, mergePoliciesOr as r, createPolicy as t };
|
|
229
|
+
|
|
230
|
+
//# sourceMappingURL=policy-BvQ4lr0v.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy-BvQ4lr0v.js","names":["pkg.name","pkg.version"],"sources":["../package.json","../src/_otel.ts","../src/policy.ts"],"sourcesContent":["","/**\n * Internal OpenTelemetry wiring for the permit package: tracer resolution,\n * the per-check span, and the checks counter. Kept out of `policy.ts` so\n * authorization logic doesn't get tangled with tracing/metrics concerns.\n *\n * @module @zap-studio/permit/otel\n */\n\nimport { SpanKind, context, metrics, trace } from \"@opentelemetry/api\";\n\nimport pkg from \"../package.json\" with { type: \"json\" };\n\n/**\n * OpenTelemetry tracer for this package. Resolved once against the global\n * `TracerProvider`; a no-op provider (the default until an app registers an\n * SDK) makes every span call below a no-op too.\n */\nconst tracer = trace.getTracer(pkg.name, pkg.version);\n\n/**\n * Records one authorization check, tagged with `permit.decision: \"allow\" |\n * \"deny\"`.\n *\n * Resolves the meter and counter fresh on every call instead of caching\n * them at module scope: unlike `trace.getTracer()`, `metrics.getMeter()`\n * has no proxy indirection — a reference grabbed before an app registers\n * its `MeterProvider` (the common case, since ESM imports resolve before\n * the importing module's own SDK-bootstrap code runs) would stay a no-op\n * forever. Repeated `createCounter` calls with the same name are cheap and\n * idempotent, so this costs nothing meaningful.\n */\nconst recordPermitCheck = (decision: \"allow\" | \"deny\"): void => {\n metrics\n .getMeter(pkg.name, pkg.version)\n .createCounter(\"permit.checks\", {\n description: \"Number of authorization checks made, tagged by decision.\",\n })\n .add(1, { \"permit.decision\": decision });\n};\n\n/**\n * Wraps one authorization check in an `INTERNAL` span named\n * `permit.check {permission}`, setting `permit.decision` and recording the\n * `permit.checks` counter from `run`'s boolean result.\n */\nexport const withCheckSpan = async (\n permission: string,\n run: () => Promise<boolean>\n): Promise<boolean> => {\n const span = tracer.startSpan(`permit.check ${permission}`, {\n kind: SpanKind.INTERNAL,\n });\n\n try {\n return await context.with(\n trace.setSpan(context.active(), span),\n async () => {\n const allowed = await run();\n const decision = allowed ? \"allow\" : \"deny\";\n span.setAttribute(\"permit.decision\", decision);\n recordPermitCheck(decision);\n return allowed;\n }\n );\n } finally {\n span.end();\n }\n};\n","/**\n * Policy creation and composition: `createPolicy`, `mergePoliciesAnd`, and\n * `mergePoliciesOr`.\n *\n * @module @zap-studio/permit/policy\n */\n\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { createStandardValidator } from \"@zap-studio/validation\";\n\nimport { withCheckSpan } from \"./_otel.js\";\nimport { PolicyError } from \"./errors.js\";\nimport type {\n Actions,\n Context,\n InferAction,\n InferResource,\n PermitConfig,\n Policy,\n Resources,\n} from \"./types.js\";\n\n/**\n * Splits a typed `resource:action` permission string into its parts.\n * Returns `null` when the string is malformed (missing/empty part or extra\n * segments) or `resourceType` is not one of `actions`' keys.\n */\nconst parsePermission = <\n TResources extends Resources,\n TActions extends Actions<TResources>,\n K extends keyof TResources & keyof TActions,\n>(\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n actions: TActions\n): { action: InferAction<TResources, TActions, K>; resourceType: K } | null => {\n const isValidResourceKey = (value: string): value is K & string =>\n Object.keys(actions).includes(value);\n\n const [resourceTypeValue, actionValue, ...rest] = permission.split(\":\");\n if (\n resourceTypeValue === undefined ||\n resourceTypeValue.length === 0 ||\n actionValue === undefined ||\n actionValue.length === 0 ||\n rest.length > 0 ||\n !isValidResourceKey(resourceTypeValue)\n ) {\n return null;\n }\n\n return {\n action: actionValue,\n resourceType: resourceTypeValue,\n };\n};\n\n/**\n * Creates a type-safe policy from resource schemas, actions, and rules.\n *\n * @example\n * ```ts\n * import { z } from \"zod\";\n * import { createPolicy, allow, deny, when } from \"@zap-studio/permit\";\n * import type { Resources, Actions } from \"@zap-studio/permit/types\";\n *\n * // Define resource schemas\n * const resources = {\n * post: z.object({\n * id: z.string(),\n * authorId: z.string(),\n * visibility: z.enum([\"public\", \"private\"]),\n * }),\n * comment: z.object({\n * id: z.string(),\n * postId: z.string(),\n * authorId: z.string(),\n * }),\n * } satisfies Resources;\n *\n * // Define actions per resource\n * const actions = {\n * post: [\"read\", \"write\", \"delete\"],\n * comment: [\"read\", \"write\"],\n * } as const satisfies Actions<typeof resources>;\n *\n * // Define context type\n * type AppContext = { user: { id: string; role: string } };\n *\n * // Create the policy\n * const policy = createPolicy<AppContext>({\n * resources,\n * actions,\n * rules: {\n * post: {\n * read: when((ctx, action, resource) => resource.visibility === \"public\"),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * delete: deny(),\n * },\n * comment: {\n * read: allow(),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * },\n * },\n * });\n *\n * // Check permissions\n * const post = { id: \"1\", authorId: \"user-1\", visibility: \"public\" as const };\n * await policy.can(ctx, \"post:read\", post); // true\n * await policy.can(ctx, \"post:write\", post); // depends on ctx.user.id\n * ```\n */\nexport const createPolicy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n config: PermitConfig<TContext, TResources, TActions>\n): Policy<TContext, TResources, TActions> => {\n const { rules, resources, actions, logger } = config;\n const validators = new Map<\n keyof TResources,\n (input: unknown) => Promise<StandardSchemaV1.Result<unknown>>\n >();\n\n const getValidatedResource = async <K extends keyof TResources>(\n resourceType: K,\n resource: InferResource<TResources, K>\n ): Promise<InferResource<TResources, K> | null> => {\n const validator = validators.get(resourceType);\n if (validator === undefined) {\n return null;\n }\n try {\n const result = await validator(resource);\n if (result.issues) {\n return null;\n }\n return result.value;\n } catch (error) {\n logger?.warn(\n `Resource validation failed for ${String(resourceType)}: ${String(error)}`,\n { error, resourceType: String(resourceType) }\n );\n return null;\n }\n };\n\n const hasAllowedAction = <K extends keyof TResources & keyof TActions>(\n resourceType: K,\n action: InferAction<TResources, TActions, K>\n ): boolean => actions[resourceType]?.includes(action) ?? false;\n\n const evaluatePolicy = <K extends keyof TResources & keyof TActions>(\n context: TContext,\n resourceType: K,\n action: InferAction<TResources, TActions, K>,\n resource: InferResource<TResources, K>\n ): boolean => {\n const policyFn = rules[resourceType]?.[action];\n if (policyFn === undefined) {\n return false;\n }\n\n try {\n const allowed = policyFn(context, action, resource) === \"allow\";\n\n if (allowed) {\n logger?.debug(\"permission allowed\", {\n action,\n resourceType: String(resourceType),\n });\n } else {\n logger?.info(\"permission denied\", {\n action,\n resourceType: String(resourceType),\n });\n }\n\n return allowed;\n } catch (error) {\n logger?.warn(\n `Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`,\n {\n action,\n error,\n resourceType: String(resourceType),\n }\n );\n return false;\n }\n };\n\n for (const key of Object.keys(resources) as (keyof TResources)[]) {\n const schema = resources[key];\n if (schema === undefined) {\n throw new PolicyError(`Missing schema for resource: ${String(key)}`);\n }\n const validator = createStandardValidator(schema);\n validators.set(key, async (input: unknown) => await validator(input));\n }\n\n return {\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>\n ): Promise<boolean> {\n return await withCheckSpan(permission, async () => {\n const parsedPermission = parsePermission<TResources, TActions, K>(\n permission,\n actions\n );\n if (parsedPermission === null) {\n return false;\n }\n\n const { action, resourceType } = parsedPermission;\n if (!hasAllowedAction(resourceType, action)) {\n return false;\n }\n\n const validatedResource = await getValidatedResource(\n resourceType,\n resource\n );\n if (validatedResource === null) {\n return false;\n }\n\n return evaluatePolicy(context, resourceType, action, validatedResource);\n });\n },\n };\n};\n\nconst mergePoliciesWithStrategy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n policies: Policy<TContext, TResources, TActions>[],\n strategy: \"and\" | \"or\"\n): Policy<TContext, TResources, TActions> => ({\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>\n ): Promise<boolean> {\n return await withCheckSpan(permission, async () => {\n if (policies.length === 0) {\n return false;\n }\n\n const settled = await Promise.allSettled(\n policies.map(\n async (policy) => await policy.can(context, permission, resource)\n )\n );\n\n const results = settled.map((result) => {\n if (result.status === \"fulfilled\") {\n return result.value;\n }\n return false;\n });\n\n return strategy === \"and\"\n ? results.every(Boolean)\n : results.some(Boolean);\n });\n },\n});\n\n/**\n * Merges multiple policies into one, requiring every policy to allow.\n * If any policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const basePolicy = createPolicy({ ... });\n * const adminPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesAnd(basePolicy, adminPolicy);\n * // Both policies must allow for the action to be permitted\n * ```\n */\nexport const mergePoliciesAnd = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> =>\n mergePoliciesWithStrategy(policies, \"and\");\n\n/**\n * Merges multiple policies into one, requiring at least one policy to allow.\n * If every policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const guestPolicy = createPolicy({ ... });\n * const memberPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesOr(guestPolicy, memberPolicy);\n * // If either policy allows, the action is permitted\n * ```\n */\nexport const mergePoliciesOr = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> =>\n mergePoliciesWithStrategy(policies, \"or\");\n"],"mappings":"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"}
|
package/dist/policy.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"policy.d.ts","names":[],"sources":["../src/policy.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"policy.d.ts","names":[],"sources":["../src/policy.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cA+Ga,eACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,aAE/C,QAAQ,aAAa,UAAU,YAAY,cAC1C,OAAO,UAAU,YAAY;;;;;;;;;;;;;;;cA0KnB,mBACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,gBAE5C,UAAU,OAAO,UAAU,YAAY,gBACzC,OAAO,UAAU,YAAY;;;;;;;;;;;;;;;cAiBnB,kBACX,iBAAiB,SACjB,mBAAmB,YAAY,WAC/B,iBAAiB,QAAQ,cAAc,QAAQ,gBAE5C,UAAU,OAAO,UAAU,YAAY,gBACzC,OAAO,UAAU,YAAY"}
|
package/dist/policy.js
CHANGED
|
@@ -1,172 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
//#region src/policy.ts
|
|
4
|
-
/**
|
|
5
|
-
* Splits a typed `resource:action` permission string into its parts.
|
|
6
|
-
* Returns `null` when the string is malformed (missing/empty part or extra
|
|
7
|
-
* segments) or `resourceType` is not one of `actions`' keys.
|
|
8
|
-
*/
|
|
9
|
-
const parsePermission = (permission, actions) => {
|
|
10
|
-
const isValidResourceKey = (value) => Object.keys(actions).includes(value);
|
|
11
|
-
const [resourceTypeValue, actionValue, ...rest] = permission.split(":");
|
|
12
|
-
if (resourceTypeValue === void 0 || resourceTypeValue.length === 0 || actionValue === void 0 || actionValue.length === 0 || rest.length > 0 || !isValidResourceKey(resourceTypeValue)) return null;
|
|
13
|
-
return {
|
|
14
|
-
action: actionValue,
|
|
15
|
-
resourceType: resourceTypeValue
|
|
16
|
-
};
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* Creates a type-safe policy from resource schemas, actions, and rules.
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* ```ts
|
|
23
|
-
* import { z } from "zod";
|
|
24
|
-
* import { createPolicy, allow, deny, when } from "@zap-studio/permit";
|
|
25
|
-
* import type { Resources, Actions } from "@zap-studio/permit/types";
|
|
26
|
-
*
|
|
27
|
-
* // Define resource schemas
|
|
28
|
-
* const resources = {
|
|
29
|
-
* post: z.object({
|
|
30
|
-
* id: z.string(),
|
|
31
|
-
* authorId: z.string(),
|
|
32
|
-
* visibility: z.enum(["public", "private"]),
|
|
33
|
-
* }),
|
|
34
|
-
* comment: z.object({
|
|
35
|
-
* id: z.string(),
|
|
36
|
-
* postId: z.string(),
|
|
37
|
-
* authorId: z.string(),
|
|
38
|
-
* }),
|
|
39
|
-
* } satisfies Resources;
|
|
40
|
-
*
|
|
41
|
-
* // Define actions per resource
|
|
42
|
-
* const actions = {
|
|
43
|
-
* post: ["read", "write", "delete"],
|
|
44
|
-
* comment: ["read", "write"],
|
|
45
|
-
* } as const satisfies Actions<typeof resources>;
|
|
46
|
-
*
|
|
47
|
-
* // Define context type
|
|
48
|
-
* type AppContext = { user: { id: string; role: string } };
|
|
49
|
-
*
|
|
50
|
-
* // Create the policy
|
|
51
|
-
* const policy = createPolicy<AppContext>({
|
|
52
|
-
* resources,
|
|
53
|
-
* actions,
|
|
54
|
-
* rules: {
|
|
55
|
-
* post: {
|
|
56
|
-
* read: when((ctx, action, resource) => resource.visibility === "public"),
|
|
57
|
-
* write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
|
|
58
|
-
* delete: deny(),
|
|
59
|
-
* },
|
|
60
|
-
* comment: {
|
|
61
|
-
* read: allow(),
|
|
62
|
-
* write: when((ctx, action, resource) => ctx.user.id === resource.authorId),
|
|
63
|
-
* },
|
|
64
|
-
* },
|
|
65
|
-
* });
|
|
66
|
-
*
|
|
67
|
-
* // Check permissions
|
|
68
|
-
* const post = { id: "1", authorId: "user-1", visibility: "public" as const };
|
|
69
|
-
* await policy.can(ctx, "post:read", post); // true
|
|
70
|
-
* await policy.can(ctx, "post:write", post); // depends on ctx.user.id
|
|
71
|
-
* ```
|
|
72
|
-
*/
|
|
73
|
-
const createPolicy = (config) => {
|
|
74
|
-
const { rules, resources, actions, logger } = config;
|
|
75
|
-
const validators = /* @__PURE__ */ new Map();
|
|
76
|
-
const getValidatedResource = async (resourceType, resource) => {
|
|
77
|
-
const validator = validators.get(resourceType);
|
|
78
|
-
if (validator === void 0) return null;
|
|
79
|
-
try {
|
|
80
|
-
const result = await validator(resource);
|
|
81
|
-
if (result.issues) return null;
|
|
82
|
-
return result.value;
|
|
83
|
-
} catch (error) {
|
|
84
|
-
logger?.warn(`Resource validation failed for ${String(resourceType)}: ${String(error)}`, {
|
|
85
|
-
error,
|
|
86
|
-
resourceType: String(resourceType)
|
|
87
|
-
});
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
const hasAllowedAction = (resourceType, action) => actions[resourceType]?.includes(action) ?? false;
|
|
92
|
-
const evaluatePolicy = (context, resourceType, action, resource) => {
|
|
93
|
-
const policyFn = rules[resourceType]?.[action];
|
|
94
|
-
if (policyFn === void 0) return false;
|
|
95
|
-
try {
|
|
96
|
-
const allowed = policyFn(context, action, resource) === "allow";
|
|
97
|
-
if (allowed) logger?.debug("permission allowed", {
|
|
98
|
-
action,
|
|
99
|
-
resourceType: String(resourceType)
|
|
100
|
-
});
|
|
101
|
-
else logger?.info("permission denied", {
|
|
102
|
-
action,
|
|
103
|
-
resourceType: String(resourceType)
|
|
104
|
-
});
|
|
105
|
-
return allowed;
|
|
106
|
-
} catch (error) {
|
|
107
|
-
logger?.warn(`Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`, {
|
|
108
|
-
action,
|
|
109
|
-
error,
|
|
110
|
-
resourceType: String(resourceType)
|
|
111
|
-
});
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
for (const key of Object.keys(resources)) {
|
|
116
|
-
const schema = resources[key];
|
|
117
|
-
if (schema === void 0) throw new PolicyError(`Missing schema for resource: ${String(key)}`);
|
|
118
|
-
const validator = createStandardValidator(schema);
|
|
119
|
-
validators.set(key, async (input) => await validator(input));
|
|
120
|
-
}
|
|
121
|
-
return { async can(context, permission, resource) {
|
|
122
|
-
const parsedPermission = parsePermission(permission, actions);
|
|
123
|
-
if (parsedPermission === null) return false;
|
|
124
|
-
const { action, resourceType } = parsedPermission;
|
|
125
|
-
if (!hasAllowedAction(resourceType, action)) return false;
|
|
126
|
-
const validatedResource = await getValidatedResource(resourceType, resource);
|
|
127
|
-
if (validatedResource === null) return false;
|
|
128
|
-
return evaluatePolicy(context, resourceType, action, validatedResource);
|
|
129
|
-
} };
|
|
130
|
-
};
|
|
131
|
-
const mergePoliciesWithStrategy = (policies, strategy) => ({ async can(context, permission, resource) {
|
|
132
|
-
if (policies.length === 0) return false;
|
|
133
|
-
const results = (await Promise.allSettled(policies.map(async (policy) => await policy.can(context, permission, resource)))).map((result) => {
|
|
134
|
-
if (result.status === "fulfilled") return result.value;
|
|
135
|
-
return false;
|
|
136
|
-
});
|
|
137
|
-
return strategy === "and" ? results.every(Boolean) : results.some(Boolean);
|
|
138
|
-
} });
|
|
139
|
-
/**
|
|
140
|
-
* Merges multiple policies into one, requiring every policy to allow.
|
|
141
|
-
* If any policy denies, the merged policy denies. Policies are evaluated
|
|
142
|
-
* in parallel; every policy is invoked regardless of outcome.
|
|
143
|
-
*
|
|
144
|
-
* @example
|
|
145
|
-
* ```ts
|
|
146
|
-
* const basePolicy = createPolicy({ ... });
|
|
147
|
-
* const adminPolicy = createPolicy({ ... });
|
|
148
|
-
*
|
|
149
|
-
* const merged = mergePoliciesAnd(basePolicy, adminPolicy);
|
|
150
|
-
* // Both policies must allow for the action to be permitted
|
|
151
|
-
* ```
|
|
152
|
-
*/
|
|
153
|
-
const mergePoliciesAnd = (...policies) => mergePoliciesWithStrategy(policies, "and");
|
|
154
|
-
/**
|
|
155
|
-
* Merges multiple policies into one, requiring at least one policy to allow.
|
|
156
|
-
* If every policy denies, the merged policy denies. Policies are evaluated
|
|
157
|
-
* in parallel; every policy is invoked regardless of outcome.
|
|
158
|
-
*
|
|
159
|
-
* @example
|
|
160
|
-
* ```ts
|
|
161
|
-
* const guestPolicy = createPolicy({ ... });
|
|
162
|
-
* const memberPolicy = createPolicy({ ... });
|
|
163
|
-
*
|
|
164
|
-
* const merged = mergePoliciesOr(guestPolicy, memberPolicy);
|
|
165
|
-
* // If either policy allows, the action is permitted
|
|
166
|
-
* ```
|
|
167
|
-
*/
|
|
168
|
-
const mergePoliciesOr = (...policies) => mergePoliciesWithStrategy(policies, "or");
|
|
169
|
-
//#endregion
|
|
1
|
+
import "./errors.js";
|
|
2
|
+
import { n as mergePoliciesAnd, r as mergePoliciesOr, t as createPolicy } from "./policy-BvQ4lr0v.js";
|
|
170
3
|
export { createPolicy, mergePoliciesAnd, mergePoliciesOr };
|
|
171
|
-
|
|
172
|
-
//# sourceMappingURL=policy.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zap-studio/permit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A type-safe, declarative, tree-shakeable authorization library for TypeScript with Standard Schema support.",
|
|
6
6
|
"keywords": [
|
|
@@ -47,14 +47,19 @@
|
|
|
47
47
|
"@zap-studio/validation": "1.0.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
+
"@opentelemetry/api": "^1.9.0",
|
|
51
|
+
"@opentelemetry/context-async-hooks": "^2.10.0",
|
|
52
|
+
"@opentelemetry/sdk-metrics": "^2.10.0",
|
|
53
|
+
"@opentelemetry/sdk-trace-base": "^2.10.0",
|
|
50
54
|
"tsdown": "^0.22.14",
|
|
51
55
|
"typescript": "^7.0.2",
|
|
52
56
|
"vitest": "^4.1.10",
|
|
53
|
-
"@zap-studio/
|
|
54
|
-
"@zap-studio/
|
|
57
|
+
"@zap-studio/typescript": "0.0.0",
|
|
58
|
+
"@zap-studio/logger": "2.0.0"
|
|
55
59
|
},
|
|
56
60
|
"peerDependencies": {
|
|
57
|
-
"@
|
|
61
|
+
"@opentelemetry/api": "^1.9.0",
|
|
62
|
+
"@zap-studio/logger": "2.0.0"
|
|
58
63
|
},
|
|
59
64
|
"peerDependenciesMeta": {
|
|
60
65
|
"@zap-studio/logger": {
|
package/dist/policy.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"policy.js","names":[],"sources":["../src/policy.ts"],"sourcesContent":["/**\n * Policy creation and composition: `createPolicy`, `mergePoliciesAnd`, and\n * `mergePoliciesOr`.\n *\n * @module @zap-studio/permit/policy\n */\n\nimport type { StandardSchemaV1 } from \"@zap-studio/validation\";\nimport { createStandardValidator } from \"@zap-studio/validation\";\n\nimport { PolicyError } from \"./errors.js\";\nimport type {\n Actions,\n Context,\n InferAction,\n InferResource,\n PermitConfig,\n Policy,\n Resources,\n} from \"./types.js\";\n\n/**\n * Splits a typed `resource:action` permission string into its parts.\n * Returns `null` when the string is malformed (missing/empty part or extra\n * segments) or `resourceType` is not one of `actions`' keys.\n */\nconst parsePermission = <\n TResources extends Resources,\n TActions extends Actions<TResources>,\n K extends keyof TResources & keyof TActions,\n>(\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n actions: TActions\n): { action: InferAction<TResources, TActions, K>; resourceType: K } | null => {\n const isValidResourceKey = (value: string): value is K & string =>\n Object.keys(actions).includes(value);\n\n const [resourceTypeValue, actionValue, ...rest] = permission.split(\":\");\n if (\n resourceTypeValue === undefined ||\n resourceTypeValue.length === 0 ||\n actionValue === undefined ||\n actionValue.length === 0 ||\n rest.length > 0 ||\n !isValidResourceKey(resourceTypeValue)\n ) {\n return null;\n }\n\n return {\n action: actionValue,\n resourceType: resourceTypeValue,\n };\n};\n\n/**\n * Creates a type-safe policy from resource schemas, actions, and rules.\n *\n * @example\n * ```ts\n * import { z } from \"zod\";\n * import { createPolicy, allow, deny, when } from \"@zap-studio/permit\";\n * import type { Resources, Actions } from \"@zap-studio/permit/types\";\n *\n * // Define resource schemas\n * const resources = {\n * post: z.object({\n * id: z.string(),\n * authorId: z.string(),\n * visibility: z.enum([\"public\", \"private\"]),\n * }),\n * comment: z.object({\n * id: z.string(),\n * postId: z.string(),\n * authorId: z.string(),\n * }),\n * } satisfies Resources;\n *\n * // Define actions per resource\n * const actions = {\n * post: [\"read\", \"write\", \"delete\"],\n * comment: [\"read\", \"write\"],\n * } as const satisfies Actions<typeof resources>;\n *\n * // Define context type\n * type AppContext = { user: { id: string; role: string } };\n *\n * // Create the policy\n * const policy = createPolicy<AppContext>({\n * resources,\n * actions,\n * rules: {\n * post: {\n * read: when((ctx, action, resource) => resource.visibility === \"public\"),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * delete: deny(),\n * },\n * comment: {\n * read: allow(),\n * write: when((ctx, action, resource) => ctx.user.id === resource.authorId),\n * },\n * },\n * });\n *\n * // Check permissions\n * const post = { id: \"1\", authorId: \"user-1\", visibility: \"public\" as const };\n * await policy.can(ctx, \"post:read\", post); // true\n * await policy.can(ctx, \"post:write\", post); // depends on ctx.user.id\n * ```\n */\nexport const createPolicy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n config: PermitConfig<TContext, TResources, TActions>\n): Policy<TContext, TResources, TActions> => {\n const { rules, resources, actions, logger } = config;\n const validators = new Map<\n keyof TResources,\n (input: unknown) => Promise<StandardSchemaV1.Result<unknown>>\n >();\n\n const getValidatedResource = async <K extends keyof TResources>(\n resourceType: K,\n resource: InferResource<TResources, K>\n ): Promise<InferResource<TResources, K> | null> => {\n const validator = validators.get(resourceType);\n if (validator === undefined) {\n return null;\n }\n try {\n const result = await validator(resource);\n if (result.issues) {\n return null;\n }\n return result.value;\n } catch (error) {\n logger?.warn(\n `Resource validation failed for ${String(resourceType)}: ${String(error)}`,\n { error, resourceType: String(resourceType) }\n );\n return null;\n }\n };\n\n const hasAllowedAction = <K extends keyof TResources & keyof TActions>(\n resourceType: K,\n action: InferAction<TResources, TActions, K>\n ): boolean => actions[resourceType]?.includes(action) ?? false;\n\n const evaluatePolicy = <K extends keyof TResources & keyof TActions>(\n context: TContext,\n resourceType: K,\n action: InferAction<TResources, TActions, K>,\n resource: InferResource<TResources, K>\n ): boolean => {\n const policyFn = rules[resourceType]?.[action];\n if (policyFn === undefined) {\n return false;\n }\n\n try {\n const allowed = policyFn(context, action, resource) === \"allow\";\n\n if (allowed) {\n logger?.debug(\"permission allowed\", {\n action,\n resourceType: String(resourceType),\n });\n } else {\n logger?.info(\"permission denied\", {\n action,\n resourceType: String(resourceType),\n });\n }\n\n return allowed;\n } catch (error) {\n logger?.warn(\n `Policy evaluation error for ${String(resourceType)}.${action}: ${String(error)}`,\n {\n action,\n error,\n resourceType: String(resourceType),\n }\n );\n return false;\n }\n };\n\n for (const key of Object.keys(resources) as (keyof TResources)[]) {\n const schema = resources[key];\n if (schema === undefined) {\n throw new PolicyError(`Missing schema for resource: ${String(key)}`);\n }\n const validator = createStandardValidator(schema);\n validators.set(key, async (input: unknown) => await validator(input));\n }\n\n return {\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>\n ): Promise<boolean> {\n const parsedPermission = parsePermission<TResources, TActions, K>(\n permission,\n actions\n );\n if (parsedPermission === null) {\n return false;\n }\n\n const { action, resourceType } = parsedPermission;\n if (!hasAllowedAction(resourceType, action)) {\n return false;\n }\n\n const validatedResource = await getValidatedResource(\n resourceType,\n resource\n );\n if (validatedResource === null) {\n return false;\n }\n\n return evaluatePolicy(context, resourceType, action, validatedResource);\n },\n };\n};\n\nconst mergePoliciesWithStrategy = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n policies: Policy<TContext, TResources, TActions>[],\n strategy: \"and\" | \"or\"\n): Policy<TContext, TResources, TActions> => ({\n async can<K extends keyof TResources & keyof TActions>(\n context: TContext,\n permission: `${K & string}:${InferAction<TResources, TActions, K> & string}`,\n resource: InferResource<TResources, K>\n ): Promise<boolean> {\n if (policies.length === 0) {\n return false;\n }\n\n const settled = await Promise.allSettled(\n policies.map(\n async (policy) => await policy.can(context, permission, resource)\n )\n );\n\n const results = settled.map((result) => {\n if (result.status === \"fulfilled\") {\n return result.value;\n }\n return false;\n });\n\n return strategy === \"and\" ? results.every(Boolean) : results.some(Boolean);\n },\n});\n\n/**\n * Merges multiple policies into one, requiring every policy to allow.\n * If any policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const basePolicy = createPolicy({ ... });\n * const adminPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesAnd(basePolicy, adminPolicy);\n * // Both policies must allow for the action to be permitted\n * ```\n */\nexport const mergePoliciesAnd = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> =>\n mergePoliciesWithStrategy(policies, \"and\");\n\n/**\n * Merges multiple policies into one, requiring at least one policy to allow.\n * If every policy denies, the merged policy denies. Policies are evaluated\n * in parallel; every policy is invoked regardless of outcome.\n *\n * @example\n * ```ts\n * const guestPolicy = createPolicy({ ... });\n * const memberPolicy = createPolicy({ ... });\n *\n * const merged = mergePoliciesOr(guestPolicy, memberPolicy);\n * // If either policy allows, the action is permitted\n * ```\n */\nexport const mergePoliciesOr = <\n TContext extends Context,\n TResources extends Resources = Resources,\n TActions extends Actions<TResources> = Actions<TResources>,\n>(\n ...policies: Policy<TContext, TResources, TActions>[]\n): Policy<TContext, TResources, TActions> =>\n mergePoliciesWithStrategy(policies, \"or\");\n"],"mappings":"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"}
|