Normalize Heimdall claim fact vocabulary
This commit is contained in:
parent
cb0041343a
commit
12ce1f45ae
10 changed files with 92 additions and 26 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
21
src/facts.ts
Normal 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;
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue