cpex-core 0.2.2

CPEX plugin runtime core — PluginManager, executor, hooks, and config.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
// Location: ./crates/cpex-core/src/extensions/security.rs
// Copyright 2025
// SPDX-License-Identifier: Apache-2.0
// Authors: Teryl Taylor
//
// SecurityExtension — labels, classification, identity, data policy.
// Mirrors cpex/framework/extensions/security.py.

use std::collections::{HashMap, HashSet};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use super::monotonic::MonotonicSet;

/// Subject type for identity classification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SubjectType {
    User,
    Agent,
    Service,
    System,
}

/// Authenticated subject identity.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SubjectExtension {
    /// Subject identifier (e.g., JWT sub).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,

    /// Subject type.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subject_type: Option<SubjectType>,

    /// Assigned roles.
    #[serde(default)]
    pub roles: HashSet<String>,

    /// Granted permissions.
    #[serde(default)]
    pub permissions: HashSet<String>,

    /// Team memberships.
    #[serde(default)]
    pub teams: HashSet<String>,

    /// Raw claims (e.g., JWT claims).
    #[serde(default)]
    pub claims: HashMap<String, String>,
}

/// Security profile for a managed object.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ObjectSecurityProfile {
    /// Who manages this object.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub managed_by: Option<String>,

    /// Required permissions.
    #[serde(default)]
    pub permissions: Vec<String>,

    /// Trust domain.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trust_domain: Option<String>,

    /// Data scope.
    #[serde(default)]
    pub data_scope: Vec<String>,
}

/// Retention policy for data.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RetentionPolicy {
    /// Maximum age in seconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_age_seconds: Option<u64>,

    /// Policy name.
    #[serde(default)]
    pub policy: String,

    /// Deletion timestamp.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub delete_after: Option<String>,
}

/// Data policy for a named data element.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DataPolicy {
    /// Labels to apply.
    #[serde(default)]
    pub apply_labels: Vec<String>,

    /// Allowed actions (None = all allowed).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub allowed_actions: Option<Vec<String>>,

    /// Denied actions.
    #[serde(default)]
    pub denied_actions: Vec<String>,

    /// Retention policy.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retention: Option<RetentionPolicy>,
}

/// Trust classification for the OAuth client / gateway that brokered
/// the request. Distinct from the *user's* subject identity — the same
/// human can connect through a first-party browser flow or a
/// third-party agent, and policies often want to distinguish them.
///
/// `Custom(String)` lets operators carry a finer-grained vocabulary
/// (e.g. `"partner-tier-A"`) without forking the type. The enum is
/// `#[non_exhaustive]` so new well-known variants can be added later
/// without breaking external matches.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClientTrustLevel {
    /// First-party clients operated by the same org as this gateway.
    FirstParty,
    /// External third-party clients, integrated but not operated by us.
    ThirdParty,
    /// Internal infrastructure clients (control plane, ops tooling).
    Internal,
    /// Operator-defined trust level — string carried verbatim into
    /// policy. Lookups by value (Hash + Eq) work as long as both
    /// sides construct identical strings.
    #[serde(untagged)]
    Custom(String),
}

impl Default for ClientTrustLevel {
    /// Default to the most restrictive well-known level so a
    /// missing-or-misconfigured client doesn't silently inherit
    /// first-party privileges.
    fn default() -> Self {
        ClientTrustLevel::ThirdParty
    }
}

