morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
session.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cstdint>
6#include <memory>
7#include <optional>
8#include <string>
9#include <string_view>
10#include <unordered_map>
11#include <vector>
12
13namespace morph::session {
14
15/// @brief Per-call session context carried through the bridge to the model.
16///
17/// `Context` is an open data bag: callers populate whichever fields make sense
18/// for the app (user identity, request tracing, locale, app-specific metadata).
19/// The framework only inspects `principal` and the action ids when consulting
20/// the configured `IAuthorizer`; everything else is passed through verbatim
21/// from caller to model.
22///
23/// On remote backends the `Context` is serialised into the wire envelope, so
24/// the server-side `RemoteServer` sees the same values the GUI sent. On the
25/// local backend it travels in-memory via the `ActionCall`.
26struct Context {
27 /// @brief Auth principal (user id).
28 ///
29 /// On the client this is whatever the caller sets and is **not** trustworthy
30 /// on its own — it is untrusted wire input. When a verifying authorizer (e.g.
31 /// `SigningAuthorizer`, `session_auth.hpp`) is installed, `RemoteServer`
32 /// **overwrites** this field with the principal extracted from a valid
33 /// `token` before dispatch, so `session::current()->principal` read inside a
34 /// model is the authenticated identity, not the client's claim.
35 std::string principal;
36
37 /// @brief Bearer credential verified server-side (empty if unauthenticated).
38 ///
39 /// Typically a signed token minted by a login action via
40 /// `session::TokenIssuer` and attached to every call (see `session_auth.hpp`
41 /// and `docs/spec/security.md`). Travels in the wire envelope's `session`.
42 std::string token;
43
44 /// @brief Stable id used for distributed tracing / log correlation. Empty if unused.
45 std::string requestId;
46
47 /// @brief BCP-47 locale tag (e.g. `en-US`, `fr-FR`) for i18n. Empty for app default.
48 std::string locale;
49
50 /// @brief Free-form bag of string→string metadata (feature flags, A/B buckets, …).
51 std::unordered_map<std::string, std::string> metadata;
52};
53
54/// @brief The signed-in user's verified identity, readable outside a dispatch.
55///
56/// `Context::principal` only exists *during* a dispatch — `session::current()`
57/// returns `nullptr` outside one, so UI code (a button's enabled state, a menu
58/// item's visibility) has no way to ask "who is signed in and what may they do?"
59/// without attempting the action and catching the failure. `Principal` is the
60/// longer-lived counterpart an application installs once, typically right
61/// after a successful login dispatch, and reads from UI code to shape itself
62/// instead: `bridge.currentPrincipal().hasRole("editor")`.
63///
64/// Deliberately **not** a process-wide global: the caller installs it on the
65/// specific `Bridge` (or other session-scoped object) whose backend it came
66/// from — see `Bridge::setPrincipal`/`Bridge::currentPrincipal`
67/// (`core/bridge.hpp`) — rather than one ambient value shared by every backend
68/// a process happens to hold.
69///
70/// @warning Populate this only from data the server actually returned (e.g. an
71/// action's `Result`, such as the bank example's `AuthResult`), never from a
72/// client-side guess — the server remains the sole authority for what a
73/// principal may do; this is a read-only cache of what it last told the
74/// client, not a second, independently-decided permission set. Roles can
75/// change server-side mid-session, so every dispatch is still authorized
76/// there regardless of what a stale `Principal` says client-side; this only
77/// shapes the UI, it never replaces server-side authorization.
78struct Principal {
79 /// @brief Verified identity (e.g. username), as returned by the server. Empty if signed out.
80 std::string id;
81
82 /// @brief Coarse-grained roles the app can gate UI on (e.g. `"editor"`, `"admin"`).
83 std::vector<std::string> roles;
84
85 /// @brief Free-form bag of string→string claims beyond `id`/`roles`.
86 std::unordered_map<std::string, std::string> claims;
87
88 /// @brief Returns `true` if `roles` contains @p role.
89 /// @param role Role name to look for.
90 /// @return `true` if @p role is present in `roles`.
91 [[nodiscard]] bool hasRole(std::string_view role) const { return std::ranges::find(roles, role) != roles.end(); }
92};
93
94/// @brief Authorizes incoming actions on a `RemoteServer`.
95///
96/// Called before dispatch on **four** of the server's own envelope kinds, not only `execute`. A
97/// `false` return causes the server to reply with `err|unauthorized` (the client
98/// surfaces the error through the `.onError(...)` callback):
99///
100/// | Envelope | `modelType` | `actionType` |
101/// |---|---|---|
102/// | `execute` | `env.modelType` | `env.actionType` |
103/// | `instances` | `env.typeId` | **empty** |
104/// | `schemas` | `env.typeId` | **empty** |
105/// | `deregister` | **empty** | **empty** |
106///
107/// A server configured with the UI service (`ui::UiService`) also calls it for
108/// that service's kinds, under the pseudo-model `ui`:
109///
110/// | Envelope | `modelType` | `actionType` |
111/// |---|---|---|
112/// | `ui-hello`, and `ui-fetch` of a public file | `ui` | `hello` |
113/// | `ui-catalog` | `ui` | `catalog` |
114/// | `ui-fetch` of any other file | `ui` | `fetch` |
115///
116/// and, to filter the catalog, with each screen's model types and action ids,
117/// as `execute` would. See `docs/spec/ui/service.md`, "Authorisation".
118///
119/// A `deregister` names no type, so both ids are empty; its type-level call
120/// is what lets a verifying authorizer refuse a release from a caller with no
121/// valid token before the per-instance `authorizeInstance` check runs.
122///
123/// The two read channels are gated deliberately, so a deployer can refuse
124/// enumeration or schema disclosure without refusing use -- `schemas` returns
125/// field names, bounds, rules and the payload fingerprint of every action, so it
126/// must not be reachable by a caller the server would not let execute.
127///
128/// **An implementation that switches or matches on `modelType` or `actionType`
129/// must handle the empty case explicitly**, or it will hit its default arm on
130/// exactly those verbs. Whether that fails open or closed is the implementation's
131/// choice, but it has to be a choice.
132///
133/// Default implementation supplied by the framework is `AllowAllAuthorizer`. Real
134/// deployments install a custom subclass that checks principal claims, action
135/// permissions, rate limits, etc.
136// NOLINTBEGIN(cppcoreguidelines-special-member-functions)
138 virtual ~IAuthorizer() = default;
139
140 /// @brief Returns `true` if @p ctx is allowed to invoke @p actionType on @p modelType.
141 ///
142 /// @param ctx Per-call session attached by the client.
143 /// @param modelType String id of the target model type, or **empty** for the
144 /// `deregister` envelope.
145 /// @param actionType String id of the action being invoked, or **empty** for
146 /// the `instances`, `schemas` and `deregister` envelopes --
147 /// see the table on this interface's own doc comment.
148 /// @return `true` to allow dispatch, `false` to reject with `err|unauthorized`.
149 [[nodiscard]] virtual bool authorize(const Context& ctx,
150 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
151 std::string_view modelType, std::string_view actionType) const = 0;
152
153 /// @brief Returns the authenticated principal for @p ctx, or `nullopt`.
154 ///
155 /// Called by `RemoteServer` after `authorize` succeeds. If it returns a
156 /// value, the server overwrites `Context::principal` with it before dispatch,
157 /// making the identity authoritative for model code that reads
158 /// `session::current()`. The default returns `nullopt` — for authorizers that
159 /// do not authenticate (e.g. `AllowAllAuthorizer`), `RemoteServer` **clears**
160 /// `Context::principal` to the empty string before dispatch rather than
161 /// passing the client's unverified claim through, so model code never sees an
162 /// unauthenticated principal presented as authoritative. A verifying authorizer
163 /// (`SigningAuthorizer`) overrides this to supply the token-derived identity.
164 /// @param ctx Per-call session attached by the client.
165 /// @return The verified principal to make authoritative, or `nullopt`.
166 [[nodiscard]] virtual std::optional<std::string> authenticate([[maybe_unused]] const Context& ctx) const {
167 return std::nullopt;
168 }
169
170 /// @brief What `admit` decides about one call: whether it may run, and
171 /// under which verified principal.
172 struct Admission {
173 /// `authorize`'s answer.
174 bool allowed = false;
175 /// `authenticate`'s answer: the principal to make authoritative, or
176 /// `nullopt` when the authorizer cannot vouch for one.
177 std::optional<std::string> principal;
178 };
179
180 /// @brief Authenticates and authorizes one call together.
181 ///
182 /// `RemoteServer` calls this once per `execute`, in place of
183 /// `authenticate` and `authorize`, so both decisions come from one look at
184 /// the session. The default calls `authenticate` and then `authorize`,
185 /// which keeps an authorizer that overrides only those two exactly as it
186 /// was. A verifying authorizer overrides it to verify the token once and
187 /// take both answers from that verification: a token that expires between
188 /// two verifications cannot be judged valid by one and expired by the
189 /// other.
190 /// @param ctx Per-call session attached by the client.
191 /// @param modelType String id of the target model type.
192 /// @param actionType String id of the action being invoked.
193 /// @return Whether the call may run, and the verified principal.
194 [[nodiscard]] virtual Admission admit(const Context& ctx,
195 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
196 std::string_view modelType, std::string_view actionType) const {
197 auto principal = authenticate(ctx);
198 return Admission{.allowed = authorize(ctx, modelType, actionType), .principal = std::move(principal)};
199 }
200
201 /// @brief Optional per-**instance** authorization hook — the multi-tenant gate.
202 ///
203 /// `authorize` sees only the model *type*, so it cannot answer "may this
204 /// caller touch *this instance* (row)?". Model instances on a `RemoteServer`
205 /// are addressable by guessable sequential ids, so without an ownership check
206 /// any authenticated caller can `execute`/`deregister` against an id it did
207 /// not create — a cross-tenant targeting gap. This hook closes it: when
208 /// installed, `RemoteServer` consults it on every `execute` **and** every
209 /// `deregister`, passing the id of the target instance and the principal
210 /// recorded as its owner at `register` time (empty if the instance was
211 /// registered by an unauthenticated caller or predates ownership tracking).
212 ///
213 /// The **default allows everything**, so an authorizer that does not override
214 /// it — including `AllowAllAuthorizer` and a plain `SigningAuthorizer` — keeps
215 /// the pre-existing behaviour exactly (no per-instance restriction). A
216 /// deployer opts into ownership enforcement by overriding this, typically to
217 /// compare @p ownerPrincipal against `ctx.principal`:
218 /// @code
219 /// bool authorizeInstance(const Context& ctx, std::string_view, std::string_view,
220 /// uint64_t, std::string_view ownerPrincipal) const override {
221 /// return ownerPrincipal.empty() || ownerPrincipal == ctx.principal;
222 /// }
223 /// @endcode
224 ///
225 /// @param ctx Per-call session (its `principal` is authoritative
226 /// only if a verifying authorizer is installed).
227 /// @param modelType Target model type id (empty for `deregister`).
228 /// @param actionType Target action type id (empty for `deregister`).
229 /// @param modelId Numeric id of the target instance.
230 /// @param ownerPrincipal Principal recorded as the instance's owner at
231 /// `register` time; empty if none was recorded.
232 /// @return `true` to allow the operation on this instance, `false` to reject.
233 [[nodiscard]] virtual bool authorizeInstance([[maybe_unused]] const Context& ctx,
234 [[maybe_unused]] std::string_view modelType,
235 [[maybe_unused]] std::string_view actionType,
236 [[maybe_unused]] std::uint64_t modelId,
237 [[maybe_unused]] std::string_view ownerPrincipal) const {
238 return true;
239 }
240
241 /// @brief Optional gate on model **creation** — bounds *who may create* an instance.
242 ///
243 /// `authorize` and `authorizeInstance` both act on an already-existing
244 /// instance; neither can answer "may this caller create one at all?".
245 /// `RemoteServer` consults this hook on every `register` envelope, after
246 /// authenticating the caller (so @p ctx carries the *verified* principal,
247 /// not the client's raw claim) and before constructing the instance. A
248 /// `false` return replies `err "unauthorized"` and **no instance is
249 /// created** — `ModelRegistryFactory::create` never runs.
250 ///
251 /// The **default allows everything**, so an authorizer that does not
252 /// override it — including `AllowAllAuthorizer` and a plain
253 /// `SigningAuthorizer` — keeps the pre-existing behaviour exactly (any
254 /// reachable client may register any known model type). A deployer opts
255 /// into bounding registration by overriding this, typically requiring
256 /// authentication and/or restricting @p modelType:
257 /// @code
258 /// bool authorizeRegister(const Context& ctx, std::string_view modelType) const override {
259 /// return !ctx.principal.empty(); // only authenticated callers may register
260 /// }
261 /// @endcode
262 ///
263 /// @param ctx Per-call session for the register envelope; its
264 /// `principal` is already the verified identity when the
265 /// authorizer authenticates (empty otherwise).
266 /// @param modelType String id of the model type being registered.
267 /// @return `true` to allow the registration, `false` to reject with
268 /// `err "unauthorized"`.
269 [[nodiscard]] virtual bool authorizeRegister([[maybe_unused]] const Context& ctx,
270 [[maybe_unused]] std::string_view modelType) const {
271 return true;
272 }
273};
274// NOLINTEND(cppcoreguidelines-special-member-functions)
275
276/// @brief Default authorizer that permits everything.
277///
278/// Wire it explicitly via `RemoteServer(pool, dispatcher, registry, allowAll)`
279/// for documentation, or rely on the server's default (which uses this type).
281 /// @brief Permits every call.
282 /// @param ctx Ignored.
283 /// @param modelType Ignored.
284 /// @param actionType Ignored.
285 /// @return Always `true`.
286 [[nodiscard]] bool authorize([[maybe_unused]] const Context& ctx,
287 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
288 [[maybe_unused]] std::string_view modelType,
289 [[maybe_unused]] std::string_view actionType) const override {
290 return true;
291 }
292};
293
294/// @brief Returns the process-wide allow-all authorizer (used as the default).
295inline std::shared_ptr<IAuthorizer> allowAllAuthorizer() {
296 static auto instance = std::make_shared<AllowAllAuthorizer>();
297 return instance;
298}
299
300namespace detail {
301
302/// @brief Thread-local pointer to the `Context` for the currently dispatched action.
303///
304/// Installed by the backend around the model call — `LocalBackend::execute` on the
305/// local path and `RemoteServer::dispatchExecute` on the remote path — via a
306/// `ScopedContext` that clears it again on scope exit. `ActionDispatcher::dispatch`
307/// itself does not touch this pointer. Models that opt in to session-aware logic
308/// call `current()` to read it.
309inline const Context*& tlsCurrent() {
310 thread_local const Context* tls = nullptr;
311 return tls;
312}
313
314/// @brief RAII helper that sets the thread-local `Context` for its scope.
316public:
317 /// @brief Installs @p ctx as the thread-local context until the scope exits.
318 /// @param ctx Context whose address is stored; must outlive this object.
319 explicit ScopedContext(const Context& ctx) : _prev{tlsCurrent()} { tlsCurrent() = &ctx; }
320 /// @brief Restores the thread-local context that was active at construction.
321 ~ScopedContext() { tlsCurrent() = _prev; }
322 ScopedContext(const ScopedContext&) = delete;
323 ScopedContext& operator=(const ScopedContext&) = delete;
324 ScopedContext(ScopedContext&&) = delete;
325 ScopedContext& operator=(ScopedContext&&) = delete;
326
327private:
328 const Context* _prev;
329};
330
331} // namespace detail
332
333/// @brief Returns the active `Context` for the in-progress action, or `nullptr` if none.
334///
335/// Models that don't need session data can ignore this entirely. Models that do
336/// can read principal/locale/metadata without changing their `execute()` signature.
337inline const Context* current() noexcept { return detail::tlsCurrent(); }
338
339} // namespace morph::session
RAII helper that sets the thread-local Context for its scope.
Definition session.hpp:315
~ScopedContext()
Restores the thread-local context that was active at construction.
Definition session.hpp:321
ScopedContext(const Context &ctx)
Installs ctx as the thread-local context until the scope exits.
Definition session.hpp:319
Default authorizer that permits everything.
Definition session.hpp:280
bool authorize(const Context &ctx, std::string_view modelType, std::string_view actionType) const override
Permits every call.
Definition session.hpp:286
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
std::string token
Bearer credential verified server-side (empty if unauthenticated).
Definition session.hpp:42
std::unordered_map< std::string, std::string > metadata
Free-form bag of string→string metadata (feature flags, A/B buckets, …).
Definition session.hpp:51
std::string principal
Auth principal (user id).
Definition session.hpp:35
std::string requestId
Stable id used for distributed tracing / log correlation. Empty if unused.
Definition session.hpp:45
std::string locale
BCP-47 locale tag (e.g. en-US, fr-FR) for i18n. Empty for app default.
Definition session.hpp:48
What admit decides about one call: whether it may run, and under which verified principal.
Definition session.hpp:172
bool allowed
authorize's answer.
Definition session.hpp:174
std::optional< std::string > principal
Definition session.hpp:177
Authorizes incoming actions on a RemoteServer.
Definition session.hpp:137
virtual bool authorizeRegister(const Context &ctx, std::string_view modelType) const
Optional gate on model creation — bounds who may create an instance.
Definition session.hpp:269
virtual Admission admit(const Context &ctx, std::string_view modelType, std::string_view actionType) const
Authenticates and authorizes one call together.
Definition session.hpp:194
virtual bool authorizeInstance(const Context &ctx, std::string_view modelType, std::string_view actionType, std::uint64_t modelId, std::string_view ownerPrincipal) const
Optional per-**instance** authorization hook — the multi-tenant gate.
Definition session.hpp:233
virtual std::optional< std::string > authenticate(const Context &ctx) const
Returns the authenticated principal for ctx, or nullopt.
Definition session.hpp:166
virtual bool authorize(const Context &ctx, std::string_view modelType, std::string_view actionType) const =0
Returns true if ctx is allowed to invoke actionType on modelType.
The signed-in user's verified identity, readable outside a dispatch.
Definition session.hpp:78
std::vector< std::string > roles
Coarse-grained roles the app can gate UI on (e.g. "editor", "admin").
Definition session.hpp:83
std::string id
Verified identity (e.g. username), as returned by the server. Empty if signed out.
Definition session.hpp:80
std::unordered_map< std::string, std::string > claims
Free-form bag of string→string claims beyond id/roles.
Definition session.hpp:86
bool hasRole(std::string_view role) const
Returns true if roles contains role.
Definition session.hpp:91