액세스 제어
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.1.0"
# For SSO/OAuth support
adk-auth = { version = "2.1.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) 또는 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}/ |
| Generic | 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인 경우에만 이메일 클레임을 사용하며, 그렇지 않으면 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(...) 구성 |
| 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 토큰을 검증하고, ClaimsMapper을(를) 사용하여 user_id을(를) 매핑한 다음, JWT scope / scp 클레임을 RequestContext.scopes(으)로 전달합니다.
Secret Providers
도구는 ToolContext::get_secret 및
InvocationContext::get_secret을(를) 통해 런타임 시크릿에 접근합니다. 이들 뒤에는 adk_auth::secrets::SecretProvider이(가) 있으며,
클라우드 구현은 기능 플래그 뒤에 있습니다:
| 공급자 | 기능 |
|---|---|
| AWS Secrets Manager | aws-secrets |
| Azure Key Vault | azure-keyvault |
| GCP 보안 비밀 관리자 | 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가 있음 | 허용됨 |
| 도구에 이름을 포함하지 않는 grant가 있음 | 거부됨; provider는 호출되지 않음 |
| 도구에 권한 부여가 없음 | 거부됨 |
| 요청에 도구 식별자가 없음 | grant_untooled에서 열지 않는 한 거부됨 |
모든 항목은 허용될 때까지 거부되며, 거부되면 Unauthorized 오류가 반환됩니다. 거부된 이름은 절대 조회되지 않으므로, 제공자 측 액세스 로그에도 시도된 읽기로 나타나지 않습니다.
ID는 도구가 주장하는 것이 아닙니다. LlmAgent은 앱, 사용자, 세션 및 호출과 함께 디스패치된 도구의 이름을 요청에 기록하므로, 도구가 다른 도구의 ID를 내세울 수 없습니다. 도구가 추가할 수 있는 것은 목적뿐입니다.
// inside a tool
let key = ctx.get_secret_for_purpose("weather-api-key", "call the forecast endpoint").await?;
참고: 도구로 호출된 에이전트는 자체 ID를 전달하지 않는
ToolContext을 통과하므로, 해당 에이전트 내부에서 이루어진 액세스에는 내부 도구의 ID가 아니라 외부 에이전트의 ID가 표시됩니다. 이에 맞게 허용을 부여하세요.
액세스 감사
SecretAuditSink은 결정마다 하나의 SecretAccessDecision을 수신하며, 여기에는 결과, 비밀 이름, 도구, 사용자, 호출 및 이유가 포함되고 비밀 값은 절대 포함되지 않습니다. 허용은 info에, 거부는 warn에 기록됩니다.
캐싱
CachedSecretProvider은 TTL 동안 값을 제공한 다음 다시 가져옵니다. 이는 범위가 제한되고 철회할 수 있습니다:
| 제어 | 동작 |
|---|---|
with_max_entries(n) | 최대 n개의 이름이 캐시되며, 캐시가 가득 차면 가장 오래 사용되지 않은 항목이 삭제됩니다. 기본값은 128이며, 0는 캐싱을 비활성화합니다 |
invalidate(name) | 보안 비밀 하나를 즉시 삭제합니다. 보안 비밀이 교체되었을 때 사용하면 TTL의 남은 기간 동안 이전 값이 제공되지 않습니다 |
invalidate_all() | 모든 항목 삭제 |
purge_expired() | 만료된 항목을 다시 읽을 때까지 기다리지 않고 삭제 |
입력에서 비밀 이름이 파생되는 경우 바인딩이 중요합니다. 바인딩이 없으면 캐시가 프로세스 수명 동안 계속 커질 수 있습니다.
캐시가 보장하는 것과 보장하지 않는 것
TTL은 캐시가 반환하는 항목을 제어하지만, 값이 프로세스 메모리에 얼마나 오래 남아 있는지는 제어하지 않습니다. 항목은 만료되거나 제거되거나 무효화될 때 제로화되며, 이에 따라 상주 시간이 대략 TTL으로 단축됩니다. 이는 삭제가 아니라 감소입니다. String은 이미 재할당되었거나, 할당자에 의해 복사되었거나, 디스크로 스왑되었거나, 코어 덤프에 포함되었을 수 있습니다. 캐시의 디버그 출력은 값이 진단 출력으로 유출되지 않도록 마스킹됩니다.
중요: 단순한
SecretProvider에는 자체 정책이 적용되지 않습니다. 컨텍스트를 보유한 모든 도구는 백업 자격 증명이 읽을 수 있는 모든 이름을 요청할 수 있습니다.AuthorizingSecretService로 감싸 도구별 경계를 설정하고, 클라우드 자격 증명 자체에도 범위를 지정하십시오. 배포마다 하나의 IAM ID를 사용하고 해당 배포에 필요한 비밀에만 액세스하도록 하십시오. 중요: 제공자 인터페이스는 비밀 이름만 받습니다. ADK 계층에는 도구별 권한 부여, 네임스페이스 또는 액세스 감사 기능이 없으므로, 컨텍스트를 보유한 모든 도구는 백업 자격 증명이 읽을 수 있는 모든 이름을 요청할 수 있습니다. 클라우드 자격 증명 자체에 범위를 지정하십시오. 배포마다 하나의 IAM ID를 사용하고 해당 배포에 필요한 비밀에만 액세스하도록 하며, 제공자 측 감사 로그를 액세스 기록으로 취급하십시오.
추출기가 보호하는 항목
추출기를 구성하면 모든 공개되지 않은 경로에 대해 인증이 활성화됩니다.
| 경로 | 추출기가 구성된 경우의 동작 |
|---|---|
/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를 읽습니다.