/// The OAuth client / gateway-access principal — *what application*
/// is brokering the request, as opposed to *which user* is using it
/// (`SubjectExtension`) and *which attested workload* is the network
/// peer (`WorkloadIdentity`). Populated from a client-credentials or
/// session JWT by an identity-resolver plugin (or supplied directly
/// by a trusted upstream gateway).
///
/// The shape is deliberately symmetric with `SubjectExtension` —
/// roles / permissions / teams / claims appear on both. That lets APL
/// policies write `client.roles.contains("partner")` and
/// `subject.roles.contains("admin")` with the same idiom; some IdPs
/// (Keycloak service accounts, Auth0 M2M apps, AWS IAM role grants)
/// attach RBAC grants to clients directly.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClientExtension {
    /// OAuth `client_id` — required. Anchor identifier for the client.
    pub client_id: String,

    /// Human-readable client name from the IdP. Useful for audit logs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_name: Option<String>,

    /// Trust classification — see [`ClientTrustLevel`].
    #[serde(default)]
    pub trust_level: ClientTrustLevel,

    /// OAuth scopes the IdP authorized for this client (across all
    /// audiences). Policy authors use this to gate on what the IdP
    /// believes the client is allowed to ask for, before checking
    /// whether the specific request stays within those scopes.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub authorized_scopes: Vec<String>,

    /// OAuth audiences the IdP authorized this client to address.
    /// Different IdPs encode this differently; the resolver
    /// normalizes them into this list.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub authorized_audiences: Vec<String>,

    /// Platform-native RBAC roles attached to the client (Keycloak
    /// service-account-roles, Auth0 M2M permissions, IAM role grants).
    /// Distinct from `authorized_scopes` — scopes are OAuth-issued,
    /// roles are platform-issued.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub roles: Vec<String>,

    /// Platform-native permissions attached to the client.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub permissions: Vec<String>,

    /// Team / tenant / account memberships, for multi-tenant
    /// platforms that scope clients to organizational units.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub teams: Vec<String>,

    /// Raw remaining JWT claims (or equivalent), keyed by claim name.
    /// `Value` (not `String`) because claim values can be booleans,
    /// numbers, nested objects, arrays — policy authors who reach
    /// here generally know the claim's expected shape.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub claims: HashMap<String, Value>,
}

/// SPIFFE-style workload identity, used for both inbound callers
/// (`SecurityExtension.caller_workload` — added in a subsequent slice)
/// and our own outbound identity (`SecurityExtension.this_workload`).
///
/// Distinct from `SubjectExtension` (the human/agent caller) and
/// `ClientExtension` (the OAuth client, added in a subsequent slice).
/// Where `Subject` is "who", `Client` is "what app", `Workload` is
/// "which attested process" — typically established at the network
/// edge via mTLS or a SPIFFE attestation API and never present on
/// the same request as an unauthenticated principal.
///
/// Populated by the framework / identity-resolver plugin from
/// attestation evidence. Plugins read it via the `read_workload`
/// capability.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkloadIdentity {
    /// SPIFFE-SVID identifier — `spiffe://<trust-domain>/<path>`.
    /// Set when the workload presented a SPIFFE-SVID (X.509 or JWT)
    /// or otherwise carries a SPIFFE-shaped identity.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spiffe_id: Option<String>,

    /// Trust domain extracted from the SPIFFE-SVID (or supplied by
    /// the attestation source for non-SPIFFE attestors). Lets policy
    /// authors gate on the trust boundary without parsing the URI.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trust_domain: Option<String>,

    /// When the attestation was performed. Useful for stale-evidence
    /// rejection in policy. Populated by the attestor; the framework
    /// doesn't refresh it on its own.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attested_at: Option<DateTime<Utc>>,

    /// Name of the attestor that vouched for the workload — `mtls`,
    /// `spire-agent`, `aws-iid`, `gke-workload-identity`, etc. The
    /// vocabulary is open; operators document the values they use.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attestor: Option<String>,

    /// SPIFFE workload selectors — `k8s:ns:foo`, `unix:uid:1000`, …
    /// Empty when no selectors were attached (the SPIFFE-ID alone is
    /// the workload's identity).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub selectors: Vec<String>,

    /// OAuth client_id, when the workload also carries one. Kept
    /// alongside SPIFFE so call sites with both shapes (a SPIFFE
    /// workload that's *also* registered as an OAuth client to a
    /// dynamic-client-registration IdP) don't have to populate two
    /// extensions. The OAuth client's authorization data
    /// (scopes / audiences / claims) lives on the separate
    /// `ClientExtension` slot, not here.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
}

