접근 제어
adk-auth를 사용하는 AI 에이전트를 위한 엔터프라이즈급 접근 제어입니다.
개요
adk-auth는 역할 기반 접근 제어(RBAC), 범위 기반 권한 부여, 감사 로깅, 그리고 SSO 지원을 ADK 에이전트에 제공합니다. 이를 통해 어떤 사용자가 어떤 도구에 접근할 수 있는지에 대해 안전하고 세밀한 제어를 가능하게 합니다.
아키텍처
┌─────────────────────────────────────────────────────────────────┐
│ 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()는 구성된 audience에 대해 모든 tenant를 허용합니다. 단, with_allowed_tenants(...)로 명시적으로 제한한 경우는 예외입니다.
TokenClaims
검증된 JWTs에서 추출된 claims:
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 group을 adk-auth role에 매핑합니다:
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 claim을 사용합니다. 그렇지 않으면 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"}
사용자 지정 감사 sink
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(...) 구성 |
| ID 매핑 전에 이메일 확인 | 이메일이 확인되지 않은 경우 user_id_from_email()가 이제 sub로 대체됩니다 |
| 철회에 대비 | 토큰 철회는 기본 제공되지 않습니다. 즉시 차단이 필요하면 사용자 지정 유효성 검사기에서 이를 적용하세요 |
| 비용이 큰 범위 조회 캐시 | ScopeResolver가 외부 시스템을 호출하는 경우, 요청/세션당 결과를 캐시합니다 |
Auth Bridge
auth-bridge를 활성화하면 adk-auth가 adk-server를 위한 재사용 가능한 JWT 기반 요청 추출기를 제공할 수 있습니다:
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 토큰을 검증하고, 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));
도구별 권한 부여
기본적으로 컨텍스트를 가진 도구는 어떤 비밀이든 이름으로 지정할 수 있고, 제공자는 그 이름만 보게 됩니다. 즉, 날씨 도구가 자신의 API 키를 요청하는 경우와 같은 도구가 결제 자격 증명을 요청하는 경우를 구분할 수 없습니다. AuthorizingSecretService는 제공자에게 조회하기 전에 도구별로 결정합니다:
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은 디스패치된 도구의 이름을 요청에 앱, 사용자, 세션, invocation과 함께 찍어 넣으므로, 도구가 다른 도구의 식별자를 내세울 수 없습니다. 도구가 추가할 수 있는 것은 purpose뿐입니다:
// inside a tool
let key = ctx.get_secret_for_purpose("weather-api-key", "call the forecast endpoint").await?;
참고: 도구로 호출된 agent는 고유한 식별자를 가지지 않는
ToolContext을 넘나드므로, 그 내부에서 수행된 접근은 내부 도구의 식별자가 아니라 외부 agent의 식별자를 나타냅니다. 그에 맞게 grant 하십시오.
액세스 감사
SecretAuditSink는 각 결정마다 하나의 SecretAccessDecision을 받으며, 결과, secret name, tool, user, invocation, reason을 담고 있고, secret value는 절대 담지 않습니다. 허용은 info에, 거부는 warn에 기록됩니다.
캐싱
CachedSecretProvider은 자신의 TTL에 대한 value를 제공한 뒤 다시 fetch합니다. 이는 제한적이며 revocable합니다:
| 제어 | 동작 |
|---|---|
with_max_entries(n) | 최대 n개의 이름을 캐시하며, 가득 차면 가장 오래 사용되지 않은 항목이 제거됩니다. 기본값은 128이며, 0은 캐싱을 비활성화합니다 |
invalidate(name) | 비밀값 하나를 즉시 삭제합니다 — 비밀이 교체되어 나머지 TTL 동안 이전 값이 제공되지 않도록 할 때 사용합니다 |
invalidate_all() | 모든 것을 삭제합니다 |
purge_expired() | 만료된 항목이 다시 읽힐 때까지 기다리지 않고 삭제합니다 |
비밀 이름이 입력에서 파생될 때는 경계가 중요합니다. 경계가 없으면 캐시는 프로세스 수명 동안 계속 커질 수 있습니다.
캐시가 보장하는 것과 보장하지 않는 것
TTL는 캐시가 반환하는 것을 제어할 뿐, 값이 프로세스 메모리에 얼마나 오래 남는지는 제어하지 않습니다. 항목은 만료되거나, 축출되거나, 무효화될 때 제로화되며, 이로 인해 상주 시간은 대략 TTL로 줄어듭니다. 이는 감소이지 삭제가 아닙니다 — String는 이미 재할당되었거나, 할당자에 의해 복사되었거나, 디스크로 스왑되었거나, 코어 덤프에 캡처되었을 수 있습니다. 캐시의 디버그 출력은 마스킹되므로 진단용 출력이 값을 유출할 수 없습니다.
중요: 단독
SecretProvider는 자체 정책을 적용하지 않습니다 — 컨텍스트를 보유한 어떤 도구든 백엔드 자격 증명이 읽을 수 있는 어떤 이름이든 요청할 수 있습니다.AuthorizingSecretService로 감싸 도구별 경계를 만들고, 클라우드 자격 증명 자체도 범위 제한하세요: 각 배포마다 하나의 IAM 식별자를 사용하고, 해당 배포에 필요한 비밀만 접근할 수 있게 하십시오. 중요: 제공자 인터페이스는 비밀 이름만 받습니다. ADK 계층에는 도구별 권한 부여, 네임스페이스, 액세스 감사가 없으므로, 컨텍스트를 보유한 어떤 도구든 백엔드 자격 증명이 읽을 수 있는 어떤 이름이든 요청할 수 있습니다. 클라우드 자격 증명 자체를 범위 제한하세요 — 각 배포마다 하나의 IAM 식별자를 사용하고, 해당 배포에 필요한 비밀만 접근할 수 있게 하십시오 — 그리고 제공자 측 감사 로그를 액세스 기록으로 취급하십시오.
추출기가 보호하는 것
추출기를 구성하면 비공개가 아닌 모든 경로에 대해 인증이 활성화됩니다:
| 경로 | 추출기가 구성된 경우의 동작 |
|---|---|
/api/sessions/*, /api/apps/*, artifacts, debug | 유효한 토큰이 없으면 401 |
/api/ui/* — bridge, notifications, resources | 유효한 토큰이 없으면 401; 인증된 사용자가 요청 본문에 지정된 사용자를 대체함 |
/api/run* | 유효한 토큰이 없을 때의 401; 인증된 사용자가 제공된 사용자를 재정의함 |
/health | 공개 |
UI 브리지 상태는 요청 본문에서 가져온 (app_name, user_id, session_id)를 기준으로 키가 지정되므로, 인증된 사용자가 본문 값 대신 대체되며 이를 신뢰하지 않습니다. 등록된 UI 리소스는 이를 등록한 사용자를 기록하며, 해당 사용자만 이를 읽거나 교체할 수 있습니다. 또한 다른 사람의 리소스를 읽으려 하면 404를 반환하므로 URI의 존재가 드러나지 않습니다.
추출기가 구성되지 않은 경우에는 바인딩할 인증된 ID가 없으므로 경로는 열려 있고 리소스는 전역적으로 표시됩니다. 인증은 선택 사항입니다. 단일 신뢰된 사용자가 아닌 모든 배포에는 추출기를 구성하십시오.
오류 처리
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 */ }
}
이전: ← 평가 | 다음: 도구 권한 부여 →
A2A 엔드포인트
| 경로 | 인증 |
|---|---|
GET /.well-known/agent.json | 공개 — 피어가 자격 증명을 보유하기 전에 카드를 가져옵니다 |
POST /a2a | RequestContextExtractor가 구성된 경우 필요함 |
POST /a2a/stream | RequestContextExtractor가 구성된 경우 필수 |
JSON-RPC 경로는 에이전트와 도구 작업을 실행하므로, 세션, 아티팩트, 디버그 라우터와 같은 레이어를 사용합니다. 추출기가 구성되지 않으면 요구할 자격 증명이 없고 경로는 계속 열려 있으므로, 게이트를 추가해도 기존 배포를 깨지 않습니다.
중요: 이 경로들은 이전에 라우터 루트에서,
/api에 적용된 레이어 밖에 병합되어 있었습니다. 다른 모든 변경 수단을 인증하던 배포라도 포트에 도달할 수 있는 어떤 클라이언트든 에이전트를 구동하고 그 비용을 발생시킬 수 있었습니다. 이는create_app_with_a2a과ServerBuilder::build모두에 적용되었습니다.
A2aServer::builder()은 기본적으로 127.0.0.1:8080에 바인딩됩니다. 이를 노출하려면 bind_addr를 호출하고, 그 전에 추출기를 구성하세요. 생성된 a2a-server 스캐폴드는 동일한 규칙을 따르며, 더 넓은 바인딩을 선택하려면 BIND_HOST를 읽습니다.