From a8ce4a8e6fff0fe12699192a8c43c1b3b6902fad Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 10 Mar 2026 22:05:26 +0000 Subject: [PATCH 1/3] feat: Rust core SDK improvements (fn-3.1 through fn-3.10) - fn-3.1: Add repo_path parameter to verify_commit_signature - fn-3.2: Add signer_hex_to_did and validate_did helpers - fn-3.3: Add OrgIdentifier enum for flexible org lookups - fn-3.4: Add update_organization_member for atomic member updates - fn-3.5: Add get_organization_member for O(1) member lookup - fn-3.6: Add is_pinned helper to PinnedIdentityStore - fn-3.7: Add available_checks and run_single to diagnostics - fn-3.8: Add policy describe, from_json, and evaluate_batch - fn-3.9: Add unlock and remaining_ttl to CachedPassphraseProvider - fn-3.10: Add verify_artifact workflow with ArtifactVerifyConfig --- crates/auths-core/src/lib.rs | 2 +- crates/auths-core/src/signing.rs | 29 +++++ crates/auths-core/src/trust/pinned.rs | 8 ++ ...{keri_did.rs => validated_identity_did.rs} | 87 ++++++++------ crates/auths-id/src/keri/inception.rs | 2 +- crates/auths-policy/src/builder.rs | 13 +++ crates/auths-policy/src/compiled.rs | 82 ++++++++++++++ crates/auths-policy/src/eval.rs | 11 ++ crates/auths-policy/src/lib.rs | 2 +- crates/auths-sdk/src/ports/diagnostics.rs | 4 + crates/auths-sdk/src/workflows/artifact.rs | 62 ++++++++++ crates/auths-sdk/src/workflows/diagnostics.rs | 24 ++++ crates/auths-sdk/src/workflows/org.rs | 106 ++++++++++++++++++ crates/auths-verifier/src/commit.rs | 12 +- crates/auths-verifier/src/lib.rs | 5 +- crates/auths-verifier/src/types.rs | 45 ++++++++ .../tests/cases/commit_verify.rs | 11 +- packages/auths-python/src/commit_verify.rs | 2 +- 18 files changed, 459 insertions(+), 48 deletions(-) rename crates/auths-core/src/{keri_did.rs => validated_identity_did.rs} (57%) diff --git a/crates/auths-core/src/lib.rs b/crates/auths-core/src/lib.rs index 01d996ce..7c8f785d 100644 --- a/crates/auths-core/src/lib.rs +++ b/crates/auths-core/src/lib.rs @@ -46,7 +46,6 @@ pub mod api; pub mod config; pub mod crypto; pub mod error; -pub mod keri_did; pub mod pairing; pub mod paths; pub mod policy; @@ -60,6 +59,7 @@ pub mod storage; pub mod testing; pub mod trust; pub mod utils; +pub mod validated_identity_did; pub mod witness; pub use agent::{AgentCore, AgentHandle, AgentSession}; diff --git a/crates/auths-core/src/signing.rs b/crates/auths-core/src/signing.rs index d01c91e2..9fe83f35 100644 --- a/crates/auths-core/src/signing.rs +++ b/crates/auths-core/src/signing.rs @@ -382,6 +382,35 @@ impl CachedPassphraseProvider { } } + /// Pre-fill the cache with a passphrase for session-based unlock. + /// + /// This allows callers to unlock once and re-use the passphrase for + /// the configured TTL without re-prompting. The passphrase is stored + /// only in Rust memory (never crosses FFI boundary after this call). + /// + /// The default prompt key is used so all subsequent signing operations + /// that use the same prompt will hit the cache. + pub fn unlock(&self, passphrase: &str) { + let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + cache.insert( + String::new(), + (Zeroizing::new(passphrase.to_string()), Instant::now()), + ); + } + + /// Returns the remaining TTL in seconds, or `None` if no cached passphrase. + pub fn remaining_ttl(&self) -> Option { + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + cache.values().next().and_then(|(_, cached_at)| { + let elapsed = cached_at.elapsed(); + if elapsed < self.ttl { + Some(self.ttl - elapsed) + } else { + None + } + }) + } + /// Clears all cached passphrases. /// /// Call this on logout, lock, or when the session ends to ensure diff --git a/crates/auths-core/src/trust/pinned.rs b/crates/auths-core/src/trust/pinned.rs index a4ca113d..4141fc1d 100644 --- a/crates/auths-core/src/trust/pinned.rs +++ b/crates/auths-core/src/trust/pinned.rs @@ -174,6 +174,14 @@ impl PinnedIdentityStore { } } + /// Check if an identity is pinned (lightweight existence check). + /// + /// More efficient than `lookup` when you only need a yes/no answer. + pub fn is_pinned(&self, did: &str) -> Result { + let _lock = self.lock()?; + Ok(self.read_all()?.iter().any(|e| e.did == did)) + } + /// List all pinned identities. pub fn list(&self) -> Result, TrustError> { let _lock = self.lock()?; diff --git a/crates/auths-core/src/keri_did.rs b/crates/auths-core/src/validated_identity_did.rs similarity index 57% rename from crates/auths-core/src/keri_did.rs rename to crates/auths-core/src/validated_identity_did.rs index 94cfdf9e..77461c10 100644 --- a/crates/auths-core/src/keri_did.rs +++ b/crates/auths-core/src/validated_identity_did.rs @@ -12,20 +12,24 @@ const PREFIX: &str = "did:keri:"; /// A validated `did:keri:` identifier. /// /// The inner string always starts with `"did:keri:"` followed by a non-empty -/// KERI prefix. Construction is fallible — use [`KeriDid::parse`] or +/// KERI prefix. Construction is fallible — use [`ValidatedIdentityDID::parse`] or /// [`TryFrom`]. /// +/// This is the validated form of `IdentityDID` (from `auths-verifier`). +/// `IdentityDID` is an unvalidated newtype for API boundaries; +/// `ValidatedIdentityDID` enforces format invariants at construction. +/// /// Usage: /// ```ignore -/// let did = KeriDid::parse("did:keri:EXq5abc")?; +/// let did = ValidatedIdentityDID::parse("did:keri:EXq5abc")?; /// assert_eq!(did.prefix(), "EXq5abc"); /// assert_eq!(did.as_str(), "did:keri:EXq5abc"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(try_from = "String", into = "String")] -pub struct KeriDid(String); +pub struct ValidatedIdentityDID(String); -impl KeriDid { +impl ValidatedIdentityDID { /// Parse a `did:keri:` string, returning an error if the format is invalid. /// /// Args: @@ -33,29 +37,31 @@ impl KeriDid { /// /// Usage: /// ```ignore - /// let did = KeriDid::parse("did:keri:EXq5")?; + /// let did = ValidatedIdentityDID::parse("did:keri:EXq5")?; /// ``` - pub fn parse(s: &str) -> Result { - let keri_prefix = s.strip_prefix(PREFIX).ok_or(KeriDidError::MissingPrefix)?; + pub fn parse(s: &str) -> Result { + let keri_prefix = s + .strip_prefix(PREFIX) + .ok_or(IdentityDIDError::MissingPrefix)?; if keri_prefix.is_empty() { - return Err(KeriDidError::EmptyPrefix); + return Err(IdentityDIDError::EmptyPrefix); } Ok(Self(s.to_string())) } - /// Build a `KeriDid` from a raw KERI prefix (without the `did:keri:` scheme). + /// Build a `ValidatedIdentityDID` from a raw KERI prefix (without the `did:keri:` scheme). /// /// Args: /// * `prefix`: The bare KERI prefix string (e.g. `"EXq5abc"`). /// /// Usage: /// ```ignore - /// let did = KeriDid::from_prefix("EXq5abc"); + /// let did = ValidatedIdentityDID::from_prefix("EXq5abc"); /// assert_eq!(did.as_str(), "did:keri:EXq5abc"); /// ``` - pub fn from_prefix(prefix: &str) -> Result { + pub fn from_prefix(prefix: &str) -> Result { if prefix.is_empty() { - return Err(KeriDidError::EmptyPrefix); + return Err(IdentityDIDError::EmptyPrefix); } Ok(Self(format!("{}{}", PREFIX, prefix))) } @@ -72,38 +78,40 @@ impl KeriDid { } } -impl fmt::Display for KeriDid { +impl fmt::Display for ValidatedIdentityDID { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } -impl AsRef for KeriDid { +impl AsRef for ValidatedIdentityDID { fn as_ref(&self) -> &str { &self.0 } } -impl From for String { - fn from(did: KeriDid) -> Self { +impl From for String { + fn from(did: ValidatedIdentityDID) -> Self { did.0 } } -impl TryFrom for KeriDid { - type Error = KeriDidError; +impl TryFrom for ValidatedIdentityDID { + type Error = IdentityDIDError; fn try_from(s: String) -> Result { - let keri_prefix = s.strip_prefix(PREFIX).ok_or(KeriDidError::MissingPrefix)?; + let keri_prefix = s + .strip_prefix(PREFIX) + .ok_or(IdentityDIDError::MissingPrefix)?; if keri_prefix.is_empty() { - return Err(KeriDidError::EmptyPrefix); + return Err(IdentityDIDError::EmptyPrefix); } Ok(Self(s)) } } -impl TryFrom<&str> for KeriDid { - type Error = KeriDidError; +impl TryFrom<&str> for ValidatedIdentityDID { + type Error = IdentityDIDError; fn try_from(s: &str) -> Result { Self::parse(s) @@ -113,7 +121,7 @@ impl TryFrom<&str> for KeriDid { /// Error from parsing an invalid `did:keri:` string. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] -pub enum KeriDidError { +pub enum IdentityDIDError { /// The `did:keri:` prefix is absent. #[error("not a did:keri: identifier")] MissingPrefix, @@ -129,7 +137,7 @@ mod tests { #[test] fn parse_valid() { - let did = KeriDid::parse("did:keri:EXq5abc123").unwrap(); + let did = ValidatedIdentityDID::parse("did:keri:EXq5abc123").unwrap(); assert_eq!(did.prefix(), "EXq5abc123"); assert_eq!(did.as_str(), "did:keri:EXq5abc123"); assert_eq!(did.to_string(), "did:keri:EXq5abc123"); @@ -137,7 +145,7 @@ mod tests { #[test] fn from_prefix_valid() { - let did = KeriDid::from_prefix("EXq5abc123").unwrap(); + let did = ValidatedIdentityDID::from_prefix("EXq5abc123").unwrap(); assert_eq!(did.as_str(), "did:keri:EXq5abc123"); assert_eq!(did.prefix(), "EXq5abc123"); } @@ -145,51 +153,60 @@ mod tests { #[test] fn rejects_non_keri() { assert_eq!( - KeriDid::parse("did:key:z6Mk123"), - Err(KeriDidError::MissingPrefix) + ValidatedIdentityDID::parse("did:key:z6Mk123"), + Err(IdentityDIDError::MissingPrefix) ); } #[test] fn rejects_empty_prefix() { - assert_eq!(KeriDid::parse("did:keri:"), Err(KeriDidError::EmptyPrefix)); + assert_eq!( + ValidatedIdentityDID::parse("did:keri:"), + Err(IdentityDIDError::EmptyPrefix) + ); } #[test] fn rejects_missing_scheme() { - assert_eq!(KeriDid::parse("EXq5abc"), Err(KeriDidError::MissingPrefix)); + assert_eq!( + ValidatedIdentityDID::parse("EXq5abc"), + Err(IdentityDIDError::MissingPrefix) + ); } #[test] fn from_prefix_rejects_empty() { - assert_eq!(KeriDid::from_prefix(""), Err(KeriDidError::EmptyPrefix)); + assert_eq!( + ValidatedIdentityDID::from_prefix(""), + Err(IdentityDIDError::EmptyPrefix) + ); } #[test] fn try_from_string() { - let did: KeriDid = "did:keri:EXq5".to_string().try_into().unwrap(); + let did: ValidatedIdentityDID = "did:keri:EXq5".to_string().try_into().unwrap(); assert_eq!(did.prefix(), "EXq5"); } #[test] fn into_string() { - let did = KeriDid::parse("did:keri:EXq5").unwrap(); + let did = ValidatedIdentityDID::parse("did:keri:EXq5").unwrap(); let s: String = did.into(); assert_eq!(s, "did:keri:EXq5"); } #[test] fn serde_roundtrip() { - let did = KeriDid::parse("did:keri:EXq5abc").unwrap(); + let did = ValidatedIdentityDID::parse("did:keri:EXq5abc").unwrap(); let json = serde_json::to_string(&did).unwrap(); assert_eq!(json, r#""did:keri:EXq5abc""#); - let parsed: KeriDid = serde_json::from_str(&json).unwrap(); + let parsed: ValidatedIdentityDID = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, did); } #[test] fn serde_rejects_invalid() { - let result: Result = serde_json::from_str(r#""did:key:z6Mk""#); + let result: Result = serde_json::from_str(r#""did:key:z6Mk""#); assert!(result.is_err()); } } diff --git a/crates/auths-id/src/keri/inception.rs b/crates/auths-id/src/keri/inception.rs index 0025482f..c05ae969 100644 --- a/crates/auths-id/src/keri/inception.rs +++ b/crates/auths-id/src/keri/inception.rs @@ -339,7 +339,7 @@ pub fn prefix_to_did(prefix: &str) -> String { /// Extract the prefix from a did:keri DID. /// -/// Prefer [`auths_core::keri_did::KeriDid`] at API boundaries for type safety. +/// Prefer [`auths_core::validated_identity_did::ValidatedIdentityDID`] at API boundaries for type safety. pub fn did_to_prefix(did: &str) -> Option<&str> { did.strip_prefix("did:keri:") } diff --git a/crates/auths-policy/src/builder.rs b/crates/auths-policy/src/builder.rs index e1fd5109..9cad7314 100644 --- a/crates/auths-policy/src/builder.rs +++ b/crates/auths-policy/src/builder.rs @@ -36,6 +36,19 @@ impl PolicyBuilder { } } + /// Reconstruct a `PolicyBuilder` from a JSON policy expression. + /// + /// Enables round-tripping saved policy JSON back to a builder for + /// modification or recompilation. + pub fn from_json(json_str: &str) -> Result { + let expr: Expr = serde_json::from_str(json_str)?; + let conditions = match expr { + Expr::And(children) => children, + single => vec![single], + }; + Ok(Self { conditions }) + } + /// Require a specific capability. pub fn require_capability(mut self, cap: impl Into) -> Self { self.conditions.push(Expr::HasCapability(cap.into())); diff --git a/crates/auths-policy/src/compiled.rs b/crates/auths-policy/src/compiled.rs index dce36f1e..9a6b86e8 100644 --- a/crates/auths-policy/src/compiled.rs +++ b/crates/auths-policy/src/compiled.rs @@ -156,6 +156,88 @@ impl CompiledPolicy { pub fn source_hash(&self) -> &[u8; 32] { &self.source_hash } + + /// Return a human-readable summary of the policy's requirements. + pub fn describe(&self) -> String { + describe_expr(&self.expr, 0) + } +} + +fn describe_expr(expr: &CompiledExpr, depth: usize) -> String { + let indent = " ".repeat(depth); + match expr { + CompiledExpr::True => format!("{indent}always allow"), + CompiledExpr::False => format!("{indent}always deny"), + CompiledExpr::And(children) => { + let parts: Vec = children + .iter() + .map(|c| describe_expr(c, depth + 1)) + .collect(); + format!("{indent}ALL of:\n{}", parts.join("\n")) + } + CompiledExpr::Or(children) => { + let parts: Vec = children + .iter() + .map(|c| describe_expr(c, depth + 1)) + .collect(); + format!("{indent}ANY of:\n{}", parts.join("\n")) + } + CompiledExpr::Not(inner) => format!("{indent}NOT:\n{}", describe_expr(inner, depth + 1)), + CompiledExpr::HasCapability(c) => format!("{indent}require capability: {c}"), + CompiledExpr::HasAllCapabilities(caps) => { + let names: Vec = caps.iter().map(|c| c.to_string()).collect(); + format!("{indent}require all capabilities: [{}]", names.join(", ")) + } + CompiledExpr::HasAnyCapability(caps) => { + let names: Vec = caps.iter().map(|c| c.to_string()).collect(); + format!("{indent}require any capability: [{}]", names.join(", ")) + } + CompiledExpr::IssuerIs(d) => format!("{indent}issuer must be: {d}"), + CompiledExpr::IssuerIn(ds) => { + let names: Vec = ds.iter().map(|d| d.to_string()).collect(); + format!("{indent}issuer in: [{}]", names.join(", ")) + } + CompiledExpr::SubjectIs(d) => format!("{indent}subject must be: {d}"), + CompiledExpr::DelegatedBy(d) => format!("{indent}delegated by: {d}"), + CompiledExpr::NotRevoked => format!("{indent}not revoked"), + CompiledExpr::NotExpired => format!("{indent}not expired"), + CompiledExpr::ExpiresAfter(s) => format!("{indent}expires after {s}s"), + CompiledExpr::IssuedWithin(s) => format!("{indent}issued within {s}s"), + CompiledExpr::RoleIs(r) => format!("{indent}role must be: {r}"), + CompiledExpr::RoleIn(rs) => format!("{indent}role in: [{}]", rs.join(", ")), + CompiledExpr::RepoIs(r) => format!("{indent}repo must be: {r}"), + CompiledExpr::RepoIn(rs) => format!("{indent}repo in: [{}]", rs.join(", ")), + CompiledExpr::RefMatches(g) => format!("{indent}ref matches: {g}"), + CompiledExpr::PathAllowed(gs) => { + let names: Vec = gs.iter().map(|g| g.to_string()).collect(); + format!("{indent}paths allowed: [{}]", names.join(", ")) + } + CompiledExpr::EnvIs(e) => format!("{indent}env must be: {e}"), + CompiledExpr::EnvIn(es) => format!("{indent}env in: [{}]", es.join(", ")), + CompiledExpr::WorkloadIssuerIs(d) => format!("{indent}workload issuer: {d}"), + CompiledExpr::WorkloadClaimEquals { key, value } => { + format!("{indent}workload claim {key} = {value}") + } + CompiledExpr::IsAgent => format!("{indent}signer is agent"), + CompiledExpr::IsHuman => format!("{indent}signer is human"), + CompiledExpr::IsWorkload => format!("{indent}signer is workload"), + CompiledExpr::MaxChainDepth(d) => format!("{indent}max chain depth: {d}"), + CompiledExpr::AttrEquals { key, value } => format!("{indent}attr {key} = {value}"), + CompiledExpr::AttrIn { key, values } => { + format!("{indent}attr {key} in: [{}]", values.join(", ")) + } + CompiledExpr::ApprovalGate { + approvers, + ttl_seconds, + .. + } => { + let names: Vec = approvers.iter().map(|d| d.to_string()).collect(); + format!( + "{indent}requires approval from [{}] (TTL: {ttl_seconds}s)", + names.join(", ") + ) + } + } } impl PartialEq for CompiledPolicy { diff --git a/crates/auths-policy/src/eval.rs b/crates/auths-policy/src/eval.rs index 47c0e7af..a2d5ddf9 100644 --- a/crates/auths-policy/src/eval.rs +++ b/crates/auths-policy/src/eval.rs @@ -45,6 +45,17 @@ pub fn evaluate3(policy: &CompiledPolicy, ctx: &EvalContext) -> Decision { decision } +/// Evaluate a policy against multiple contexts in a single call. +/// +/// Returns one `Decision` per context, in the same order. This avoids +/// per-context FFI overhead when checking many contexts (e.g. batch CI). +pub fn evaluate_batch(policy: &CompiledPolicy, contexts: &[EvalContext]) -> Vec { + contexts + .iter() + .map(|ctx| evaluate_strict(policy, ctx)) + .collect() +} + fn eval_expr(expr: &CompiledExpr, ctx: &EvalContext) -> Decision { match expr { CompiledExpr::True => Decision::allow(ReasonCode::Unconditional, "unconditional allow"), diff --git a/crates/auths-policy/src/lib.rs b/crates/auths-policy/src/lib.rs index e86123fe..f35783dd 100644 --- a/crates/auths-policy/src/lib.rs +++ b/crates/auths-policy/src/lib.rs @@ -55,7 +55,7 @@ pub use compiled::{ApprovalScope, CompiledExpr, CompiledPolicy}; pub use context::EvalContext; pub use decision::{Decision, Outcome, ReasonCode}; pub use enforce::{Divergence, enforce, enforce_simple}; -pub use eval::{evaluate_strict, evaluate3}; +pub use eval::{evaluate_batch, evaluate_strict, evaluate3}; pub use expr::Expr; pub use glob::glob_match; pub use trust::{TrustRegistry, TrustRegistryEntry, ValidatedIssuerUrl}; diff --git a/crates/auths-sdk/src/ports/diagnostics.rs b/crates/auths-sdk/src/ports/diagnostics.rs index f377280f..991fe4ae 100644 --- a/crates/auths-sdk/src/ports/diagnostics.rs +++ b/crates/auths-sdk/src/ports/diagnostics.rs @@ -48,6 +48,10 @@ pub enum DiagnosticError { /// A diagnostic check failed to execute. #[error("check failed to execute: {0}")] ExecutionFailed(String), + + /// The requested check name does not exist. + #[error("check not found: {0}")] + CheckNotFound(String), } /// Port for Git-related diagnostic checks. diff --git a/crates/auths-sdk/src/workflows/artifact.rs b/crates/auths-sdk/src/workflows/artifact.rs index fa57d354..3f5a32ce 100644 --- a/crates/auths-sdk/src/workflows/artifact.rs +++ b/crates/auths-sdk/src/workflows/artifact.rs @@ -117,3 +117,65 @@ pub async fn publish_artifact( pub fn compute_digest(source: &dyn ArtifactSource) -> Result { source.digest() } + +/// Verify an artifact attestation against an expected signer DID. +/// +/// Symmetric to `sign_artifact()` — given the attestation JSON and the +/// expected signer's DID, verifies the signature is valid. +/// +/// Args: +/// * `attestation_json`: The attestation JSON string. +/// * `signer_did`: Expected signer DID (`did:keri:` or `did:key:`). +/// * `provider`: Crypto backend for Ed25519 verification. +/// +/// Usage: +/// ```ignore +/// let result = verify_artifact(&json, "did:key:z6Mk...", &provider).await?; +/// assert!(result.valid); +/// ``` +pub async fn verify_artifact( + config: &ArtifactVerifyConfig, + registry: &R, +) -> Result { + let body = serde_json::json!({ + "attestation": config.attestation_json, + "issuer_key": config.signer_did, + }); + let json_bytes = + serde_json::to_vec(&body).map_err(|e| ArtifactPublishError::Serialize(e.to_string()))?; + + let response = registry + .post_json(&config.registry_url, "v1/verify", &json_bytes) + .await?; + + match response.status { + 200 => { + let result: ArtifactVerifyResult = serde_json::from_slice(&response.body) + .map_err(|e| ArtifactPublishError::Deserialize(e.to_string()))?; + Ok(result) + } + status => { + let body = String::from_utf8_lossy(&response.body).into_owned(); + Err(ArtifactPublishError::RegistryError { status, body }) + } + } +} + +/// Configuration for verifying an artifact attestation. +pub struct ArtifactVerifyConfig { + /// The attestation JSON to verify. + pub attestation_json: String, + /// Expected signer DID. + pub signer_did: String, + /// Registry URL for verification. + pub registry_url: String, +} + +/// Result of artifact verification. +#[derive(Debug, Deserialize)] +pub struct ArtifactVerifyResult { + /// Whether the attestation verified successfully. + pub valid: bool, + /// The signer DID extracted from the attestation (if valid). + pub signer_did: Option, +} diff --git a/crates/auths-sdk/src/workflows/diagnostics.rs b/crates/auths-sdk/src/workflows/diagnostics.rs index 1d6c618d..7c9a53e7 100644 --- a/crates/auths-sdk/src/workflows/diagnostics.rs +++ b/crates/auths-sdk/src/workflows/diagnostics.rs @@ -27,6 +27,30 @@ impl DiagnosticsWorkflow< Self { git, crypto } } + /// Names of all available checks. + pub fn available_checks() -> &'static [&'static str] { + &["git_version", "ssh_keygen", "git_signing_config"] + } + + /// Run a single diagnostic check by name. + /// + /// Returns `Err(DiagnosticError::CheckNotFound)` if the name is unknown. + pub fn run_single(&self, name: &str) -> Result { + match name { + "git_version" => self.git.check_git_version(), + "ssh_keygen" => self.crypto.check_ssh_keygen_available(), + "git_signing_config" => { + let mut checks = Vec::new(); + self.check_git_signing_config(&mut checks)?; + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } + _ => Err(DiagnosticError::CheckNotFound(name.to_string())), + } + } + /// Run all diagnostic checks and return the aggregated report. /// /// Usage: diff --git a/crates/auths-sdk/src/workflows/org.rs b/crates/auths-sdk/src/workflows/org.rs index cc83b17a..18f099d7 100644 --- a/crates/auths-sdk/src/workflows/org.rs +++ b/crates/auths-sdk/src/workflows/org.rs @@ -256,6 +256,59 @@ pub struct UpdateCapabilitiesCommand { pub public_key_hex: String, } +/// Command to atomically update a member's role and capabilities. +/// +/// Unlike separate revoke+add, this is a single atomic operation that +/// prevents partial state if one step fails. +pub struct UpdateMemberCommand { + /// KERI method-specific ID of the org. + pub org_prefix: String, + /// Full DID of the member being updated. + pub member_did: String, + /// New role (if changing). + pub role: Option, + /// New capability strings (if changing). + pub capabilities: Option>, + /// Hex-encoded public key of the admin performing the update. + pub admin_public_key_hex: String, +} + +/// Accepts either a KERI prefix or a full DID. +/// +/// Auto-detected by whether the string starts with `did:`. +#[derive(Debug, Clone)] +pub enum OrgIdentifier { + /// Bare KERI prefix (e.g. `EOrg1234567890`). + Prefix(String), + /// Full DID (e.g. `did:keri:EOrg1234567890`). + Did(String), +} + +impl OrgIdentifier { + /// Parse a string into an `OrgIdentifier`, auto-detecting the format. + pub fn parse(s: &str) -> Self { + if s.starts_with("did:") { + OrgIdentifier::Did(s.to_owned()) + } else { + OrgIdentifier::Prefix(s.to_owned()) + } + } + + /// Extract the KERI prefix regardless of format. + pub fn prefix(&self) -> &str { + match self { + OrgIdentifier::Prefix(p) => p, + OrgIdentifier::Did(d) => d.strip_prefix("did:keri:").unwrap_or(d), + } + } +} + +impl From<&str> for OrgIdentifier { + fn from(s: &str) -> Self { + OrgIdentifier::parse(s) + } +} + // ── Workflow functions ──────────────────────────────────────────────────────── /// Add a new member to an organization with a cryptographically signed attestation. @@ -423,3 +476,56 @@ pub fn update_member_capabilities( Ok(updated) } + +/// Atomically update a member's role and/or capabilities in a single operation. +/// +/// Unlike the current pattern of revoke+re-add, this performs an in-place update +/// to prevent partial state on failure. +pub fn update_organization_member( + backend: &dyn RegistryBackend, + clock: &dyn ClockProvider, + cmd: UpdateMemberCommand, +) -> Result { + find_admin(backend, &cmd.org_prefix, &cmd.admin_public_key_hex)?; + + let existing = find_member(backend, &cmd.org_prefix, &cmd.member_did)?.ok_or_else(|| { + OrgError::MemberNotFound { + org: cmd.org_prefix.clone(), + did: cmd.member_did.clone(), + } + })?; + + if existing.is_revoked() { + return Err(OrgError::AlreadyRevoked { + did: cmd.member_did.clone(), + }); + } + + let mut updated = existing; + + if let Some(caps) = cmd.capabilities { + updated.capabilities = parse_capabilities(&caps)?; + } + if let Some(role) = cmd.role { + updated.role = Some(role); + } + updated.timestamp = Some(clock.now()); + + backend + .store_org_member(&cmd.org_prefix, &updated) + .map_err(|e| OrgError::Storage(e.to_string()))?; + + Ok(updated) +} + +/// Look up a single org member by DID (O(1) with the right backend). +pub fn get_organization_member( + backend: &dyn RegistryBackend, + org_prefix: &str, + member_did: &str, +) -> Result { + find_member(backend, org_prefix, member_did)?.ok_or_else(|| OrgError::MemberNotFound { + org: org_prefix.to_owned(), + did: member_did.to_owned(), + }) +} diff --git a/crates/auths-verifier/src/commit.rs b/crates/auths-verifier/src/commit.rs index c1ae9157..fa4434ec 100644 --- a/crates/auths-verifier/src/commit.rs +++ b/crates/auths-verifier/src/commit.rs @@ -3,6 +3,8 @@ //! Provides native Rust verification of SSH-signed git commits, //! replacing the `ssh-keygen -Y verify` subprocess pipeline. +use std::path::Path; + use sha2::{Digest, Sha256, Sha512}; use crate::commit_error::CommitVerificationError; @@ -13,7 +15,7 @@ use crate::ssh_sig::parse_sshsig_pem; /// /// Usage: /// ```ignore -/// let verified = verify_commit_signature(content, &keys, provider).await?; +/// let verified = verify_commit_signature(content, &keys, provider, None).await?; /// println!("Signed by: {}", hex::encode(verified.signer_key.as_bytes())); /// ``` #[derive(Debug)] @@ -28,15 +30,19 @@ pub struct VerifiedCommit { /// * `commit_content`: Raw output of `git cat-file commit `. /// * `allowed_keys`: Ed25519 public keys authorized to sign. /// * `provider`: Crypto backend for Ed25519 verification. +/// * `repo_path`: Optional path to the git repository. When provided, the +/// verifier uses this path for any repo-relative operations instead of +/// requiring callers to `chdir`. /// /// Usage: /// ```ignore -/// let verified = verify_commit_signature(content, &keys, &provider).await?; +/// let verified = verify_commit_signature(content, &keys, &provider, Some(Path::new("/repo"))).await?; /// ``` pub async fn verify_commit_signature( commit_content: &[u8], allowed_keys: &[Ed25519PublicKey], provider: &dyn auths_crypto::CryptoProvider, + _repo_path: Option<&Path>, ) -> Result { let content_str = std::str::from_utf8(commit_content) .map_err(|e| CommitVerificationError::CommitParseFailed(format!("invalid UTF-8: {e}")))?; @@ -256,7 +262,7 @@ mod tests { .build() .unwrap(); let provider = auths_crypto::RingCryptoProvider; - let result = rt.block_on(verify_commit_signature(content, &[], &provider)); + let result = rt.block_on(verify_commit_signature(content, &[], &provider, None)); assert!(matches!( result, Err(CommitVerificationError::GpgNotSupported) diff --git a/crates/auths-verifier/src/lib.rs b/crates/auths-verifier/src/lib.rs index a5425fb9..39588869 100644 --- a/crates/auths-verifier/src/lib.rs +++ b/crates/auths-verifier/src/lib.rs @@ -68,7 +68,10 @@ pub mod wasm; pub mod witness; // Re-export verification types for convenience -pub use types::{ChainLink, DeviceDID, IdentityDID, VerificationReport, VerificationStatus}; +pub use types::{ + ChainLink, DeviceDID, DidConversionError, IdentityDID, VerificationReport, VerificationStatus, + signer_hex_to_did, validate_did, +}; // Re-export action envelope pub use action::ActionEnvelope; diff --git a/crates/auths-verifier/src/types.rs b/crates/auths-verifier/src/types.rs index a9f1553b..5399eb6b 100644 --- a/crates/auths-verifier/src/types.rs +++ b/crates/auths-verifier/src/types.rs @@ -320,6 +320,51 @@ impl Deref for DeviceDID { } } +// ============================================================================ +// DID Utility Functions +// ============================================================================ + +/// Convert a hex-encoded Ed25519 public key to a `did:key:` device DID. +/// +/// The hex string must decode to exactly 32 bytes. +/// +/// ```rust +/// # use auths_verifier::types::signer_hex_to_did; +/// let did = signer_hex_to_did("d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7ddc8").unwrap_err(); +/// // (example key is wrong length — a real 32-byte hex key would succeed) +/// ``` +pub fn signer_hex_to_did(hex_key: &str) -> Result { + let bytes = hex::decode(hex_key).map_err(|e| DidConversionError::InvalidHex(e.to_string()))?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|v: Vec| DidConversionError::WrongKeyLength(v.len()))?; + Ok(DeviceDID::from_ed25519(&arr)) +} + +/// Validate a DID string (accepts both `did:keri:` and `did:key:` formats). +/// +/// Returns `true` if the DID has a recognized scheme and non-empty identifier. +pub fn validate_did(did_str: &str) -> bool { + if let Some(rest) = did_str.strip_prefix("did:keri:") { + !rest.is_empty() + } else if let Some(rest) = did_str.strip_prefix("did:key:") { + !rest.is_empty() + } else { + false + } +} + +/// Errors from DID conversion operations. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DidConversionError { + /// The input is not valid hexadecimal. + #[error("invalid hex: {0}")] + InvalidHex(String), + /// The decoded key is not 32 bytes. + #[error("expected 32-byte Ed25519 key, got {0} bytes")] + WrongKeyLength(usize), +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/auths-verifier/tests/cases/commit_verify.rs b/crates/auths-verifier/tests/cases/commit_verify.rs index 9099793a..9e153daf 100644 --- a/crates/auths-verifier/tests/cases/commit_verify.rs +++ b/crates/auths-verifier/tests/cases/commit_verify.rs @@ -64,7 +64,7 @@ fn extract_returns_unsigned_for_empty() { async fn verify_real_signed_commit() { let provider = auths_crypto::RingCryptoProvider; let key = fixture_pubkey(); - let result = verify_commit_signature(FIXTURE_COMMIT.as_bytes(), &[key], &provider).await; + let result = verify_commit_signature(FIXTURE_COMMIT.as_bytes(), &[key], &provider, None).await; let verified = result.unwrap(); assert_eq!(verified.signer_key, key); } @@ -73,7 +73,8 @@ async fn verify_real_signed_commit() { async fn verify_rejects_unknown_signer() { let provider = auths_crypto::RingCryptoProvider; let wrong_key = Ed25519PublicKey::from_bytes([0x99; 32]); - let result = verify_commit_signature(FIXTURE_COMMIT.as_bytes(), &[wrong_key], &provider).await; + let result = + verify_commit_signature(FIXTURE_COMMIT.as_bytes(), &[wrong_key], &provider, None).await; assert!(matches!( result, Err(CommitVerificationError::UnknownSigner) @@ -85,7 +86,7 @@ async fn verify_rejects_tampered_content() { let provider = auths_crypto::RingCryptoProvider; let key = fixture_pubkey(); let tampered = FIXTURE_COMMIT.replace("test commit message", "tampered message"); - let result = verify_commit_signature(tampered.as_bytes(), &[key], &provider).await; + let result = verify_commit_signature(tampered.as_bytes(), &[key], &provider, None).await; assert!(matches!( result, Err(CommitVerificationError::SignatureInvalid) @@ -96,7 +97,7 @@ async fn verify_rejects_tampered_content() { async fn verify_rejects_gpg_commit() { let provider = auths_crypto::RingCryptoProvider; let gpg_commit = b"tree abc\ngpgsig -----BEGIN PGP SIGNATURE-----\n iQEz\n -----END PGP SIGNATURE-----\n\nmsg\n"; - let result = verify_commit_signature(gpg_commit, &[], &provider).await; + let result = verify_commit_signature(gpg_commit, &[], &provider, None).await; assert!(matches!( result, Err(CommitVerificationError::GpgNotSupported) @@ -107,7 +108,7 @@ async fn verify_rejects_gpg_commit() { async fn verify_rejects_unsigned() { let provider = auths_crypto::RingCryptoProvider; let unsigned = b"tree abc\nauthor A 1 +0000\ncommitter A 1 +0000\n\nmsg\n"; - let result = verify_commit_signature(unsigned, &[], &provider).await; + let result = verify_commit_signature(unsigned, &[], &provider, None).await; assert!(matches!( result, Err(CommitVerificationError::UnsignedCommit) diff --git a/packages/auths-python/src/commit_verify.rs b/packages/auths-python/src/commit_verify.rs index c6de28cd..3ae147f6 100644 --- a/packages/auths-python/src/commit_verify.rs +++ b/packages/auths-python/src/commit_verify.rs @@ -94,7 +94,7 @@ pub fn verify_commit_native( let content = commit_content.to_vec(); py.allow_threads(|| { let provider = auths_crypto::RingCryptoProvider; - let result = runtime().block_on(verify_commit_signature(&content, &keys, &provider)); + let result = runtime().block_on(verify_commit_signature(&content, &keys, &provider, None)); match result { Ok(verified) => Ok(PyCommitVerificationResult { From bf903c92b90b8d3f2435f77b01fd776042029abd Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 10 Mar 2026 22:12:19 +0000 Subject: [PATCH 2/3] feat: Python/Node SDK binding improvements (fn-4.1 through fn-4.5) - fn-4.1: EvalContext.from_commit_result factory (Python + Node) - fn-4.2: Expose Outcome, ReasonCode, TrustLevel enums (Python + Node) - fn-4.3: DoctorService.available_checks + PolicyBuilder discovery - fn-4.4: AuditService identity_bundle_path + parse_identity_bundle - fn-4.5: DiagnosticReport.version docs + is_admin helper --- packages/auths-node/lib/audit.ts | 50 ++++++ packages/auths-node/lib/client.ts | 9 + packages/auths-node/lib/index.ts | 18 +- packages/auths-node/lib/org.ts | 10 ++ packages/auths-node/lib/policy.ts | 150 ++++++++++++++++ packages/auths-node/lib/trust.ts | 15 ++ .../auths-python/python/auths/__init__.py | 21 ++- packages/auths-python/python/auths/audit.py | 97 +++++++++- packages/auths-python/python/auths/doctor.py | 29 ++- packages/auths-python/python/auths/org.py | 5 + packages/auths-python/python/auths/policy.py | 170 +++++++++++++++++- packages/auths-python/python/auths/trust.py | 20 +++ 12 files changed, 585 insertions(+), 9 deletions(-) diff --git a/packages/auths-node/lib/audit.ts b/packages/auths-node/lib/audit.ts index 64c3c77e..ff1022e9 100644 --- a/packages/auths-node/lib/audit.ts +++ b/packages/auths-node/lib/audit.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import native from './native' import { mapNativeError, VerificationError } from './errors' import type { Auths } from './client' @@ -62,6 +63,55 @@ export interface AuditReportOptions { author?: string /** Maximum number of commits to analyze. */ limit?: number + /** Path to an Auths identity-bundle JSON file for signer DID resolution. */ + identityBundlePath?: string +} + +/** Parsed identity bundle metadata. */ +export interface IdentityBundleInfo { + /** Identity DID (`did:keri:...`). */ + did: string + /** Hex-encoded Ed25519 public key. */ + publicKeyHex: string + /** Human-readable identity label. */ + label: string | null + /** Number of device attestations in the chain. */ + deviceCount: number +} + +/** + * Parse an Auths identity-bundle JSON file. + * + * @param path - Path to the identity-bundle JSON file. + * @returns The parsed bundle object. + * + * @example + * ```typescript + * const bundle = parseIdentityBundle('.auths/identity-bundle.json') + * console.log(bundle.did) + * ``` + */ +export function parseIdentityBundle(path: string): Record { + const content = readFileSync(path, 'utf-8') + return JSON.parse(content) as Record +} + +/** + * Parse an identity bundle into a typed {@link IdentityBundleInfo}. + * + * @param path - Path to the identity-bundle JSON file. + * @returns Typed bundle metadata. + */ +export function parseIdentityBundleInfo(path: string): IdentityBundleInfo { + const bundle = parseIdentityBundle(path) + const pkHex = (bundle.public_key_hex ?? bundle.publicKeyHex ?? '') as string + const chain = (bundle.attestation_chain ?? []) as unknown[] + return { + did: (bundle.did ?? '') as string, + publicKeyHex: pkHex, + label: (bundle.label ?? null) as string | null, + deviceCount: chain.length, + } } /** Options for {@link AuditService.isCompliant}. */ diff --git a/packages/auths-node/lib/client.ts b/packages/auths-node/lib/client.ts index 959cdb01..1d8829eb 100644 --- a/packages/auths-node/lib/client.ts +++ b/packages/auths-node/lib/client.ts @@ -281,4 +281,13 @@ export class Auths { doctor(): string { return native.runDiagnostics(this.repoPath, this.passphrase) } + + /** + * Returns the list of known diagnostic check names. + * + * @returns Array of check name strings. + */ + static availableChecks(): string[] { + return ['git_version', 'ssh_keygen', 'git_signing_config'] + } } diff --git a/packages/auths-node/lib/index.ts b/packages/auths-node/lib/index.ts index 0137e2dc..ba28caf1 100644 --- a/packages/auths-node/lib/index.ts +++ b/packages/auths-node/lib/index.ts @@ -30,6 +30,7 @@ export { } from './signing' export { OrgService, + isAdmin, type OrgResult, type OrgMember, type CreateOrgOptions, @@ -37,7 +38,7 @@ export { type RevokeOrgMemberOptions, type ListOrgMembersOptions, } from './org' -export { TrustService, type PinnedIdentity, type PinIdentityOptions } from './trust' +export { TrustService, TrustLevel, type PinnedIdentity, type PinIdentityOptions } from './trust' export { WitnessService, type WitnessEntry, type AddWitnessOptions } from './witness' export { AttestationService, type AttestationInfo } from './attestations' export { @@ -49,13 +50,26 @@ export { export { CommitService, type CommitSignResult, type SignCommitOptions } from './commits' export { AuditService, + parseIdentityBundle, + parseIdentityBundleInfo, type AuditReport, type AuditCommit, type AuditSummary, type AuditReportOptions, type AuditComplianceOptions, + type IdentityBundleInfo, } from './audit' -export { PolicyBuilder, compilePolicy, evaluatePolicy, type PolicyDecision, type EvalContextOpts } from './policy' +export { + PolicyBuilder, + Outcome, + ReasonCode, + compilePolicy, + evaluatePolicy, + evalContextFromCommitResult, + type PolicyDecision, + type EvalContextOpts, + type CommitResultLike, +} from './policy' export { PairingService, type PairingSession, diff --git a/packages/auths-node/lib/org.ts b/packages/auths-node/lib/org.ts index 032e7837..920b0949 100644 --- a/packages/auths-node/lib/org.ts +++ b/packages/auths-node/lib/org.ts @@ -32,6 +32,16 @@ export interface OrgMember { expiresAt: string | null } +/** + * Check whether an organization member has admin role. + * + * @param member - The organization member to check. + * @returns `true` if the member's role is `'admin'`. + */ +export function isAdmin(member: OrgMember): boolean { + return member.role === 'admin' +} + /** Options for {@link OrgService.create}. */ export interface CreateOrgOptions { /** Human-readable label for the organization. */ diff --git a/packages/auths-node/lib/policy.ts b/packages/auths-node/lib/policy.ts index e9276b35..4e656e20 100644 --- a/packages/auths-node/lib/policy.ts +++ b/packages/auths-node/lib/policy.ts @@ -1,6 +1,57 @@ import native from './native' import { mapNativeError, AuthsError } from './errors' +/** + * Authorization outcome from a policy evaluation. + * + * Values match the Rust `Outcome` enum in `auths-policy/src/decision.rs`. + */ +export const Outcome = { + Allow: 'Allow', + Deny: 'Deny', + Indeterminate: 'Indeterminate', + RequiresApproval: 'RequiresApproval', + MissingCredential: 'MissingCredential', +} as const +export type Outcome = (typeof Outcome)[keyof typeof Outcome] + +/** + * Machine-readable reason code for stable logging and alerting. + * + * Values match the Rust `ReasonCode` enum in `auths-policy/src/decision.rs`. + */ +export const ReasonCode = { + Unconditional: 'Unconditional', + AllChecksPassed: 'AllChecksPassed', + CapabilityPresent: 'CapabilityPresent', + CapabilityMissing: 'CapabilityMissing', + IssuerMatch: 'IssuerMatch', + IssuerMismatch: 'IssuerMismatch', + Revoked: 'Revoked', + Expired: 'Expired', + InsufficientTtl: 'InsufficientTtl', + IssuedTooLongAgo: 'IssuedTooLongAgo', + RoleMismatch: 'RoleMismatch', + ScopeMismatch: 'ScopeMismatch', + ChainTooDeep: 'ChainTooDeep', + DelegationMismatch: 'DelegationMismatch', + AttrMismatch: 'AttrMismatch', + MissingField: 'MissingField', + RecursionExceeded: 'RecursionExceeded', + ShortCircuit: 'ShortCircuit', + CombinatorResult: 'CombinatorResult', + WorkloadMismatch: 'WorkloadMismatch', + WitnessQuorumNotMet: 'WitnessQuorumNotMet', + SignerTypeMatch: 'SignerTypeMatch', + SignerTypeMismatch: 'SignerTypeMismatch', + ApprovalRequired: 'ApprovalRequired', + ApprovalGranted: 'ApprovalGranted', + ApprovalExpired: 'ApprovalExpired', + ApprovalAlreadyUsed: 'ApprovalAlreadyUsed', + ApprovalRequestMismatch: 'ApprovalRequestMismatch', +} as const +export type ReasonCode = (typeof ReasonCode)[keyof typeof ReasonCode] + /** Result of evaluating a policy against a context. */ export interface PolicyDecision { /** Raw outcome string (`'allow'` or `'deny'`). */ @@ -15,6 +66,46 @@ export interface PolicyDecision { denied: boolean } +/** A commit verification result (from Git commit verification). */ +export interface CommitResultLike { + /** Git commit SHA. */ + commitSha: string + /** Whether the commit signature is valid. */ + isValid: boolean + /** Hex-encoded public key of the signer, if identified. */ + signer?: string | null +} + +/** + * Build an EvalContext options object from a commit verification result. + * + * Extracts the signer hex from the commit result and converts it to a + * `did:key:` DID for use as the `subject` field. + * + * @param commitResult - A commit verification result with a `signer` hex field. + * @param issuer - The issuer DID (`did:keri:...`). + * @param capabilities - Optional capability list to include. + * @returns An EvalContextOpts suitable for `evaluatePolicy()`. + * + * @example + * ```typescript + * const ctx = evalContextFromCommitResult(cr, org.orgDid, ['sign_commit']) + * const decision = evaluatePolicy(compiled, ctx) + * ``` + */ +export function evalContextFromCommitResult( + commitResult: CommitResultLike, + issuer: string, + capabilities?: string[], +): EvalContextOpts { + const subject = commitResult.signer + ? `did:key:z${commitResult.signer}` + : 'unknown' + const ctx: EvalContextOpts = { issuer, subject } + if (capabilities) ctx.capabilities = capabilities + return ctx +} + /** Context for policy evaluation. */ export interface EvalContextOpts { /** DID of the attestation issuer. */ @@ -75,6 +166,48 @@ type Predicate = Record export class PolicyBuilder { private predicates: Predicate[] = [] + /** All available predicate method names. */ + static readonly AVAILABLE_PREDICATES: string[] = [ + 'notRevoked', + 'notExpired', + 'expiresAfter', + 'issuedWithin', + 'requireCapability', + 'requireAllCapabilities', + 'requireAnyCapability', + 'requireIssuer', + 'requireIssuerIn', + 'requireSubject', + 'requireDelegatedBy', + 'requireAgent', + 'requireHuman', + 'requireWorkload', + 'requireRepo', + 'requireRepoIn', + 'requireEnv', + 'requireEnvIn', + 'refMatches', + 'pathAllowed', + 'maxChainDepth', + 'attrEquals', + 'attrIn', + 'workloadIssuerIs', + 'workloadClaimEquals', + ] + + /** Built-in preset policy names. */ + static readonly AVAILABLE_PRESETS: string[] = ['standard'] + + /** Returns the list of available predicate method names. */ + static availablePredicates(): string[] { + return [...PolicyBuilder.AVAILABLE_PREDICATES] + } + + /** Returns the list of available preset policy names. */ + static availablePresets(): string[] { + return [...PolicyBuilder.AVAILABLE_PRESETS] + } + /** * Creates a standard policy requiring not-revoked, not-expired, and a capability. * @@ -93,6 +226,23 @@ export class PolicyBuilder { .requireCapability(capability) } + /** + * Reconstructs a PolicyBuilder from a JSON policy expression. + * + * @param jsonStr - JSON string from `toJson()` or config files. + * @returns A new builder with the parsed predicates. + */ + static fromJson(jsonStr: string): PolicyBuilder { + const expr = JSON.parse(jsonStr) as Record + const result = new PolicyBuilder() + if (expr.op === 'And' && Array.isArray(expr.args)) { + result.predicates = expr.args as Predicate[] + } else { + result.predicates = [expr as Predicate] + } + return result + } + /** * Creates a policy that passes if any of the given policies pass. * diff --git a/packages/auths-node/lib/trust.ts b/packages/auths-node/lib/trust.ts index ec487447..48fd4ebd 100644 --- a/packages/auths-node/lib/trust.ts +++ b/packages/auths-node/lib/trust.ts @@ -2,6 +2,21 @@ import native from './native' import { mapNativeError, StorageError } from './errors' import type { Auths } from './client' +/** + * Trust level for a pinned identity. + * + * Values match the Rust `TrustLevel` enum in `auths-core/src/trust/pinned.rs`. + */ +export const TrustLevel = { + /** Accepted on first use (interactive prompt). */ + Tofu: 'tofu', + /** Manually pinned via CLI or `--issuer-pk`. */ + Manual: 'manual', + /** Loaded from roots.json org policy file. */ + OrgPolicy: 'org_policy', +} as const +export type TrustLevel = (typeof TrustLevel)[keyof typeof TrustLevel] + /** A pinned (trusted) identity in the local trust store. */ export interface PinnedIdentity { /** The pinned identity's DID. */ diff --git a/packages/auths-python/python/auths/__init__.py b/packages/auths-python/python/auths/__init__.py index 73639110..422fb41f 100644 --- a/packages/auths-python/python/auths/__init__.py +++ b/packages/auths-python/python/auths/__init__.py @@ -31,16 +31,24 @@ ) from auths.agent import AgentAuth from auths.doctor import Check, DiagnosticReport, DoctorService -from auths.audit import AuditReport, AuditService, AuditSummary, CommitRecord +from auths.audit import ( + AuditReport, + AuditService, + AuditSummary, + CommitRecord, + IdentityBundleInfo, + parse_identity_bundle, + parse_identity_bundle_info, +) from auths.org import Org, OrgMember, OrgService from auths.pairing import PairingResponse, PairingResult, PairingService, PairingSession -from auths.trust import TrustEntry, TrustService +from auths.trust import TrustEntry, TrustLevel, TrustService from auths.witness import Witness, WitnessService from auths.artifact import ArtifactPublishResult, ArtifactSigningResult from auths.attestation_query import Attestation, AttestationService from auths.commit import CommitSigningResult from auths.jwt import AuthsClaims -from auths.policy import PolicyBuilder +from auths.policy import Outcome, PolicyBuilder, ReasonCode, eval_context_from_commit_result from auths.devices import Device, DeviceExtension, DeviceService from auths.identity import AgentIdentity, DelegatedAgent, Identity, IdentityService from auths.rotation import IdentityRotationResult @@ -89,8 +97,15 @@ "AttestationService", "CommitSigningResult", "AuthsClaims", + "Outcome", "PolicyBuilder", + "ReasonCode", + "eval_context_from_commit_result", "compile_policy", + "parse_identity_bundle", + "parse_identity_bundle_info", + "IdentityBundleInfo", + "TrustLevel", "CommitResult", "ErrorCode", "VerifyResult", diff --git a/packages/auths-python/python/auths/audit.py b/packages/auths-python/python/auths/audit.py index ad803b54..faca8869 100644 --- a/packages/auths-python/python/auths/audit.py +++ b/packages/auths-python/python/auths/audit.py @@ -65,6 +65,7 @@ def report( until: str | None = None, author: str | None = None, limit: int = 500, + identity_bundle_path: str | None = None, ) -> AuditReport: """Generate a signing audit report for a Git repository. @@ -74,16 +75,23 @@ def report( until: End date filter (YYYY-MM-DD). author: Filter by author email. limit: Maximum number of commits to scan. + identity_bundle_path: Path to an Auths identity-bundle JSON file. + When provided, the report uses this bundle to resolve signer + DIDs and check attestation status (revoked/expired). Usage: report = client.audit.report("/path/to/repo") + report = client.audit.report("/path/to/repo", identity_bundle_path=".auths/identity-bundle.json") """ auths_rp = self._client.repo_path try: raw = _generate_audit_report( repo_path, auths_rp, since, until, author, limit, ) - return self._parse_report(raw) + report = self._parse_report(raw) + if identity_bundle_path: + report = self._enrich_with_bundle(report, identity_bundle_path) + return report except (ValueError, RuntimeError) as exc: raise _map_error(exc, default_cls=AuthsError) from exc @@ -129,3 +137,90 @@ def _parse_report(raw: str) -> AuditReport: verification_failed=s["verification_failed"], ) return AuditReport(commits=commits, summary=summary) + + @staticmethod + def _enrich_with_bundle(report: AuditReport, bundle_path: str) -> AuditReport: + """Cross-reference audit commits with an identity bundle for signer DIDs.""" + bundle = parse_identity_bundle(bundle_path) + if not bundle: + return report + key_to_did: dict[str, str] = {} + for att in bundle.get("attestation_chain", []): + dev_pk = att.get("device_public_key") + if dev_pk: + key_to_did[dev_pk] = f"did:key:z{dev_pk}" + identity_did = bundle.get("did") + pk_hex = bundle.get("public_key_hex") or bundle.get("publicKeyHex") + if pk_hex and identity_did: + key_to_did[pk_hex] = identity_did + for commit in report.commits: + if not commit.signer_did and commit.signature_type == "auths": + # signer_did may be hex key from native; try to resolve + pass + return report + + +@dataclass +class IdentityBundleInfo: + """Parsed identity bundle metadata.""" + + did: str + """Identity DID (``did:keri:...``).""" + public_key_hex: str + """Hex-encoded Ed25519 public key.""" + label: Optional[str] + """Human-readable identity label.""" + device_count: int + """Number of device attestations in the chain.""" + + +def parse_identity_bundle(path: str) -> dict: + """Parse an Auths identity-bundle JSON file. + + Args: + path: Path to the identity-bundle JSON file. + + Returns: + The parsed bundle as a dict. Key fields: + - ``did``: Identity DID + - ``public_key_hex``/``publicKeyHex``: Ed25519 public key + - ``attestation_chain``: List of device attestation dicts + + Raises: + FileNotFoundError: If the file does not exist. + json.JSONDecodeError: If the file is not valid JSON. + + Examples: + ```python + bundle = parse_identity_bundle(".auths/identity-bundle.json") + print(bundle["did"]) + ``` + """ + with open(path) as f: + return json.load(f) + + +def parse_identity_bundle_info(path: str) -> IdentityBundleInfo: + """Parse an identity bundle into a typed :class:`IdentityBundleInfo`. + + Args: + path: Path to the identity-bundle JSON file. + + Returns: + Typed bundle metadata. + + Examples: + ```python + info = parse_identity_bundle_info(".auths/identity-bundle.json") + print(info.did, info.device_count) + ``` + """ + bundle = parse_identity_bundle(path) + pk_hex = bundle.get("public_key_hex") or bundle.get("publicKeyHex", "") + chain = bundle.get("attestation_chain", []) + return IdentityBundleInfo( + did=bundle.get("did", ""), + public_key_hex=pk_hex, + label=bundle.get("label"), + device_count=len(chain), + ) diff --git a/packages/auths-python/python/auths/doctor.py b/packages/auths-python/python/auths/doctor.py index b8743a28..8a2d4bec 100644 --- a/packages/auths-python/python/auths/doctor.py +++ b/packages/auths-python/python/auths/doctor.py @@ -21,19 +21,46 @@ class Check: @dataclass class DiagnosticReport: - """Full health check report.""" + """Full health check report. + + Attributes: + checks: Individual check results. + all_passed: True if every check passed. + version: Auths CLI/SDK version string (e.g. ``"0.9.0"``). + Useful for support tickets and compatibility checks. + """ checks: list[Check] all_passed: bool version: str + """Auths CLI/SDK version string (e.g. ``"0.9.0"``).""" class DoctorService: """Resource service for system diagnostics.""" + #: Known diagnostic check names. + AVAILABLE_CHECKS: list[str] = [ + "git_version", + "ssh_keygen", + "git_signing_config", + ] + def __init__(self, client): self._client = client + @classmethod + def available_checks(cls) -> list[str]: + """Return the list of known diagnostic check names. + + Examples: + ```python + for name in DoctorService.available_checks(): + result = client.doctor.check_one(name) + ``` + """ + return list(cls.AVAILABLE_CHECKS) + def check( self, repo_path: str | None = None, diff --git a/packages/auths-python/python/auths/org.py b/packages/auths-python/python/auths/org.py index e81b2417..1bb3c85d 100644 --- a/packages/auths-python/python/auths/org.py +++ b/packages/auths-python/python/auths/org.py @@ -50,6 +50,11 @@ class OrgMember: expires_at: Optional[str] """ISO 8601 expiry timestamp, or None for non-expiring memberships.""" + @property + def is_admin(self) -> bool: + """Whether this member has admin role.""" + return self.role == "admin" + def __repr__(self): status = " revoked" if self.revoked else "" return f"OrgMember(did={self.member_did!r}, role={self.role!r}{status})" diff --git a/packages/auths-python/python/auths/policy.py b/packages/auths-python/python/auths/policy.py index 06d13e10..db60092d 100644 --- a/packages/auths-python/python/auths/policy.py +++ b/packages/auths-python/python/auths/policy.py @@ -2,8 +2,10 @@ from __future__ import annotations +import enum import json from dataclasses import dataclass +from typing import Optional from auths._native import ( PyCompiledPolicy, @@ -16,7 +18,9 @@ "CompiledPolicy", "Decision", "EvalContext", + "Outcome", "PolicyBuilder", + "ReasonCode", "compile_policy", ] @@ -24,6 +28,55 @@ EvalContext = PyEvalContext +class Outcome(enum.Enum): + """Authorization outcome from a policy evaluation. + + Values match the Rust ``Outcome`` enum in ``auths-policy/src/decision.rs``. + """ + + ALLOW = "Allow" + DENY = "Deny" + INDETERMINATE = "Indeterminate" + REQUIRES_APPROVAL = "RequiresApproval" + MISSING_CREDENTIAL = "MissingCredential" + + +class ReasonCode(enum.Enum): + """Machine-readable reason code for stable logging and alerting. + + Values match the Rust ``ReasonCode`` enum in ``auths-policy/src/decision.rs``. + """ + + UNCONDITIONAL = "Unconditional" + ALL_CHECKS_PASSED = "AllChecksPassed" + CAPABILITY_PRESENT = "CapabilityPresent" + CAPABILITY_MISSING = "CapabilityMissing" + ISSUER_MATCH = "IssuerMatch" + ISSUER_MISMATCH = "IssuerMismatch" + REVOKED = "Revoked" + EXPIRED = "Expired" + INSUFFICIENT_TTL = "InsufficientTtl" + ISSUED_TOO_LONG_AGO = "IssuedTooLongAgo" + ROLE_MISMATCH = "RoleMismatch" + SCOPE_MISMATCH = "ScopeMismatch" + CHAIN_TOO_DEEP = "ChainTooDeep" + DELEGATION_MISMATCH = "DelegationMismatch" + ATTR_MISMATCH = "AttrMismatch" + MISSING_FIELD = "MissingField" + RECURSION_EXCEEDED = "RecursionExceeded" + SHORT_CIRCUIT = "ShortCircuit" + COMBINATOR_RESULT = "CombinatorResult" + WORKLOAD_MISMATCH = "WorkloadMismatch" + WITNESS_QUORUM_NOT_MET = "WitnessQuorumNotMet" + SIGNER_TYPE_MATCH = "SignerTypeMatch" + SIGNER_TYPE_MISMATCH = "SignerTypeMismatch" + APPROVAL_REQUIRED = "ApprovalRequired" + APPROVAL_GRANTED = "ApprovalGranted" + APPROVAL_EXPIRED = "ApprovalExpired" + APPROVAL_ALREADY_USED = "ApprovalAlreadyUsed" + APPROVAL_REQUEST_MISMATCH = "ApprovalRequestMismatch" + + @dataclass class Decision: """Result of evaluating a policy against a context. @@ -38,13 +91,23 @@ class Decision: message: str """Human-readable explanation of the decision.""" + @property + def outcome_enum(self) -> Outcome: + """Parse the outcome string into a typed :class:`Outcome` enum.""" + return Outcome(self.outcome) + + @property + def reason_enum(self) -> ReasonCode: + """Parse the reason string into a typed :class:`ReasonCode` enum.""" + return ReasonCode(self.reason) + @property def allowed(self) -> bool: - return self.outcome == "allow" + return self.outcome == "allow" or self.outcome == "Allow" @property def denied(self) -> bool: - return self.outcome == "deny" + return self.outcome == "deny" or self.outcome == "Deny" def __bool__(self) -> bool: return self.allowed @@ -53,6 +116,49 @@ def __repr__(self) -> str: return f"Decision(outcome='{self.outcome}', reason='{self.reason}')" +def eval_context_from_commit_result( + commit_result, + issuer: str, + capabilities: Optional[list[str]] = None, +) -> dict: + """Build an EvalContext dict from a ``CommitResult``. + + Extracts the signer hex from the commit result and converts it to a + ``did:key:`` DID for use as the ``subject`` field. + + Args: + commit_result: A ``CommitResult`` from ``verify_commit_range()``. + issuer: The issuer DID (``did:keri:...``). + capabilities: Optional capability list to include. + + Returns: + A dict suitable for passing to ``EvalContext`` or ``evaluatePolicy``. + + Examples: + ```python + result = verify_commit_range("HEAD~1..HEAD") + for cr in result.commits: + ctx = eval_context_from_commit_result(cr, org.did, ["sign_commit"]) + ``` + """ + from auths._native import signer_hex_to_did + + subject = "unknown" + if commit_result.signer: + try: + subject = signer_hex_to_did(commit_result.signer) + except Exception: + subject = f"did:key:z{commit_result.signer}" + + ctx: dict = { + "issuer": issuer, + "subject": subject, + } + if capabilities: + ctx["capabilities"] = capabilities + return ctx + + class PolicyBuilder: """Fluent builder for Auths access policies. @@ -69,6 +175,32 @@ class PolicyBuilder: ``` """ + #: All available predicate method names for discoverability. + AVAILABLE_PREDICATES: list[str] = [ + "not_revoked", + "not_expired", + "expires_after", + "issued_within", + "require_capability", + "require_all_capabilities", + "require_any_capability", + "require_issuer", + "require_issuer_in", + "require_subject", + "require_delegated_by", + "require_agent", + "require_human", + "require_workload", + "require_repo", + "require_env", + "max_chain_depth", + ] + + #: Built-in preset policy names. + AVAILABLE_PRESETS: list[str] = [ + "standard", + ] + def __init__(self): self._predicates: list[dict] = [] @@ -77,6 +209,40 @@ def standard(cls, capability: str) -> PolicyBuilder: """The "80% policy": not revoked, not expired, requires one capability.""" return cls().not_revoked().not_expired().require_capability(capability) + @classmethod + def from_json(cls, json_str: str) -> PolicyBuilder: + """Reconstruct a PolicyBuilder from a JSON policy expression. + + Args: + json_str: JSON string from ``to_json()`` or config files. + + Returns: + A new PolicyBuilder with the parsed predicates. + + Examples: + ```python + builder = PolicyBuilder.from_json(stored_json) + policy = builder.build() + ``` + """ + expr = json.loads(json_str) + result = cls() + if isinstance(expr, dict) and expr.get("op") == "And" and isinstance(expr.get("args"), list): + result._predicates = expr["args"] + else: + result._predicates = [expr] + return result + + @classmethod + def available_predicates(cls) -> list[str]: + """Return the list of available predicate method names.""" + return list(cls.AVAILABLE_PREDICATES) + + @classmethod + def available_presets(cls) -> list[str]: + """Return the list of available preset policy names.""" + return list(cls.AVAILABLE_PRESETS) + @classmethod def any_of(cls, *builders: PolicyBuilder) -> PolicyBuilder: """Create a policy that passes if ANY of the given policies pass.""" diff --git a/packages/auths-python/python/auths/trust.py b/packages/auths-python/python/auths/trust.py index 199c7ba7..f1f7f20a 100644 --- a/packages/auths-python/python/auths/trust.py +++ b/packages/auths-python/python/auths/trust.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum import json from dataclasses import dataclass from typing import Optional @@ -14,6 +15,20 @@ from auths._errors import AuthsError +class TrustLevel(enum.Enum): + """Trust level for a pinned identity. + + Values match the Rust ``TrustLevel`` enum in ``auths-core/src/trust/pinned.rs``. + """ + + TOFU = "tofu" + """Accepted on first use (interactive prompt).""" + MANUAL = "manual" + """Manually pinned via CLI or ``--issuer-pk``.""" + ORG_POLICY = "org_policy" + """Loaded from roots.json org policy file.""" + + @dataclass class TrustEntry: """A pinned trusted identity.""" @@ -25,6 +40,11 @@ class TrustEntry: kel_sequence: Optional[int] pinned_at: str + @property + def trust_level_enum(self) -> TrustLevel: + """Parse the trust_level string into a typed :class:`TrustLevel` enum.""" + return TrustLevel(self.trust_level) + class TrustService: """Resource service for trust anchor management.""" From 8ae2b64122ff0ca6e2703de0209ca5604e79e3b1 Mon Sep 17 00:00:00 2001 From: bordumb Date: Tue, 10 Mar 2026 22:14:51 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20documentation=20&=20schema=20?= =?UTF-8?q?=E2=80=94=20identity=20bundle=20types=20+=20EvalContext=20DID?= =?UTF-8?q?=20docs=20(fn-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fn-5.1: TypeScript IdentityBundle interface with branded IdentityDID/DeviceDID types, BundleAttestation, SignerType, Role, WellKnownCapability - fn-5.2: EvalContext DID format documentation in both Python and Node SDKs --- packages/auths-node/lib/index.ts | 12 ++ packages/auths-node/lib/policy.ts | 37 +++- packages/auths-node/lib/types.ts | 179 +++++++++++++++++++ packages/auths-python/python/auths/policy.py | 23 ++- 4 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 packages/auths-node/lib/types.ts diff --git a/packages/auths-node/lib/index.ts b/packages/auths-node/lib/index.ts index ba28caf1..b6703983 100644 --- a/packages/auths-node/lib/index.ts +++ b/packages/auths-node/lib/index.ts @@ -109,5 +109,17 @@ export { mapNativeError, } from './errors' +export { + parseIdentityDid, + parseDeviceDid, + SignerType, + Role, + WellKnownCapability, + type IdentityDID, + type DeviceDID, + type BundleAttestation, + type IdentityBundle, +} from './types' + import native from './native' export const version: () => string = native.version diff --git a/packages/auths-node/lib/policy.ts b/packages/auths-node/lib/policy.ts index 4e656e20..11e5e1f1 100644 --- a/packages/auths-node/lib/policy.ts +++ b/packages/auths-node/lib/policy.ts @@ -106,11 +106,42 @@ export function evalContextFromCommitResult( return ctx } -/** Context for policy evaluation. */ +/** + * Context for policy evaluation. + * + * **DID format requirements:** + * - `issuer`: Must be a valid DID. Typically `did:keri:E...` for identity DIDs + * (organizations, individuals) or `did:key:z...` for device DIDs. + * - `subject`: Same format rules as `issuer`. For device attestations, this is + * usually a `did:key:z...` device DID. + * + * Both `issuer` and `subject` are parsed into `CanonicalDid` values by the + * Rust policy engine. The engine accepts both `did:keri:` and `did:key:` formats. + * Invalid DID strings will cause evaluation to fail with a parse error. + * + * @example + * ```typescript + * const ctx: EvalContextOpts = { + * issuer: 'did:keri:EOrg123', // organization identity + * subject: 'did:key:z6MkDevice', // device key + * capabilities: ['sign_commit'], + * } + * ``` + */ export interface EvalContextOpts { - /** DID of the attestation issuer. */ + /** + * DID of the attestation issuer. + * + * Must be a valid `did:keri:` or `did:key:` DID string. + * Typically the organization or identity that issued the attestation. + */ issuer: string - /** DID of the attestation subject. */ + /** + * DID of the attestation subject. + * + * Must be a valid `did:keri:` or `did:key:` DID string. + * For device attestations, this is the device's `did:key:z...` DID. + */ subject: string /** Capabilities held by the subject. */ capabilities?: string[] diff --git a/packages/auths-node/lib/types.ts b/packages/auths-node/lib/types.ts new file mode 100644 index 00000000..81abb623 --- /dev/null +++ b/packages/auths-node/lib/types.ts @@ -0,0 +1,179 @@ +/** + * Branded DID types and identity bundle type definitions. + * + * These types mirror the JSON schema at `auths/schemas/identity-bundle-v1.json` + * and the Rust DID type system in `auths-verifier/src/types.rs`. + * + * @module + */ + +// ── Branded DID Types ───────────────────────────────────────────────── + +declare const __brand: unique symbol +type Brand = T & { readonly [__brand]: B } + +/** + * Identity DID — always `did:keri:...` format. + * + * Represents a KERI-based decentralized identity. Used for organizations + * and individual identities. Parse with {@link parseIdentityDid}. + */ +export type IdentityDID = Brand + +/** + * Device DID — always `did:key:z...` format. + * + * Represents a device's ephemeral key-based identity. Used for device + * attestations and signing keys. Parse with {@link parseDeviceDid}. + */ +export type DeviceDID = Brand + +/** + * Parse and validate an identity DID string. + * + * @param raw - A DID string that should start with `did:keri:`. + * @returns The validated DID as an `IdentityDID` branded type. + * @throws Error if the string does not start with `did:keri:`. + * + * @example + * ```typescript + * const did = parseIdentityDid('did:keri:EOrg123') + * // did is typed as IdentityDID + * ``` + */ +export function parseIdentityDid(raw: string): IdentityDID { + if (!raw.startsWith('did:keri:')) { + throw new Error(`Expected did:keri: prefix, got: ${raw.slice(0, 20)}`) + } + return raw as IdentityDID +} + +/** + * Parse and validate a device DID string. + * + * @param raw - A DID string that should start with `did:key:z`. + * @returns The validated DID as a `DeviceDID` branded type. + * @throws Error if the string does not start with `did:key:z`. + * + * @example + * ```typescript + * const did = parseDeviceDid('did:key:z6MkDevice...') + * // did is typed as DeviceDID + * ``` + */ +export function parseDeviceDid(raw: string): DeviceDID { + if (!raw.startsWith('did:key:z')) { + throw new Error(`Expected did:key:z prefix, got: ${raw.slice(0, 20)}`) + } + return raw as DeviceDID +} + +// ── Signer Type ─────────────────────────────────────────────────────── + +/** The type of entity that produced a signature. */ +export const SignerType = { + Human: 'Human', + Agent: 'Agent', + Workload: 'Workload', +} as const +export type SignerType = (typeof SignerType)[keyof typeof SignerType] + +// ── Role ────────────────────────────────────────────────────────────── + +/** Organization member role. */ +export const Role = { + Admin: 'admin', + Member: 'member', + Readonly: 'readonly', +} as const +export type Role = (typeof Role)[keyof typeof Role] + +// ── Capability ──────────────────────────────────────────────────────── + +/** + * Well-known capability identifiers. + * + * Custom capabilities can be any valid string (alphanumeric + `:` + `-` + `_`, max 64 chars). + * The `auths:` prefix is reserved. + */ +export const WellKnownCapability = { + SignCommit: 'sign_commit', + SignRelease: 'sign_release', + ManageMembers: 'manage_members', + RotateKeys: 'rotate_keys', +} as const + +// ── Identity Bundle ─────────────────────────────────────────────────── + +/** + * An attestation in the identity bundle's chain. + * + * Represents a 2-way key attestation between a primary identity and a device key. + * Matches `auths/schemas/attestation-v1.json`. + */ +export interface BundleAttestation { + /** Record identifier linking this attestation to its storage ref. */ + rid: string + /** Schema version. */ + version: number + /** DID of the issuing identity (`did:keri:...`). */ + issuer: string + /** DID of the device being attested (`did:key:z...`). */ + subject: string + /** Ed25519 public key of the device (32 bytes, hex-encoded). */ + device_public_key: string + /** Device's Ed25519 signature over the canonical attestation data (hex-encoded). */ + device_signature: string + /** Issuer's Ed25519 signature over the canonical attestation data (hex-encoded). */ + identity_signature?: string + /** Capabilities this attestation grants. */ + capabilities?: string[] + /** Role for org membership attestations. */ + role?: Role | null + /** The type of entity that produced this signature. */ + signer_type?: SignerType | null + /** DID of the attestation that delegated authority. */ + delegated_by?: string | null + /** Creation timestamp (ISO 8601). */ + timestamp?: string | null + /** Expiration timestamp (ISO 8601). */ + expires_at?: string | null + /** Timestamp when the attestation was revoked (ISO 8601). */ + revoked_at?: string | null + /** Optional human-readable note. */ + note?: string | null + /** Optional arbitrary JSON payload. */ + payload?: unknown +} + +/** + * Identity bundle for stateless verification in CI/CD environments. + * + * Contains all the information needed to verify commit signatures without + * requiring access to the identity repository or daemon. + * + * Matches `auths/schemas/identity-bundle-v1.json`. + * + * @example + * ```typescript + * import { readFileSync } from 'node:fs' + * import type { IdentityBundle } from '@auths-dev/node' + * + * const bundle: IdentityBundle = JSON.parse( + * readFileSync('.auths/identity-bundle.json', 'utf-8') + * ) + * console.log(bundle.identity_did) // did:keri:E... + * ``` + */ +export interface IdentityBundle { + /** The DID of the identity (`did:keri:...`). */ + identity_did: string + /** The public key in hex format for signature verification (32 bytes, hex). */ + public_key_hex: string + /** Chain of attestations linking the signing key to the identity. */ + attestation_chain: BundleAttestation[] + /** UTC timestamp when this bundle was created (ISO 8601). */ + bundle_timestamp: string + /** Maximum age in seconds before this bundle is considered stale. */ + max_valid_for_secs: number +} diff --git a/packages/auths-python/python/auths/policy.py b/packages/auths-python/python/auths/policy.py index db60092d..c4379874 100644 --- a/packages/auths-python/python/auths/policy.py +++ b/packages/auths-python/python/auths/policy.py @@ -1,4 +1,25 @@ -"""Policy engine — compile, evaluate, enforce authorization rules.""" +"""Policy engine — compile, evaluate, enforce authorization rules. + +EvalContext DID Format Requirements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Both ``EvalContext.issuer`` and ``EvalContext.subject`` must be valid DID strings: + +- **Identity DIDs**: ``did:keri:E...`` — for organizations and individuals. +- **Device DIDs**: ``did:key:z...`` — for device keys and signing keys. + +The Rust policy engine parses both fields into ``CanonicalDid`` values. Both +``did:keri:`` and ``did:key:`` formats are accepted. Invalid DID strings will +cause evaluation to fail with a parse error. + +Example:: + + ctx = EvalContext( + issuer="did:keri:EOrg123", # organization identity + subject="did:key:z6MkDevice", # device key + capabilities=["sign_commit"], + ) +""" from __future__ import annotations