Contrôle d’accès
Contrôle d’accès de niveau entreprise pour les agents IA utilisant adk-auth.
Vue d’ensemble
adk-auth fournit le contrôle d’accès basé sur les rôles (RBAC), l’autorisation basée sur les portées, la journalisation d’audit et la prise en charge de SSO pour les agents ADK. Cela permet un contrôle sécurisé et granulaire des outils auxquels chaque utilisateur peut accéder.
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. La priorité au refus
Lorsqu’un rôle contient à la fois des règles d’autorisation et de refus, le refus l’emporte toujours :
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 de plusieurs rôles
Les utilisateurs ayant plusieurs rôles obtiennent l’union des permissions, mais les règles de refus de n’importe quel rôle s’appliquent toujours :
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.0.0"
# For SSO/OAuth support
adk-auth = { version = "2.0.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
Enveloppe 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 en lot plusieurs outils :
let middleware = AuthMiddleware::new(ac);
let protected_tools = middleware.protect_all(tools);
ScopeGuard
Utilisez des portées pour l’autorisation au niveau de la requête provenant des revendications JWT ou de l’état de 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 à « cet utilisateur peut-il accéder à l’outil, oui ou non ? » Les portées répondent à « cette requête spécifique est-elle autorisée maintenant ? »
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 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 n’importe quel tenant 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
Mappe 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() n’utilise le champ de revendication email que lorsque email_verified == true ; sinon, il se rabat sur sub.
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 d’audit
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"}
Récepteur 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 | N’accorder que les autorisations explicitement nécessaires |
| Refus explicites | Ajouter des règles de refus pour les opérations dangereuses |
| Auditer tout | Activer la journalisation pour la 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 se rafraîchit automatiquement toutes les heures |
| Limiter la durée de vie des jetons | Utiliser des jetons d’accès à courte durée de vie |
| Restreindre les locataires Azure | Pour les applications Azure multi-locataires, configurez with_allowed_tenants(...) |
| Vérifier l’e-mail avant le mappage d’identité | user_id_from_email() revient désormais à sub lorsque l’e-mail n’est pas vérifié |
| 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 voulez 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, associe user_id à ClaimsMapper, et transmet les revendications JWT scope / scp vers RequestContext.scopes.
Fournisseurs de secrets
Les outils accèdent aux secrets d’exécution via ToolContext::get_secret et
InvocationContext::get_secret. Derrière ceux-ci 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 (coffre de clés) | azure-keyvault |
| Gestionnaire de secrets GCP | gcp-secrets |
Attachez-en un à une exécution en l’enveloppant 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 qui détient un contexte peut nommer n’importe quel secret, et le fournisseur ne voit que
ce nom — rien ne distingue un outil météo demandant sa propre clé API d’une
demande du même outil pour un identifiant de paiement. AuthorizingSecretService décide par 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 aucun droit | Refusé |
| La requête ne porte 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é, il n’apparaît donc pas dans les journaux d’accès côté fournisseur comme une lecture tentée.
L’identité n’est pas quelque chose qu’un outil affirme. LlmAgent appose le nom de l’outil envoyé sur la requête, avec l’application, l’utilisateur, la session et l’invocation, de sorte qu’un outil ne peut pas présenter l’identité d’un autre outil. Un outil ne peut ajouter qu’un purpose :
// inside a tool
let key = ctx.get_secret_for_purpose("weather-api-key", "call the forecast endpoint").await?;
Note : un agent invoqué comme outil franchit un
ToolContext, qui ne porte aucune identité propre, donc les accès effectués à l’intérieur de cet agent présentent 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 la raison — et jamais une valeur secrète. Les autorisations sont également journalisées à info et les refus à warn.
Mise en cache
CachedSecretProvider sert une valeur pour son TTL, puis la récupère à nouveau. Elle est bornée et révocable :
| Contrôle | Comportement |
|---|---|
with_max_entries(n) | Au plus n noms mis en cache ; le moins récemment utilisé est supprimé quand 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 ceci lorsqu’un secret est remplacé afin que l’ancienne valeur ne soit pas servie pendant le reste de sa TTL |
invalidate_all() | Supprime tout |
purge_expired() | Supprime les entrées expirées sans attendre qu’elles soient relues |
Une limite est importante lorsque les noms de 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, pas la durée pendant laquelle une valeur reste dans la mémoire du processus. Les entrées sont mises à zéro lorsqu’elles expirent, sont évincées ou sont invalidées, ce qui réduit leur présence à peu près à la TTL. Il s’agit d’une réduction, pas d’un effacement — un String a peut-être déjà été réalloué, copié par l’allocateur, échangé sur disque ou capturé dans un dump mémoire. La sortie de débogage du cache est masquée afin qu’une impression de diagnostic ne puisse pas divulguer une valeur.
Important : un
SecretProvidernu n’applique aucune politique en propre — tout outil détenant un contexte peut demander n’importe quel nom que les identifiants sous-jacents peuvent lire. Enveloppez-le dansAuthorizingSecretServicepour obtenir une frontière par outil, et limitez aussi les identifiants cloud eux-mêmes : une identité IAM par déploiement, avec accès uniquement aux secrets dont ce déploiement a besoin. Important : l’interface du fournisseur n’accepte qu’un nom de secret. Il n’existe pas de permission par outil, d’espace de noms ni d’audit d’accès au niveau de la couche ADK, donc tout outil détenant un contexte peut 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 accès à uniquement les secrets dont ce déploiement a besoin — et considérez les journaux d’audit côté fournisseur comme l’enregistrement des accès.
Ce que l’extracteur protège
Configurer 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 UI est indexé par (app_name, user_id, session_id) pris dans le corps de la requête, de sorte que l’utilisateur authentifié est substitué à la valeur du corps plutôt que de lui faire confiance. Une ressource UI enregistrée consigne l’utilisateur qui l’a enregistrée ; seul cet utilisateur peut la lire ou la remplacer, et une lecture de la ressource de quelqu’un d’autre renvoie 404 afin que l’existence de URI ne soit pas divulguée.
Avec aucun extracteur configuré, il n’y a pas d’identité authentifiée à lier, donc les routes restent ouvertes et les ressources restent visibles globalement. L’authentification est optionnelle — configurez un extracteur pour tout déploiement qui n’est pas un seul utilisateur de confiance.
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
| Itinéraire | Authentification |
|---|---|
GET /.well-known/agent.json | public — les pairs récupèrent la carte avant de détenir un identifiant |
POST /a2a | requis, 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 portent donc la même couche que les routeurs de session, d’artefact et de débogage. En l’absence d’extracteur configuré, aucune credential n’est demandée et les routes restent ouvertes, donc l’ajout de la porte ne casse 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 laissait malgré tout n’importe quel client capable d’atteindre le port piloter l’agent et en supporter le coût. Cela s’appliquait à la fois àcreate_app_with_a2aet àServerBuilder::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. L’ossature a2a-server générée suit la même règle
et lit BIND_HOST pour opter pour une liaison plus large.