Skip to main content

aperion_shield/identity/
mod.rs

1//! Aperion Shield -- identity-gated tool calls.
2//!
3//! When a rule in `shieldset.yaml` carries an `identity:` block and would
4//! otherwise resolve to **Approval** or **Block**, the engine emits a
5//! [`crate::Decision::IdentityVerification`] instead. The MCP middleman
6//! then:
7//!
8//!   1. Looks up a fresh, cryptographically-signed proof in the local
9//!      cache (`~/.aperion-shield/identity-cache.json`). A "fresh" proof
10//!      is one whose `provider`, `scope`, allowed-subject set, and
11//!      level-of-assurance all satisfy the rule's [`Requirement`], and
12//!      whose `expires_at` is in the future.
13//!   2. **Cache hit** -- the call is released and we log
14//!      `identity_satisfied` to the audit stream.
15//!   3. **Cache miss** -- Shield lazily starts a tiny localhost HTTP
16//!      server (random port), mints a [`Challenge`], hands the user a
17//!      `verify_url`, and holds the tool call server-side for at most
18//!      `hold_seconds` (default 120). If the user completes the
19//!      provider's flow (e.g. ID.me OAuth + biometric) within that
20//!      window, Shield mints a [`Proof`], persists it, and releases the
21//!      held call. If the window elapses, Shield returns a structured
22//!      `shield_identity_required` error to the agent; the user can
23//!      verify out-of-band and retry the call.
24//!
25//! All identity work is gated by the YAML schema -- a rule with no
26//! `identity:` block behaves exactly as it did pre-v0.4. The free
27//! standalone build ships both a fully-working `mock` provider (for
28//! integration tests and demos) and a stubbed `id_me` provider whose
29//! OAuth client is ready to activate the moment we receive sandbox
30//! credentials from the ID.me partner program.
31//!
32//! Module layout:
33//!
34//! ```text
35//! identity/
36//!   mod.rs       -- public surface: types, trait, [`IdentityGate`]
37//!   config.rs    -- `identity.yaml` schema + loader
38//!   proof.rs     -- Ed25519 sign / verify, on-disk keypair
39//!   cache.rs     -- file-backed proof cache with TTL eviction
40//!   server.rs    -- hyper localhost callback server
41//!   providers/
42//!     mock.rs    -- always-verify provider (tests, demos)
43//!     idme.rs    -- ID.me OAuth provider (creds not yet activated)
44//! ```
45
46#![allow(clippy::module_inception)]
47
48pub mod cache;
49pub mod config;
50pub mod proof;
51pub mod providers;
52pub mod server;
53
54use std::collections::HashSet;
55use std::sync::Arc;
56use std::time::{SystemTime, UNIX_EPOCH};
57
58use async_trait::async_trait;
59use serde::{Deserialize, Serialize};
60
61pub use cache::ProofCache;
62pub use config::{IdentityConfig, ProviderConfig, ProviderKind};
63pub use proof::{Proof, ProofSigner};
64pub use providers::{idme::IdMeProvider, mock::MockProvider};
65pub use server::{CallbackServer, Inflight, ServerHandle};
66
67// ────────────────────────────────────────────────────────────────────
68// Public types
69// ────────────────────────────────────────────────────────────────────
70
71/// A single requirement attached to a rule. Authored in YAML as the
72/// `identity:` block under a rule; compiled into this struct once at
73/// rule-load time and reused on every match.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Requirement {
76    /// `id` of the provider in `identity.yaml` to invoke. Must resolve
77    /// to a known [`ProviderConfig`].
78    pub provider: String,
79
80    /// Logical scope name (free-form). Doubles as the cache partition
81    /// key, so a proof for `scm.commit_to_main` does **not** satisfy a
82    /// rule asking for `db.production_apply`.
83    pub scope: String,
84
85    /// Set of subjects allowed to satisfy this gate. Each entry is
86    /// matched against [`VerifiedIdentity::subject`] AND
87    /// [`VerifiedIdentity::email`]; ANY hit passes.
88    ///
89    /// Accepted forms:
90    ///   * Email      -- `[email protected]`
91    ///   * Subject    -- `idme|550e8400-e29b-41d4-a716-446655440000`
92    ///   * Wildcard   -- `*` (any user verifying is enough; useful
93    ///                  for single-operator laptops)
94    pub allowed_subjects: Vec<String>,
95
96    /// Maximum acceptable age (seconds) for a cached proof. Older
97    /// proofs are ignored and the user is re-prompted.
98    #[serde(default = "default_max_age")]
99    pub max_proof_age_seconds: u64,
100
101    /// Required ID.me level-of-assurance (0/1/2/3). A proof issued at
102    /// a lower LOA does not satisfy the gate.
103    #[serde(default)]
104    pub loa: u8,
105}
106
107fn default_max_age() -> u64 {
108    900 // 15 minutes
109}
110
111impl Requirement {
112    /// Does this allow-list include the given identity?
113    pub fn allows(&self, vi: &VerifiedIdentity) -> bool {
114        let want: HashSet<&str> = self.allowed_subjects.iter().map(String::as_str).collect();
115        if want.contains("*") {
116            return true;
117        }
118        let prefixed = format!("{}|{}", vi.provider, vi.subject);
119        if want.contains(prefixed.as_str()) {
120            return true;
121        }
122        if want.contains(vi.subject.as_str()) {
123            return true;
124        }
125        if let Some(email) = vi.email.as_deref() {
126            if want.contains(email) {
127                return true;
128            }
129        }
130        false
131    }
132
133    /// True if the given (already-decoded, signature-verified) proof
134    /// satisfies all of: provider, scope, allow-list, LOA, freshness.
135    pub fn is_satisfied_by(&self, proof: &Proof, now_secs: u64) -> bool {
136        if proof.provider != self.provider {
137            return false;
138        }
139        if proof.scope != self.scope {
140            return false;
141        }
142        if proof.loa < self.loa {
143            return false;
144        }
145        if proof.expires_at <= now_secs {
146            return false;
147        }
148        if now_secs.saturating_sub(proof.verified_at) > self.max_proof_age_seconds {
149            return false;
150        }
151        // Subject / email check.
152        let vi = VerifiedIdentity {
153            provider: proof.provider.clone(),
154            subject: proof.subject.clone(),
155            email: proof.email.clone(),
156            loa: proof.loa,
157            raw: serde_json::Value::Null,
158        };
159        self.allows(&vi)
160    }
161}
162
163/// The "begin verification" input. Produced by the engine, consumed by
164/// a provider's [`IdentityProvider::begin`] implementation.
165#[derive(Debug, Clone)]
166pub struct ChallengeRequest {
167    pub rule_id: String,
168    pub requirement: Requirement,
169    /// The URL the provider should redirect back to once the user
170    /// completes the flow. Always `http://127.0.0.1:<port>/callback`
171    /// where `<port>` is the OS-assigned port the callback server is
172    /// listening on.
173    pub callback_url: String,
174    /// Opaque, unguessable challenge id (also the cache key for the
175    /// in-flight challenge state held by the callback server).
176    pub challenge_id: String,
177}
178
179/// A provider-issued challenge. The `verify_url` is what we surface to
180/// the user; the optional state-bag (`pkce_verifier`, `nonce`) is what
181/// we hold privately so we can validate the callback.
182#[derive(Debug, Clone)]
183pub struct Challenge {
184    pub challenge_id: String,
185    /// The URL the user opens in a browser to complete verification.
186    /// For real OAuth providers this is the authorize URL with a
187    /// `state=<challenge_id>` query param. For the mock provider it's
188    /// the local server's `/verify/<id>` endpoint.
189    pub verify_url: String,
190    /// PKCE verifier (for OAuth 2.0 + PKCE flows). Stored alongside
191    /// the challenge so the callback handler can complete the token
192    /// exchange.
193    pub pkce_verifier: Option<String>,
194    /// Anti-replay nonce embedded in the authorize URL and validated
195    /// on callback.
196    pub nonce: String,
197    /// When this challenge expires; the callback server reaps any
198    /// challenge older than this.
199    pub expires_at: u64,
200}
201
202/// The result of a successful provider exchange. Translated by Shield
203/// into a signed [`Proof`] before being cached / returned.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct VerifiedIdentity {
206    pub provider: String,
207    /// Stable provider-side subject id (e.g. ID.me's `sub` claim). This
208    /// is the identity key -- emails can change, subjects do not.
209    pub subject: String,
210    /// Email at the time of verification, for human-readable logs and
211    /// allow-list matching by address.
212    pub email: Option<String>,
213    /// Level of assurance achieved (ID.me IAL/LOA tier).
214    pub loa: u8,
215    /// Provider-specific raw response payload, retained for audit but
216    /// NEVER persisted to the proof cache (PII surface).
217    #[serde(skip_serializing_if = "serde_json::Value::is_null", default)]
218    pub raw: serde_json::Value,
219}
220
221/// Provider trait. Every concrete provider (mock, ID.me, future Okta /
222/// Auth0 / etc.) implements this. Designed to be `Send + Sync + 'static`
223/// so providers can live inside an `Arc` for the whole process.
224#[async_trait]
225pub trait IdentityProvider: Send + Sync + 'static {
226    /// Stable identifier matching the `id:` field in `identity.yaml`.
227    fn id(&self) -> &str;
228
229    /// True if this provider has the credentials it needs to operate.
230    /// The ID.me adapter returns `false` until sandbox credentials are
231    /// supplied; the mock provider always returns `true`.
232    fn is_ready(&self) -> bool;
233
234    /// Begin a verification flow. Returns the URL the user should open
235    /// plus the bookkeeping needed to validate the callback.
236    async fn begin(&self, req: ChallengeRequest) -> anyhow::Result<Challenge>;
237
238    /// Complete the verification flow given the provider's response.
239    /// For OAuth providers, `code` is the authorization code; for the
240    /// mock provider it's a synthetic token. `state` must match the
241    /// challenge id we minted in [`begin`].
242    async fn exchange(
243        &self,
244        challenge_id: &str,
245        code: &str,
246        state: &str,
247        pkce_verifier: Option<&str>,
248    ) -> anyhow::Result<VerifiedIdentity>;
249}
250
251// ────────────────────────────────────────────────────────────────────
252// Gate -- the public composition object
253// ────────────────────────────────────────────────────────────────────
254
255/// The [`IdentityGate`] is the single object `main.rs` interacts with.
256/// It owns the provider registry, the signed proof cache, and (lazily)
257/// the local callback server.
258///
259/// Construction is cheap (loads config, opens cache file, generates or
260/// loads the signing key). The callback server is **not** spun up until
261/// the first [`IdentityGate::start_challenge`] call -- shields that
262/// never hit an identity-gated rule pay zero runtime cost.
263pub struct IdentityGate {
264    config: IdentityConfig,
265    providers: Vec<Arc<dyn IdentityProvider>>,
266    cache: Arc<ProofCache>,
267    signer: Arc<ProofSigner>,
268    server: tokio::sync::OnceCell<ServerHandle>,
269}
270
271impl IdentityGate {
272    /// Build a gate from a config and a list of provider implementations.
273    /// Cache key + signing keypair live in `<state_dir>` (typically
274    /// `~/.aperion-shield`).
275    pub fn new(
276        config: IdentityConfig,
277        providers: Vec<Arc<dyn IdentityProvider>>,
278        state_dir: std::path::PathBuf,
279    ) -> anyhow::Result<Self> {
280        let signer = Arc::new(ProofSigner::load_or_create(&state_dir)?);
281        let cache = Arc::new(ProofCache::open(state_dir.join("identity-cache.json"), &signer)?);
282        Ok(Self {
283            config,
284            providers,
285            cache,
286            signer,
287            server: tokio::sync::OnceCell::new(),
288        })
289    }
290
291    /// Look up a cached proof satisfying `req`. None means we have to
292    /// prompt the user.
293    pub fn cached_proof_for(&self, req: &Requirement) -> Option<Proof> {
294        let now = unix_now();
295        self.cache.find_satisfying(req, now)
296    }
297
298    /// Provider with the given id, or None.
299    pub fn provider(&self, id: &str) -> Option<Arc<dyn IdentityProvider>> {
300        self.providers.iter().find(|p| p.id() == id).cloned()
301    }
302
303    /// Lazily start the callback server, sharing the same provider /
304    /// cache / signer instances the gate already holds. Subsequent calls
305    /// return the cached handle.
306    async fn ensure_server(&self) -> anyhow::Result<&ServerHandle> {
307        self.server
308            .get_or_try_init(|| async {
309                CallbackServer::spawn(
310                    &self.config.callback_host,
311                    self.config.callback_port,
312                    self.providers.clone(),
313                    self.cache.clone(),
314                    self.signer.clone(),
315                )
316                .await
317            })
318            .await
319    }
320
321    /// Ensure the callback server is running and return its base URL
322    /// (e.g. `http://127.0.0.1:53201`).
323    pub async fn callback_base(&self) -> anyhow::Result<String> {
324        let h = self.ensure_server().await?;
325        Ok(h.base_url())
326    }
327
328    /// Hand the gate a freshly-minted challenge so the callback server
329    /// can correlate the user's redirect back to its in-flight state.
330    pub async fn register_inflight(
331        &self,
332        challenge: &Challenge,
333        requirement: Requirement,
334        provider: String,
335        rule_id: String,
336    ) -> anyhow::Result<()> {
337        let h = self.ensure_server().await?;
338        h.register(
339            challenge.clone(),
340            server::Inflight { rule_id, provider, requirement },
341        )
342        .await;
343        Ok(())
344    }
345
346    /// Block up to `hold_seconds` waiting for a proof to land in the
347    /// cache for `req`. Returns the proof if one arrives; None on
348    /// timeout. Callers should treat None as "tell the agent to retry".
349    pub async fn wait_for_proof(
350        &self,
351        req: &Requirement,
352        hold_seconds: u64,
353    ) -> Option<Proof> {
354        let deadline = std::time::Instant::now()
355            + std::time::Duration::from_secs(hold_seconds);
356        loop {
357            if let Some(p) = self.cached_proof_for(req) {
358                return Some(p);
359            }
360            if std::time::Instant::now() >= deadline {
361                return None;
362            }
363            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
364        }
365    }
366
367    /// Persist a freshly-verified identity as a signed proof.
368    pub fn mint_and_cache(
369        &self,
370        vi: &VerifiedIdentity,
371        req: &Requirement,
372    ) -> anyhow::Result<Proof> {
373        let now = unix_now();
374        let proof = Proof {
375            v: 1,
376            provider: vi.provider.clone(),
377            subject: vi.subject.clone(),
378            email: vi.email.clone(),
379            loa: vi.loa,
380            scope: req.scope.clone(),
381            verified_at: now,
382            expires_at: now.saturating_add(req.max_proof_age_seconds),
383            nonce: hex::encode(rand::random::<[u8; 16]>()),
384            sig: String::new(),
385        };
386        let signed = self.signer.sign(proof)?;
387        self.cache.insert(signed.clone())?;
388        Ok(signed)
389    }
390
391    /// Number of valid (signature-verified, non-expired) proofs cached.
392    pub fn cached_count(&self) -> usize {
393        self.cache.count_valid(unix_now())
394    }
395
396    /// Drop every cached proof. Returns how many were evicted.
397    pub fn flush(&self) -> anyhow::Result<usize> {
398        self.cache.flush()
399    }
400
401    /// Hold seconds configured for this gate.
402    pub fn hold_seconds(&self) -> u64 {
403        self.config.hold_seconds
404    }
405
406    /// True if at least one provider is registered AND ready.
407    pub fn has_ready_provider(&self) -> bool {
408        self.providers.iter().any(|p| p.is_ready())
409    }
410
411    /// Read-only access to the loaded config.
412    pub fn config(&self) -> &IdentityConfig {
413        &self.config
414    }
415}
416
417pub(crate) fn unix_now() -> u64 {
418    SystemTime::now()
419        .duration_since(UNIX_EPOCH)
420        .map(|d| d.as_secs())
421        .unwrap_or(0)
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn vi(provider: &str, subject: &str, email: Option<&str>, loa: u8) -> VerifiedIdentity {
429        VerifiedIdentity {
430            provider: provider.into(),
431            subject: subject.into(),
432            email: email.map(str::to_string),
433            loa,
434            raw: serde_json::Value::Null,
435        }
436    }
437
438    #[test]
439    fn allow_list_matches_email() {
440        // Compose the email literals at runtime so the source contains
441        // no `name@domain` token. Some doc/display layers normalise
442        // such tokens to a placeholder which made earlier versions of
443        // this test compare two identical strings without us noticing.
444        let allowed = format!("{}@{}", "ace", "aperion.ai");
445        let denied  = format!("{}@{}", "bee", "other.com");
446        let req = Requirement {
447            provider: "id_me".into(),
448            scope: "scm.commit".into(),
449            allowed_subjects: vec![allowed.clone()],
450            max_proof_age_seconds: 900,
451            loa: 2,
452        };
453        // Email matches the allow list -> allowed.
454        assert!(req.allows(&vi("id_me", "sub-1", Some(allowed.as_str()), 2)));
455        // Different email, no other identifier on the allow list -> denied.
456        assert!(!req.allows(&vi("id_me", "sub-2", Some(denied.as_str()), 2)));
457        // No email at all -> denied.
458        assert!(!req.allows(&vi("id_me", "sub-3", None, 2)));
459    }
460
461    #[test]
462    fn allow_list_matches_subject_with_prefix() {
463        let req = Requirement {
464            provider: "id_me".into(),
465            scope: "x".into(),
466            allowed_subjects: vec!["id_me|sub-1".into()],
467            max_proof_age_seconds: 900,
468            loa: 0,
469        };
470        assert!(req.allows(&vi("id_me", "sub-1", None, 0)));
471    }
472
473    #[test]
474    fn wildcard_lets_anyone_pass() {
475        let req = Requirement {
476            provider: "id_me".into(),
477            scope: "x".into(),
478            allowed_subjects: vec!["*".into()],
479            max_proof_age_seconds: 900,
480            loa: 0,
481        };
482        assert!(req.allows(&vi("id_me", "sub-99", Some("[email protected]"), 0)));
483    }
484
485    #[test]
486    fn requirement_freshness_check() {
487        let req = Requirement {
488            provider: "id_me".into(),
489            scope: "x".into(),
490            allowed_subjects: vec!["*".into()],
491            max_proof_age_seconds: 60,
492            loa: 0,
493        };
494        let now = unix_now();
495        let fresh = Proof {
496            v: 1, provider: "id_me".into(), subject: "s".into(), email: None,
497            loa: 0, scope: "x".into(), verified_at: now, expires_at: now + 60,
498            nonce: "".into(), sig: "".into(),
499        };
500        let stale = Proof { verified_at: now - 120, expires_at: now + 60, ..fresh.clone() };
501        let wrong_scope = Proof { scope: "y".into(), ..fresh.clone() };
502        let low_loa = Proof { loa: 0, ..fresh.clone() };
503        let high_loa_req = Requirement { loa: 2, ..req.clone() };
504
505        assert!(req.is_satisfied_by(&fresh, now));
506        assert!(!req.is_satisfied_by(&stale, now));
507        assert!(!req.is_satisfied_by(&wrong_scope, now));
508        assert!(!high_loa_req.is_satisfied_by(&low_loa, now));
509    }
510}