morph::session provides the per-call context that travels from the caller
through the bridge to the model, together with a pluggable authorizer that
remote servers use to gate action dispatch and, optionally, to authenticate the
caller.
The authentication mechanism — signed bearer tokens, SigningAuthorizer, and
the RemoteServer enforcement points — lives in
security.md, the primary companion to this spec. This file
documents the session types and their contracts; it cross-references
security.md for the full trust model rather than duplicating it.
- Context — an open data bag
- How a
Contextoriginates and flows - Principal — readable authorization state outside a dispatch
- IAuthorizer — gate for action dispatch
- The
authenticatehook — the authoritative principal - The
authorizeInstancehook — per-instance ownership - The
authorizeRegisterhook — gating registration - AllowAllAuthorizer and
allowAllAuthorizer() - Trust boundary
- Thread safety —
current()andScopedContext - API reference
- Design decisions
- Limitations
- Cross-references
Context is a plain struct with five fields that callers populate however they
see fit. The framework only inspects principal, token, and the action ids
when consulting the configured IAuthorizer; everything else is passed through
verbatim from caller to model.
| Field | Type | Purpose |
|---|---|---|
principal |
std::string |
Auth principal — user/identity id. A client claim until a verifying authorizer overwrites it (see below); authoritative for model code only after that. |
token |
std::string |
Bearer credential verified server-side. Typically a signed token minted by a login action via session::TokenIssuer and attached to every call. Empty when unauthenticated. |
requestId |
std::string |
Stable id for distributed tracing / log correlation. Empty when unused. |
locale |
std::string |
BCP-47 locale tag (en-US, fr-FR) for i18n. Empty for app default. |
metadata |
std::unordered_map<std::string, std::string> |
Free-form bag for feature flags, A/B buckets, app-specific metadata. |
On remote backends the entire Context — including token — is serialised into
the wire envelope's session field (see wire.md) so RemoteServer
sees the same values the GUI sent. On the local backend it travels in-memory via
ActionCall.
token is the credential the server verifies; principal is the identity the
server derives from that credential. See
the authenticate hook
for how the derived principal replaces the client's claim, and
security.md for the token format and login flow.
A Context is not threaded through individual call sites. The Bridge holds a
single default session — a Context set once via
Bridge::setDefaultSession(session) (typically at startup, or after login once a
token is available) and read back with Bridge::defaultSession(). Every
outbound executeVia stamps that default onto the ActionCall
(call.session = _defaultSession) under the bridge's session mutex, so callers
never construct a Context per call. There is no per-call session override:
changing the session for one call means calling setDefaultSession again.
setDefaultSession({}) clears it back to an empty (unauthenticated) Context.
From the ActionCall the session travels backend-specifically:
- Local backend — the
Contextrides in-memory on theActionCall;LocalBackend::executewraps the model call in aScopedContextsocurrent()sees it. No authorizer is consulted (see Trust boundary). - Remote backend — the
Contextis serialised into the wire envelope'ssessionfield, deserialised verbatim byRemoteServer, passed to the authorizer, then (post-authenticate) installed viaScopedContextaround dispatch.
The bridge plumbing (setDefaultSession/defaultSession, the ActionCall
stamp) is specified in bridge.md; this spec covers only the
Context payload and its server-side handling.
Context::principal only exists during a dispatch: session::current()
returns nullptr outside one (see Thread safety),
so UI code — a button's enabled state, a menu item's visibility — has no way
to ask "who is signed in and what may they do?" without attempting the action
and catching the failure.
Principal is the longer-lived counterpart:
struct Principal {
std::string id;
std::vector<std::string> roles;
std::unordered_map<std::string, std::string> claims;
[[nodiscard]] bool hasRole(std::string_view role) const;
};An application installs it once — typically right after a successful login
dispatch, from data the server actually returned (e.g. the bank example's
AuthResult) — via Bridge::setPrincipal(principal), and UI code reads it
back with Bridge::currentPrincipal(), outside any dispatch:
deleteButton.setEnabled(bridge.currentPrincipal().hasRole("editor"));Scoped to the Bridge instance, not a process-wide global. The Principal
lives on the specific Bridge whose backend it came from — the same object
that already holds the default Context (setDefaultSession/defaultSession)
— rather than one ambient value shared by every backend a process happens to
hold. Guarded by its own mutex (_principalMtx, separate from the session
mutex), since it is expected to be read far more frequently — by UI code on
every relevant repaint/state check — than the per-call session snapshot.
Trust: a read-only cache of what the server last said, never a second
authority. Populate it only from data the server actually returned, never
from a client-side guess — otherwise it becomes a client-controlled permission
set. Roles can change server-side mid-session; a stale Principal only
shapes what the UI offers, it never substitutes for server-side
authorization — every dispatch is still authorized there via IAuthorizer
regardless of what currentPrincipal() says client-side.
Relationship to Context. Context is the per-call payload that travels
with every dispatch and is what the server actually authorizes against;
Principal is a client-side, longer-lived snapshot of the outcome of that
authorization (as told to the client at login), read outside any one call.
Setting a Principal does not affect Context or dispatch behavior in any
way — it is purely a UI-facing convenience with no wire representation.
IAuthorizer is an abstract interface called once per execute envelope,
before the action is dispatched. A false return causes the server to reply
with err|unauthorized (the client surfaces the error through .onError(...)).
[[nodiscard]] virtual bool authorize(
const Context& ctx,
std::string_view modelType,
std::string_view actionType) const = 0;The three parameters represent the caller's session, the target model type
string id, and the action type being invoked. authorize sees only type
ids, never the target model instance id. Row/instance-level authorization
(e.g. "may this principal edit this account?") is expressed by the separate
optional authorizeInstance hook,
not here — or, for logic the framework cannot know, inside the model's
execute().
Real deployments subclass this to check principal claims, action permissions,
rate limits, etc. authorize is const and stateless by contract; any stateful
policy (rate-limit counters, revocation lists) must live in the subclass's own
members, guarded for concurrent access — the framework calls it from the
dispatch thread and gives it no per-call mutable state.
IAuthorizer has a second, optional virtual:
[[nodiscard]] virtual std::optional<std::string>
authenticate(const Context& ctx) const { return std::nullopt; }The default returns nullopt, meaning "I do not authenticate". RemoteServer
calls authenticate after authorize has already succeeded:
- If it returns a value, the server overwrites
Context::principalwith that verified value before building theScopedContextaround dispatch. From then on, a model readingsession::current()->principalsees the verified identity the authorizer vouched for, not the string the client sent. - If it returns
nullopt, the server clearsContext::principalto the empty string. An unverified principal is never presented to model code as authoritative — the client's claim does not pass through. This closes a time-of-check/time-of-use gap (a token can passauthorizeand then expire beforeauthenticate, whosenulloptwould otherwise let the client's claim survive) and the analogous authorize-only passthrough. See security.md.
This split keeps the two concerns separate: authorize answers "is this call
permitted?" and authenticate answers "who is actually making it?". An
authorizer may implement either, both, or (via the default) only the first —
but a caller that is not authenticated is presented to the model with an empty
principal regardless.
AllowAllAuthorizeruses the defaultauthenticate— it performs no authentication, sodispatchExecuteclears the principal: the call still dispatches, but the model sees an empty principal, never the client's claim.SigningAuthorizer(session_auth.hpp) overrides it: it verifiesContext::tokenand returns the token'sprincipal, so the authenticated identity becomes authoritative for model code.
The full mechanism — token format, verification order, RemoteServer's
dispatchExecute call site, and the login flow — is documented in
security.md. This spec fixes the contract: authenticate
is consulted post-authorize, its return replaces the principal when present,
and its nullopt clears the principal.
IAuthorizer has a third, optional virtual that closes the cross-tenant
targeting gap authorize cannot (it only sees the model type):
[[nodiscard]] virtual bool authorizeInstance(
const Context& ctx,
std::string_view modelType, // empty for deregister
std::string_view actionType, // empty for deregister
std::uint64_t modelId,
std::string_view ownerPrincipal // recorded at register time; empty if none
) const { return true; } // DEFAULT: allowRemoteServer records an owner principal for each instance at register time —
the verified identity of the register call (authenticate(env.session)), never
the client's raw claim; empty when the authorizer does not authenticate. It then
consults authorizeInstance on every execute (after authorize succeeds
and the verified principal is stamped) and on every deregister (with empty
type/action ids), passing the target instance id and its recorded owner. A
false return replies err "unauthorized" and the operation does not proceed.
The default returns true, so AllowAllAuthorizer and a plain
SigningAuthorizer impose no per-instance restriction — behaviour is unchanged
unless an authorizer overrides the hook (typically ownerPrincipal.empty() || ownerPrincipal == ctx.principal). The full mechanism, the register-time owner
recording, and the trust model are in security.md
("The per-instance ownership hook"). This spec fixes the contract:
authorizeInstance is consulted per execute and per deregister, defaults to
allow, and receives the instance id plus its recorded owner.
IAuthorizer has a fourth, optional virtual that closes the gap neither
authorize nor authorizeInstance can: bounding who may create a model
instance in the first place.
[[nodiscard]] virtual bool authorizeRegister(
const Context& ctx,
std::string_view modelType) const { return true; } // DEFAULT: allowRemoteServer consults it on every register envelope, after
authenticating the caller (so ctx.principal is already the verified
identity — never the client's raw claim — when the hook runs) and before
constructing the instance. A false return replies err "unauthorized" and
no instance is created; ModelRegistryFactory::create never runs, so the
rejection costs nothing beyond the authenticate/authorize check.
The default returns true, so AllowAllAuthorizer and a plain
SigningAuthorizer impose no register restriction — an unconfigured server
registers any known model type exactly as before. A deployer opts into
bounding registration by overriding the hook, typically requiring
authentication and/or restricting modelType:
struct RegisterGate : morph::session::SigningAuthorizer {
using SigningAuthorizer::SigningAuthorizer;
bool authorizeRegister(const morph::session::Context& ctx, std::string_view) const override {
return !ctx.principal.empty(); // only authenticated callers may register
}
};authorizeRegister composes with authorizeInstance: registration decides
whether an instance may be created and by whom; the owner recorded from
that same register call then drives per-instance execute/deregister
decisions. The full mechanism and the handleInline synchronous path are in
security.md ("The register-authorization hook").
The framework ships a default authorizer that permits every call and performs no
authentication (it inherits the default authenticate). It is used as the
default by RemoteServer and can also be wired explicitly.
allowAllAuthorizer() returns a std::shared_ptr<IAuthorizer> pointing at a
process-wide singleton. This avoids allocating a new shared_ptr per server
instance when the default is sufficient.
AllowAllAuthorizer is fail-open: it is convenient for local and simulated
development and wrong for production. See Trust boundary and
security.md ("The default is fail-open") for why an exposed
server must replace it.
The authorizer exists because a Context arriving over the wire is untrusted
input. The rules that govern where trust begins:
- On the remote path, every
Contextfield is unauthenticated wire input.principal,token,requestId,locale, andmetadataare all deserialised verbatim from the envelope the client sent.principalin particular is a claim, not an identity, until a verifying authorizer'sauthenticateoverwrites it — and when no authorizer authenticates, the server clearsprincipalrather than passing the claim through. Model code must not treat any field as trusted before that point. - The local backend does not authorize at all.
authorizeis a remote-only gate:LocalBackend::executeinstalls the session context but never consults the authorizer (its sole call site isRemoteServer'sdispatchExecute, see backend.md). Any security-critical check must therefore be enforced inside the model so it holds in both local and remote modes. - The
RemoteServerdefault authorizer is fail-open. An unconfigured server usesallowAllAuthorizer()and permits everything. Production deployments must install a verifying, deny-by-default authorizer. authorizesees only type ids, not the instance id. Instance-/row-level authorization is expressed by the optionalauthorizeInstancehook (consulted perexecute/deregisterwith the instance id and its recorded owner; defaults to allow) — or, for logic the framework cannot know, in the model.
The registry that maps those type ids to runners is described in registry.md; the enforcement points and the full threat model are in security.md.
Models that need session data can access it without changing their
execute() signature. The mechanism is a thread-local pointer (the
detail::tlsCurrent() const Context*&) installed by the backend for the
duration of the model invocation.
current() returns const Context* — the active context for the
in-progress action, or nullptr when called outside a dispatch. It is declared
noexcept. Models that don't need session data ignore it entirely.
The context is thread-local and dispatch-scoped. The backend installs the pointer on the exact thread that runs the model call and clears it when that call returns. It is therefore valid only on that dispatch thread:
current()returnsnullptron any thread the model spawns, and on async work the model schedules to run later — the thread-local is not propagated across thread boundaries.- A model that needs session data after crossing a thread boundary must
capture what it needs (copy the
principal,locale, or specific metadata values) before handing work to another thread, rather than callingcurrent()from the other side.
ScopedContext (in detail namespace) is the RAII helper that installs a
Context pointer for its scope and restores the previous one on
destruction. Because it saves and restores the prior pointer rather than
clearing to nullptr, nested dispatch composes correctly — an inner
ScopedContext shadows the outer context and the outer one is restored when the
inner scope exits. Construction is explicit, and copy and move are all four
deleted. The stored pointer is the address of the referenced Context, which
must outlive the ScopedContext.
It is used by LocalBackend::execute to wrap localOp(*holder) and by
RemoteServer::dispatchExecute to wrap ActionDispatcher::dispatch, so the
context is live while the model's execute() runs. ActionDispatcher::dispatch
itself does not touch the thread-local pointer — it only looks up and invokes the
registered runner.
A usage example from the bank example (bank/core/principal.hpp):
if (const auto* ctx = morph::session::current(); ctx != nullptr) {
// use ctx->principal, ctx->locale, etc.
}| Member | Signature | Notes |
|---|---|---|
principal |
std::string |
Auth principal; a client claim until a verifying authorizer's authenticate overwrites it. |
token |
std::string |
Bearer credential verified server-side; travels in the wire envelope's session. Empty if unauthenticated. |
requestId |
std::string |
Trace id; empty when unused. |
locale |
std::string |
BCP-47 locale; empty for default. |
metadata |
std::unordered_map<std::string, std::string> |
Free-form metadata bag. |
| Member | Signature | Notes |
|---|---|---|
id |
std::string |
Verified identity (e.g. username), as returned by the server. Empty if signed out. |
roles |
std::vector<std::string> |
Coarse-grained roles the app can gate UI on. |
claims |
std::unordered_map<std::string, std::string> |
Free-form claims beyond id/roles. |
hasRole |
[[nodiscard]] bool hasRole(std::string_view role) const |
true if roles contains @p role. |
Bridge::setPrincipal/Bridge::currentPrincipal (core/bridge.hpp) install
and read it; see Principal
above and bridge.md.
| Member | Signature | Notes |
|---|---|---|
~IAuthorizer() |
virtual ~IAuthorizer() = default |
Virtual destructor for polymorphic use. |
authorize |
[[nodiscard]] virtual bool authorize(const Context&, std::string_view modelType, std::string_view actionType) const = 0 |
Returns true to allow dispatch, false to reject. Called per execute envelope. Sees only type ids. |
authenticate |
[[nodiscard]] virtual std::optional<std::string> authenticate(const Context&) const |
Optional. Default returns nullopt. Called after authorize succeeds; a returned value overwrites Context::principal (making it authoritative), and nullopt clears Context::principal so an unverified claim is never presented to the model. Also called at register time to record the instance's owner principal. |
authorizeInstance |
[[nodiscard]] virtual bool authorizeInstance(const Context&, std::string_view modelType, std::string_view actionType, std::uint64_t modelId, std::string_view ownerPrincipal) const |
Optional. Default returns true (allow). Consulted per execute and per deregister with the target instance id and its recorded owner. Override to enforce per-instance ownership; modelType/actionType are empty for deregister. |
authorizeRegister |
[[nodiscard]] virtual bool authorizeRegister(const Context&, std::string_view modelType) const |
Optional. Default returns true (allow). Consulted on every register, after authentication, before the instance is constructed. Override to bound who may create an instance. |
| Member | Signature | Notes |
|---|---|---|
authorize |
[[nodiscard]] bool authorize(const Context&, std::string_view, std::string_view) const override |
Always returns true. All parameters ignored. |
authenticate |
(inherited default) | Not overridden — returns nullopt, so dispatchExecute clears the client's principal (the call still dispatches, but the model sees an empty principal). |
| Symbol | Signature | Notes |
|---|---|---|
allowAllAuthorizer() |
std::shared_ptr<IAuthorizer> allowAllAuthorizer() |
Returns a process-wide singleton AllowAllAuthorizer. |
current() |
const Context* current() noexcept |
Returns the active context, or nullptr when none / on a non-dispatch thread. |
| Member | Signature | Notes |
|---|---|---|
| ctor | explicit ScopedContext(const Context& ctx) |
Saves the current thread-local context and installs ctx. ctx must outlive this object. |
| dtor | ~ScopedContext() |
Restores the previously active context (composes under nesting). |
| (copy/move) | deleted | Non-copyable, non-movable. |
| Decision | Choice | Why |
|---|---|---|
| Context shape | Plain struct, not polymorphic | The context is a data bag, not a behaviour abstraction; a struct is simpler to construct, serialise, and consume. |
| Thread-local context | const Context* TLS pointer, installed by the backend around the model call |
Models read session data without changing execute() signatures; the pointer is const (immutable during dispatch) and RAII-guarded by ScopedContext. |
ScopedContext restores the previous pointer |
Save/restore rather than clear-to-null | Nested dispatch (a model dispatching into another) composes: the inner context shadows the outer and the outer is restored on exit. |
| Authorizer granularity | Per-envelope, before dispatch, type ids only for authorize; a separate optional authorizeInstance for row-level |
authorize stays a cheap type-level gate; per-instance ownership is a distinct optional hook so a deployer can enforce multi-tenancy without patching the framework, while the default (allow) keeps existing servers unchanged. Logic the framework cannot know still lives in the model. |
| Instance ownership hook defaults to allow | authorizeInstance returns true by default; owner recorded from the verified register principal |
Backward compatibility: unconfigured and allow-all servers behave exactly as before. Recording the owner from the verified (not claimed) principal makes the check meaningful only under a verifying authorizer, which is the only context where ownership is trustworthy. |
| Authentication as a separate hook | authenticate distinct from authorize, optional with a nullopt default; nullopt clears the principal |
Separates "is this permitted?" from "who is it?"; authorizers that don't authenticate cost nothing, and the verified principal — not the client's claim — becomes authoritative when one does. A nullopt result clears the principal so an unauthenticated claim is never trusted, closing the TOCTOU/authorize-only passthrough. |
| Singleton authorizer | Static local in allowAllAuthorizer() |
All trivial RemoteServer instances share one AllowAllAuthorizer allocation rather than each owning one. |
| Serialisation | Entire Context travels on the wire |
The remote backend's RemoteServer sees the same principal, token, requestId, locale, and metadata the GUI sent; no information is stripped. |
Principal scope |
Per-Bridge instance, not a process-wide global |
A global "current user" is convenient for a single-backend desktop client but wrong in general — an app may hold more than one Bridge, and a global would let one backend's identity leak into another's UI gating. Scoping it to the same object that already holds the default Context (setDefaultSession) keeps one consistent place to look for "this backend's session state," with no new ambient state. |
principalcarries no integrity on its own. It is a plain string in a serialised struct; nothing in the type prevents a client from sending any value. It becomes trustworthy only after a verifying authorizer'sauthenticateoverwrites it; absent that,dispatchExecuteclears it so the claim is never presented to the model as authoritative.- Authentication depends on the transport plus a verifying authorizer. The
tokenis a bearer credential with no envelope-level confidentiality or replay protection; its guarantees hold only under TLS (so the token cannot be captured and replayed) and with an authorizer such asSigningAuthorizerinstalled. A plainRemoteServerwith the default authorizer authenticates nothing. - Stateful authorization policy needs the impl's own state.
authorizeisconstand receives no mutable per-call state, so rate limits, revocation lists, or lockout counters must be stored in (and synchronised by) the authorizer subclass itself. current()is dispatch-thread-only. It returnsnullptroff the dispatch thread; session data needed across a thread boundary must be captured first.Bridge::currentPrincipal()(see Principal) closes this specifically for "who is signed in and what may they do?" — the common case a UI needs — without making the full per-callContextreadable off-thread.
See security.md for the complete threat model and hardening checklist (TLS, message-size bounds, control-message authorization, secret rotation).
- security.md — the authentication subsystem: signed bearer
tokens,
SigningAuthorizer, theRemoteServerenforcement points, the threat model, and hardening guidance. The primary companion to this spec. - wire.md — the envelope the
Context(includingtoken) is serialised into on the remote path. - backend.md —
RemoteServer/LocalBackenddispatch, and where the authorizer is (and is not) consulted. - registry.md — the model/action type registry behind the type
ids
authorizereceives.