Skip to content

Map a login to principals

Signed-in login login origin + subject Current principals person, team, manager-of maps to
Signed-in login login origin + subject Current principals person, team, manager-of maps to
Map each login to the person, approval-team and manager-of principals used for access and assignments.

Turn a signed-in login into its principals: the person, teams and managed people that access rules and dialog assignments use. The test checks the mapping without a database.

Save the two files as identity-hook.hooks.ts and identity-hook.hooks.test.ts. The host discovers the hooks file by its .hooks.ts ending, as described in Let the host find the file.

After saving both files, run the test, then adapt the example to your system.

Detailed behavior diagram
COMPLETE SET, EVERY TIME Account login-clara Provider acme principals() Empty set unknown login person:p-2041 who they are approval-team:vacation-eu a team they share manager-of:p-2042 whom they manage manager-of:p-2043 whom they manage passed to returns not found
COMPLETE SET, EVERY TIME Account login-clara Provider acme principals() Empty set unknown login person:p-2041 who they are approval-team:vacation-eu a team they share manager-of:p-2042 whom they manage manager-of:p-2043 whom they manage passed to returns not found
One login becomes a complete set of person, team and manager subjects; a login the directory does not know gets an empty set.
import process from 'node:process';
import * as v from '@identity-flow/sdk/valibot';
import { type Account, type PrincipalRef, defineHooks } from '@identity-flow/sdk';
const PROVIDER_ID = 'acme';
/** Everything the directory knows about one login. The rest is derived here. */
export interface DirectoryMembership {
readonly personId: string;
readonly approvalTeamIds: readonly string[];
readonly managesPersonIds: readonly string[];
}
export interface Directory {
findMembership(account: Account): Promise<DirectoryMembership | null>;
}
// Three kinds of opaque, provider-scoped subject. IdentityFlow compares them and
// never parses them, so the prefixes are the provider's own vocabulary.
export const person = (personId: string): PrincipalRef => ({
providerId: PROVIDER_ID,
subject: `person:${personId}`,
});
export const approvalTeam = (teamId: string): PrincipalRef => ({
providerId: PROVIDER_ID,
subject: `approval-team:${teamId}`,
});
export const managerOf = (personId: string): PrincipalRef => ({
providerId: PROVIDER_ID,
subject: `manager-of:${personId}`,
});
/**
* Maps one authenticated login to its complete current principal set: who the
* person is, which approval teams they sit in, and whom they manage. IdentityFlow
* calls this outside workflow replay, so it may read the directory.
*
* Return the complete set every time. It is a snapshot, not a patch.
*/
export function createIdentityHooks(directory: Directory) {
return defineHooks(PROVIDER_ID, (hooks) => {
hooks.identity.provider(PROVIDER_ID, {
async principals(account) {
const membership = await directory.findMembership(account);
// An unknown login is not an error. It holds no principal from this
// provider, so nothing this directory grants reaches it.
if (!membership) return [];
return [
person(membership.personId),
...membership.approvalTeamIds.map(approvalTeam),
...membership.managesPersonIds.map(managerOf),
];
},
});
});
}
const Membership = v.object({
personId: v.string(),
approvalTeamIds: v.array(v.string()),
managesPersonIds: v.array(v.string()),
});
/**
* The acme directory's HTTP API. Replace this object with a client for your own
* directory; the hooks above do not change. It reads its settings on every call,
* so the file loads without them, and a lookup without them fails.
*/
const acmeDirectory: Directory = {
async findMembership(account) {
// A base URL without a trailing slash would lose its last path segment.
const baseUrl = requiredEnv('ACME_DIRECTORY_URL').replace(/\/?$/, '/');
const response = await fetch(
new URL(`logins/${encodeURIComponent(account.subject)}`, baseUrl),
{
headers: { authorization: `Bearer ${requiredEnv('ACME_DIRECTORY_TOKEN')}` },
// The host stops waiting after 5 seconds; stop the request as well.
signal: AbortSignal.timeout(5000),
},
);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`The acme directory answered ${response.status}`);
return v.parse(Membership, await response.json());
},
};
// The host loads only the default export of a hooks file.
export default createIdentityHooks(acmeDirectory);
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}

The host loads only the file’s default export. createIdentityHooks takes the directory as an argument so the test can pass an in-memory one; the default export passes acmeDirectory, a small HTTP client for an invented directory API. Replace it with a client for your directory. It reads ACME_DIRECTORY_URL and ACME_DIRECTORY_TOKEN on every lookup, so the host loads the file without them, and every lookup fails until both are set. A 404 means an unknown login; any other error status fails the lookup. When the lookup for an account that acts fails, or takes longer than 5 seconds, IdentityFlow refuses the action, so the client stops its request after 5 seconds as well.

signal: AbortSignal.timeout(5000),
  1. host gives up after 5 s

