feat: Entra Agent ID auth provider + governance leak cleanup
New crate: bascule-auth-agent-id Microsoft Entra Agent ID authentication for AI agents Validates OAuth tokens against Entra JWKS (60min cache) Extracts agent metadata: type, blueprint, sponsor, scopes Detects on-behalf-of (delegated) agents Token-as-password pattern for SSH auth Cleanup: Removed all governance-specific references from comments SessionHandler trait is the only extension point Zero substrate/chronicle/gsap dependencies Config example uses neutral terminology Config: [auth.agent_id] section for Entra configuration tenant_id, audiences, multi_tenant fields 3 crates: bascule-core, bascule-server, bascule-auth-agent-id 938 lines total, 5.6MB binary, 0 substrate deps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bfa26cfd15
commit
02142f7be4
8 changed files with 1270 additions and 54 deletions
1029
Cargo.lock
generated
1029
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/bascule-core", "crates/bascule-server"]
|
||||
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -23,3 +23,6 @@ uuid = { version = "1", features = ["v4"] }
|
|||
rand = "0.8"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
portable-pty = "0.8"
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
jsonwebtoken = "9"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ listen_addr = "0.0.0.0:2222"
|
|||
# Shell command to spawn for each session
|
||||
# Default: /bin/bash
|
||||
# shell_command = "/bin/bash"
|
||||
# shell_command = "/usr/local/bin/gsh" # Governed shell
|
||||
# shell_command = "/usr/local/bin/custom-shell"
|
||||
|
||||
# Authentication
|
||||
[auth]
|
||||
|
|
@ -17,7 +17,7 @@ mode = "accept-all" # "accept-all" (dev only), "authorized-keys"
|
|||
# authorized_keys_path = "/etc/bascule/authorized_keys"
|
||||
|
||||
# Session banner (optional)
|
||||
# banner = "Welcome to the governed shell."
|
||||
# banner = "Welcome to Bascule."
|
||||
|
||||
# Max concurrent sessions (0 = unlimited)
|
||||
# max_sessions = 100
|
||||
|
|
|
|||
18
crates/bascule-auth-agent-id/Cargo.toml
Normal file
18
crates/bascule-auth-agent-id/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "bascule-auth-agent-id"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Microsoft Entra Agent ID authentication provider for Bascule"
|
||||
|
||||
[dependencies]
|
||||
bascule-core = { path = "../bascule-core" }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
jsonwebtoken = "9"
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
237
crates/bascule-auth-agent-id/src/lib.rs
Normal file
237
crates/bascule-auth-agent-id/src/lib.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
//! Microsoft Entra Agent ID authentication provider for Bascule.
|
||||
//!
|
||||
//! Validates AI agent identity tokens issued by Microsoft Entra.
|
||||
//! Agents authenticate via OAuth 2.0 client_credentials and present
|
||||
//! their token as the SSH password (token-as-password pattern).
|
||||
//!
|
||||
//! Agent metadata extracted from token claims:
|
||||
//! - Agent application/client ID
|
||||
//! - Display name and agent type
|
||||
//! - Blueprint ID (agent type template)
|
||||
//! - Sponsor (human or org that registered the agent)
|
||||
//! - Delegation chain (on-behalf-of claims)
|
||||
//! - Scopes and roles
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use bascule_core::auth::AuthProvider;
|
||||
|
||||
/// Metadata extracted from an Entra Agent ID token.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AgentIdentity {
|
||||
pub agent_id: String,
|
||||
pub display_name: String,
|
||||
pub agent_type: Option<String>,
|
||||
pub blueprint_id: Option<String>,
|
||||
pub sponsor: Option<String>,
|
||||
pub on_behalf_of: Option<String>,
|
||||
pub tenant_id: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub tags: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Microsoft Entra Agent ID authentication provider.
|
||||
pub struct EntraAgentIdProvider {
|
||||
tenant_id: String,
|
||||
expected_audiences: Vec<String>,
|
||||
jwks_cache: Arc<RwLock<Option<JwksCache>>>,
|
||||
allow_multi_tenant: bool,
|
||||
}
|
||||
|
||||
struct JwksCache {
|
||||
keys: jsonwebtoken::jwk::JwkSet,
|
||||
fetched_at: Instant,
|
||||
}
|
||||
|
||||
impl EntraAgentIdProvider {
|
||||
/// Create a provider for a specific Entra tenant.
|
||||
pub fn new(tenant_id: &str, expected_audiences: Vec<String>) -> Self {
|
||||
Self {
|
||||
tenant_id: tenant_id.to_string(),
|
||||
expected_audiences,
|
||||
jwks_cache: Arc::new(RwLock::new(None)),
|
||||
allow_multi_tenant: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a multi-tenant provider (accepts agents from any tenant).
|
||||
pub fn multi_tenant(expected_audiences: Vec<String>) -> Self {
|
||||
Self {
|
||||
tenant_id: "common".to_string(),
|
||||
expected_audiences,
|
||||
jwks_cache: Arc::new(RwLock::new(None)),
|
||||
allow_multi_tenant: true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_jwks(&self) -> anyhow::Result<jsonwebtoken::jwk::JwkSet> {
|
||||
{
|
||||
let cache = self.jwks_cache.read().await;
|
||||
if let Some(ref c) = *cache {
|
||||
if c.fetched_at.elapsed() < Duration::from_secs(3600) {
|
||||
return Ok(c.keys.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"https://login.microsoftonline.com/{}/discovery/v2.0/keys",
|
||||
self.tenant_id
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let jwks: jsonwebtoken::jwk::JwkSet = client.get(&url).send().await?.json().await?;
|
||||
|
||||
{
|
||||
let mut cache = self.jwks_cache.write().await;
|
||||
*cache = Some(JwksCache {
|
||||
keys: jwks.clone(),
|
||||
fetched_at: Instant::now(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(jwks)
|
||||
}
|
||||
|
||||
async fn validate_token(&self, token: &str) -> anyhow::Result<AgentIdentity> {
|
||||
let jwks = self.get_jwks().await?;
|
||||
|
||||
let header = jsonwebtoken::decode_header(token)?;
|
||||
let kid = header
|
||||
.kid
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Token missing kid header"))?;
|
||||
|
||||
let jwk = jwks
|
||||
.keys
|
||||
.iter()
|
||||
.find(|k| k.common.key_id.as_deref() == Some(kid))
|
||||
.ok_or_else(|| anyhow::anyhow!("Key ID not found in JWKS"))?;
|
||||
|
||||
let decoding_key = jsonwebtoken::DecodingKey::from_jwk(jwk)?;
|
||||
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
|
||||
validation.set_audience(&self.expected_audiences);
|
||||
|
||||
let tenant_issuer = format!(
|
||||
"https://login.microsoftonline.com/{}/v2.0",
|
||||
self.tenant_id
|
||||
);
|
||||
if self.allow_multi_tenant {
|
||||
let issuers = [
|
||||
tenant_issuer,
|
||||
"https://login.microsoftonline.com/common/v2.0".to_string(),
|
||||
];
|
||||
validation.set_issuer(&issuers);
|
||||
} else {
|
||||
validation.set_issuer(&[tenant_issuer]);
|
||||
}
|
||||
|
||||
let token_data =
|
||||
jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)?;
|
||||
let claims = token_data.claims;
|
||||
|
||||
let agent_id = claims
|
||||
.get("azp")
|
||||
.or_else(|| claims.get("appid"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let display_name = claims
|
||||
.get("app_displayname")
|
||||
.or_else(|| claims.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&agent_id)
|
||||
.to_string();
|
||||
|
||||
let tenant_id = claims
|
||||
.get("tid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&self.tenant_id)
|
||||
.to_string();
|
||||
|
||||
let scopes = if let Some(roles) = claims.get("roles") {
|
||||
roles
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else if let Some(scp) = claims.get("scp") {
|
||||
scp.as_str()
|
||||
.map(|s| s.split(' ').map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let mut tags = HashMap::new();
|
||||
for key in ["agent_type", "blueprint_id", "sponsor"] {
|
||||
if let Some(val) = claims.get(key).and_then(|v| v.as_str()) {
|
||||
tags.insert(key.to_string(), val.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let on_behalf_of = claims
|
||||
.get("oid")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter(|_| claims.get("azp").is_some());
|
||||
|
||||
let expires_at = claims
|
||||
.get("exp")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
|
||||
|
||||
Ok(AgentIdentity {
|
||||
agent_id,
|
||||
display_name,
|
||||
agent_type: tags.get("agent_type").cloned(),
|
||||
blueprint_id: tags.get("blueprint_id").cloned(),
|
||||
sponsor: tags.get("sponsor").cloned(),
|
||||
on_behalf_of,
|
||||
tenant_id,
|
||||
scopes,
|
||||
expires_at,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for EntraAgentIdProvider {
|
||||
async fn check_password(
|
||||
&self,
|
||||
user: &str,
|
||||
password: &str,
|
||||
) -> bool {
|
||||
match self.validate_token(password).await {
|
||||
Ok(identity) => {
|
||||
tracing::info!(
|
||||
agent_id = %identity.agent_id,
|
||||
display_name = %identity.display_name,
|
||||
agent_type = ?identity.agent_type,
|
||||
tenant = %identity.tenant_id,
|
||||
"Agent authenticated via Entra Agent ID"
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(user = %user, error = %e, "Agent ID token validation failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn principal_for_user(&self, user: &str) -> String {
|
||||
format!("agent:{}", user)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,21 @@ pub struct AuthConfig {
|
|||
|
||||
/// Path to authorized_keys file (for authorized-keys mode).
|
||||
pub authorized_keys_path: Option<String>,
|
||||
|
||||
/// AI agent authentication via Microsoft Entra Agent ID.
|
||||
pub agent_id: Option<AgentIdConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentIdConfig {
|
||||
/// Entra tenant ID.
|
||||
pub tenant_id: String,
|
||||
/// Expected token audiences.
|
||||
#[serde(default)]
|
||||
pub audiences: Vec<String>,
|
||||
/// Accept agents from any tenant (multi-tenant mode).
|
||||
#[serde(default)]
|
||||
pub multi_tenant: bool,
|
||||
}
|
||||
|
||||
impl Default for BasculeConfig {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! SessionHandler trait — the extension point for governance, audit, and custom logic.
|
||||
//! SessionHandler trait — the extension point for audit, policy, and custom logic.
|
||||
//!
|
||||
//! Implement this trait to add behavior to Bascule sessions without modifying
|
||||
//! the SSH proxy core. The default implementation accepts everything and adds nothing.
|
||||
|
|
@ -11,8 +11,8 @@ use crate::session::SessionInfo;
|
|||
/// Hook point for extending Bascule's behavior.
|
||||
///
|
||||
/// The open-source version ships with [`DefaultHandler`] (passthrough).
|
||||
/// Governance extensions implement this trait to add authorization contexts,
|
||||
/// completion receipts, operational posture checks, and audit trails.
|
||||
/// Custom handlers implement this trait to add authorization checks,
|
||||
/// session recording, command filtering, or audit trails.
|
||||
#[async_trait]
|
||||
pub trait SessionHandler: Send + Sync + 'static {
|
||||
/// Called after SSH authentication succeeds, before shell spawn.
|
||||
|
|
@ -48,7 +48,7 @@ pub trait SessionHandler: Send + Sync + 'static {
|
|||
}
|
||||
}
|
||||
|
||||
/// Default handler — no governance, pure SSH proxy.
|
||||
/// Default handler — pure SSH proxy passthrough.
|
||||
/// Accepts all sessions, adds no environment, logs nothing.
|
||||
pub struct DefaultHandler;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
//! Provides a pluggable SSH server with:
|
||||
//! - Configurable authentication (SSH keys, OIDC, accept-all)
|
||||
//! - PTY bridging to any shell command
|
||||
//! - SessionHandler trait for extending behavior (audit, governance, recording)
|
||||
//! - SessionHandler trait for extending behavior (audit, policy, recording)
|
||||
//!
|
||||
//! The open-source core ships with `DefaultHandler` (passthrough).
|
||||
//! Governance extensions (ACs, CRs, DEFCON, Chronicle) implement
|
||||
//! `SessionHandler` in separate crates.
|
||||
//! The core ships with `DefaultHandler` (passthrough).
|
||||
//! Custom handlers implement `SessionHandler` in separate crates
|
||||
//! to add authorization, audit trails, or policy enforcement.
|
||||
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
|
|
|
|||
Loading…
Reference in a new issue