Skip to content

Commit 902cb67

Browse files
authored
Merge pull request #2 from vegtelenseg/1-consider-dropping-asyncconditions-config-field
feat: add runtime detection for async conditions, deprecate asyncConditions flag
2 parents 349c1e1 + 37ff061 commit 902cb67

5 files changed

Lines changed: 111 additions & 9 deletions

File tree

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,11 @@ Conditions receive the full `EvaluationContext` — subject, action, resource, r
325325

326326
### Async Conditions
327327

328-
For conditions that need database lookups or API calls:
328+
For conditions that need database lookups or API calls, use async functions and the `*Async` evaluation methods:
329329

330330
```typescript
331331
const engine = new AccessEngine<MySchema>({
332332
schema: {} as MySchema,
333-
asyncConditions: true,
334333
});
335334

336335
engine.addRule(
@@ -348,7 +347,7 @@ engine.addRule(
348347
const decision = await engine.evaluateAsync(user, "report:export", "report");
349348
```
350349

351-
When `asyncConditions` is enabled, use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()` instead of their synchronous counterparts.
350+
Use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()` when you have async conditions. If you accidentally call the synchronous `evaluate()` or `explain()` with async conditions, the engine throws a clear error guiding you to the async API.
352351

353352
### Wildcard Action Patterns
354353

@@ -986,7 +985,7 @@ See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
986985
| `defaultEffect` | `"deny"` (default) or `"allow"` |
987986
| `onDecision` | Listener called on every evaluation |
988987
| `onConditionError` | Called when a condition throws (fail-closed) |
989-
| `asyncConditions` | Enable async condition support |
988+
| `asyncConditions` | *(deprecated)* When true, sync methods throw immediately. Will be removed in v2. Async conditions are now detected automatically. |
990989
| `strictTenancy` | Throw if tenantId is omitted for tenant-scoped subjects |
991990
| `roleHierarchy` | A `RoleHierarchy` instance |
992991
| `cacheSize` | LRU cache capacity (0 = disabled) |

src/engine.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,39 @@ describe("Async conditions", () => {
245245
"evaluateAsync",
246246
);
247247
});
248+
249+
it("evaluateAsync works without asyncConditions flag", async () => {
250+
const engine = new AccessEngine<TestSchema>({ schema });
251+
engine.addRule(
252+
allow<TestSchema>()
253+
.id("async-no-flag")
254+
.roles("member")
255+
.actions("report:export")
256+
.on("report")
257+
.when(async (ctx) => ctx.subject.attributes?.["canExport"] === true)
258+
.build(),
259+
);
260+
const user = makeUser("u9", [{ role: "member" }], { canExport: true });
261+
const decision = await engine.evaluateAsync(user, "report:export", "report");
262+
expect(decision.allowed).toBe(true);
263+
});
264+
265+
it("throws clear error when evaluate() hits async condition without flag", () => {
266+
const engine = new AccessEngine<TestSchema>({ schema });
267+
engine.addRule(
268+
allow<TestSchema>()
269+
.id("async-condition")
270+
.roles("member")
271+
.actions("report:export")
272+
.on("report")
273+
.when(async () => true)
274+
.build(),
275+
);
276+
const user = makeUser("u10", [{ role: "member" }]);
277+
expect(() => engine.evaluate(user, "report:export", "report")).toThrow(
278+
"Async condition encountered. Use evaluateAsync() instead.",
279+
);
280+
});
248281
});
249282

250283
// ---------------------------------------------------------------------------

src/engine.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ function escapeRegexMeta(s: string): string {
3232
return s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
3333
}
3434

