From f0dbe58ad3ea76e4484288207b806426ac0014a8 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 19:39:23 +0300 Subject: [PATCH 1/2] session: add Principal, readable authorization state outside a dispatch session::current() (Context::principal) only exists during a dispatch, so UI code -- a button's enabled state, a menu item's visibility -- had no way to ask "who is signed in and what may they do?" without attempting the action and catching the refusal. Applications ended up maintaining a second, parallel notion of the signed-in user's permissions for the UI, with no guarantee it agreed with the one morph verifies server-side. Add morph::session::Principal (id, roles, claims, hasRole()) and Bridge::setPrincipal/currentPrincipal: an application installs it once, typically right after a successful login dispatch from data the server actually returned, and UI code reads it back outside any dispatch. Scoped to the specific Bridge instance -- not a process-wide global -- alongside the existing setDefaultSession/defaultSession, each guarded by its own mutex. Purely a client-side convenience: no wire representation, and it never substitutes for server-side authorization, which still runs on every dispatch via IAuthorizer regardless of what currentPrincipal() says. Closes #24 Signed-off-by: Yaraslau Tamashevich --- docs/spec/core/bridge.md | 15 +++++ docs/spec/session/session.md | 70 ++++++++++++++++++++ include/morph/core/bridge.hpp | 30 +++++++++ include/morph/session/session.hpp | 44 +++++++++++++ tests/CMakeLists.txt | 1 + tests/test_principal.cpp | 106 ++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+) create mode 100644 tests/test_principal.cpp diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 40ebc9b5..7bbc0c9b 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -223,6 +223,19 @@ sending a now-destroyed `ModelId` to the backend. `morph::session::Context` that is attached to every `executeVia()` call. Thread-safe, separate mutex from `_mtx`. +**`setPrincipal(principal)`** / **`currentPrincipal()`** installs and reads +back a `morph::session::Principal` — the verified identity + roles, readable +*outside* a dispatch (unlike `session::current()`, which only exists during +one), so UI code can gate itself (`bridge.currentPrincipal().hasRole("editor")`) +instead of attempting an action and catching the refusal. Thread-safe, its own +mutex (`_principalMtx`, separate from both `_mtx` and the session mutex). +Scoped to this `Bridge` instance, not a process-wide global — see +[session.md](../session/session.md#principal--readable-authorization-state-outside-a-dispatch) +for the full rationale and trust model. Purely a client-side convenience: it +has no wire representation and does not affect dispatch or `Context` in any +way — every dispatch is still authorized server-side via `IAuthorizer` +regardless of what `currentPrincipal()` says. + **Destructor** first **clears the active backend's reconnect handler** (`setReconnectHandler(nullptr)`), then cancels every pending completion on that backend with `BridgeDestroyedError`. In-flight replies that arrive after @@ -500,6 +513,8 @@ make teardown order-independent.) | `executeVia` | `Completion executeVia(const shared_ptr&, Action, IExecutor*)` | Lock-free dispatch. Attaches default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records a journal `LogEntry` for loggable actions on both success (`Outcome::Succeeded`) and a throwing `Model::execute` (`Outcome::Failed`, rethrown unchanged). Value-forwarding into the typed `Completion` is `try`/`catch`-guarded — a throwing result move/copy resolves the completion via `onError` instead of hanging or terminating. The bridge-touching side effects (`onResult`, `hasSubscribers()`/`publishResult`) are gated on the `_liveness` token, checked before either runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. | | `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context. | | `defaultSession` | `session::Context defaultSession() const` | Returns snapshot of default session. | +| `setPrincipal` | `void setPrincipal(session::Principal)` | Installs the verified `Principal`, readable outside a dispatch. Pass `Principal{}` to clear (sign-out). | +| `currentPrincipal` | `session::Principal currentPrincipal() const` | Returns a snapshot of the installed `Principal`; default-constructed if none was ever set. | ### `BridgeHandler` diff --git a/docs/spec/session/session.md b/docs/spec/session/session.md index 98e25b0d..4868ccb7 100644 --- a/docs/spec/session/session.md +++ b/docs/spec/session/session.md @@ -15,6 +15,7 @@ security.md for the full trust model rather than duplicating it. - [Context — an open data bag](#context--an-open-data-bag) - [How a `Context` originates and flows](#how-a-context-originates-and-flows) +- [Principal — readable authorization state outside a dispatch](#principal--readable-authorization-state-outside-a-dispatch) - [IAuthorizer — gate for action dispatch](#iauthorizer--gate-for-action-dispatch) - [The `authenticate` hook — the authoritative principal](#the-authenticate-hook--the-authoritative-principal) - [The `authorizeInstance` hook — per-instance ownership](#the-authorizeinstance-hook--per-instance-ownership) @@ -79,6 +80,57 @@ The bridge plumbing (`setDefaultSession`/`defaultSession`, the `ActionCall` stamp) is specified in [bridge.md](../core/bridge.md); this spec covers only the `Context` payload and its server-side handling. +## Principal — readable authorization state outside a dispatch + +`Context::principal` only exists **during** a dispatch: `session::current()` +returns `nullptr` outside one (see [Thread safety](#thread-safety--current-and-scopedcontext)), +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: + +```cpp +struct Principal { + std::string id; + std::vector roles; + std::unordered_map 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: + +```cpp +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 — gate for action dispatch `IAuthorizer` is an abstract interface called once per `execute` envelope, @@ -324,6 +376,19 @@ if (const auto* ctx = morph::session::current(); ctx != nullptr) { | `locale` | `std::string` | BCP-47 locale; empty for default. | | `metadata` | `std::unordered_map` | Free-form metadata bag. | +### `Principal` + +| Member | Signature | Notes | +|---|---|---| +| `id` | `std::string` | Verified identity (e.g. username), as returned by the server. Empty if signed out. | +| `roles` | `std::vector` | Coarse-grained roles the app can gate UI on. | +| `claims` | `std::unordered_map` | 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](#principal--readable-authorization-state-outside-a-dispatch) +above and [bridge.md](../core/bridge.md). + ### `IAuthorizer` | Member | Signature | Notes | @@ -368,6 +433,7 @@ if (const auto* ctx = morph::session::current(); ctx != nullptr) { | 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. | ## Limitations @@ -388,6 +454,10 @@ if (const auto* ctx = morph::session::current(); ctx != nullptr) { authorizer subclass itself. - **`current()` is dispatch-thread-only.** It returns `nullptr` off the dispatch thread; session data needed across a thread boundary must be captured first. + `Bridge::currentPrincipal()` (see [Principal](#principal--readable-authorization-state-outside-a-dispatch)) + closes this specifically for "who is signed in and what may they do?" — the + common case a UI needs — without making the full per-call `Context` readable + off-thread. See [security.md](../security.md) for the complete threat model and hardening checklist (TLS, message-size bounds, control-message authorization, secret diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 9f942fc6..a207f934 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -497,6 +497,34 @@ class Bridge { return _defaultSession; } + /// @brief Installs the verified `Principal` for this `Bridge`. Thread-safe. + /// + /// Typically called once right after a successful login dispatch, from + /// data the server actually returned (see `session::Principal`'s doc + /// comment on the trust model). Distinct from `setDefaultSession`: that + /// installs the per-call `Context` forwarded with every dispatch; this + /// installs the longer-lived identity UI code reads *outside* a dispatch + /// via `currentPrincipal()` to shape itself. Guarded by its own mutex + /// (`_principalMtx`, not `_sessionMtx`) since it is read far more + /// frequently, by UI code, than the per-call session snapshot. + /// @param principal Verified identity to install. Pass a + /// default-constructed `Principal{}` (or call this from a sign-out + /// handler) to clear it. + void setPrincipal(::morph::session::Principal principal) { + std::scoped_lock const lock{_principalMtx}; + _principal = std::move(principal); + } + + /// @brief Returns a copy of the currently installed `Principal`. Thread-safe. + /// + /// Default-constructed (empty `id`, no `roles`) if `setPrincipal` was + /// never called or the application signed out by clearing it. + /// @return Snapshot of the installed `Principal`. + [[nodiscard]] ::morph::session::Principal currentPrincipal() const { + std::scoped_lock const lock{_principalMtx}; + return _principal; + } + /// @brief Atomically replaces the active backend with @p newBackend. /// /// All live bindings are re-registered on the new backend and their @@ -908,6 +936,8 @@ class Bridge { std::mutex _attachMtx; mutable std::mutex _sessionMtx; ::morph::session::Context _defaultSession; + mutable std::mutex _principalMtx; + ::morph::session::Principal _principal; // Instance subscriptions. Held against the binding rather than a fixed // instance id so a re-pointed handler keeps its subscriptions; matched at // publish time by comparing the binding's current instance. diff --git a/include/morph/session/session.hpp b/include/morph/session/session.hpp index 2c57f2a2..f47ebbc6 100644 --- a/include/morph/session/session.hpp +++ b/include/morph/session/session.hpp @@ -1,12 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include #include #include #include #include #include +#include namespace morph::session { @@ -49,6 +51,48 @@ struct Context { std::unordered_map metadata; }; +/// @brief The signed-in user's verified identity, readable outside a dispatch. +/// +/// `Context::principal` only exists *during* a dispatch — `session::current()` +/// returns `nullptr` outside one, 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 an application installs once, typically right +/// after a successful login dispatch, and reads from UI code to shape itself +/// instead: `bridge.currentPrincipal().hasRole("editor")`. +/// +/// Deliberately **not** a process-wide global: the caller installs it on the +/// specific `Bridge` (or other session-scoped object) whose backend it came +/// from — see `Bridge::setPrincipal`/`Bridge::currentPrincipal` +/// (`core/bridge.hpp`) — rather than one ambient value shared by every backend +/// a process happens to hold. +/// +/// @warning Populate this only from data the server actually returned (e.g. an +/// action's `Result`, such as the bank example's `AuthResult`), never from a +/// client-side guess — the server remains the sole authority for what a +/// principal may do; this is a read-only cache of what it last told the +/// client, not a second, independently-decided permission set. Roles can +/// change server-side mid-session, so every dispatch is still authorized +/// there regardless of what a stale `Principal` says client-side; this only +/// shapes the UI, it never replaces server-side authorization. +struct Principal { + /// @brief Verified identity (e.g. username), as returned by the server. Empty if signed out. + std::string id; + + /// @brief Coarse-grained roles the app can gate UI on (e.g. `"editor"`, `"admin"`). + std::vector roles; + + /// @brief Free-form bag of string→string claims beyond `id`/`roles`. + std::unordered_map claims; + + /// @brief Returns `true` if `roles` contains @p role. + /// @param role Role name to look for. + /// @return `true` if @p role is present in `roles`. + [[nodiscard]] bool hasRole(std::string_view role) const { + return std::ranges::find(roles, role) != roles.end(); + } +}; + /// @brief Authorizes incoming actions on a `RemoteServer`. /// /// Called once per `execute` envelope, before the action is dispatched. A `false` diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7fe1ac70..48e0e828 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable(morph_tests test_registry_extra.cpp test_registration_qualified_types.cpp test_registration_same_line.cpp + test_principal.cpp test_bridge_local.cpp test_bridge_remote.cpp test_bridge_execute_json.cpp diff --git a/tests/test_principal.cpp b/tests/test_principal.cpp new file mode 100644 index 00000000..ee20b88f --- /dev/null +++ b/tests/test_principal.cpp @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Coverage for issue #24: morph::session::Principal and Bridge::setPrincipal/ +// currentPrincipal -- readable authorization state outside a dispatch, so UI +// code can gate itself (e.g. disable a button) instead of attempting an +// action and catching the refusal. + +#include +#include +#include +#include + +#include +#include +#include + +using morph::session::Principal; + +// ── morph::session::Principal ──────────────────────────────────────────────── + +TEST_CASE("morph::session::Principal: default-constructed has no id and no roles", "[session][principal]") { + Principal principal; + REQUIRE(principal.id.empty()); + REQUIRE(principal.roles.empty()); + REQUIRE_FALSE(principal.hasRole("editor")); +} + +TEST_CASE("morph::session::Principal::hasRole: true for a present role, false for an absent one", + "[session][principal]") { + Principal principal{.id = "alice", .roles = {"viewer", "editor"}}; + REQUIRE(principal.hasRole("viewer")); + REQUIRE(principal.hasRole("editor")); + REQUIRE_FALSE(principal.hasRole("admin")); +} + +// ── morph::bridge::Bridge::setPrincipal / currentPrincipal ────────────────── + +TEST_CASE("morph::bridge::Bridge::currentPrincipal: empty before any setPrincipal call", "[bridge][principal]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + + auto principal = bridge.currentPrincipal(); + REQUIRE(principal.id.empty()); + REQUIRE(principal.roles.empty()); +} + +TEST_CASE("morph::bridge::Bridge::setPrincipal/currentPrincipal: round-trips id, roles, and claims", + "[bridge][principal]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + + bridge.setPrincipal(Principal{ + .id = "alice", + .roles = {"editor", "admin"}, + .claims = {{"tenant", "acme"}}, + }); + + auto principal = bridge.currentPrincipal(); + REQUIRE(principal.id == "alice"); + REQUIRE(principal.hasRole("editor")); + REQUIRE(principal.hasRole("admin")); + REQUIRE_FALSE(principal.hasRole("viewer")); + REQUIRE(principal.claims.at("tenant") == "acme"); +} + +TEST_CASE("morph::bridge::Bridge::setPrincipal: readable without an active dispatch (UI-gating use case)", + "[bridge][principal]") { + // No BridgeHandler, no execute() call anywhere in this test -- proves the + // Principal is readable purely from the Bridge, independent of any + // in-flight or prior dispatch. This is exactly the gap issue #24 reports: + // session::current() (Context) only exists during a dispatch; Principal + // does not have that restriction. + morph::exec::ThreadPoolExecutor pool{2}; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + + bridge.setPrincipal(Principal{.id = "bob", .roles = {"viewer"}}); + REQUIRE(bridge.currentPrincipal().hasRole("viewer")); + REQUIRE_FALSE(bridge.currentPrincipal().hasRole("editor")); +} + +TEST_CASE("morph::bridge::Bridge::setPrincipal: passing a default-constructed Principal clears it (sign-out)", + "[bridge][principal]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + + bridge.setPrincipal(Principal{.id = "alice", .roles = {"editor"}}); + REQUIRE(bridge.currentPrincipal().id == "alice"); + + bridge.setPrincipal(Principal{}); // sign-out + REQUIRE(bridge.currentPrincipal().id.empty()); + REQUIRE(bridge.currentPrincipal().roles.empty()); +} + +TEST_CASE("morph::bridge::Bridge::setPrincipal/currentPrincipal: independent per Bridge instance", + "[bridge][principal]") { + morph::exec::ThreadPoolExecutor pool1{2}; + morph::exec::ThreadPoolExecutor pool2{2}; + morph::bridge::Bridge bridgeA{std::make_unique(pool1)}; + morph::bridge::Bridge bridgeB{std::make_unique(pool2)}; + + bridgeA.setPrincipal(Principal{.id = "alice"}); + bridgeB.setPrincipal(Principal{.id = "bob"}); + + REQUIRE(bridgeA.currentPrincipal().id == "alice"); + REQUIRE(bridgeB.currentPrincipal().id == "bob"); +} From 899c275a5bd3cbcc960e890f3d3fbe597ce9eb9a Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 3 Aug 2026 22:30:43 +0300 Subject: [PATCH 2/2] tests: name every Principal field in test_principal.cpp designated inits Principal{.id = ..., .roles = ...} (and similar partial forms) tripped -Wmissing-designated-field-initializers (clang) / -Wmissing-field-initializers (gcc) under this project's -Werror build, since neither warning is in the opt-out list in apply_warnings(). This broke every real build/test CI job (15 of them) plus clang-tidy-diff, which all share the same compile step. Name every field (id/roles/claims) in each partial designated initializer; Principal{} (all-default) is unaffected since it names zero fields, which both compilers accept. Signed-off-by: Yaraslau Tamashevich --- tests/test_principal.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_principal.cpp b/tests/test_principal.cpp index ee20b88f..a5dc10d0 100644 --- a/tests/test_principal.cpp +++ b/tests/test_principal.cpp @@ -27,7 +27,7 @@ TEST_CASE("morph::session::Principal: default-constructed has no id and no roles TEST_CASE("morph::session::Principal::hasRole: true for a present role, false for an absent one", "[session][principal]") { - Principal principal{.id = "alice", .roles = {"viewer", "editor"}}; + Principal principal{.id = "alice", .roles = {"viewer", "editor"}, .claims = {}}; REQUIRE(principal.hasRole("viewer")); REQUIRE(principal.hasRole("editor")); REQUIRE_FALSE(principal.hasRole("admin")); @@ -73,7 +73,7 @@ TEST_CASE("morph::bridge::Bridge::setPrincipal: readable without an active dispa morph::exec::ThreadPoolExecutor pool{2}; morph::bridge::Bridge bridge{std::make_unique(pool)}; - bridge.setPrincipal(Principal{.id = "bob", .roles = {"viewer"}}); + bridge.setPrincipal(Principal{.id = "bob", .roles = {"viewer"}, .claims = {}}); REQUIRE(bridge.currentPrincipal().hasRole("viewer")); REQUIRE_FALSE(bridge.currentPrincipal().hasRole("editor")); } @@ -83,7 +83,7 @@ TEST_CASE("morph::bridge::Bridge::setPrincipal: passing a default-constructed Pr morph::exec::ThreadPoolExecutor pool{2}; morph::bridge::Bridge bridge{std::make_unique(pool)}; - bridge.setPrincipal(Principal{.id = "alice", .roles = {"editor"}}); + bridge.setPrincipal(Principal{.id = "alice", .roles = {"editor"}, .claims = {}}); REQUIRE(bridge.currentPrincipal().id == "alice"); bridge.setPrincipal(Principal{}); // sign-out @@ -98,8 +98,8 @@ TEST_CASE("morph::bridge::Bridge::setPrincipal/currentPrincipal: independent per morph::bridge::Bridge bridgeA{std::make_unique(pool1)}; morph::bridge::Bridge bridgeB{std::make_unique(pool2)}; - bridgeA.setPrincipal(Principal{.id = "alice"}); - bridgeB.setPrincipal(Principal{.id = "bob"}); + bridgeA.setPrincipal(Principal{.id = "alice", .roles = {}, .claims = {}}); + bridgeB.setPrincipal(Principal{.id = "bob", .roles = {}, .claims = {}}); REQUIRE(bridgeA.currentPrincipal().id == "alice"); REQUIRE(bridgeB.currentPrincipal().id == "bob");