cpex_core/extensions/tiers.rs
1// Location: ./crates/cpex-core/src/extensions/tiers.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Mutability tiers and capability definitions.
7//
8// Each extension slot has a mutability tier that controls how plugins
9// can interact with it. Capabilities gate per-plugin access.
10//
11// Mirrors cpex/framework/extensions/tiers.py.
12
13use serde::{Deserialize, Serialize};
14
15/// Mutability tier for an extension slot.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum MutabilityTier {
19 /// Cannot be modified after creation.
20 Immutable,
21 /// Can only grow (add-only sets, append-only chains).
22 Monotonic,
23 /// Can be freely modified by plugins with write capability.
24 Mutable,
25}
26
27/// Declared permission that controls extension access.
28///
29/// # Why no `Write*` for identity slots
30///
31/// The IdentityResolve and TokenDelegate hook families return result
32/// payloads that the framework consumes to mutate `Extensions`. Plugins
33/// never write to `security.subject` / `security.client` /
34/// `security.*_workload` / `raw_credentials.*` directly — those slots
35/// are owned by the framework on behalf of return-based handlers. The
36/// matching write capabilities are therefore absent from this enum
37/// until a use case appears for plugin-driven mutation of these slots
38/// outside the resolve/delegate hooks.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum Capability {
42 // ----- Subject (user identity) -----
43 /// Read the authenticated subject identity (`security.subject`).
44 /// Unlocks the slot but not its sub-fields — roles / teams /
45 /// claims / permissions each have their own cap below.
46 ReadSubject,
47 /// Read subject roles (`security.subject.roles`).
48 ReadRoles,
49 /// Read subject team memberships (`security.subject.teams`).
50 ReadTeams,
51 /// Read subject claims (`security.subject.claims`).
52 ReadClaims,
53 /// Read subject permissions (`security.subject.permissions`).
54 ReadPermissions,
55
56 // ----- Client (OAuth application identity) -----
57 /// Read the OAuth client / gateway-access identity
58 /// (`security.client`). Distinct from the user identity
59 /// (`subject`) — a single user can connect through different
60 /// clients (first-party browser, third-party agent) and policies
61 /// sometimes want to gate on the client.
62 ReadClient,
63
64 // ----- Workload (attested SPIFFE / mTLS identity) -----
65 /// Read either workload-identity slot — both
66 /// `security.caller_workload` (the inbound attested peer) and
67 /// `security.this_workload` (our own outbound identity). One
68 /// capability covers both: a plugin either has access to
69 /// attested-workload identity or it doesn't. Distinct from
70 /// `read_agent` which governs session / conversation context,
71 /// **NOT** identity.
72 ReadWorkload,
73
74 // ----- Agent execution context (session / conversation) -----
75 /// Read the agent execution context (`AgentExtension`).
76 /// **NOT a credential** — this carries session / conversation /
77 /// lineage state, not identity. Identity reads use
78 /// `read_subject` / `read_client` / `read_workload`.
79 ReadAgent,
80
81 // ----- HTTP wire layer -----
82 /// Read HTTP headers.
83 ReadHeaders,
84 /// Write (modify) HTTP headers.
85 WriteHeaders,
86
87 // ----- Security labels (taint flow) -----
88 /// Read security labels.
89 ReadLabels,
90 /// Append security labels (monotonic add-only).
91 AppendLabels,
92
93 // ----- Delegation chain (validated) -----
94 /// Read the delegation chain.
95 ReadDelegation,
96 /// Append to the delegation chain (monotonic).
97 AppendDelegation,
98
99 // ----- Raw credentials (Layer 3) -----
100 /// Read raw inbound tokens
101 /// (`raw_credentials.inbound_tokens`) — the bearer-token
102 /// strings captured at the wire layer before validation.
103 /// Narrowly scoped: only IdentityResolve handlers, forwarding
104 /// plugins, and a small set of audit plugins should declare it.
105 /// Out-of-process plugins can't see these tokens regardless of
106 /// capability — token fields are `#[serde(skip)]`.
107 ReadInboundCredentials,
108 /// Read minted outbound delegated tokens
109 /// (`raw_credentials.delegated_tokens`) — the credentials a
110 /// TokenDelegate handler produced for an upstream call. Held by
111 /// forwarding / proxy plugins that re-attach them on the outbound
112 /// request. Same out-of-process caveat as
113 /// `read_inbound_credentials`.
114 ReadDelegatedTokens,
115}
116
117/// Access policy for an extension slot.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum AccessPolicy {
121 /// All plugins can access.
122 Unrestricted,
123 /// Only plugins with the declared capability can access.
124 CapabilityGated,
125}
126
127/// Policy for a single extension slot.
128///
129/// Declares the mutability tier, access policy, and required
130/// capabilities for reading and writing.
131#[derive(Debug, Clone)]
132pub struct SlotPolicy {
133 /// How the slot can be modified.
134 pub tier: MutabilityTier,
135 /// Whether access requires a capability.
136 pub access: AccessPolicy,
137 /// Capability required for reading (if capability-gated).
138 pub read_cap: Option<Capability>,
139 /// Capability required for writing (if capability-gated).
140 pub write_cap: Option<Capability>,
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn test_tier_serde() {
149 let tier = MutabilityTier::Monotonic;
150 let json = serde_json::to_string(&tier).unwrap();
151 assert_eq!(json, "\"monotonic\"");
152 }
153
154 #[test]
155 fn test_capability_serde() {
156 let cap = Capability::AppendLabels;
157 let json = serde_json::to_string(&cap).unwrap();
158 assert_eq!(json, "\"append_labels\"");
159 }
160}