Identity and Authorisation
A Beach application takes inbound traffic from many places — a chat panel, an inbound email, an A2A peer agent, a webhook from an internal scheduler. Each arrives with its own notion of who is on the other end. Beach makes the who visible end-to-end via a single primitive — Principal — and gives consumers a pluggable seam for the what they can do via the Authoriser interface.
This guide walks the shape of both, the responsibilities at the inbound edge, and the pattern for layering in a real policy engine without rewriting handlers.
The model
Two structurally separate concerns:
- Identity (
Principal) — who is making the request. Beach propagates this record unchanged through every descendant event in the chain. - Authorisation (
Authoriser) — what that principal is allowed to do. Beach exposes the interface; consumers wire an implementation when the application needs gating.
Beach does not issue Principal records. Inbound channel adapters at the identity boundary construct them from whatever identity source the channel uses — an HTTP session cookie, a verified phone number, an A2A peer's signed agent card, an IMAP From: header. Beach carries the record forward; consumers gate on it via the Authoriser.
This split is deliberate. The shape of identity is independent of how the application chooses to authorise; conflating them produces a system where every new channel forces a rewrite of the policy layer, and every policy change forces a rewrite of the inbound adapters. The split lets the two evolve independently.
The Principal shape
export type PrincipalType = 'person' | 'agent' | 'service';
export interface Principal {
principalId: string;
principalType: PrincipalType;
claims: Record<string, unknown>;
source: string;
}
Each field carries a defined meaning:
principalId— a stable, unique identifier within the issuing source's namespace. A holbrookmillhm.siduser id, a WhatsApp E.164 phone number, an A2A peer's signed-card subject DID, a service-account name. Beach does not enforce a format; consumers MUST ensure ids are stable so capability checks and audit trails correlate correctly.principalType— one of three:'person'— a human user (a chat user, an email sender).'agent'— an AI agent (an A2A peer, a Beach application calling another).'service'— a non-AI system process (cron, webhook source, scheduler, internal microservice).
claims— verified claims attached at the inbound boundary. Examples:email-verified,phone-verified,a2a-card-signed,holbrookmill-session,service-account-token. Claims are advisory; handlers MAY inspect them directly for ad-hoc gating, but the canonical path is to consult theAuthoriser.source— a short string identifying which inbound adapter constructed this record. Useful for audit and for distinguishing identity-source-specific claim conventions.
A Principal record is optional on every event. Anonymous channels (an unauthenticated chat panel, a public webhook) and system-internal events carry principal: undefined. Consumers wanting to require identity gate on it explicitly.
How a Principal flows through the system
Three checkpoints:
Inbound construction. The channel adapter constructs a Principal from the inbound identity. For a holbrookmill SSE chat session, the adapter reads the
hm.sidcookie and looks up the user; for an inbound WhatsApp message, the adapter looks up the sender's phone number; for an A2A peer call, the adapter validates the signed agent card. The Principal is attached to the inbound event beforerouteEvent()is called.Router propagation.
EventRoutercarries the Principal record unchanged on every descendant event. A handler that emits a child viactx.routeEvent({ … })carries the same Principal forward; consumers do not re-attach it.Handler inspection. Inside a handler, the Principal is on
ctx.principal:import type { HandlerContext } from '@cool-ai/beach-core'; router.register('concierge', async (event, ctx: HandlerContext) => { if (ctx.principal === undefined) { // anonymous or system-internal — handler decides what that means } else if (ctx.principal.principalType === 'agent') { // tighter policy for A2A peer agents than for direct users } // child events inherit the Principal automatically await ctx.routeEvent({ source: 'concierge', eventType: 'follow_up', data: {} }); });
The propagation is automatic. The handler does not pass the Principal manually; the router does it. This is what makes audit trails consistent — every routed event carries the originator's identity, regardless of how many hops downstream it has travelled.
Constructing a Principal at the inbound edge
Each channel adapter has its own identity source; the Principal is the canonical shape it normalises into. Three worked examples:
SSE chat (holbrookmill session cookie)
import type { Principal } from '@cool-ai/beach-core';
function principalFromHmSession(req: Request): Principal | undefined {
const session = req.session?.user;
if (!session) return undefined;
return {
principalId: String(session.userId),
principalType: 'person',
claims: {
'email-verified': session.emailVerified === true,
'holbrookmill-session': true,
},
source: 'channel-sse',
};
}
WhatsApp inbound
function principalFromWhatsApp(parsed: ParsedInboundWhatsApp): Principal {
return {
principalId: parsed.from, // E.164 number
principalType: 'person',
claims: { 'phone-verified': true }, // WhatsApp delivers only verified senders
source: 'channel-whatsapp',
};
}
A2A peer call
function principalFromA2APeer(card: VerifiedAgentCard): Principal {
return {
principalId: card.subjectDid,
principalType: 'agent',
claims: {
'a2a-card-signed': true,
'card-issuer': card.issuer,
},
source: 'channel-a2a',
};
}
In each case the channel adapter is the single point where channel-shaped identity becomes Principal-shaped. The interior never sees the underlying identity source; it sees a uniformly shaped record.
The Authoriser interface
Identity is the who; authorisation is the what. Beach exposes a single interface for the policy decision:
export interface Authoriser {
check(
principal: Principal | undefined,
capability: string,
scope?: string,
): Promise<boolean>;
}
check() returns true if the principal is allowed to exercise capability (optionally within scope); false otherwise. The interface is async so real implementations can call out to policy engines (Cerbos, OPA, in-process AuthZed clients, etc.) without blocking the router.
Beach does not canonicalise a vocabulary for capability or scope. Both are opaque strings whose meaning is defined by the consumer and their Authoriser implementation. The mechanism ships; the vocabulary is the consumer's.
Reference implementations
Two reference Authorisers ship with @cool-ai/beach-core:
import { AllowAllAuthoriser, DenyAllAuthoriser } from '@cool-ai/beach-core';
const authoriser = new AllowAllAuthoriser(); // tests where auth is not the unit under test
const deny = new DenyAllAuthoriser(); // verify defensive-shape behaviour on denial
Choose one explicitly at startup. The failure mode the explicit choice protects against: a consumer wires Beach without realising the Authoriser slot exists, and silently gets no-op authorisation in production. Forcing the choice at startup means the absence of a real implementation is visible in the application's bootstrap, not buried in the runtime.
Do not ship AllowAllAuthoriser to production for an application that genuinely needs gating. The class exists for tests and very early integration; production wiring connects to a real policy engine.
A real policy-engine adapter
The Authoriser interface is small enough that adapter classes against most policy engines are short. A worked example against Cerbos:
import type { Authoriser, Principal } from '@cool-ai/beach-core';
import { GRPC as Cerbos } from '@cerbos/grpc';
export class CerbosAuthoriser implements Authoriser {
constructor(private readonly cerbos: Cerbos) {}
async check(
principal: Principal | undefined,
capability: string,
scope?: string,
): Promise<boolean> {
if (principal === undefined) return false; // policy: anonymous denies
const decision = await this.cerbos.checkResource({
principal: {
id: principal.principalId,
roles: this.rolesForType(principal.principalType),
attributes: principal.claims,
},
resource: {
kind: scope ?? 'global',
id: scope ?? 'global',
},
actions: [capability],
});
return decision.isAllowed(capability);
}
private rolesForType(type: 'person' | 'agent' | 'service'): string[] {
return [type];
}
}
The adapter maps Beach's Principal shape onto whatever shape the policy engine expects, calls the engine, and returns the boolean. The mapping decisions — how principalType maps to roles, what scope means in the engine's vocabulary — are application-specific. Beach does not prescribe.
Wiring the Authoriser into routing
The current Beach release exposes the interface and the reference implementations. Router-side enforcement — routing rules declaring requiresCapability and the router consulting the Authoriser before dispatch — lands in a later release. Consumers wanting to layer in early authorisation logic write the calls explicitly inside their handlers:
router.register('account-update-tool', async (event, ctx) => {
const allowed = await authoriser.check(ctx.principal, 'account.update');
if (!allowed) {
await ctx.routeEvent({
source: 'authoriser', eventType: 'denied',
data: { capability: 'account.update', principalId: ctx.principal?.principalId },
});
return;
}
// … the work
});
This is the manual path; when router-side enforcement ships, the same gate is declared on the routing rule and the router consults the Authoriser automatically. Code written against the interface today migrates by removing the explicit check and adding the rule declaration.
Pitfalls
Re-attaching Principal to child events. Don't. The router propagates it automatically. Re-attaching is at best redundant and at worst a vector for subtly wrong identity (the wrong Principal ends up on the child).
Storing Principal in handler-local state. A turn outlives any individual handler invocation; the Principal is on every event. Read ctx.principal when you need it; don't cache it in module-scope state, because the same handler may be invoked for different principals concurrently.
Treating claims as load-bearing without verification. Claims are advisory when set by the inbound adapter — they reflect what the adapter verified. A claim like 'email-verified': true is only as trustworthy as the inbound adapter's verification logic. The Authoriser is where you decide which claims to trust for which capabilities.
Shipping AllowAllAuthoriser to production. It's a no-op. Forced explicit choice at startup is Beach's only defence against this; honour it.
Mixing identity into the orchestrator's prompt. The orchestrator is channel-blind; it should also generally be identity-blind. If a tool's behaviour depends on the principal, the gating happens inside the tool handler (via ctx.principal and Authoriser), not in the orchestrator's prompt. Prompts that say "if the user is an admin, allow X" are the wrong shape — admin-vs-not is a structural decision, not a phrasing decision.
Related
@cool-ai/beach-coreREADME § Identity and authorisation — API reference.- Reference: design principles § 2.1 (the router is the boundary) and § 2.5 (specialists own their internals) — the architectural framing identity propagation depends on.
- Anti-patterns — the failure modes around identity and gating.