Identity Context
Immutable identity snapshot flowing through the execution chain, enabling permission enforcement at the tool level.
Overview
IdentityContext is an immutable identity snapshot that flows through the entire execution chain, enabling identity-based permission enforcement at the tool execution level.
Every request that enters Aleph carries an identity -- whether from the owner, a guest with limited scope, or an unauthenticated caller. This identity is captured once at session creation and becomes read-only for the remainder of the request lifecycle. By making the identity immutable, the system prevents privilege escalation attacks where a caller might attempt to change roles mid-execution.
Identity Context Flow
1. Session Creation
SessionManager
┌────────────────────────────────────────────┐
│ Owner Session: │
│ metadata = SessionIdentityMeta { │
│ role: Role::Owner, │
│ identity_id: "owner", │
│ scope: None, │
│ source_channel: "gateway", │
│ } │
│ │
│ Guest Session: │
│ metadata = SessionIdentityMeta { │
│ role: Role::Guest, │
│ identity_id: "<guest-uuid>", │
│ scope: Some(GuestScope { ... }), │
│ source_channel: "telegram", │
│ } │
└────────────────────────────────────────────┘
│
▼
2. Resolved into IdentityContext
IdentityContext::owner(session_key, source_channel)
IdentityContext::guest(session_key, guest_id, scope, source_channel)
IdentityContext::anonymous(session_key, source_channel)
│
▼
3. Harness Execution (Orchestrator::dispatch)
FlowRequest { agent_id, input, channel, ... }
HarnessRunner::run(session_key, spec, input, sandbox, events, ...)
│
▼
4. Tool Execution (ScopedToolService)
┌────────────────────────────────────────────┐
│ let svc = ScopedToolService::new( │
│ registry, │
│ allowed_tools, │
│ ); │
│ │
│ match identity.role { │
│ Role::Owner => full tool set visible, │
│ Role::Guest => scope.allows_tool(name), │
│ Role::Anonymous => Denied, │
│ } │
└────────────────────────────────────────────┘Key Components
| Component | Location | Responsibility |
|---|---|---|
IdentityContext / GuestScope / Role | shared/protocol/src/auth.rs (aleph_protocol) | Immutable identity snapshot, guest scope, role enum |
SessionIdentityMeta | src/gateway/session_manager/mod.rs | Persistent session identity metadata |
AgentKeystore / AgentLedger / LedgerRecord / LedgerAction / ChainReport | src/identity/{keystore,ledger,record,verify}.rs | Per-agent Ed25519 key + hash-chained ledger |
as_actor / current_actor | src/identity/actor.rs | Delegated-role task_local for sub-agent / delegation attribution |
SessionKey / DmScope | src/routing/session_key.rs | Hierarchical session keys and DM isolation strategy |
Orchestrator / HarnessRunner | src/orchestrator/ | Session-key resolution, dispatch, FlowRequest assembly |
ScopedToolService | src/gateway/execution_engine/ | Tool-execution-layer allowed_tools enforcement |
Role Types
Aleph defines three role tiers (in shared/protocol/src/auth.rs) that determine what a caller is allowed to do:
| Role | Permissions | Use Case |
|---|---|---|
Owner | Skip tool filtering | Server administrator |
Guest | Constrained by allowed_tools | Temporary access via invitation |
Anonymous | Denied by default | Unauthenticated requests; the #[default] of Role |
Owner
The owner role is assigned to the server administrator. IdentityContext::owner fills role = Role::Owner, identity_id = "owner", scope = None at construction; the execution side skips allowed_tools filtering, so ScopedToolService forwards the full visible tool set.
Guest
Guest sessions are created through the invitation system. A guest receives a bounded GuestScope defining which tools they can invoke and when access expires. The guest tool path goes through scope.allows_tool(name) (the GuestScope::allows_tool method), which is an exact-match == comparison -- no wildcards or category prefixes.
Anonymous
Anonymous requests follow IdentityContext::anonymous; the Role derive's #[default] picks the same path when no role is supplied. They are denied at the execution layer unless the call chain is running with authentication disabled in development mode.
GuestScope
When a guest session is created, SessionIdentityMeta carries a GuestScope (in shared/protocol/src/auth.rs):
pub struct GuestScope {
pub allowed_tools: Vec<String>, // Tool names (exact match)
pub expires_at: Option<i64>, // Unix seconds; None = no expiry
pub display_name: Option<String>, // Human-readable name for audit logs
}| Field | Type | Description |
|---|---|---|
allowed_tools | Vec<String> | Exact-match tool names; the executor compares with == per element |
expires_at | Option<i64> | Unix timestamp; GuestScope::is_expired(now) triggers at >= |
display_name | Option<String> | Friendly name shown in audit logs |
Guest scope is enforced at the ScopedToolService tool-execution layer, not at the API boundary. A guest can send any request, but any tool call not listed in allowed_tools is dropped at the execution layer.
IdentityContext
IdentityContext embodies the "Certificate of Authority" pattern: permissions are frozen at construction, the value is immutable for its entire lifetime, all audit information is embedded, and no external queries are needed for independent verification.
pub struct IdentityContext {
pub request_id: String, // crate::ids::next_id()
pub session_key: String,
pub role: Role,
pub identity_id: String, // Owner="owner" | Guest=<uuid> | Anonymous="anonymous"
pub scope: Option<GuestScope>,
pub created_at: i64, // SystemTime::now().duration_since(UNIX_EPOCH).as_secs()
pub source_channel: String,
}Factory methods:
IdentityContext::owner(session_key, source_channel)IdentityContext::guest(session_key, guest_id, scope, source_channel)IdentityContext::anonymous(session_key, source_channel)
Each factory self-fills request_id and created_at, so two contexts built at the same call site never share a request_id.
Identity Ledger and Signing
src/identity/ provides the "who acted" + "what happened" ledger pair:
AgentKeystore(src/identity/keystore.rs) -- per-agent Ed25519 keypair; public half and fingerprint live insecurity.db, private half lives in the shared Vault. Rotation retires a key rather than replacing it, so records signed under the old key remain verifiable.AgentLedger(src/identity/ledger.rs) -- per-agent hash-chained ledger; every tool call (the only choke-point istools::scoped::dispatch) appends one signed record.verify_chain(src/identity/verify.rs) -- returnsChainReport/ChainFaultto confirm chain integrity.as_actor/current_actor(src/identity/actor.rs) -- atokio::task_localthat lets a delegated role file its own records rather than the parent's.
The ledger is not an impersonation defence: agent_id still arrives as a caller-supplied string on chat.send and is checked only for existence (crate::gateway::router). The ledger faithfully records the identity it was given (see docs/reference/AGENT_IDENTITY.md).
Example: Owner Request
use aleph_protocol::{IdentityContext, Role};
use std::time::{SystemTime, UNIX_EPOCH};
let session_key = String::from("agent:main:main");
let metadata = SessionIdentityMeta::owner("gateway");
let identity = metadata.to_identity_context(session_key);
// identity.role == Role::Owner
// identity.identity_id == "owner"
// identity.scope == NoneExample: Guest Request with Invitation
use aleph_protocol::{GuestScope, IdentityContext, Role};
let scope = GuestScope {
allowed_tools: vec!["translate".to_string()],
expires_at: Some(
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 + 3600
),
display_name: Some("Alice".to_string()),
};
let session_key = String::from("agent:main:telegram:dm:<alice-uuid>");
let metadata = SessionIdentityMeta::guest(
"<alice-uuid>".to_string(),
scope,
"telegram".to_string(),
);
let identity = metadata.to_identity_context(session_key);
// identity.role == Role::Guest
// identity.scope.allows_tool("translate") == true
// identity.scope.allows_tool("bash_exec") == falseSee Also
- Tool Infrastructure -- how
ScopedToolServiceenforcesallowed_tools - Security Overview -- defense-in-depth security architecture
- AGENT_IDENTITY.md -- identity ledger design
26.7.x Addendum
Delegated Role Chain
26.7.26+: the as_actor task-local gives every delegated role on a delegation chain its own signing identity:
- The identity chain is closed within the chain's lifecycle -- once a delegation is established, all signing happens within the chain
- Sub-agents are no longer filed on the parent ledger;
AgentKeystoregives each agent role its own key, and the private half never leaves the node verify_chainreplaysLedgerRecordentries against the chain the signer belongs to
Fail-Closed
26.7.21 hardening: authorization fails closed when a policy file is missing, unreadable, or invalid. The [sandbox.command_policy] hard floor has the highest priority; no exec_tier can lower it.
Real Client IP
26.7.17+: the [gateway] trusted_proxy list decides whether the gateway reads the real client IP. Capacity limits, rate limits, audit, and connect-auth all depend on this. When misconfigured, all of these metrics use the proxy's IP.
Device Tickets and Token Rotation
26.7.17+ remote-auth on the gateway side:
gateway.ticket.createissues device ticketsgateway.token.current/gateway.token.rotatemanage tokensgateway.devices.list/gateway.devices.revokemanage the device list- Remote sessions are closed on token rotation; loopback sessions are preserved
TOFU Trust
26.7.18+ client-side Trust-on-First-Use for self-signed certificates:
- Shared decision core + pinned TOFU trust store
- SHA-256 fingerprint + SAN/Subject parsing
- Approval splash page (fingerprint + SAN + TOFU/change warning)
- Pending-cert state with approve/reject paths