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

FournisseurConstructeurÉmetteur
GoogleGoogleProvider::new(client_id)accounts.google.com
Azure ADAzureADProvider::new(tenant, client) or AzureADProvider::multi_tenant(client).with_allowed_tenants(["tenant-id"])login.microsoftonline.com
OktaOktaProvider::new(domain, client){domain}/oauth2/default
Auth0Auth0Provider::new(domain, audience){domain}/
GénériqueOidcProvider::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é

PratiqueDescription
Refuser par défautAccorder uniquement les permissions explicitement nécessaires
Refus explicitesAjouter des règles de refus pour les opérations dangereuses
Auditer toutActiver la journalisation à des fins de conformité
Valider côté serveurToujours valider JWTs sur le serveur
Utiliser HTTPSLes points de terminaison JWKS nécessitent des connexions sécurisées
Faire tourner les clésLe cache JWKS s’actualise automatiquement toutes les heures
Limiter la durée de vie des jetonsUtilisez des jetons d’accès à courte durée de vie
Restreindre les locataires AzurePour 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évocationLa 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ûteusesSi 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é :

FournisseurFonctionnalité
AWS Secrets Manageraws-secrets
Azure Key Vaultazure-keyvault
Gestionnaire de secrets GCPgcp-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ègleComportement
L’outil dispose d’une autorisation couvrant le nomAutorisé
L’outil dispose d’une autorisation qui ne couvre pas le nomRefusé ; le fournisseur n’est jamais appelé
L’outil n’a aucune autorisationRefusé
La requête ne contient aucune identité d’outilRefusé 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ôleComportement
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 SecretProvider nu 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 dans AuthorizingSecretService pour 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 :

RoutesComportement avec un extracteur configuré
/api/sessions/*, /api/apps/*, artifacts, debug401 sans jeton valide
/api/ui/* — bridge, notifications, resources401 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
/healthPublic

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

RouteAuthentification
GET /.well-known/agent.jsonpublique — les pairs récupèrent la fiche avant de détenir un justificatif
POST /a2arequise, lorsqu’un RequestContextExtractor est configuré
POST /a2a/streamobligatoire 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_a2a et 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. Le squelette a2a-server généré suit la même règle et lit BIND_HOST pour opter pour une liaison plus large.