Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -500,6 +513,8 @@ make teardown order-independent.)
| `executeVia<Model, Action>` | `Completion<R> executeVia(const shared_ptr<HandlerBinding>&, 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<Model>`

Expand Down
70 changes: 70 additions & 0 deletions docs/spec/session/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<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:

```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,
Expand Down Expand Up @@ -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<std::string, std::string>` | 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<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](#principal--readable-authorization-state-outside-a-dispatch)
above and [bridge.md](../core/bridge.md).

### `IAuthorizer`

| Member | Signature | Notes |
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
30 changes: 30 additions & 0 deletions include/morph/core/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions include/morph/session/session.hpp
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: Apache-2.0

#pragma once
#include <algorithm>
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>

namespace morph::session {

Expand Down Expand Up @@ -49,6 +51,48 @@ struct Context {
std::unordered_map<std::string, std::string> 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<std::string> roles;

/// @brief Free-form bag of string→string claims beyond `id`/`roles`.
std::unordered_map<std::string, std::string> 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`
Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions tests/test_principal.cpp
Original file line number Diff line number Diff line change
@@ -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 <morph/core/backend.hpp>
#include <morph/core/bridge.hpp>
#include <morph/core/executor.hpp>
#include <morph/session/session.hpp>

#include <catch2/catch_test_macros.hpp>
#include <memory>
#include <string>

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"}, .claims = {}};
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<morph::backend::LocalBackend>(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<morph::backend::LocalBackend>(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<morph::backend::LocalBackend>(pool)};

bridge.setPrincipal(Principal{.id = "bob", .roles = {"viewer"}, .claims = {}});
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<morph::backend::LocalBackend>(pool)};

bridge.setPrincipal(Principal{.id = "alice", .roles = {"editor"}, .claims = {}});
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<morph::backend::LocalBackend>(pool1)};
morph::bridge::Bridge bridgeB{std::make_unique<morph::backend::LocalBackend>(pool2)};

bridgeA.setPrincipal(Principal{.id = "alice", .roles = {}, .claims = {}});
bridgeB.setPrincipal(Principal{.id = "bob", .roles = {}, .claims = {}});

REQUIRE(bridgeA.currentPrincipal().id == "alice");
REQUIRE(bridgeB.currentPrincipal().id == "bob");
}
Loading