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}