Heimdall/tests/capability-rules.test.ts
Meta Vens 1d221ab7b6 Make app capability rules data instead of code
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>
2026-09-06 23:58:58 +02:00

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.");
});
});