/// Security-related extensions.
///
/// Carries security labels (monotonic add-only), classification,
/// up to four distinct identity principals, and data-policy metadata.
/// The four principal slots map to the identity sources documented in
/// `docs/specs/delegation-hooks-rust-spec.md` §4.1:
///
/// - `subject` — the *user* (or service-as-user) initiating the request
/// - `client`  — the *OAuth client / application* brokering the request
/// - `caller_workload` — the *attested workload* on the inbound network
///                       peer (SPIFFE-SVID, mTLS cert chain)
/// - `this_workload` — *our own* gateway's attested identity, used for
///                     outbound calls
///
/// A request can populate any subset; identity-resolver plugins are
/// expected to fill the slots they're configured for. Policy authors
/// reason about all four uniformly through the `subject.*` /
/// `client.*` / `caller_workload.*` / `this_workload.*` bag namespaces.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecurityExtension {
    /// Security labels (monotonic — add-only via MonotonicSet).
    /// No remove() method — enforced at compile time.
    #[serde(default)]
    pub labels: MonotonicSet<String>,

    /// Data classification level.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub classification: Option<String>,

    /// Authenticated *user* identity (who is calling).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subject: Option<SubjectExtension>,

    /// Authenticated *OAuth client / application* brokering the
    /// request. Distinct from `subject` — the same user can connect
    /// through different clients (first-party web, third-party
    /// integration), and policies sometimes want to gate on which.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client: Option<ClientExtension>,

    /// The inbound caller's attested workload identity — the network
    /// peer's SPIFFE-SVID or mTLS-attested identity. Distinct from
    /// `client` (the OAuth-layer identity of the application) and
    /// `subject` (the user). All three can be present on the same
    /// request when an agent acts on behalf of a user through our
    /// gateway, peered via mTLS.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub caller_workload: Option<WorkloadIdentity>,

    /// This agent / gateway's own workload identity — the SPIFFE-SVID
    /// or attested identity *we* present when making outbound calls.
    /// Populated by the host at startup, not per request.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub this_workload: Option<WorkloadIdentity>,

    /// Authentication method used (e.g., "jwt", "mtls", "spiffe", "api_key").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub auth_method: Option<String>,

    /// Object security profiles keyed by object name.
    #[serde(default)]
    pub objects: HashMap<String, ObjectSecurityProfile>,

    /// Data policies keyed by data element name.
    #[serde(default)]
    pub data: HashMap<String, DataPolicy>,
}

impl SecurityExtension {
    /// Add a security label (monotonic — cannot remove).
    pub fn add_label(&mut self, label: impl Into<String>) {
        self.labels.add_label(label);
    }

    /// Check if a label exists (case-insensitive).
    pub fn has_label(&self, label: &str) -> bool {
        self.labels.has_label(label)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_security_labels_monotonic() {
        let mut sec = SecurityExtension::default();
        sec.add_label("PII");
        sec.add_label("HIPAA");
        assert!(sec.has_label("PII"));
        assert!(sec.has_label("pii")); // case-insensitive
        assert!(sec.has_label("HIPAA"));
        assert!(!sec.has_label("SOX"));
    }

    #[test]
    fn test_security_classification() {
        let sec = SecurityExtension {
            classification: Some("confidential".into()),
            ..Default::default()
        };
        assert_eq!(sec.classification.as_deref(), Some("confidential"));
    }

    #[test]
    fn test_subject_extension() {
        let subject = SubjectExtension {
            id: Some("alice".into()),
            subject_type: Some(SubjectType::User),
            roles: ["admin".to_string(), "hr".to_string()].into(),
            permissions: ["read_all".to_string()].into(),
            teams: ["engineering".to_string()].into(),
            claims: [("iss".to_string(), "auth.example.com".to_string())].into(),
        };
        assert_eq!(subject.id.as_deref(), Some("alice"));
        assert_eq!(subject.subject_type, Some(SubjectType::User));
        assert!(subject.roles.contains("admin"));
        assert!(subject.permissions.contains("read_all"));
        assert!(subject.teams.contains("engineering"));
        assert_eq!(subject.claims.get("iss").unwrap(), "auth.example.com");
    }

    #[test]
    fn test_workload_identity() {
        let w = WorkloadIdentity {
            spiffe_id: Some("spiffe://example.com/ns/team1/sa/weather-tool".into()),
            trust_domain: Some("example.com".into()),
            attestor: Some("spire-agent".into()),
            selectors: vec!["k8s:ns:team1".into(), "k8s:sa:weather-tool".into()],
            client_id: Some("weather-agent".into()),
            ..Default::default()
        };
        assert_eq!(
            w.spiffe_id.as_deref(),
            Some("spiffe://example.com/ns/team1/sa/weather-tool")
        );
        assert_eq!(w.trust_domain.as_deref(), Some("example.com"));
        assert_eq!(w.attestor.as_deref(), Some("spire-agent"));
        assert_eq!(w.selectors.len(), 2);
        assert_eq!(w.client_id.as_deref(), Some("weather-agent"));
    }

    #[test]
    fn test_workload_identity_default() {
        let w = WorkloadIdentity::default();
        assert!(w.spiffe_id.is_none());
        assert!(w.trust_domain.is_none());
        assert!(w.attested_at.is_none());
        assert!(w.attestor.is_none());
        assert!(w.selectors.is_empty());
        assert!(w.client_id.is_none());
    }

    #[test]
    fn test_security_with_this_workload_and_subject() {
        let sec = SecurityExtension {
            labels: {
                let mut l = super::super::MonotonicSet::new();
                l.add_label("PII");
                l
            },
            classification: Some("confidential".into()),
            subject: Some(SubjectExtension {
                id: Some("alice".into()),
                subject_type: Some(SubjectType::User),
                ..Default::default()
            }),
            this_workload: Some(WorkloadIdentity {
                spiffe_id: Some("spiffe://corp.com/hr-agent".into()),
                trust_domain: Some("corp.com".into()),
                client_id: Some("hr-agent".into()),
                ..Default::default()
            }),
            auth_method: Some("jwt".into()),
            ..Default::default()
        };

        // Caller identity
        assert_eq!(sec.subject.as_ref().unwrap().id.as_deref(), Some("alice"));
        // Our own workload identity (distinct from caller)
        assert_eq!(
            sec.this_workload.as_ref().unwrap().client_id.as_deref(),
            Some("hr-agent")
        );
        assert_eq!(
            sec.this_workload.as_ref().unwrap().trust_domain.as_deref(),
            Some("corp.com")
        );
        // Auth method
        assert_eq!(sec.auth_method.as_deref(), Some("jwt"));
        // Labels
        assert!(sec.has_label("PII"));
    }

    #[test]
    fn test_security_serde_roundtrip() {
        let mut sec = SecurityExtension::default();
        sec.add_label("PII");
        sec.classification = Some("internal".into());
        sec.this_workload = Some(WorkloadIdentity {
            client_id: Some("my-agent".into()),
            ..Default::default()
        });
        sec.auth_method = Some("mtls".into());

        let json = serde_json::to_string(&sec).unwrap();
        let deserialized: SecurityExtension = serde_json::from_str(&json).unwrap();

        assert!(deserialized.has_label("PII"));
        assert_eq!(deserialized.classification.as_deref(), Some("internal"));
        assert_eq!(
            deserialized
                .this_workload
                .as_ref()
                .unwrap()
                .client_id
                .as_deref(),
            Some("my-agent")
        );
        assert_eq!(deserialized.auth_method.as_deref(), Some("mtls"));
    }

    #[test]
    fn test_object_security_profile() {
        let profile = ObjectSecurityProfile {
            managed_by: Some("hr-system".into()),
            permissions: vec!["read".into(), "write".into()],
            trust_domain: Some("corp.com".into()),
            data_scope: vec!["employee_data".into()],
        };
        assert_eq!(profile.managed_by.as_deref(), Some("hr-system"));
        assert_eq!(profile.permissions.len(), 2);
    }

    #[test]
    fn test_data_policy() {
        let policy = DataPolicy {
            apply_labels: vec!["PII".into()],
            allowed_actions: Some(vec!["read".into()]),
            denied_actions: vec!["delete".into()],
            retention: Some(RetentionPolicy {
                max_age_seconds: Some(86400),
                policy: "30-day".into(),
                delete_after: Some("2026-05-01".into()),
            }),
        };
        assert_eq!(policy.apply_labels[0], "PII");
        assert!(policy.retention.is_some());
        assert_eq!(
            policy.retention.as_ref().unwrap().max_age_seconds,
            Some(86400)
        );
    }
}