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>
This commit is contained in:
parent
5443ae7daf
commit
1d221ab7b6
4 changed files with 341 additions and 70 deletions
|
|
@ -1,15 +1,12 @@
|
|||
import {
|
||||
evaluateCapabilityRules,
|
||||
type CapabilityDefinition,
|
||||
type CapabilityMode,
|
||||
} from "./capability-rules.js";
|
||||
import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js";
|
||||
import { entitlementFacts, grantFacts, identityFacts } from "./facts.js";
|
||||
|
||||
export type CapabilityMode = "shared" | "hybrid";
|
||||
|
||||
export interface CapabilityDefinition {
|
||||
key: string;
|
||||
mode: CapabilityMode;
|
||||
summary: string;
|
||||
sharedRule?: string;
|
||||
localRequirement?: string;
|
||||
}
|
||||
export type { CapabilityDefinition, CapabilityMode };
|
||||
|
||||
export interface ClaimEvaluationContext {
|
||||
accountId: string;
|
||||
|
|
@ -17,6 +14,15 @@ export interface ClaimEvaluationContext {
|
|||
identities: LinkedIdentityInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An app's auth profile. Entirely data.
|
||||
*
|
||||
* It used to carry an evaluateSharedCapabilities function beside a sharedRule
|
||||
* string that described what the function did — two representations of one rule,
|
||||
* free to drift, and the reason a profile could not arrive at runtime. Every
|
||||
* rule was a disjunction over a closed vocabulary, so anyOf now carries it and
|
||||
* evaluation is a free function over the data.
|
||||
*/
|
||||
export interface AppProfile {
|
||||
slug: AppSlug;
|
||||
displayName: string;
|
||||
|
|
@ -25,12 +31,14 @@ export interface AppProfile {
|
|||
entitlementSources: Provider[];
|
||||
managedConnectionProviders: Provider[];
|
||||
capabilities: CapabilityDefinition[];
|
||||
evaluateSharedCapabilities(context: ClaimEvaluationContext): string[];
|
||||
}
|
||||
|
||||
function hasAnyFact(facts: Set<string>, values: readonly string[]): boolean {
|
||||
return values.some((value) => facts.has(value));
|
||||
}
|
||||
/** The membership signal shared by every app that gates on GameCult membership. */
|
||||
const gameCultMembership = [
|
||||
entitlementFacts.appAccess,
|
||||
grantFacts.globalMember,
|
||||
grantFacts.appAccess,
|
||||
];
|
||||
|
||||
const repixelizerProfile: AppProfile = {
|
||||
slug: "repixelizer",
|
||||
|
|
@ -44,13 +52,13 @@ const repixelizerProfile: AppProfile = {
|
|||
key: "app_access",
|
||||
mode: "shared",
|
||||
summary: "May load the protected hosted GUI.",
|
||||
sharedRule: "entitlement.app_access || grant.global_member || grant.app_access",
|
||||
anyOf: gameCultMembership,
|
||||
},
|
||||
{
|
||||
key: "queue_submit",
|
||||
mode: "shared",
|
||||
summary: "May create a repixelizer job.",
|
||||
sharedRule: "app_access",
|
||||
anyOf: ["app_access"],
|
||||
},
|
||||
{
|
||||
key: "job_read_own",
|
||||
|
|
@ -68,27 +76,9 @@ const repixelizerProfile: AppProfile = {
|
|||
key: "admin_access",
|
||||
mode: "shared",
|
||||
summary: "May inspect grant/admin surfaces.",
|
||||
sharedRule: "grant.operator || grant.admin_access",
|
||||
anyOf: [grantFacts.operator, grantFacts.adminAccess],
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
const capabilities: string[] = [];
|
||||
const appAccess = hasAnyFact(context.facts, [
|
||||
entitlementFacts.appAccess,
|
||||
grantFacts.globalMember,
|
||||
grantFacts.appAccess,
|
||||
]);
|
||||
|
||||
if (appAccess) {
|
||||
capabilities.push("app_access", "queue_submit");
|
||||
}
|
||||
|
||||
if (hasAnyFact(context.facts, [grantFacts.operator, grantFacts.adminAccess])) {
|
||||
capabilities.push("admin_access");
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
},
|
||||
};
|
||||
|
||||
const streampixelsProfile: AppProfile = {
|
||||
|
|
@ -103,7 +93,7 @@ const streampixelsProfile: AppProfile = {
|
|||
key: "viewer_access",
|
||||
mode: "shared",
|
||||
summary: "Authenticated viewer session for control-plane surfaces.",
|
||||
sharedRule: identityFacts.authenticated,
|
||||
anyOf: [identityFacts.authenticated],
|
||||
},
|
||||
{
|
||||
key: "creator_access",
|
||||
|
|
@ -123,22 +113,9 @@ const streampixelsProfile: AppProfile = {
|
|||
key: "operator_access",
|
||||
mode: "shared",
|
||||
summary: "Global operator access.",
|
||||
sharedRule: grantFacts.operator,
|
||||
anyOf: [grantFacts.operator],
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
const capabilities: string[] = [];
|
||||
|
||||
if (context.facts.has(identityFacts.authenticated)) {
|
||||
capabilities.push("viewer_access");
|
||||
}
|
||||
|
||||
if (context.facts.has(grantFacts.operator)) {
|
||||
capabilities.push("operator_access");
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
},
|
||||
};
|
||||
|
||||
const bifrostProfile: AppProfile = {
|
||||
|
|
@ -153,18 +130,9 @@ const bifrostProfile: AppProfile = {
|
|||
key: "member_access",
|
||||
mode: "shared",
|
||||
summary: "May enter the Bifrost member alpha through a Heimdall-verified GameCult membership signal.",
|
||||
sharedRule: "entitlement.app_access || grant.global_member || grant.app_access",
|
||||
anyOf: gameCultMembership,
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
return hasAnyFact(context.facts, [
|
||||
entitlementFacts.appAccess,
|
||||
grantFacts.globalMember,
|
||||
grantFacts.appAccess,
|
||||
])
|
||||
? ["member_access"]
|
||||
: [];
|
||||
},
|
||||
};
|
||||
|
||||
const ghostlightProfile: AppProfile = {
|
||||
|
|
@ -179,7 +147,7 @@ const ghostlightProfile: AppProfile = {
|
|||
key: "app_access",
|
||||
mode: "shared",
|
||||
summary: "May enter Ghostlight Dungeon after Heimdall verifies the app-supplied GameCult Discord role policy.",
|
||||
sharedRule: "entitlement.app_access || grant.global_member || grant.app_access",
|
||||
anyOf: gameCultMembership,
|
||||
},
|
||||
{
|
||||
key: "campaign_play",
|
||||
|
|
@ -188,26 +156,30 @@ const ghostlightProfile: AppProfile = {
|
|||
localRequirement: "Ghostlight must combine app_access with local campaign ownership.",
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
return hasAnyFact(context.facts, [
|
||||
entitlementFacts.appAccess,
|
||||
grantFacts.globalMember,
|
||||
grantFacts.appAccess,
|
||||
]) ? ["app_access"] : [];
|
||||
},
|
||||
};
|
||||
|
||||
export const appProfiles: Record<AppSlug, AppProfile> = {
|
||||
/**
|
||||
* Profiles that ship with Heimdall. These are seed data, not special cases:
|
||||
* a registered app produces the same shape and is evaluated by the same code.
|
||||
*/
|
||||
export const builtInAppProfiles: Record<AppSlug, AppProfile> = {
|
||||
repixelizer: repixelizerProfile,
|
||||
streampixels: streampixelsProfile,
|
||||
bifrost: bifrostProfile,
|
||||
ghostlight: ghostlightProfile,
|
||||
};
|
||||
|
||||
export const appProfiles = builtInAppProfiles;
|
||||
|
||||
export function getAppProfile(appSlug: AppSlug): AppProfile {
|
||||
return appProfiles[appSlug];
|
||||
}
|
||||
|
||||
/** Shared capabilities the account holds for this app, from the profile's rules. */
|
||||
export function evaluateSharedCapabilities(profile: AppProfile, context: ClaimEvaluationContext): string[] {
|
||||
return evaluateCapabilityRules(profile.capabilities, context.facts);
|
||||
}
|
||||
|
||||
export function serializeAppProfile(profile: AppProfile): Record<string, unknown> {
|
||||
return {
|
||||
slug: profile.slug,
|
||||
|
|
|
|||
136
src/capability-rules.ts
Normal file
136
src/capability-rules.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Capability rules as data.
|
||||
//
|
||||
// App profiles used to carry an evaluateSharedCapabilities function alongside a
|
||||
// sharedRule string describing what that function did. Two representations of
|
||||
// one rule, free to drift, and the function is the reason an app profile could
|
||||
// not be registered at runtime: you cannot accept code from a stranger and run
|
||||
// it inside the auth authority.
|
||||
//
|
||||
// The rules were never more than a disjunction. Every sharedRule in the built-in
|
||||
// profiles is "a || b || c" over a closed vocabulary, so the executable form is
|
||||
// a list, not an expression language. anyOf is now the only representation.
|
||||
//
|
||||
// The safety property that makes runtime registration acceptable: a term must
|
||||
// name either 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, and it cannot reach into
|
||||
// another app's capabilities.
|
||||
|
||||
export type CapabilityMode = "shared" | "hybrid";
|
||||
|
||||
/** Prefixes of facts Heimdall produces. A rule term outside these must name a capability in the same profile. */
|
||||
export const FACT_NAMESPACES = ["identity.", "entitlement.", "grant."] as const;
|
||||
|
||||
export interface CapabilityDefinition {
|
||||
key: string;
|
||||
mode: CapabilityMode;
|
||||
summary: string;
|
||||
/**
|
||||
* Granted when the account holds any of these terms. A term is a fact
|
||||
* (identity.*, entitlement.*, grant.*) or another capability key in this
|
||||
* profile. Omitted for hybrid capabilities, which the host app resolves.
|
||||
*/
|
||||
anyOf?: string[];
|
||||
/** For hybrid capabilities: what the host app must combine with the shared claim. */
|
||||
localRequirement?: string;
|
||||
}
|
||||
|
||||
export interface CapabilityRuleProblem {
|
||||
capability: string;
|
||||
term: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function isFactTerm(term: string): boolean {
|
||||
return FACT_NAMESPACES.some((namespace) => term.startsWith(namespace));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a rule set that reaches outside what Heimdall can vouch for.
|
||||
*
|
||||
* This is the gate on runtime registration. An unknown term is refused rather
|
||||
* than treated as false, because a rule that silently never fires is a
|
||||
* capability an app believes it has and never receives, and that failure
|
||||
* surfaces as a support ticket rather than an error.
|
||||
*/
|
||||
export function validateCapabilityRules(capabilities: readonly CapabilityDefinition[]): CapabilityRuleProblem[] {
|
||||
const problems: CapabilityRuleProblem[] = [];
|
||||
const known = new Set(capabilities.map((capability) => capability.key));
|
||||
|
||||
for (const capability of capabilities) {
|
||||
if (capability.mode === "hybrid") {
|
||||
if (capability.anyOf?.length) {
|
||||
problems.push({
|
||||
capability: capability.key,
|
||||
term: capability.anyOf.join(" || "),
|
||||
reason: "A hybrid capability is resolved by the host app and must not carry shared rules.",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!capability.anyOf?.length) {
|
||||
problems.push({
|
||||
capability: capability.key,
|
||||
term: "",
|
||||
reason: "A shared capability needs at least one term, or it can never be granted.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const term of capability.anyOf) {
|
||||
if (isFactTerm(term)) continue;
|
||||
if (known.has(term)) {
|
||||
if (term === capability.key) {
|
||||
problems.push({ capability: capability.key, term, reason: "A capability cannot depend on itself." });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
problems.push({
|
||||
capability: capability.key,
|
||||
term,
|
||||
reason: "Unknown term. Use a Heimdall fact (identity./entitlement./grant.) or a capability in this profile.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants every shared capability whose terms are satisfied.
|
||||
*
|
||||
* Capabilities may reference each other, so this runs to a fixed point rather
|
||||
* than in one pass. It terminates because the granted set only grows and is
|
||||
* bounded by the rule count; a self-reference is refused by validation rather
|
||||
* than looped on here.
|
||||
*/
|
||||
export function evaluateCapabilityRules(
|
||||
capabilities: readonly CapabilityDefinition[],
|
||||
facts: ReadonlySet<string>,
|
||||
): string[] {
|
||||
const shared = capabilities.filter((capability) => capability.mode === "shared" && capability.anyOf?.length);
|
||||
const granted = new Set<string>();
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const capability of shared) {
|
||||
if (granted.has(capability.key)) continue;
|
||||
const satisfied = capability.anyOf!.some((term) => (isFactTerm(term) ? facts.has(term) : granted.has(term)));
|
||||
if (satisfied) {
|
||||
granted.add(capability.key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Declaration order, so a profile reads the way it evaluates.
|
||||
return capabilities.filter((capability) => granted.has(capability.key)).map((capability) => capability.key);
|
||||
}
|
||||
|
||||
/** Human-readable form of a rule, derived rather than stored, so it cannot drift from what runs. */
|
||||
export function describeCapabilityRule(capability: CapabilityDefinition): string {
|
||||
if (capability.mode === "hybrid") return capability.localRequirement ?? "Resolved by the host app.";
|
||||
return (capability.anyOf ?? []).join(" || ");
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type LinkedIdentityInput,
|
||||
type RefreshTokenPayload,
|
||||
} from "./contracts.js";
|
||||
import { getAppProfile } from "./app-profiles.js";
|
||||
import { evaluateSharedCapabilities, getAppProfile } from "./app-profiles.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
import { identityFacts } from "./facts.js";
|
||||
import { signJwt, type RuntimeKeyMaterial } from "./signing.js";
|
||||
|
|
@ -78,7 +78,7 @@ export async function issueAccessClaim(options: {
|
|||
const profile = getAppProfile(options.input.appSlug);
|
||||
const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts);
|
||||
const factSet = new Set(facts);
|
||||
const sharedCapabilities = profile.evaluateSharedCapabilities({
|
||||
const sharedCapabilities = evaluateSharedCapabilities(profile, {
|
||||
accountId: options.input.accountId,
|
||||
facts: factSet,
|
||||
identities: options.input.linkedIdentities,
|
||||
|
|
|
|||
163
tests/capability-rules.test.ts
Normal file
163
tests/capability-rules.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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.");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue