Normalize Heimdall claim fact vocabulary

This commit is contained in:
Meta Vens 2026-04-26 14:58:58 +01:00
commit 12ce1f45ae
10 changed files with 92 additions and 26 deletions

View file

@ -49,8 +49,7 @@ job_cancel_own = app_access + resource ownership check
```text
app_access =
discord.allowed_role
|| patreon.allowed_tier
entitlement.app_access
|| grant.global_member
|| grant.app_access

View file

@ -294,14 +294,22 @@ AppAccessProfile {
Example rule style:
```text
app_access = discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access
app_access = entitlement.app_access || grant.global_member || grant.app_access
queue_submit = app_access
admin_access = grant.operator || grant.admin_access
```
The important part is not the provider names in the example. It is that app
profiles choose which identity facts, entitlement snapshots, and local grants
feed each capability.
The important part is not naming a provider in every rule. It is that app
profiles choose which identity facts, app-facing entitlement facts, snapshots,
and local grants feed each capability.
Preferred fact-vocabulary rule:
- signed claim facts should prefer app-facing names such as
`entitlement.app_access`
- provider-specific evidence such as Discord role ids or Patreon tiers should
usually stay in entitlement snapshots and linked-identity metadata unless a
consumer truly needs that exact upstream detail in the claim
## Request path discipline

View file

@ -184,7 +184,7 @@ Request body:
{
"accountId": "acct_repixelizer_001",
"displayName": "Meta",
"facts": ["discord.allowed_role", "grant.operator"],
"facts": ["entitlement.app_access", "grant.operator"],
"linkedIdentities": [
{
"provider": "discord",
@ -247,6 +247,9 @@ Important current rule:
- Heimdall only grants fully shared capabilities directly
- hybrid capabilities still require app-local checks after claim verification
- claim facts should prefer app-facing entitlement names over provider-branded
trivia when a provider-specific detail is not actually needed by the
consumer
Examples:

View file

@ -1,4 +1,5 @@
import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js";
import { entitlementFacts, grantFacts, identityFacts } from "./facts.js";
export type CapabilityMode = "shared" | "hybrid";
@ -34,7 +35,7 @@ function hasAnyFact(facts: Set<string>, values: readonly string[]): boolean {
const repixelizerProfile: AppProfile = {
slug: "repixelizer",
displayName: "Repixelizer",
profileVersion: "2026-04-26",
profileVersion: "2026-04-26.1",
identityProviders: ["discord", "patreon"],
entitlementSources: ["discord", "patreon"],
managedConnectionProviders: [],
@ -43,8 +44,7 @@ const repixelizerProfile: AppProfile = {
key: "app_access",
mode: "shared",
summary: "May load the protected hosted GUI.",
sharedRule:
"discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access",
sharedRule: "entitlement.app_access || grant.global_member || grant.app_access",
},
{
key: "queue_submit",
@ -74,17 +74,16 @@ const repixelizerProfile: AppProfile = {
evaluateSharedCapabilities(context) {
const capabilities: string[] = [];
const appAccess = hasAnyFact(context.facts, [
"discord.allowed_role",
"patreon.allowed_tier",
"grant.global_member",
"grant.app_access",
entitlementFacts.appAccess,
grantFacts.globalMember,
grantFacts.appAccess,
]);
if (appAccess) {
capabilities.push("app_access", "queue_submit");
}
if (hasAnyFact(context.facts, ["grant.operator", "grant.admin_access"])) {
if (hasAnyFact(context.facts, [grantFacts.operator, grantFacts.adminAccess])) {
capabilities.push("admin_access");
}
@ -104,7 +103,7 @@ const streampixelsProfile: AppProfile = {
key: "viewer_access",
mode: "shared",
summary: "Authenticated viewer session for control-plane surfaces.",
sharedRule: "identity.authenticated",
sharedRule: identityFacts.authenticated,
},
{
key: "creator_access",
@ -124,17 +123,17 @@ const streampixelsProfile: AppProfile = {
key: "operator_access",
mode: "shared",
summary: "Global operator access.",
sharedRule: "grant.operator",
sharedRule: grantFacts.operator,
},
],
evaluateSharedCapabilities(context) {
const capabilities: string[] = [];
if (context.facts.has("identity.authenticated")) {
if (context.facts.has(identityFacts.authenticated)) {
capabilities.push("viewer_access");
}
if (context.facts.has("grant.operator")) {
if (context.facts.has(grantFacts.operator)) {
capabilities.push("operator_access");
}

View file

@ -16,6 +16,7 @@ import {
import { mapIssueClaimRequest, issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
import { type HeimdallConfig, loadConfig } from "./config.js";
import { grantFact } from "./facts.js";
import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.js";
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
@ -108,7 +109,7 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
}
function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] {
return grants.map((grant) => `grant.${grant.capability}`);
return grants.map((grant) => grantFact(grant.capability));
}
function prefersHtml(acceptHeader: string | undefined): boolean {

View file

@ -7,6 +7,7 @@ import {
} from "./contracts.js";
import { getAppProfile } from "./app-profiles.js";
import { type HeimdallConfig } from "./config.js";
import { identityFacts } from "./facts.js";
import { signJwt, type RuntimeKeyMaterial } from "./signing.js";
import { type CreateSessionInput, type HeimdallStore } from "./store/types.js";
@ -26,7 +27,7 @@ function normalizeFacts(accountId: string, linkedIdentities: LinkedIdentityInput
const normalized = new Set(facts);
if (accountId.trim() || linkedIdentities.length) {
normalized.add("identity.authenticated");
normalized.add(identityFacts.authenticated);
}
return [...normalized].sort();

21
src/facts.ts Normal file
View file

@ -0,0 +1,21 @@
// Shared claim facts should describe app-facing auth state.
// Provider-specific detail belongs in linked identities and entitlement snapshots.
export function grantFact(capability: string): string {
return `grant.${capability}`;
}
export const identityFacts = {
authenticated: "identity.authenticated",
} as const;
export const entitlementFacts = {
appAccess: "entitlement.app_access",
} as const;
export const grantFacts = {
globalMember: grantFact("global_member"),
appAccess: grantFact("app_access"),
adminAccess: grantFact("admin_access"),
operator: grantFact("operator"),
} as const;

View file

@ -1,6 +1,7 @@
import { Buffer } from "node:buffer";
import { type AppSlug, type OAuthConnectionBinding, type Provider } from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { entitlementFacts } from "./facts.js";
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
export interface OAuthTokenSet {
@ -211,10 +212,10 @@ async function evaluateDiscordEntitlements(options: {
};
const roles = member.roles ?? [];
const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId));
const facts = ["discord.in_guild"];
const facts: string[] = [];
if (matchedRoles.length > 0) {
facts.push("discord.allowed_role");
facts.push(entitlementFacts.appAccess);
}
return {
@ -229,7 +230,7 @@ async function evaluateDiscordEntitlements(options: {
reasonCode: matchedRoles.length > 0 ? "matched_role" : "missing_role",
reasonDetail:
matchedRoles.length > 0
? `Matched Discord roles: ${matchedRoles.join(", ")}`
? "Matched a configured Repixelizer access role."
: allowedRoleIds.length
? "User is in the guild but lacks the configured Repixelizer access role."
: "No Repixelizer Discord role ids are configured.",

View file

@ -5,3 +5,4 @@
{"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."}
{"ts": "2026-04-26T11:24:39+00:00", "type": "design", "status": "accepted", "note": "The first Heimdall implementation pass landed as a standalone TypeScript/Fastify service skeleton with Ed25519 JWT signing, JWKS discovery, signed OAuth state, and app-profile-driven claim issuance for Repixelizer and StreamPixels."}
{"ts": "2026-04-26T11:49:41+00:00", "type": "implementation", "status": "accepted", "note": "The first stateful Heimdall slice landed durable auth-control-plane persistence plus a real Discord callback path for Repixelizer, with accounts, linked identities, sessions, grants, entitlement snapshots, and audit events stored behind in-memory/Postgres backends."}
{"ts": "2026-04-26T13:58:45+00:00", "type": "design", "status": "accepted", "note": "Heimdall claim facts should prefer provider-agnostic, app-facing entitlement names such as entitlement.app_access. Provider-specific evidence like Discord role matches stays in linked identities and entitlement snapshots unless a consumer truly needs that upstream detail in the claim surface."}

View file

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { type FastifyInstance } from "fastify";
import { buildApp } from "../src/app.js";
import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
import { verifyJwt } from "../src/signing.js";
@ -58,7 +59,7 @@ function createMockDiscordRuntime(): OAuthProviderRuntime {
},
async evaluateEntitlements({ callback }) {
return {
facts: ["discord.in_guild", "discord.allowed_role"],
facts: [entitlementFacts.appAccess],
snapshots: [
{
accountId: callback.accountId,
@ -204,7 +205,7 @@ describe("Heimdall service", () => {
primaryEmail: "meta@gamecult.org",
})
);
expect(payload.entitlements.facts).toEqual(expect.arrayContaining(["discord.allowed_role"]));
expect(payload.entitlements.facts).toEqual(expect.arrayContaining([entitlementFacts.appAccess]));
const verified = verifyJwt(payload.accessToken as string, getPublicKey(app));
expect(verified.valid).toBe(true);
@ -216,10 +217,41 @@ describe("Heimdall service", () => {
account_id: payload.account.id,
})
);
expect(verified.payload.facts).toEqual(
expect.arrayContaining([identityFacts.authenticated, entitlementFacts.appAccess])
);
expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
}
});
it("issues direct claims from provider-agnostic entitlement facts", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
const response = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/claims/issue",
payload: {
accountId: "acct_repixelizer_001",
displayName: "Meta",
facts: [entitlementFacts.appAccess, grantFacts.operator],
linkedIdentities: [
{
provider: "discord",
providerUserId: "123456789",
username: "meta",
},
],
},
});
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.sharedCapabilities).toEqual(
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
);
});
it("redirects browser callbacks back to the app with a token fragment", async () => {
const app = await buildApp({
config: createTestConfig(),