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]
|
[workspace]
|
||||||
members = ["crates/bascule-core", "crates/bascule-server"]
|
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
@ -23,3 +23,6 @@ uuid = { version = "1", features = ["v4"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
portable-pty = "0.8"
|
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
|
# Shell command to spawn for each session
|
||||||
# Default: /bin/bash
|
# Default: /bin/bash
|
||||||
# shell_command = "/bin/bash"
|
# shell_command = "/bin/bash"
|
||||||
# shell_command = "/usr/local/bin/gsh" # Governed shell
|
# shell_command = "/usr/local/bin/custom-shell"
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
[auth]
|
[auth]
|
||||||
|
|
@ -17,7 +17,7 @@ mode = "accept-all" # "accept-all" (dev only), "authorized-keys"
|
||||||
# authorized_keys_path = "/etc/bascule/authorized_keys"
|
# authorized_keys_path = "/etc/bascule/authorized_keys"
|
||||||
|
|
||||||
# Session banner (optional)
|
# Session banner (optional)
|
||||||
# banner = "Welcome to the governed shell."
|
# banner = "Welcome to Bascule."
|
||||||
|
|
||||||
# Max concurrent sessions (0 = unlimited)
|
# Max concurrent sessions (0 = unlimited)
|
||||||
# max_sessions = 100
|
# 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).
|
/// Path to authorized_keys file (for authorized-keys mode).
|
||||||
pub authorized_keys_path: Option<String>,
|
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 {
|
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
|
//! Implement this trait to add behavior to Bascule sessions without modifying
|
||||||
//! the SSH proxy core. The default implementation accepts everything and adds nothing.
|
//! 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.
|
/// Hook point for extending Bascule's behavior.
|
||||||
///
|
///
|
||||||
/// The open-source version ships with [`DefaultHandler`] (passthrough).
|
/// The open-source version ships with [`DefaultHandler`] (passthrough).
|
||||||
/// Governance extensions implement this trait to add authorization contexts,
|
/// Custom handlers implement this trait to add authorization checks,
|
||||||
/// completion receipts, operational posture checks, and audit trails.
|
/// session recording, command filtering, or audit trails.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SessionHandler: Send + Sync + 'static {
|
pub trait SessionHandler: Send + Sync + 'static {
|
||||||
/// Called after SSH authentication succeeds, before shell spawn.
|
/// 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.
|
/// Accepts all sessions, adds no environment, logs nothing.
|
||||||
pub struct DefaultHandler;
|
pub struct DefaultHandler;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
//! Provides a pluggable SSH server with:
|
//! Provides a pluggable SSH server with:
|
||||||
//! - Configurable authentication (SSH keys, OIDC, accept-all)
|
//! - Configurable authentication (SSH keys, OIDC, accept-all)
|
||||||
//! - PTY bridging to any shell command
|
//! - 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).
|
//! The core ships with `DefaultHandler` (passthrough).
|
||||||
//! Governance extensions (ACs, CRs, DEFCON, Chronicle) implement
|
//! Custom handlers implement `SessionHandler` in separate crates
|
||||||
//! `SessionHandler` in separate crates.
|
//! to add authorization, audit trails, or policy enforcement.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue