Initial Commit
This commit is contained in:
146
packages/identity/auth/permissions/tests/integration.test.ts
Normal file
146
packages/identity/auth/permissions/tests/integration.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryAdapter } from "../src/adapters/memory/memory.js";
|
||||
import { PermissionCache } from "../src/cache/permission-cache/permission-cache.js";
|
||||
import { type Ability, buildAbility } from "../src/core/ability/ability.js";
|
||||
import { PermissionManager } from "../src/core/permission-manager/permission-manager.js";
|
||||
import { PolicyEngine } from "../src/core/policy-engine/policy-engine.js";
|
||||
import { RoleManager } from "../src/core/role-manager/role-manager.js";
|
||||
import { CircularInheritanceError } from "../src/errors/permission-error.js";
|
||||
import {
|
||||
createAdminRole,
|
||||
createViewerRole,
|
||||
} from "../src/presets/role-presets/role-presets.js";
|
||||
import type { Permission } from "../src/types/permissions.types.js";
|
||||
|
||||
function makePerm(id: string, resource = "posts", action = "read"): Permission {
|
||||
return { id, resource, action };
|
||||
}
|
||||
|
||||
describe("Full permissions flow", () => {
|
||||
it("role inheritance transitivity", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
const roleManager = new RoleManager(adapter);
|
||||
const _manager = new PermissionManager(adapter);
|
||||
|
||||
const cPerm = makePerm("p-c", "comments", "read");
|
||||
const bPerm = makePerm("p-b", "posts", "write");
|
||||
const aPerm = makePerm("p-a", "posts", "read");
|
||||
|
||||
await adapter.createRole({ id: "c", name: "C", permissions: [cPerm] });
|
||||
await adapter.createRole({
|
||||
id: "b",
|
||||
name: "B",
|
||||
permissions: [bPerm],
|
||||
inherits: ["c"],
|
||||
});
|
||||
await adapter.createRole({
|
||||
id: "a",
|
||||
name: "A",
|
||||
permissions: [aPerm],
|
||||
inherits: ["b"],
|
||||
});
|
||||
|
||||
const perms = await roleManager.getEffectivePermissions("a");
|
||||
const permIds = perms.map((p) => p.id);
|
||||
expect(permIds).toContain("p-a");
|
||||
expect(permIds).toContain("p-b");
|
||||
expect(permIds).toContain("p-c");
|
||||
});
|
||||
|
||||
it("policy engine with cache", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
await adapter.createRole({
|
||||
id: "r1",
|
||||
name: "R",
|
||||
permissions: [makePerm("p1")],
|
||||
});
|
||||
await adapter.assignRole({
|
||||
id: "ur1",
|
||||
userId: "u1",
|
||||
roleId: "r1",
|
||||
assignedAt: new Date(),
|
||||
});
|
||||
|
||||
const manager = new PermissionManager(adapter);
|
||||
const cache = new PermissionCache({ ttl: 60_000 });
|
||||
const engine = new PolicyEngine(manager, { cache });
|
||||
|
||||
const r1 = await engine.evaluate("u1", "posts", "read");
|
||||
const r2 = await engine.evaluate("u1", "posts", "read");
|
||||
expect(r1.allowed).toBe(true);
|
||||
expect(r2.matchedRule).toBe("cached-allow");
|
||||
|
||||
// Invalidate cache and re-check
|
||||
cache.invalidate("u1");
|
||||
const r3 = await engine.evaluate("u1", "posts", "read");
|
||||
expect(r3.allowed).toBe(true);
|
||||
expect(r3.matchedRule).toBe("posts:read");
|
||||
});
|
||||
|
||||
it("circular inheritance detection", { timeout: 5000 }, async () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
const roleManager = new RoleManager(adapter);
|
||||
|
||||
await adapter.createRole({
|
||||
id: "x",
|
||||
name: "X",
|
||||
permissions: [],
|
||||
inherits: ["y"],
|
||||
});
|
||||
await adapter.createRole({
|
||||
id: "y",
|
||||
name: "Y",
|
||||
permissions: [],
|
||||
inherits: ["x"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
roleManager.getRoleInheritanceChain("x")
|
||||
).rejects.toBeInstanceOf(CircularInheritanceError);
|
||||
});
|
||||
|
||||
it("preset roles work with PermissionManager", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new MemoryAdapter();
|
||||
await adapter.createRole(createAdminRole());
|
||||
await adapter.assignRole({
|
||||
id: "ur-admin",
|
||||
userId: "admin-user",
|
||||
roleId: "admin",
|
||||
assignedAt: new Date(),
|
||||
});
|
||||
|
||||
await adapter.createRole(createViewerRole());
|
||||
await adapter.assignRole({
|
||||
id: "ur-viewer",
|
||||
userId: "viewer-user",
|
||||
roleId: "viewer",
|
||||
assignedAt: new Date(),
|
||||
});
|
||||
|
||||
const manager = new PermissionManager(adapter);
|
||||
expect(await manager.hasPermission("admin-user", "any", "any")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
await manager.hasPermission("viewer-user", "posts", "read")
|
||||
).toBe(true);
|
||||
expect(
|
||||
await manager.hasPermission("viewer-user", "posts", "delete")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ability system end-to-end", () => {
|
||||
it("can/cannot consistency", { timeout: 5000 }, () => {
|
||||
const ability: Ability = buildAbility((b) => {
|
||||
b.can("read", "posts");
|
||||
b.cannot("delete", "posts");
|
||||
});
|
||||
expect(ability.can("read", "posts")).toBe(true);
|
||||
expect(ability.cannot("read", "posts")).toBe(false);
|
||||
expect(ability.can("delete", "posts")).toBe(false);
|
||||
expect(ability.cannot("delete", "posts")).toBe(true);
|
||||
});
|
||||
});
|
||||
109
packages/identity/auth/permissions/tests/property.test.ts
Normal file
109
packages/identity/auth/permissions/tests/property.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import { PermissionCache } from "../src/cache/permission-cache/permission-cache.js";
|
||||
import { compareValues } from "../src/conditions/condition-matcher/condition-matcher.js";
|
||||
import { buildAbility } from "../src/core/ability/ability.js";
|
||||
import type { ConditionOperator } from "../src/types/permissions.types.js";
|
||||
|
||||
describe("Property: Ability can/cannot consistency", () => {
|
||||
it("cannot is always negation of can for any action/resource", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 16 }),
|
||||
fc.string({ minLength: 1, maxLength: 16 }),
|
||||
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
|
||||
minLength: 0,
|
||||
maxLength: 5,
|
||||
}),
|
||||
(action, resource, grantedActions) => {
|
||||
const ability = buildAbility((b) => {
|
||||
for (const a of grantedActions) {
|
||||
b.can(a, resource);
|
||||
}
|
||||
});
|
||||
const canResult = ability.can(action, resource);
|
||||
const cannotResult = ability.cannot(action, resource);
|
||||
return canResult !== cannotResult;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: Cache invalidation completeness", () => {
|
||||
it("after invalidate(userId), no stale entry for that user", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1, maxLength: 16 }),
|
||||
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
|
||||
minLength: 1,
|
||||
maxLength: 10,
|
||||
}),
|
||||
(userId, resources) => {
|
||||
const cache = new PermissionCache({ ttl: 99999 });
|
||||
for (const r of resources) {
|
||||
cache.set(`${userId}:${r}:read`, true);
|
||||
}
|
||||
cache.invalidate(userId);
|
||||
return resources.every(
|
||||
(r) => cache.get(`${userId}:${r}:read`) === undefined
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: compareValues determinism", () => {
|
||||
it("same inputs always produce same result", { timeout: 5000 }, () => {
|
||||
const ops: ConditionOperator[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"contains",
|
||||
"startsWith",
|
||||
];
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 0, maxLength: 32 }),
|
||||
fc.string({ minLength: 0, maxLength: 32 }),
|
||||
fc.constantFrom(...ops),
|
||||
(a, b, op) => {
|
||||
const r1 = compareValues(a, b, op);
|
||||
const r2 = compareValues(a, b, op);
|
||||
return r1 === r2;
|
||||
}
|
||||
),
|
||||
{ numRuns: 300 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: Role inheritance transitivity via Ability", () => {
|
||||
it("abilities compose additively across rule sets", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.string({ minLength: 1, maxLength: 8 }), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
fc.string({ minLength: 1, maxLength: 8 }),
|
||||
(actions, resource) => {
|
||||
const ability = buildAbility((b) => {
|
||||
for (const a of actions) b.can(a, resource);
|
||||
});
|
||||
// All granted actions must return can=true
|
||||
return actions.every((a) => ability.can(a, resource));
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
Reference in New Issue
Block a user