diff --git a/src/app-profiles.ts b/src/app-profiles.ts index d0e8d71..083329c 100644 --- a/src/app-profiles.ts +++ b/src/app-profiles.ts @@ -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, 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 = { +/** + * 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 = { 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 { return { slug: profile.slug, diff --git a/src/capability-rules.ts b/src/capability-rules.ts new file mode 100644 index 0000000..88f2198 --- /dev/null +++ b/src/capability-rules.ts @@ -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[] { + const shared = capabilities.filter((capability) => capability.mode === "shared" && capability.anyOf?.length); + const granted = new Set(); + + 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(" || "); +} diff --git a/src/claims.ts b/src/claims.ts index f1a483a..4f50fca 100644 --- a/src/claims.ts +++ b/src/claims.ts @@ -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, diff --git a/tests/capability-rules.test.ts b/tests/capability-rules.test.ts new file mode 100644 index 0000000..52ea500 --- /dev/null +++ b/tests/capability-rules.test.ts @@ -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."); + }); +});