Every app profile carried an evaluateSharedCapabilities function beside a sharedRule string describing what that function did. Two representations of one rule, free to drift, and the reason a profile could never arrive at runtime: you cannot accept a function from a stranger and run it inside the auth authority. The rules were never more than a disjunction. Every sharedRule across all four built-in profiles is "a || b || c" over a closed vocabulary, and one of them is a bare reference to another capability. So the executable form is a list, not an expression language, and anyOf is now the only representation. The readable form is derived from it rather than stored beside it. Evaluation runs to a fixed point because capabilities may reference each other: repixelizer's queue_submit derives from app_access, and a single pass grants it only when the rules happen to be declared in dependency order. It terminates because the granted set only grows and is bounded by the rule count. validateCapabilityRules is the gate that makes runtime registration acceptable. A term must name a fact Heimdall itself produces or another capability in the same profile. A registered app selects from what Heimdall has already decided is true about an account; it cannot invent a fact to grant itself something, and it cannot reach into another app's capabilities. An unknown term is refused rather than treated as false, because a rule that silently never fires becomes a support ticket instead of an error. The four built-in profiles are now seed data rather than special cases: a registered app produces the same shape and runs through the same evaluator. Behaviour is unchanged. 74 tests pass, 18 of them new, covering derived capabilities in both declaration orders, hybrid capabilities never being granted from shared rules, and every rejection path in the validator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
163 lines
6 KiB
TypeScript
163 lines
6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
describeCapabilityRule,
|
|
evaluateCapabilityRules,
|
|
isFactTerm,
|
|
validateCapabilityRules,
|
|
type CapabilityDefinition,
|
|
} from "../src/capability-rules.js";
|
|
import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js";
|
|
|
|
const shared = (key: string, anyOf: string[]): CapabilityDefinition => ({
|
|
key,
|
|
mode: "shared",
|
|
summary: key,
|
|
anyOf,
|
|
});
|
|
|
|
const hybrid = (key: string): CapabilityDefinition => ({
|
|
key,
|
|
mode: "hybrid",
|
|
summary: key,
|
|
localRequirement: "Host app resolves this.",
|
|
});
|
|
|
|
describe("evaluateCapabilityRules", () => {
|
|
it("grants a capability when any one term holds", () => {
|
|
const rules = [shared("app_access", [entitlementFacts.appAccess, grantFacts.globalMember])];
|
|
|
|
expect(evaluateCapabilityRules(rules, new Set([grantFacts.globalMember]))).toEqual(["app_access"]);
|
|
expect(evaluateCapabilityRules(rules, new Set([entitlementFacts.appAccess]))).toEqual(["app_access"]);
|
|
});
|
|
|
|
it("grants nothing when no term holds", () => {
|
|
const rules = [shared("app_access", [entitlementFacts.appAccess])];
|
|
expect(evaluateCapabilityRules(rules, new Set([identityFacts.authenticated]))).toEqual([]);
|
|
});
|
|
|
|
it("resolves a capability that depends on another capability", () => {
|
|
// queue_submit derives from app_access in the real repixelizer profile, so
|
|
// one pass is not enough.
|
|
const rules = [
|
|
shared("app_access", [grantFacts.globalMember]),
|
|
shared("queue_submit", ["app_access"]),
|
|
];
|
|
|
|
expect(evaluateCapabilityRules(rules, new Set([grantFacts.globalMember]))).toEqual([
|
|
"app_access",
|
|
"queue_submit",
|
|
]);
|
|
});
|
|
|
|
it("resolves a dependency declared before the capability it depends on", () => {
|
|
const rules = [
|
|
shared("queue_submit", ["app_access"]),
|
|
shared("app_access", [grantFacts.globalMember]),
|
|
];
|
|
|
|
expect(evaluateCapabilityRules(rules, new Set([grantFacts.globalMember]))).toEqual([
|
|
"queue_submit",
|
|
"app_access",
|
|
]);
|
|
});
|
|
|
|
it("does not grant a derived capability when its dependency fails", () => {
|
|
const rules = [
|
|
shared("app_access", [grantFacts.globalMember]),
|
|
shared("queue_submit", ["app_access"]),
|
|
];
|
|
|
|
expect(evaluateCapabilityRules(rules, new Set([identityFacts.authenticated]))).toEqual([]);
|
|
});
|
|
|
|
it("never grants a hybrid capability, which the host app owns", () => {
|
|
const rules = [shared("app_access", [grantFacts.globalMember]), hybrid("job_read_own")];
|
|
|
|
expect(evaluateCapabilityRules(rules, new Set([grantFacts.globalMember]))).toEqual(["app_access"]);
|
|
});
|
|
|
|
it("returns capabilities in declaration order so a profile reads as it evaluates", () => {
|
|
const rules = [
|
|
shared("alpha", [grantFacts.operator]),
|
|
shared("beta", [grantFacts.operator]),
|
|
shared("gamma", [grantFacts.operator]),
|
|
];
|
|
|
|
expect(evaluateCapabilityRules(rules, new Set([grantFacts.operator]))).toEqual(["alpha", "beta", "gamma"]);
|
|
});
|
|
});
|
|
|
|
describe("validateCapabilityRules", () => {
|
|
it("accepts facts from Heimdall's own vocabulary", () => {
|
|
const rules = [
|
|
shared("app_access", [entitlementFacts.appAccess, grantFacts.globalMember, identityFacts.authenticated]),
|
|
];
|
|
expect(validateCapabilityRules(rules)).toEqual([]);
|
|
});
|
|
|
|
it("accepts a reference to another capability in the same profile", () => {
|
|
const rules = [shared("app_access", [grantFacts.globalMember]), shared("queue_submit", ["app_access"])];
|
|
expect(validateCapabilityRules(rules)).toEqual([]);
|
|
});
|
|
|
|
it("refuses a term that is neither a fact nor a capability here", () => {
|
|
// The registration gate: an app selects from what Heimdall vouches for and
|
|
// cannot invent a fact to grant itself something.
|
|
const rules = [shared("app_access", ["invented.fact"])];
|
|
const problems = validateCapabilityRules(rules);
|
|
|
|
expect(problems).toHaveLength(1);
|
|
expect(problems[0]!.term).toBe("invented.fact");
|
|
expect(problems[0]!.reason).toMatch(/Unknown term/);
|
|
});
|
|
|
|
it("refuses another app's capability key", () => {
|
|
const rules = [shared("member_access", ["someone_elses_capability"])];
|
|
expect(validateCapabilityRules(rules)[0]!.reason).toMatch(/Unknown term/);
|
|
});
|
|
|
|
it("refuses a self-referential capability", () => {
|
|
const rules = [shared("app_access", ["app_access"])];
|
|
expect(validateCapabilityRules(rules)[0]!.reason).toMatch(/cannot depend on itself/);
|
|
});
|
|
|
|
it("refuses a shared capability with no terms, which could never be granted", () => {
|
|
const rules: CapabilityDefinition[] = [{ key: "app_access", mode: "shared", summary: "x" }];
|
|
expect(validateCapabilityRules(rules)[0]!.reason).toMatch(/at least one term/);
|
|
});
|
|
|
|
it("refuses shared rules on a hybrid capability", () => {
|
|
const rules: CapabilityDefinition[] = [
|
|
{ key: "job_read_own", mode: "hybrid", summary: "x", anyOf: [grantFacts.operator] },
|
|
];
|
|
expect(validateCapabilityRules(rules)[0]!.reason).toMatch(/resolved by the host app/i);
|
|
});
|
|
|
|
it("reports every problem rather than stopping at the first", () => {
|
|
const rules = [shared("a", ["nope.one"]), shared("b", ["nope.two"])];
|
|
expect(validateCapabilityRules(rules)).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("isFactTerm", () => {
|
|
it("recognises Heimdall's namespaces and nothing else", () => {
|
|
expect(isFactTerm("identity.authenticated")).toBe(true);
|
|
expect(isFactTerm("entitlement.app_access")).toBe(true);
|
|
expect(isFactTerm("grant.operator")).toBe(true);
|
|
expect(isFactTerm("app_access")).toBe(false);
|
|
expect(isFactTerm("custom.thing")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("describeCapabilityRule", () => {
|
|
it("derives the readable form rather than storing a second copy", () => {
|
|
expect(describeCapabilityRule(shared("app_access", ["entitlement.app_access", "grant.global_member"]))).toBe(
|
|
"entitlement.app_access || grant.global_member",
|
|
);
|
|
});
|
|
|
|
it("describes a hybrid capability by its local requirement", () => {
|
|
expect(describeCapabilityRule(hybrid("job_read_own"))).toBe("Host app resolves this.");
|
|
});
|
|
});
|