访问控制
使用 adk-auth 为 AI agent 提供企业级访问控制。
概述
adk-auth 提供基于角色的访问控制(RBAC)、基于范围的授权、审计日志,以及对 ADK agent 的 SSO 支持。它能够对哪些用户可以访问哪些工具进行安全、细粒度的控制。
架构
┌─────────────────────────────────────────────────────────────────┐
│ Agent Request │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SSO Token Validation │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Google │ │ Azure AD │ │ OIDC Discovery │ │
│ │ Provider │ │ Provider │ │ (Okta, Auth0, etc) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ JWKS Cache │ ← Auto-refresh keys │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼ TokenClaims
┌─────────────────────────────────────────────────────────────────┐
│ Claims Mapper │
│ │
│ IdP Groups → adk-auth Roles │
│ ───────────────────────────────────────── │
│ "AdminGroup" → "admin" │
│ "DataAnalysts" → "analyst" │
│ (default) → "viewer" │
└─────────────────────────────────────────────────────────────────┘
│
▼ Roles
┌─────────────────────────────────────────────────────────────────┐
│ Access Control │
│ │
│ Role: admin │
│ ├── allow: AllTools │
│ └── allow: AllAgents │
│ │
│ Role: analyst │
│ ├── allow: Tool("search") │
│ ├── allow: Tool("summarize") │
│ └── deny: Tool("code_exec") ← Deny takes precedence │
└─────────────────────────────────────────────────────────────────┘
│
▼ Check Result
┌─────────────────────────────────────────────────────────────────┐
│ Audit Logging │
│ │
│ {"user":"alice","resource":"search","outcome":"allowed"} │
│ {"user":"bob","resource":"exec","outcome":"denied"} │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Tool Execution │
│ (only if access granted) │
└─────────────────────────────────────────────────────────────────┘
设计原则
1. 拒绝优先
当某个角色同时具有允许和拒绝规则时,拒绝始终优先:
let role = Role::new("limited")
.allow(Permission::AllTools) // Allow everything...
.deny(Permission::Tool("admin")); // ...except admin
// Result: Can access any tool EXCEPT "admin"
2. 多角色并集
拥有多个角色的用户会获得权限的并集,但任何角色中的拒绝规则仍然生效:
let ac = AccessControl::builder()
.role(reader) // allow: search
.role(writer) // allow: write
.assign("alice", "reader")
.assign("alice", "writer")
.build()?;
// Alice can access both "search" AND "write"
3. 显式优于隐式
权限必须显式声明——默认不会授予任何访问权限:
let role = Role::new("empty");
// This role grants NO permissions
ac.check("user", &Permission::Tool("anything")); // → Denied
4. 身份验证与授权分离
- 身份验证(SSO):通过 JWT 验证用户是谁
- 授权(RBAC):确定他们可以访问什么
// Authentication: validate JWT, extract claims
let claims = provider.validate(token).await?;
// Authorization: check specific permission
ac.check(&claims.sub, &Permission::Tool("search"))?;
// Combined: SsoAccessControl does both
sso.check_token(token, &permission).await?;
安装
[dependencies]
adk-auth = "2.0.0"
# For SSO/OAuth support
adk-auth = { version = "2.0.0", features = ["sso"] }
核心组件
权限
pub enum Permission {
Tool(String), // Specific tool by name
AllTools, // Wildcard: all tools
Agent(String), // Specific agent by name
AllAgents, // Wildcard: all agents
}
角色
let analyst = Role::new("analyst")
.allow(Permission::Tool("search".into()))
.allow(Permission::Tool("summarize".into()))
.deny(Permission::Tool("code_exec".into()));
AccessControl
let ac = AccessControl::builder()
.role(admin)
.role(analyst)
.assign("alice@company.com", "admin")
.assign("bob@company.com", "analyst")
.build()?;
// Check permission
ac.check("bob@company.com", &Permission::Tool("search".into()))?;
ProtectedTool
将工具包装起来并自动进行权限检查:
use adk_auth::ToolExt;
let protected = my_tool.with_access_control(Arc::new(ac));
// When executed, checks permission before running
protected.execute(ctx, args).await?;
AuthMiddleware
批量保护多个工具:
let middleware = AuthMiddleware::new(ac);
let protected_tools = middleware.protect_all(tools);
ScopeGuard
使用范围来进行来自 JWT 声明或会话状态的请求级授权:
use adk_auth::{ContextScopeResolver, ScopeGuard};
let guard = ScopeGuard::new(ContextScopeResolver);
let protected = guard.protect(my_tool);
组合 RBAC + 范围
RBAC 回答“该用户是否根本可以访问这个工具?”,而范围回答“此时此刻这个具体请求是否已被授权?”
use std::sync::Arc;
use adk_auth::{AuthMiddleware, ContextScopeResolver, ScopeGuard};
let rbac = AuthMiddleware::new(ac);
let scoped = ScopeGuard::new(ContextScopeResolver);
let protected = scoped.protect(rbac.protect(transfer_tool));
SSO 集成
支持的提供方
| 提供方 | 构造函数 | 发行方 |
|---|---|---|
GoogleProvider::new(client_id) | accounts.google.com | |
| Azure AD | AzureADProvider::new(tenant, client) or AzureADProvider::multi_tenant(client).with_allowed_tenants(["tenant-id"]) | login.microsoftonline.com |
| Okta | OktaProvider::new(domain, client) | {domain}/oauth2/default |
| Auth0 | Auth0Provider::new(domain, audience) | {domain}/ |
| 通用 | OidcProvider::from_discovery(issuer, client) | 任何 OIDC 提供商 |
AzureADProvider::multi_tenant() 接受配置受众的任何租户,除非你明确使用 with_allowed_tenants(...) 将其限制。
TokenClaims
从已验证的 JWTs 中提取的声明:
pub struct TokenClaims {
pub sub: String, // Subject (user ID)
pub email: Option<String>, // Email
pub name: Option<String>, // Display name
pub groups: Vec<String>, // IdP groups
pub roles: Vec<String>, // IdP roles
pub hd: Option<String>, // Google hosted domain
pub tid: Option<String>, // Azure tenant ID
// ... more standard OIDC claims
}
ClaimsMapper
将 IdP 组映射到 adk-auth 角色:
let mapper = ClaimsMapper::builder()
.map_group("AdminGroup", "admin")
.map_group("Users", "viewer")
.default_role("guest")
.user_id_from_email()
.build();
user_id_from_email() 仅在 email_verified == true 时使用 email 声明;否则会回退到 sub。
SsoAccessControl
将 SSO 验证与 RBAC 组合在一次调用中:
let sso = SsoAccessControl::builder()
.validator(GoogleProvider::new("client-id"))
.mapper(mapper)
.access_control(ac)
.audit_sink(audit)
.build()?;
// Validate token + check permission + audit log
let claims = sso.check_token(token, &Permission::Tool("search".into())).await?;
审计日志
FileAuditSink
let audit = FileAuditSink::new("/var/log/adk/audit.jsonl")?;
let middleware = AuthMiddleware::with_audit(ac, audit);
输出格式(JSONL)
{"timestamp":"2025-01-01T10:30:00Z","user":"bob","session_id":"sess-123","event_type":"tool_access","resource":"search","outcome":"allowed"}
{"timestamp":"2025-01-01T10:30:01Z","user":"bob","session_id":"sess-123","event_type":"tool_access","resource":"code_exec","outcome":"denied"}
自定义审计接收端
use adk_auth::{AuditSink, AuditEvent, AuthError};
use async_trait::async_trait;
pub struct DatabaseAuditSink { /* ... */ }
#[async_trait]
impl AuditSink for DatabaseAuditSink {
async fn log(&self, event: AuditEvent) -> Result<(), AuthError> {
// Insert into database
sqlx::query("INSERT INTO audit_log ...")
.bind(event.user)
.bind(event.resource)
.execute(&self.pool)
.await?;
Ok(())
}
}
示例
cargo check -p adk-auth
cargo check -p adk-auth --features sso
安全最佳实践
| 实践 | 描述 |
|---|---|
| 默认拒绝 | 仅授予明确需要的权限 |
| 显式拒绝 | 为危险操作添加拒绝规则 |
| 审计所有内容 | 为合规启用日志记录 |
| 在服务端验证 | 始终在服务端验证 JWTs |
| 使用 HTTPS | JWKS 端点需要安全连接 |
| 轮换密钥 | JWKS 缓存每小时自动刷新 |
| 限制令牌生命周期 | 使用短期访问令牌 |
| 限制 Azure 租户 | 对于多租户 Azure 应用,配置 with_allowed_tenants(...) |
| 在身份映射前验证电子邮件 | 当电子邮件未验证时,user_id_from_email() 现在回退到 sub |
| 为撤销做好规划 | 令牌撤销不是内置功能;如果需要立即切断,请在自定义验证器中强制执行 |
| 缓存昂贵的作用域查找 | 如果你的 ScopeResolver 调用外部系统,请按请求/会话缓存结果 |
认证桥接
当你希望 adk-auth 为 adk-server 提供一个可复用的、基于 JWT 的请求提取器时,启用 auth-bridge:
use adk_auth::auth_bridge::JwtRequestContextExtractor;
use adk_auth::sso::{ClaimsMapper, GoogleProvider};
let extractor = JwtRequestContextExtractor::builder()
.validator(GoogleProvider::new("client-id"))
.mapper(ClaimsMapper::builder().user_id_from_email().build())
.build()?;
该提取器会验证 Bearer token,将 user_id 与 ClaimsMapper 进行映射,并将 JWT scope / scp 的声明转发到 RequestContext.scopes。
密钥提供器
工具通过 ToolContext::get_secret 和
InvocationContext::get_secret 访问运行时密钥。其背后是 adk_auth::secrets::SecretProvider,
云端实现则位于特性标志之后:
| 提供方 | 功能 |
|---|---|
| AWS Secrets Manager | aws-secrets |
| Azure Key Vault(密钥保管库) | azure-keyvault |
| GCP Secret Manager | gcp-secrets |
将服务封装为 SecretService,即可把它附加到一次运行:
use adk_auth::secrets::{CachedSecretProvider, SecretProvider, SecretServiceAdapter};
use std::sync::Arc;
use std::time::Duration;
// Any SecretProvider — here wrapped in the cache
let cached = Arc::new(CachedSecretProvider::new(provider, Duration::from_secs(300)));
let service = Arc::new(SecretServiceAdapter::new(cached));
按工具授权
默认情况下,持有上下文的工具可以命名任意 secret,provider 只能看到
该名称——无法区分一个天气工具在请求自己的 API key,还是同一个工具在请求一个支付凭证。AuthorizingSecretService 会在 provider 被咨询之前,
按工具进行决策:
use adk_auth::secrets::authorizing::{AuthorizingSecretService, SecretGrant};
use std::sync::Arc;
let service = Arc::new(
AuthorizingSecretService::new(inner)
.grant("weather_lookup", SecretGrant::none().name("weather-api-key"))
.grant("charge_card", SecretGrant::none().prefix("billing/"))
.with_audit_sink(audit_sink),
);
| 规则 | 行为 |
|---|---|
| 工具具有覆盖该名称的授权 | 允许 |
| 工具具有不覆盖该名称的授权 | 拒绝;不会调用提供程序 |
| 工具没有授权 | 拒绝 |
| 请求不携带工具身份 | 除非 grant_untooled 打开它,否则拒绝 |
一切默认都被拒绝,直到被授予权限,而拒绝会返回一个 Unauthorized 错误。被拒绝的名称根本不会被查找,因此在提供方侧访问日志中不会作为一次尝试读取而出现。
身份不是由工具声明的。LlmAgent 会把已派发工具的名称附加到请求上,同时带上应用、用户、会话和调用信息,因此工具不能冒用另一个工具的身份。工具只能添加一个 purpose:
// inside a tool
let key = ctx.get_secret_for_purpose("weather-api-key", "call the forecast endpoint").await?;
注意: 作为工具被调用的 agent 会跨越一个
ToolContext,它自身不携带任何身份,因此在该 agent 内部发起的访问会呈现外层 agent 的身份,而不是内层工具的身份。请据此授予权限。
审计访问
SecretAuditSink 会针对每个决策接收一个 SecretAccessDecision,其中包含结果、secret 名称、工具、用户、调用以及原因——但绝不会包含 secret 值。允许也会在 info 记录,拒绝则会在 warn 记录。
缓存
CachedSecretProvider 会先为其 TTL 提供一个值,然后重新获取。它是有界的,并且可撤销:
| 控制 | 行为 |
|---|---|
with_max_entries(n) | 最多缓存 n 个名称;满时会丢弃最近最少使用的项。默认值为 128;0 会禁用缓存 |
invalidate(name) | 立即丢弃一个密钥——当密钥轮换时使用此项,这样旧值在其剩余的 TTL 中不会被提供 |
invalidate_all() | 丢弃所有内容 |
purge_expired() | 丢弃已过期的条目,而不等待它们再次被读取 |
当秘密名称从输入派生时,绑定就很重要:如果没有绑定,缓存可能会在进程的整个生命周期内持续增长。
缓存会做什么以及不会保证什么
TTL 控制缓存返回什么,而不是值在进程内存中停留多久。条目在过期、被逐出或被失效时会被清零,这将驻留时间缩短到大约 TTL。这只是减少,不是擦除——String 可能已经被重新分配、被分配器复制、交换到磁盘,或者被捕获在 core dump 中。缓存的调试输出会被脱敏,因此诊断打印不会泄露值。
**重要:**一个裸的
SecretProvider本身不施加任何策略——任何持有上下文的工具都可以请求后端凭证能够读取的任何名称。将它包装在AuthorizingSecretService中,以获得每个工具的边界,并且仍然要限定云凭证本身的范围:每个部署使用一个 IAM 身份,只能访问该部署所需的秘密。 **重要:**提供程序接口只接受秘密名称。在 ADK 层没有每工具授权、命名空间或访问审计,因此任何持有上下文的工具都可以请求后端凭证能够读取的任何名称。限定云凭证本身的范围——每个部署使用一个 IAM 身份,只能访问该部署所需的秘密——并将提供程序侧审计日志视为访问记录。
提取器保护了什么
配置提取器会为所有非公开路由开启身份验证:
| 路由 | 配置了解析器时的行为 |
|---|---|
/api/sessions/*, /api/apps/*, artifacts, debug | 在没有有效令牌的情况下返回 401 |
/api/ui/* — bridge, notifications, resources | 在没有有效令牌的情况下返回 401;已认证用户会替换请求体中命名的任何用户 |
/api/run* | 未提供有效令牌时返回 401;已认证用户将覆盖所提供的用户 |
/health | 公开 |
UI bridge 状态由从请求体中取出的 (app_name, user_id, session_id) 作为键,因此会用已认证用户替换正文中的值,而不是信任该值。已注册的 UI 资源会记录注册它的用户;只有该用户可以读取或替换它,而读取他人的资源会返回 404,因此不会泄露 URI 的存在。
如果没有配置 extractor,就没有可绑定的已认证身份,因此路由保持开放,资源也会全局可见。认证是可选的——对于任何不是单一受信任用户的部署,都应配置 extractor。
错误处理
use adk_auth::{AccessDenied, AuthError};
use adk_auth::sso::TokenError;
// RBAC errors
match ac.check("user", &Permission::Tool("admin".into())) {
Ok(()) => { /* access granted */ }
Err(AccessDenied { user, permission }) => {
eprintln!("Denied: {} cannot access {}", user, permission);
}
}
// SSO errors
match provider.validate(token).await {
Ok(claims) => { /* token valid */ }
Err(TokenError::Expired) => { /* token expired */ }
Err(TokenError::InvalidSignature) => { /* signature invalid */ }
Err(TokenError::InvalidIssuer { expected, actual }) => { /* wrong issuer */ }
Err(e) => { /* other error */ }
}
上一页: ← Evaluation | 下一页: Tool Authorization →
A2A 端点
| 路由 | 身份验证 |
|---|---|
GET /.well-known/agent.json | 公共 — 对等方在持有凭据之前先获取卡片 |
POST /a2a | 必需,当配置了 RequestContextExtractor 时 |
POST /a2a/stream | 必需,当配置了 RequestContextExtractor 时 |
JSON-RPC 路由执行 agent 和 tool 工作,因此它们与 session、artifact 和 debug 路由使用相同的层。由于未配置 extractor,就没有需要验证的凭据,路由会保持开放,所以添加这个门禁不会破坏现有部署。
重要: 这些路由之前是在 router 根部合并的,位于应用于
/api的层之外。即使某个部署对其他所有 mutation 接口都进行了认证,只要任何能访问该端口的 client,就仍然可以驱动 agent 并承担其成本。这同时适用于create_app_with_a2a和ServerBuilder::build。
A2aServer::builder() 默认绑定 127.0.0.1:8080。调用 bind_addr 以将其暴露,并且在此之前先配置 extractor。生成的 a2a-server 脚手架遵循相同规则,并读取 BIND_HOST 以选择更宽的绑定。