defineHooks(PROVIDER_ID, …) names the hooks file acme, and hooks.identity.provider(PROVIDER_ID, …) registers the provider acme. The host configuration decides which logins reach this provider; see Route the login to your provider.

hooks.identity.provider(PROVIDER_ID, {
  1. registers a provider
  2. its ID: acme

Three kinds of subject come out of one login: person:… is who they are, approval-team:… is a team they share with others, and manager-of:… is a relationship to someone else. IdentityFlow compares these strings and never parses them, so the vocabulary is yours. This directory uses person IDs like p-2041, not the user:e-2041 subjects of the workflow recipes.

Two rules are easy to get wrong. Return the complete current set every time: it is a snapshot, not a patch, and anything you leave out is authority the account no longer has. And an unknown login is not an error. It holds no principal from this provider, so nothing this directory grants reaches it.

Hooks run outside workflow replay, which is why this one may call the directory directly. Inside a workflow the same call belongs in a flow.do step, so its result is recorded once and not requested again on replay. Call a GraphQL service through a binding shows that.

import type { Account } from '@identity-flow/sdk';
import { createHookTestHost } from '@identity-flow/testing';
import { afterEach, expect, test, vi } from 'vitest';
import hooks, { createIdentityHooks } from './identity-hook.hooks';
const clara: Account = {
id: 'AZlKqAAAeACAAAAAAAAAAQ' as Account['id'],
origin: 'acme-oidc',
subject: 'login-clara',
};
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
test('maps one login to person, team and manager principals', async () => {
const host = createHookTestHost();
host.load(
createIdentityHooks({
findMembership: () =>
Promise.resolve({
personId: 'p-2041',
approvalTeamIds: ['vacation-eu'],
managesPersonIds: ['p-2042', 'p-2043'],
}),
}),
);
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([
{ providerId: 'acme', subject: 'person:p-2041' },
{ providerId: 'acme', subject: 'approval-team:vacation-eu' },
{ providerId: 'acme', subject: 'manager-of:p-2042' },
{ providerId: 'acme', subject: 'manager-of:p-2043' },
]);
expect(host.identity.calls).toEqual([{ providerId: 'acme', account: clara }]);
});
test('gives a login the directory does not know no principals at all', async () => {
const host = createHookTestHost();
host.load(createIdentityHooks({ findMembership: () => Promise.resolve(null) }));
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([]);
});
test('asks the acme directory over HTTP in the file the host loads', async () => {
vi.stubEnv('ACME_DIRECTORY_URL', 'https://directory.example.com/api');
vi.stubEnv('ACME_DIRECTORY_TOKEN', 'token');
const fetch = vi.fn(() =>
Promise.resolve(
Response.json({ personId: 'p-2041', approvalTeamIds: [], managesPersonIds: [] }),
),
);
vi.stubGlobal('fetch', fetch);
const host = createHookTestHost();
host.load(hooks);
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([
{ providerId: 'acme', subject: 'person:p-2041' },
]);
expect(fetch).toHaveBeenCalledWith(
new URL('https://directory.example.com/api/logins/login-clara'),
expect.objectContaining({ headers: { authorization: 'Bearer token' } }),
);
});
test('treats a 404 as an unknown login and any other error as a failed lookup', async () => {
vi.stubEnv('ACME_DIRECTORY_URL', 'https://directory.example.com/api/');
vi.stubEnv('ACME_DIRECTORY_TOKEN', 'token');
const host = createHookTestHost();
host.load(hooks);
vi.stubGlobal('fetch', () => Promise.resolve(new Response(null, { status: 404 })));
await expect(host.identity.resolve('acme', clara)).resolves.toEqual([]);
vi.stubGlobal('fetch', () => Promise.resolve(new Response(null, { status: 500 })));
await expect(host.identity.resolve('acme', clara)).rejects.toThrow(
'The acme directory answered 500',
);
});
test('fails the lookup while the directory is not configured', async () => {
vi.stubEnv('ACME_DIRECTORY_URL', '');
const host = createHookTestHost();
host.load(hooks);
await expect(host.identity.resolve('acme', clara)).rejects.toThrow(
'ACME_DIRECTORY_URL is required',
);
});

createHookTestHost() loads a hooks definition and calls it the way the host would, with no engine and no database. It records every call, so the first test can check that the provider was asked once, for exactly this account. The other tests load the default export, the file as the host loads it: with a stubbed fetch for a found login, a 404 and a 500, and without the directory URL.

This is a hook test, not a Workflow Test, so the file name ends in .test.ts. With the configuration from Before you start, the Workflow Test distribution runs it together with the Workflow Tests. Test hooks explains how hook tests fit into a project.

Next: Connect your identity provider for routing, change events and a test and a live directory, and Hooks API for every registration.