Skip to main content

cpex_core/extensions/
security.rs

1// Location: ./crates/cpex-core/src/extensions/security.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// SecurityExtension — labels, classification, identity, data policy.
7// Mirrors cpex/framework/extensions/security.py.
8
9use std::collections::{HashMap, HashSet};
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use super::monotonic::MonotonicSet;
16
17/// Subject type for identity classification.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum SubjectType {
21    User,
22    Agent,
23    Service,
24    System,
25}
26
27/// Authenticated subject identity.
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct SubjectExtension {
30    /// Subject identifier (e.g., JWT sub).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub id: Option<String>,
33
34    /// Subject type.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub subject_type: Option<SubjectType>,
37
38    /// Assigned roles.
39    #[serde(default)]
40    pub roles: HashSet<String>,
41
42    /// Granted permissions.
43    #[serde(default)]
44    pub permissions: HashSet<String>,
45
46    /// Team memberships.
47    #[serde(default)]
48    pub teams: HashSet<String>,
49
50    /// Raw claims (e.g., JWT claims).
51    #[serde(default)]
52    pub claims: HashMap<String, String>,
53}
54
55/// Security profile for a managed object.
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct ObjectSecurityProfile {
58    /// Who manages this object.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub managed_by: Option<String>,
61
62    /// Required permissions.
63    #[serde(default)]
64    pub permissions: Vec<String>,
65
66    /// Trust domain.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub trust_domain: Option<String>,
69
70    /// Data scope.
71    #[serde(default)]
72    pub data_scope: Vec<String>,
73}
74
75/// Retention policy for data.
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct RetentionPolicy {
78    /// Maximum age in seconds.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub max_age_seconds: Option<u64>,
81
82    /// Policy name.
83    #[serde(default)]
84    pub policy: String,
85
86    /// Deletion timestamp.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub delete_after: Option<String>,
89}
90
91/// Data policy for a named data element.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct DataPolicy {
94    /// Labels to apply.
95    #[serde(default)]
96    pub apply_labels: Vec<String>,
97
98    /// Allowed actions (None = all allowed).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub allowed_actions: Option<Vec<String>>,
101
102    /// Denied actions.
103    #[serde(default)]
104    pub denied_actions: Vec<String>,
105
106    /// Retention policy.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub retention: Option<RetentionPolicy>,
109}
110
111/// Trust classification for the OAuth client / gateway that brokered
112/// the request. Distinct from the *user's* subject identity — the same
113/// human can connect through a first-party browser flow or a
114/// third-party agent, and policies often want to distinguish them.
115///
116/// `Custom(String)` lets operators carry a finer-grained vocabulary
117/// (e.g. `"partner-tier-A"`) without forking the type. The enum is
118/// `#[non_exhaustive]` so new well-known variants can be added later
119/// without breaking external matches.
120#[non_exhaustive]
121#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum ClientTrustLevel {
124    /// First-party clients operated by the same org as this gateway.
125    FirstParty,
126    /// External third-party clients, integrated but not operated by us.
127    ThirdParty,
128    /// Internal infrastructure clients (control plane, ops tooling).
129    Internal,
130    /// Operator-defined trust level — string carried verbatim into
131    /// policy. Lookups by value (Hash + Eq) work as long as both
132    /// sides construct identical strings.
133    #[serde(untagged)]
134    Custom(String),
135}
136
137impl Default for ClientTrustLevel {
138    /// Default to the most restrictive well-known level so a
139    /// missing-or-misconfigured client doesn't silently inherit
140    /// first-party privileges.
141    fn default() -> Self {
142        ClientTrustLevel::ThirdParty
143    }
144}
145
146/// The OAuth client / gateway-access principal — *what application*
147/// is brokering the request, as opposed to *which user* is using it
148/// (`SubjectExtension`) and *which attested workload* is the network
149/// peer (`WorkloadIdentity`). Populated from a client-credentials or
150/// session JWT by an identity-resolver plugin (or supplied directly
151/// by a trusted upstream gateway).
152///
153/// The shape is deliberately symmetric with `SubjectExtension` —
154/// roles / permissions / teams / claims appear on both. That lets APL
155/// policies write `client.roles.contains("partner")` and
156/// `subject.roles.contains("admin")` with the same idiom; some IdPs
157/// (Keycloak service accounts, Auth0 M2M apps, AWS IAM role grants)
158/// attach RBAC grants to clients directly.
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160pub struct ClientExtension {
161    /// OAuth `client_id` — required. Anchor identifier for the client.
162    pub client_id: String,
163
164    /// Human-readable client name from the IdP. Useful for audit logs.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub client_name: Option<String>,
167
168    /// Trust classification — see [`ClientTrustLevel`].
169    #[serde(default)]
170    pub trust_level: ClientTrustLevel,
171
172    /// OAuth scopes the IdP authorized for this client (across all
173    /// audiences). Policy authors use this to gate on what the IdP
174    /// believes the client is allowed to ask for, before checking
175    /// whether the specific request stays within those scopes.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub authorized_scopes: Vec<String>,
178
179    /// OAuth audiences the IdP authorized this client to address.
180    /// Different IdPs encode this differently; the resolver
181    /// normalizes them into this list.
182    #[serde(default, skip_serializing_if = "Vec::is_empty")]
183    pub authorized_audiences: Vec<String>,
184
185    /// Platform-native RBAC roles attached to the client (Keycloak
186    /// service-account-roles, Auth0 M2M permissions, IAM role grants).
187    /// Distinct from `authorized_scopes` — scopes are OAuth-issued,
188    /// roles are platform-issued.
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub roles: Vec<String>,
191
192    /// Platform-native permissions attached to the client.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub permissions: Vec<String>,
195
196    /// Team / tenant / account memberships, for multi-tenant
197    /// platforms that scope clients to organizational units.
198    #[serde(default, skip_serializing_if = "Vec::is_empty")]
199    pub teams: Vec<String>,
200
201    /// Raw remaining JWT claims (or equivalent), keyed by claim name.
202    /// `Value` (not `String`) because claim values can be booleans,
203    /// numbers, nested objects, arrays — policy authors who reach
204    /// here generally know the claim's expected shape.
205    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
206    pub claims: HashMap<String, Value>,
207}
208
209/// SPIFFE-style workload identity, used for both inbound callers
210/// (`SecurityExtension.caller_workload` — added in a subsequent slice)
211/// and our own outbound identity (`SecurityExtension.this_workload`).
212///
213/// Distinct from `SubjectExtension` (the human/agent caller) and
214/// `ClientExtension` (the OAuth client, added in a subsequent slice).
215/// Where `Subject` is "who", `Client` is "what app", `Workload` is
216/// "which attested process" — typically established at the network
217/// edge via mTLS or a SPIFFE attestation API and never present on
218/// the same request as an unauthenticated principal.
219///
220/// Populated by the framework / identity-resolver plugin from
221/// attestation evidence. Plugins read it via the `read_workload`
222/// capability.
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct WorkloadIdentity {
225    /// SPIFFE-SVID identifier — `spiffe://<trust-domain>/<path>`.
226    /// Set when the workload presented a SPIFFE-SVID (X.509 or JWT)
227    /// or otherwise carries a SPIFFE-shaped identity.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub spiffe_id: Option<String>,
230
231    /// Trust domain extracted from the SPIFFE-SVID (or supplied by
232    /// the attestation source for non-SPIFFE attestors). Lets policy
233    /// authors gate on the trust boundary without parsing the URI.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub trust_domain: Option<String>,
236
237    /// When the attestation was performed. Useful for stale-evidence
238    /// rejection in policy. Populated by the attestor; the framework
239    /// doesn't refresh it on its own.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub attested_at: Option<DateTime<Utc>>,
242
243    /// Name of the attestor that vouched for the workload — `mtls`,
244    /// `spire-agent`, `aws-iid`, `gke-workload-identity`, etc. The
245    /// vocabulary is open; operators document the values they use.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub attestor: Option<String>,
248
249    /// SPIFFE workload selectors — `k8s:ns:foo`, `unix:uid:1000`, …
250    /// Empty when no selectors were attached (the SPIFFE-ID alone is
251    /// the workload's identity).
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub selectors: Vec<String>,
254
255    /// OAuth client_id, when the workload also carries one. Kept
256    /// alongside SPIFFE so call sites with both shapes (a SPIFFE
257    /// workload that's *also* registered as an OAuth client to a
258    /// dynamic-client-registration IdP) don't have to populate two
259    /// extensions. The OAuth client's authorization data
260    /// (scopes / audiences / claims) lives on the separate
261    /// `ClientExtension` slot, not here.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub client_id: Option<String>,
264}
265
266/// Security-related extensions.
267///
268/// Carries security labels (monotonic add-only), classification,
269/// up to four distinct identity principals, and data-policy metadata.
270/// The four principal slots map to the identity sources documented in
271/// `docs/specs/delegation-hooks-rust-spec.md` §4.1:
272///
273/// - `subject` — the *user* (or service-as-user) initiating the request
274/// - `client`  — the *OAuth client / application* brokering the request
275/// - `caller_workload` — the *attested workload* on the inbound network
276///                       peer (SPIFFE-SVID, mTLS cert chain)
277/// - `this_workload` — *our own* gateway's attested identity, used for
278///                     outbound calls
279///
280/// A request can populate any subset; identity-resolver plugins are
281/// expected to fill the slots they're configured for. Policy authors
282/// reason about all four uniformly through the `subject.*` /
283/// `client.*` / `caller_workload.*` / `this_workload.*` bag namespaces.
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
285pub struct SecurityExtension {
286    /// Security labels (monotonic — add-only via MonotonicSet).
287    /// No remove() method — enforced at compile time.
288    #[serde(default)]
289    pub labels: MonotonicSet<String>,
290
291    /// Data classification level.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub classification: Option<String>,
294
295    /// Authenticated *user* identity (who is calling).
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub subject: Option<SubjectExtension>,
298
299    /// Authenticated *OAuth client / application* brokering the
300    /// request. Distinct from `subject` — the same user can connect
301    /// through different clients (first-party web, third-party
302    /// integration), and policies sometimes want to gate on which.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub client: Option<ClientExtension>,
305
306    /// The inbound caller's attested workload identity — the network
307    /// peer's SPIFFE-SVID or mTLS-attested identity. Distinct from
308    /// `client` (the OAuth-layer identity of the application) and
309    /// `subject` (the user). All three can be present on the same
310    /// request when an agent acts on behalf of a user through our
311    /// gateway, peered via mTLS.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub caller_workload: Option<WorkloadIdentity>,
314
315    /// This agent / gateway's own workload identity — the SPIFFE-SVID
316    /// or attested identity *we* present when making outbound calls.
317    /// Populated by the host at startup, not per request.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub this_workload: Option<WorkloadIdentity>,
320
321    /// Authentication method used (e.g., "jwt", "mtls", "spiffe", "api_key").
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub auth_method: Option<String>,
324
325    /// Object security profiles keyed by object name.
326    #[serde(default)]
327    pub objects: HashMap<String, ObjectSecurityProfile>,
328
329    /// Data policies keyed by data element name.
330    #[serde(default)]
331    pub data: HashMap<String, DataPolicy>,
332}
333
334impl SecurityExtension {
335    /// Add a security label (monotonic — cannot remove).
336    pub fn add_label(&mut self, label: impl Into<String>) {
337        self.labels.add_label(label);
338    }
339
340    /// Check if a label exists (case-insensitive).
341    pub fn has_label(&self, label: &str) -> bool {
342        self.labels.has_label(label)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_security_labels_monotonic() {
352        let mut sec = SecurityExtension::default();
353        sec.add_label("PII");
354        sec.add_label("HIPAA");
355        assert!(sec.has_label("PII"));
356        assert!(sec.has_label("pii")); // case-insensitive
357        assert!(sec.has_label("HIPAA"));
358        assert!(!sec.has_label("SOX"));
359    }
360
361    #[test]
362    fn test_security_classification() {
363        let sec = SecurityExtension {
364            classification: Some("confidential".into()),
365            ..Default::default()
366        };
367        assert_eq!(sec.classification.as_deref(), Some("confidential"));
368    }
369
370    #[test]
371    fn test_subject_extension() {
372        let subject = SubjectExtension {
373            id: Some("alice".into()),
374            subject_type: Some(SubjectType::User),
375            roles: ["admin".to_string(), "hr".to_string()].into(),
376            permissions: ["read_all".to_string()].into(),
377            teams: ["engineering".to_string()].into(),
378            claims: [("iss".to_string(), "auth.example.com".to_string())].into(),
379        };
380        assert_eq!(subject.id.as_deref(), Some("alice"));
381        assert_eq!(subject.subject_type, Some(SubjectType::User));
382        assert!(subject.roles.contains("admin"));
383        assert!(subject.permissions.contains("read_all"));
384        assert!(subject.teams.contains("engineering"));
385        assert_eq!(subject.claims.get("iss").unwrap(), "auth.example.com");
386    }
387
388    #[test]
389    fn test_workload_identity() {
390        let w = WorkloadIdentity {
391            spiffe_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()),
392            trust_domain: Some("example.com".into()),
393            attestor: Some("spire-agent".into()),
394            selectors: vec!["k8s:ns:team1".into(), "k8s:sa:weather-tool".into()],
395            client_id: Some("weather-agent".into()),
396            ..Default::default()
397        };
398        assert_eq!(
399            w.spiffe_id.as_deref(),
400            Some("spiffe://example.com/ns/team1/sa/weather-tool")
401        );
402        assert_eq!(w.trust_domain.as_deref(), Some("example.com"));
403        assert_eq!(w.attestor.as_deref(), Some("spire-agent"));
404        assert_eq!(w.selectors.len(), 2);
405        assert_eq!(w.client_id.as_deref(), Some("weather-agent"));
406    }
407
408    #[test]
409    fn test_workload_identity_default() {
410        let w = WorkloadIdentity::default();
411        assert!(w.spiffe_id.is_none());
412        assert!(w.trust_domain.is_none());
413        assert!(w.attested_at.is_none());
414        assert!(w.attestor.is_none());
415        assert!(w.selectors.is_empty());
416        assert!(w.client_id.is_none());
417    }
418
419    #[test]
420    fn test_security_with_this_workload_and_subject() {
421        let sec = SecurityExtension {
422            labels: {
423                let mut l = super::super::MonotonicSet::new();
424                l.add_label("PII");
425                l
426            },
427            classification: Some("confidential".into()),
428            subject: Some(SubjectExtension {
429                id: Some("alice".into()),
430                subject_type: Some(SubjectType::User),
431                ..Default::default()
432            }),
433            this_workload: Some(WorkloadIdentity {
434                spiffe_id: Some("spiffe://corp.com/hr-agent".into()),
435                trust_domain: Some("corp.com".into()),
436                client_id: Some("hr-agent".into()),
437                ..Default::default()
438            }),
439            auth_method: Some("jwt".into()),
440            ..Default::default()
441        };
442
443        // Caller identity
444        assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice"));
445        // Our own workload identity (distinct from caller)
446        assert_eq!(
447            sec.this_workload.as_ref().unwrap().client_id.as_deref(),
448            Some("hr-agent")
449        );
450        assert_eq!(
451            sec.this_workload.as_ref().unwrap().trust_domain.as_deref(),
452            Some("corp.com")
453        );
454        // Auth method
455        assert_eq!(sec.auth_method.as_deref(), Some("jwt"));
456        // Labels
457        assert!(sec.has_label("PII"));
458    }
459
460    #[test]
461    fn test_security_serde_roundtrip() {
462        let mut sec = SecurityExtension::default();
463        sec.add_label("PII");
464        sec.classification = Some("internal".into());
465        sec.this_workload = Some(WorkloadIdentity {
466            client_id: Some("my-agent".into()),
467            ..Default::default()
468        });
469        sec.auth_method = Some("mtls".into());
470
471        let json = serde_json::to_string(&sec).unwrap();
472        let deserialized: SecurityExtension = serde_json::from_str(&json).unwrap();
473
474        assert!(deserialized.has_label("PII"));
475        assert_eq!(deserialized.classification.as_deref(), Some("internal"));
476        assert_eq!(
477            deserialized
478                .this_workload
479                .as_ref()
480                .unwrap()
481                .client_id
482                .as_deref(),
483            Some("my-agent")
484        );
485        assert_eq!(deserialized.auth_method.as_deref(), Some("mtls"));
486    }
487
488    #[test]
489    fn test_object_security_profile() {
490        let profile = ObjectSecurityProfile {
491            managed_by: Some("hr-system".into()),
492            permissions: vec!["read".into(), "write".into()],
493            trust_domain: Some("corp.com".into()),
494            data_scope: vec!["employee_data".into()],
495        };
496        assert_eq!(profile.managed_by.as_deref(), Some("hr-system"));
497        assert_eq!(profile.permissions.len(), 2);
498    }
499
500    #[test]
501    fn test_data_policy() {
502        let policy = DataPolicy {
503            apply_labels: vec!["PII".into()],
504            allowed_actions: Some(vec!["read".into()]),
505            denied_actions: vec!["delete".into()],
506            retention: Some(RetentionPolicy {
507                max_age_seconds: Some(86400),
508                policy: "30-day".into(),
509                delete_after: Some("2026-05-01".into()),
510            }),
511        };
512        assert_eq!(policy.apply_labels[0], "PII");
513        assert!(policy.retention.is_some());
514        assert_eq!(
515            policy.retention.as_ref().unwrap().max_age_seconds,
516            Some(86400)
517        );
518    }
519}