Contrôle d’accès
Contrôle d’accès de niveau entreprise pour les agents d’IA utilisant adk-auth.
Vue d’ensemble
adk-auth fournit un contrôle d’accès basé sur les rôles (RBAC), une autorisation basée sur les portées, la journalisation des audits et la prise en charge de SSO pour les agents ADK. Il permet un contrôle sécurisé et précis des utilisateurs pouvant accéder à chaque outil.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────────────┘
Principes de conception
1. Priorité au refus
Lorsqu’un rôle possède à la fois des règles d’autorisation et de refus, le refus est toujours prioritaire :
let role = Role::new("limited")
.allow(Permission::AllTools) // Allow everything...
.deny(Permission::Tool("admin")); // ...except admin
// Result: Can access any tool EXCEPT "admin"
2. Union des rôles
Les utilisateurs possédant plusieurs rôles obtiennent l’union des permissions, mais les règles de refus de n’importe quel rôle continuent de s’appliquer :
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. Explicite plutôt qu’implicite
Les permissions sont explicites : aucun accès n’est accordé par défaut :
let role = Role::new("empty");
// This role grants NO permissions
ac.check("user", &Permission::Tool("anything")); // → Denied
4. Séparation de l’authentification et de l’autorisation
- Authentification (SSO) : vérifie QUI est l’utilisateur (via JWT)
- Autorisation (RBAC) : détermine À QUOI il peut accéder
// 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?;
Installation
[dependencies]
adk-auth = "2.1.0"
# For SSO/OAuth support
adk-auth = { version = "2.1.0", features = ["sso"] }
Composants principaux
Permission
pub enum Permission {
Tool(String), // Specific tool by name
AllTools, // Wildcard: all tools
Agent(String), // Specific agent by name
AllAgents, // Wildcard: all agents
}
Rôle
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
Encapsule un outil avec une vérification automatique des permissions :
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
Protège plusieurs outils par lots :
let middleware = AuthMiddleware::new(ac);
let protected_tools = middleware.protect_all(tools);
ScopeGuard
Utilisez les portées pour l’autorisation au niveau de la requête provenant des revendications JWT ou de l’état de la session :
use adk_auth::{ContextScopeResolver, ScopeGuard};
let guard = ScopeGuard::new(ContextScopeResolver);
let protected = guard.protect(my_tool);
Combinaison de RBAC + Portées
RBAC répond à la question « cet utilisateur peut-il accéder à l’outil ? ». Les portées répondent à la question « cette requête précise est-elle actuellement autorisée ? »
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));
Intégration de SSO
Fournisseurs pris en charge
| Fournisseur | Constructeur | Émetteur |
|---|---|---|
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}/ |
| Générique | OidcProvider::from_discovery(issuer, client) | Tout fournisseur OIDC |
AzureADProvider::multi_tenant() accepte tout locataire pour l’audience configurée, sauf si vous le restreignez explicitement avec with_allowed_tenants(...).
TokenClaims
Revendications extraites de JWTs validé :
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
Associe les groupes IdP aux rôles 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() utilise uniquement la revendication d’adresse e-mail lorsque email_verified == true ; sinon, il utilise sub comme solution de secours.
SsoAccessControl
Combine la validation de SSO avec RBAC en un seul appel :
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?;
Journalisation des audits
FileAuditSink
let audit = FileAuditSink::new("/var/log/adk/audit.jsonl")?;
let middleware = AuthMiddleware::with_audit(ac, audit);
Format de sortie (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 d’audit personnalisé
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(())
}
}
Exemples
cargo check -p adk-auth
cargo check -p adk-auth --features sso
Bonnes pratiques de sécurité
| Pratique | Description |
|---|---|
| Refuser par défaut | Accorder uniquement les permissions explicitement nécessaires |
| Refus explicites | Ajouter des règles de refus pour les opérations dangereuses |
| Auditer tout | Activer la journalisation à des fins de conformité |
| Valider côté serveur | Toujours valider JWTs sur le serveur |
| Utiliser HTTPS | Les points de terminaison JWKS nécessitent des connexions sécurisées |
| Faire tourner les clés | Le cache JWKS s’actualise automatiquement toutes les heures |
| Limiter la durée de vie des jetons | Utilisez des jetons d’accès à courte durée de vie |
| Restreindre les locataires Azure | Pour les applications Azure multilocataires, configurez with_allowed_tenants(...) |
| Vérifier l’adresse e-mail avant le mappage de l’identité | user_id_from_email() utilise désormais sub comme solution de repli lorsque l’adresse e-mail n’est pas vérifiée |
| Prévoir la révocation | La révocation des jetons n’est pas intégrée ; appliquez-la dans un validateur personnalisé si vous avez besoin d’une coupure immédiate |
| Mettre en cache les recherches de portée coûteuses | Si votre ScopeResolver appelle des systèmes externes, mettez le résultat en cache par requête/session |
Pont d’authentification
Activez auth-bridge lorsque vous souhaitez que adk-auth fournisse un extracteur de requêtes réutilisable basé sur JWT pour adk-server :
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()?;
L’extracteur valide le jeton Bearer, mappe user_id avec ClaimsMapper, puis transmet les revendications JWT scope / scp à RequestContext.scopes.
Fournisseurs de secrets
Les outils accèdent aux secrets d’exécution via ToolContext::get_secret et
InvocationContext::get_secret. En arrière-plan se trouve adk_auth::secrets::SecretProvider,
avec des implémentations cloud derrière des indicateurs de fonctionnalité :
| Fournisseur | Fonctionnalité |
|---|---|
| AWS Secrets Manager | aws-secrets |
| Azure Key Vault | azure-keyvault |
| Gestionnaire de secrets GCP | gcp-secrets |
Associez-en un à une exécution en l’encapsulant comme un 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));
Autorisation par outil
Par défaut, un outil détenant un contexte peut nommer n’importe quel secret, et le fournisseur ne voit que ce nom — rien ne permet de distinguer un outil météo demandant sa propre clé API d’un même outil demandant un identifiant de paiement. AuthorizingSecretService détermine cela pour chaque outil avant que le fournisseur ne soit consulté :
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),
);
| Règle | Comportement |
|---|---|
| L’outil dispose d’une autorisation couvrant le nom | Autorisé |
| L’outil dispose d’une autorisation qui ne couvre pas le nom | Refusé ; le fournisseur n’est jamais appelé |
| L’outil n’a aucune autorisation | Refusé |
| La requête ne contient aucune identité d’outil | Refusé sauf si grant_untooled l’ouvre |
Tout est refusé jusqu’à autorisation, et un refus renvoie une erreur Unauthorized. Un nom refusé n’est jamais recherché et n’apparaît donc pas dans les journaux d’accès côté fournisseur comme une tentative de lecture.
L’identité n’est pas quelque chose qu’un outil déclare. LlmAgent appose le nom de l’outil distribué sur la requête, avec l’application, l’utilisateur, la session et l’invocation, de sorte qu’un outil ne puisse pas se présenter comme un autre outil. Un outil peut uniquement ajouter un objectif :
// inside a tool
let key = ctx.get_secret_for_purpose("weather-api-key", "call the forecast endpoint").await?;
Remarque : un agent invoqué comme outil franchit une
ToolContext, qui ne possède aucune identité propre ; les accès effectués à l’intérieur de cet agent présentent donc l’identité de l’agent externe plutôt que celle de l’outil interne. Accordez les autorisations en conséquence.
Audit des accès
SecretAuditSink reçoit un SecretAccessDecision par décision, contenant le résultat, le nom du secret, l’outil, l’utilisateur, l’invocation et le motif — et jamais la valeur du secret. Les autorisations sont également journalisées à info et les refus à warn.
Mise en cache
CachedSecretProvider fournit une valeur pendant son TTL, puis effectue une nouvelle récupération. Elle est limitée et révocable :
| Contrôle | Comportement |
|---|---|
with_max_entries(n) | Au maximum n noms sont mis en cache ; le moins récemment utilisé est supprimé lorsque le cache est plein. La valeur par défaut est 128 ; 0 désactive la mise en cache |
invalidate(name) | Supprime immédiatement un secret — utilisez cette option lorsqu’un secret est renouvelé afin que l’ancienne valeur ne soit pas fournie pendant le reste de sa TTL |
invalidate_all() | Supprime tout |
purge_expired() | Supprime les entrées expirées sans attendre qu'elles soient lues à nouveau |
Une limite est importante lorsque les noms des secrets sont dérivés de l’entrée : sans elle, le cache peut croître pendant toute la durée de vie du processus.
Ce que le cache garantit et ne garantit pas
Un TTL contrôle ce que le cache retourne, et non la durée pendant laquelle une valeur reste en mémoire du processus. Les entrées sont mises à zéro lorsqu’elles expirent, sont évincées ou invalidées, ce qui réduit leur durée de présence à environ TTL. Il s’agit d’une réduction, et non d’un effacement — un String a peut-être déjà été réalloué, copié par l’allocateur, échangé sur le disque ou capturé dans un core dump. La sortie de débogage du cache est expurgée afin qu’une impression de diagnostic ne puisse pas divulguer une valeur.
Important : un
SecretProvidernu n’applique aucune politique de son propre chef — tout outil détenant un contexte peut demander n’importe quel nom que les identifiants sous-jacents peuvent lire. Encapsulez-le dansAuthorizingSecretServicepour obtenir une limite par outil, et limitez également les identifiants cloud eux-mêmes : une identité IAM par déploiement, avec un accès limité aux seuls secrets dont ce déploiement a besoin. Important : l’interface du fournisseur n’accepte qu’un nom de secret. Il n’existe aucune autorisation par outil, aucun espace de noms ni aucun audit des accès au niveau ADK ; tout outil détenant un contexte peut donc demander n’importe quel nom que les identifiants sous-jacents peuvent lire. Limitez les identifiants cloud eux-mêmes — une identité IAM par déploiement, avec un accès limité aux seuls secrets dont ce déploiement a besoin — et considérez les journaux d’audit côté fournisseur comme la trace des accès.
Ce que l’extracteur protège
La configuration d’un extracteur active l’authentification pour chaque route non publique :
| Routes | Comportement avec un extracteur configuré |
|---|---|
/api/sessions/*, /api/apps/*, artifacts, debug | 401 sans jeton valide |
/api/ui/* — bridge, notifications, resources | 401 sans jeton valide ; l’utilisateur authentifié remplace tout utilisateur nommé dans le corps de la requête |
/api/run* | 401 sans jeton valide ; l’utilisateur authentifié remplace l’utilisateur fourni |
/health | Public |
L’état du pont d’interface utilisateur est indexé par (app_name, user_id, session_id) extrait du corps de la requête, de sorte que l’utilisateur authentifié est substitué à la valeur du corps au lieu de lui faire confiance. Une ressource d’interface utilisateur enregistrée mémorise l’utilisateur qui l’a enregistrée ; seul cet utilisateur peut la lire ou la remplacer, et la lecture de la ressource de quelqu’un d’autre renvoie 404 afin de ne pas divulguer l’existence de URI.
Avec aucun extracteur configuré, il n’existe aucune identité authentifiée à associer, les routes restent donc ouvertes et les ressources restent visibles globalement. L’authentification est facultative — configurez un extracteur pour tout déploiement qui n’est pas destiné à un utilisateur de confiance unique.
Gestion des erreurs
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 */ }
}
Précédent : ← Évaluation | Suivant : Autorisation des outils →
Points de terminaison A2A
| Route | Authentification |
|---|---|
GET /.well-known/agent.json | publique — les pairs récupèrent la fiche avant de détenir un justificatif |
POST /a2a | requise, lorsqu’un RequestContextExtractor est configuré |
POST /a2a/stream | obligatoire lorsqu’un RequestContextExtractor est configuré |
Les routes JSON-RPC exécutent le travail de l’agent et des outils ; elles utilisent donc la même couche que les routeurs de session, d’artefacts et de débogage. Lorsqu’aucun extracteur n’est configuré, aucun identifiant n’est requis et les routes restent ouvertes ; l’ajout de cette barrière ne perturbe donc pas un déploiement existant.
Important : ces routes étaient auparavant fusionnées à la racine du routeur, en dehors de la couche appliquée à
/api. Un déploiement qui authentifiait toutes les autres surfaces de mutation permettait toujours à n’importe quel client pouvant atteindre le port de piloter l’agent et d’en engendrer le coût. Cela s’appliquait à la fois àcreate_app_with_a2aetServerBuilder::build.
A2aServer::builder() se lie par défaut à 127.0.0.1:8080. Appelez bind_addr pour l’exposer, et
configurez un extracteur avant de le faire. Le squelette a2a-server généré suit la même règle
et lit BIND_HOST pour opter pour une liaison plus large.