35+
function isThenable(value: unknown): value is PromiseLike<unknown> {
36+
return (
37+
value != null &&
38+
typeof value === "object" &&
39+
typeof (value as PromiseLike<unknown>).then === "function"
40+
);
41+
}
42+
43+
const ASYNC_CONDITION_EVALUATE_MSG =
44+
"Async condition encountered. Use evaluateAsync() instead.";
45+
const ASYNC_CONDITION_EXPLAIN_MSG =
46+
"Async condition encountered. Use explainAsync() instead.";
47+
3548
function compileActionPatterns(actions: string[] | "*"): RegExp[] | null {
3649
if (actions === "*") return null;
3750
const patterns: RegExp[] = [];
@@ -362,13 +375,22 @@ export class AccessEngine<S extends SchemaDefinition> {
362375
for (let i = 0; i < rule.conditions.length; i++) {
363376
try {
364377
const result = rule.conditions[i]!(ctx);
378+
if (isThenable(result)) {
379+
throw new Error(ASYNC_CONDITION_EXPLAIN_MSG);
380+
}
365381
if (result !== true) {
366382
conditionResults.push({ index: i, passed: false });
367383
allConditionsPassed = false;
368384
} else {
369385
conditionResults.push({ index: i, passed: true });
370386
}
371387
} catch (err) {
388+
if (
389+
err instanceof Error &&
390+
err.message === ASYNC_CONDITION_EXPLAIN_MSG
391+
) {
392+
throw err;
393+
}
372394
conditionResults.push({
373395
index: i,
374396
passed: false,
@@ -552,8 +574,18 @@ export class AccessEngine<S extends SchemaDefinition> {
552574
if (!rule.conditions) return true;
553575
for (let i = 0; i < rule.conditions.length; i++) {
554576
try {
555-
if (rule.conditions[i]!(ctx) !== true) return false;
577+
const result = rule.conditions[i]!(ctx);
578+
if (isThenable(result)) {
579+
throw new Error(ASYNC_CONDITION_EVALUATE_MSG);
580+
}
581+
if (result !== true) return false;
556582
} catch (err) {
583+
if (
584+
err instanceof Error &&
585+
err.message === ASYNC_CONDITION_EVALUATE_MSG
586+
) {
587+
throw err;
588+
}
557589
this.emitConditionError(rule.id, i, err);
558590
return false;
559591
}

src/security-and-dx.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,27 @@ describe("DX: engine.explain()", () => {
450450
const user = makeUser("u1", [{ role: "admin" }]);
451451
expect(() => engine.explain(user, "invoice:read", "invoice")).toThrow("explainAsync");
452452
});
453+
454+
it("throws clear error when explain() hits async condition without flag", () => {
455+
const engine = new AccessEngine<TestSchema>({ schema });
456+
engine.addRule(
457+
allow<TestSchema>()
458+
.id("async-explain")
459+
.roles("member")
460+
.actions("report:export")
461+
.on("report")
462+
.when(async () => true)
463+
.build(),
464+
);
465+
const user = makeUser("u1", [{ role: "member" }]);
466+
expect(() => engine.explain(user, "report:export", "report")).toThrow(
467+
"Async condition encountered. Use explainAsync() instead.",
468+
);
469+
});
453470
});
454471

455472
describe("DX: explainAsync()", () => {
456-
it("works with async conditions", async () => {
473+
it("works with async conditions (with asyncConditions flag)", async () => {
457474
const engine = new AccessEngine<TestSchema>({ schema, asyncConditions: true });
458475
engine.addRule(
459476
allow<TestSchema>()
@@ -471,6 +488,22 @@ describe("DX: explainAsync()", () => {
471488
expect(result.allowed).toBe(true);
472489
expect(result.evaluatedRules[0]!.conditionResults[0]!.passed).toBe(true);
473490
});
491+
492+
it("works with async conditions without asyncConditions flag", async () => {
493+
const engine = new AccessEngine<TestSchema>({ schema });
494+
engine.addRule(
495+
allow<TestSchema>()
496+
.id("async-no-flag")
497+
.roles("member")
498+
.actions("report:export")
499+
.on("report")
500+
.when(async (ctx) => ctx.subject.attributes?.["canExport"] === true)
501+
.build(),
502+
);
503+
const user = makeUser("u1", [{ role: "member" }], { canExport: true });
504+
const result = await engine.explainAsync(user, "report:export", "report");
505+
expect(result.allowed).toBe(true);
506+
});
474507
});
475508

476509
describe("DX: toAuditEntry()", () => {

src/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,14 @@ export interface EngineOptions<S extends SchemaDefinition> {
206206
onDecision?: DecisionListener<S>;
207207
onConditionError?: ConditionErrorHandler;
208208
/**
209-
* When true, async conditions are awaited.
210-
* When false (default), only synchronous conditions are supported
211-
* and evaluate is guaranteed synchronous.
209+
* When true, sync methods (evaluate, explain, permitted) throw immediately
210+
* to force use of evaluateAsync, explainAsync, permittedAsync.
211+
* When false (default), async conditions are detected at runtime and throw
212+
* with a clear error pointing to the async API.
213+
*
214+
* @deprecated This option is deprecated and will be removed in v2. Async
215+
* conditions are now detected automatically. Use evaluateAsync(),
216+
* explainAsync(), or permittedAsync() when you have async conditions.
212217
*/
213218
asyncConditions?: boolean;
214219
/**

0 commit comments

Comments
 (0)