Gestão de Acessos · Multi-Agentes

Token Propagation
Architecture

Como a identidade e as permissões do usuário trafegam de forma segura desde o Azure EntraID até os agentes especialistas, sem expor o token original. Azure EntraID (JWT Bearer) · LangGraph StateGraph · DynamoDB ACL Cache · Client Credentials B2B.

Visão Geral

O sistema recebe um Access Token do Azure EntraID como porta de entrada. Esse token não é repassado para os agentes especialistas — em vez disso, as informações de identidade relevantes são extraídas, sanitizadas e propagadas como um objeto estruturado (UserContext) dentro do estado do grafo LangGraph.

Para se comunicar com os agentes, o Supervisor usa um token separado obtido via Client Credentials — autenticação de sistema a sistema, onde o token representa o Supervisor, não o usuário. O contexto do usuário viaja no payload, não no token.

Princípio Central: o token original do usuário nunca sai do Supervisor. O que propaga é um objeto UserContext construído via allowlist explícita, contendo apenas o que os agentes precisam saber para validar acesso e auditar ações.
Client Access Token
JWT Authorizer ──→
API Gateway Valida assinatura
claims extraídas ──→
Supervisor UserContext
client_credentials ──→
Agente Especialista B2B Token + Payload

O que é ACL

ACL (Access Control List) é uma lista que define quem pode fazer o quê em um sistema. Para cada recurso (um agente, uma tool, um dado de cliente), existe uma lista explícita de quais grupos e roles têm permissão de acessá-lo — e com qual nível de acesso.

No contexto deste sistema, você tem RBAC + ACL combinados: o EntraID emite roles e grupos (RBAC), e o sistema de parametrização define quais grupos/roles podem acessar cada recurso (ACL). O cache no DynamoDB é a materialização em runtime dessa ACL.

Comportamento Conservador: se não for possível resolver a regra de acesso (cache miss + API indisponível), o sistema nega o acesso por padrão. Nunca libera por ausência de regra.

Comparativo de modelos de controle de acesso

RBAC  Role-Based Access Control
      "Quem tem a role manager pode fazer POST"
      → O que o Azure EntraID faz nativamente com claims de roles

ABAC  Attribute-Based Access Control
      "Quem tem department=finance AND region=BR pode acessar"
      → Mais flexível, mais complexo de implementar

ACL   Access Control List
      "O agente financial permite os grupos [grp-finance, grp-admin]"
      → Lista explícita por recurso, direta ao ponto

Este sistema usa RBAC (EntraID) + ACL (parametrização) combinados.

Camadas de ACL

A validação de acesso é feita em cascata com três níveis de granularidade. O usuário precisa passar por cada nível relevante à sua ação antes de obter uma resposta.

NÍVEL 01
Agente

O usuário pode sequer invocar este agente especialista?

AccessScope.AGENT
NÍVEL 02
Dados de Cliente

O usuário tem acesso aos dados do cliente consultado? Validado pelo agente via API externa.

AccessScope.CLIENT_DATA
NÍVEL 03
Tool Específica

O usuário pode executar esta operação? GET vs POST têm permissões distintas.

AccessScope.TOOL

Estrutura da tabela DynamoDB (ACL Cache)

PK SK allowed_groups allowed_roles
AGENT#financial ACCESS ["grp-finance", "grp-admin"] ["analyst"]
AGENT#financial CLIENT_DATA ["grp-account-owners"] []
AGENT#financial TOOL#create_client ["grp-finance-write"] ["manager"]
AGENT#crm ACCESS ["grp-sales", "grp-admin"] ["account-manager"]

UserContext

O UserContext é construído uma única vez, no entrypoint do Supervisor, a partir do JWT recebido via allowlist explícita. Uma vez montado, é injetado no invoke inicial do grafo e viaja imutável por todos os nós.

Isso garante que a identidade do usuário não pode ser adulterada por nenhum nó intermediário e que qualquer log ou auditoria tem sempre a mesma fonte de verdade.

UserContext — campos e origem
user_idstr · JWT subimutável
emailstr · preferred_usernameimutável
groupslist[str] · groups claimimutável
roleslist[str] · roles claimimutável
scopeslist[str] · scp claimimutável
tenant_idstr · tid claimimutável
session_idstr · propagadoimutável
thread_idstr · propagadoimutável
token_issued_atdatetime · iatimutável
token_expires_atdatetime · expimutável
Nunca propagado: raw token, refresh token, e quaisquer dados PII além de email e user_id são descartados na construção do UserContext e jamais entram no AgentState ou nos payloads dos agentes.

AgentState

O AgentState é o único objeto que trafega entre todos os nós do grafo. Nada é passado fora dele. Cada nó recebe o state completo, executa sua responsabilidade, e retorna apenas os campos que modificou — o LangGraph faz o merge automático.

AgentState — todos os campos
user_contextUserContextimutável
session_idstrimutável
thread_idstrimutável
messageslist[BaseMessage]acumula
intentOptional[str]mutável
active_agentOptional[str]mutável
access_deniedOptional[AccessDeniedContext]mutável
specialist_responseOptional[dict]mutável
Annotated + operator.add: o campo messages usa Annotated[list, operator.add]. Isso instrui o LangGraph a concatenar as listas retornadas por diferentes nós em vez de substituir — mecanismo nativo de acumulação de histórico de mensagens.

Fluxo de Tokens

Existem dois tokens distintos no sistema, com propósitos e escopos diferentes. Entender essa separação é fundamental para a segurança da arquitetura.

Token 1 — Access Token do usuário (entrada)

Emitido pelo Azure EntraID para o usuário final. Validado pelo JWT Authorizer do API Gateway antes de chegar ao Supervisor. Nunca é repassado para os agentes. O Supervisor apenas lê as claims e descarta o token.

Token 2 — Client Credentials (back-to-back)

Obtido pelo Supervisor via fluxo client_credentials do OAuth2. Representa a identidade do Supervisor, não do usuário. É este token que acompanha as chamadas HTTP para os agentes especialistas — o contexto do usuário viaja separadamente no payload JSON.

Cache de token client_credentials: tokens client_credentials têm validade de ~1h no EntraID. O AgentTokenProvider cacheia o token em memória com verificação de expiração (com buffer de 60s), evitando um roundtrip HTTP ao EntraID a cada invocação de agente.

O que o agente recebe

POST /agents/financial
Authorization: Bearer {token do Supervisor — client_credentials}
Content-Type: application/json

{
  "user_context": {           ← allowlist mínima do UserContext
    "user_id":    "sub-do-usuario",
    "groups":     ["grp-finance"],
    "roles":      ["analyst"],
    "session_id": "sess-abc123",
    "thread_id":  "thread-xyz789"
  },
  "messages": [...]            ← histórico de mensagens
}

ACL Cache — DynamoDB

O sistema de parametrização (API externa) é a fonte da verdade das regras de acesso. O DynamoDB funciona como cache de longa duração dessas regras, evitando chamadas repetidas à API a cada request.

Estratégia de resolução

1. Supervisor precisa validar acesso

2. Consulta DynamoDB (PK=AGENT#id, SK=SCOPE)
   ├── HIT + TTL válido   → usa a regra cacheada
   └── MISS ou TTL expirado → vai para o passo 3

3. Chama API de parametrização
   ├── Sucesso            → popula DynamoDB + retorna regra
   └── Falha (timeout/erro) → NEGA ACESSO (fail-closed)

4. Verifica interseção de grupos/roles do usuário
   com allowed_groups e allowed_roles da regra
TTL recomendado: 5 minutos (300s) como padrão. ACL é uma configuração que muda com pouca frequência. O DynamoDB remove itens expirados automaticamente, mas com possível atraso — por isso o código também verifica ttl > time.time() manualmente.
DynamoDB Streams — preparado para o futuro: a arquitetura já contempla DynamoDB Streams para invalidação automática do cache quando uma regra for alterada no sistema de parametrização. A feature está arquitetada mas não ativada — sem custo adicional de design quando for necessário.

Estrutura de Arquivos

supervisor/
├── graph/
│   ├── state.py        # AgentState, UserContext, AccessDeniedContext
│   ├── nodes.py        # todos os nós do grafo LangGraph
│   ├── edges.py        # lógica de roteamento condicional
│   └── builder.py      # montagem e compilação do grafo
├── auth/
│   ├── context.py      # parse do JWT → UserContext (allowlist)
│   └── tokens.py       # client_credentials cache in-memory
├── acl/
│   └── cache.py        # ACL cache no DynamoDB com fallback para API
└── main.py             # entrypoint — Lambda/ECS handler

UserContext & AgentState

O contrato de dados do grafo. O UserContext é construído via allowlist explícita — apenas as claims mapeadas aqui são extraídas do JWT. Qualquer outra claim é descartada silenciosamente, independente do que o EntraID enviar.

python — graph/state.py
from __future__ import annotations
from typing import Annotated, Optional
from datetime import datetime
from enum import Enum
import operator
from pydantic import BaseModel, Field
from langchain_core.messages import BaseMessage


# ── UserContext ───────────────────────────────────────
# Montado uma única vez a partir do JWT.
# Viaja imutável pelo grafo inteiro.

class UserContext(BaseModel):
    # Identidade
    user_id: str
    email:   Optional[str] = None
    name:    Optional[str] = None

    # Claims de autorização (vindas do EntraID)
    groups: list[str] = Field(default_factory=list)
    roles:  list[str] = Field(default_factory=list)
    scopes: list[str] = Field(default_factory=list)

    # Metadados do tenant/app
    tenant_id: Optional[str] = None
    client_id: Optional[str] = None  # azp — app que emitiu o token

    # Rastreabilidade — propagados em todo state e payload
    session_id: str
    thread_id:  str

    # Janela de validade (apenas para logging)
    token_issued_at:  Optional[datetime] = None
    token_expires_at: Optional[datetime] = None

    @classmethod
    def from_jwt_claims(
        cls,
        claims: dict,
        session_id: str,
        thread_id: str,
    ) -> "UserContext":
        """
        Allowlist explícita — só extrai o que está mapeado aqui.
        Qualquer claim fora dessa lista é descartada silenciosamente.
        """
        return cls(
            user_id   = claims["sub"],
            email     = claims.get("preferred_username"),
            name      = claims.get("name"),
            groups    = claims.get("groups", []),
            roles     = claims.get("roles", []),
            scopes    = claims.get("scp", "").split() if claims.get("scp") else [],
            tenant_id = claims.get("tid"),
            client_id = claims.get("azp"),
            session_id = session_id,
            thread_id  = thread_id,
            token_issued_at  = datetime.fromtimestamp(claims["iat"]) if "iat" in claims else None,
            token_expires_at = datetime.fromtimestamp(claims["exp"]) if "exp" in claims else None,
        )

    def to_propagatable_payload(self) -> dict:
        """
        Segunda camada de allowlist: só o que o agente precisa.
        Raw token NUNCA entra aqui.
        """
        return {
            "user_id":    self.user_id,
            "groups":     self.groups,
            "roles":      self.roles,
            "session_id": self.session_id,
            "thread_id":  self.thread_id,
        }


# ── AccessDenied ──────────────────────────────────────

class AccessDeniedReason(str, Enum):
    AGENT_RESTRICTED       = "AGENT_RESTRICTED"
    CLIENT_DATA_RESTRICTED = "CLIENT_DATA_RESTRICTED"
    TOOL_RESTRICTED        = "TOOL_RESTRICTED"


class AccessDeniedContext(BaseModel):
    reason:     AccessDeniedReason
    agent_id:   str
    tool_name:  Optional[str] = None
    client_id:  Optional[str] = None
    detail:     str  # mensagem técnica para log, nunca exposta ao usuário


# ── AgentState ────────────────────────────────────────
# Annotated[list, operator.add] → LangGraph concatena
# em vez de substituir (acumulação de mensagens)

class AgentState(TypedDict):
    user_context:        UserContext           # imutável após inject
    session_id:          str                    # duplicado para acesso rápido
    thread_id:           str
    messages:            Annotated[list[BaseMessage], operator.add]
    intent:              Optional[str]
    active_agent:        Optional[str]
    access_denied:       Optional[AccessDeniedContext]
    specialist_response: Optional[dict]

ACL Cache

Responsável por responder: "este usuário pode fazer X?" Implementa a estratégia DynamoDB → API → fail-closed em um único ponto de entrada (check_access). Todo o Supervisor passa por aqui para qualquer validação de acesso.

python — acl/cache.py
import time, logging, requests, boto3
from enum import Enum
from typing import Optional
from supervisor.graph.state import UserContext

logger = logging.getLogger(__name__)


class AccessScope(str, Enum):
    AGENT       = "ACCESS"
    CLIENT_DATA = "CLIENT_DATA"
    TOOL        = "TOOL"


class ACLCache:
    """
    Estratégia de resolução (em ordem):
    1. DynamoDB (cache local)
    2. MISS/expirado → API de parametrização → popula DynamoDB
    3. API falhou   → nega acesso (fail-closed)
    """

    def __init__(self, table_name: str, acl_api_url: str, ttl_seconds: int = 300):
        self.table       = boto3.resource("dynamodb").Table(table_name)
        self.acl_api_url = acl_api_url
        self.ttl_seconds = ttl_seconds

    def check_access(
        self,
        agent_id: str,
        scope: AccessScope,
        user_context: UserContext,
        tool_name: Optional[str] = None,
    ) -> bool:
        sk   = f"TOOL#{tool_name}" if scope == AccessScope.TOOL else scope.value
        pk   = f"AGENT#{agent_id}"
        rule = self._get_from_dynamo(pk, sk)

        if rule is None:
            logger.info("acl_cache_miss pk=%s sk=%s user=%s", pk, sk, user_context.user_id)
            rule = self._fetch_from_api_and_cache(pk, sk, agent_id, scope, tool_name)

        if rule is None:
            logger.error("acl_resolution_failed pk=%s sk=%s — denying", pk, sk)
            return False  # fail-closed

        allowed_groups = set(rule.get("allowed_groups", []))
        allowed_roles  = set(rule.get("allowed_roles", []))
        user_groups    = set(user_context.groups)
        user_roles     = set(user_context.roles)

        return bool(user_groups & allowed_groups or user_roles & allowed_roles)

    def _get_from_dynamo(self, pk: str, sk: str) -> Optional[dict]:
        try:
            resp = self.table.get_item(Key={"PK": pk, "SK": sk})
            item = resp.get("Item")
            if not item:
                return None
            # Verifica TTL manualmente (DynamoDB pode demorar para limpar)
            if item.get("ttl", 0) < time.time():
                return None
            return item
        except Exception as e:
            logger.error("dynamo_read_error pk=%s sk=%s error=%s", pk, sk, e)
            return None

    def _fetch_from_api_and_cache(
        self, pk, sk, agent_id, scope, tool_name
    ) -> Optional[dict]:
        try:
            params = {"agent_id": agent_id, "scope": scope.value}
            if tool_name:
                params["tool"] = tool_name

            resp = requests.get(
                f"{self.acl_api_url}/rules",
                params=params, timeout=3
            )
            resp.raise_for_status()
            rule = resp.json()

            self.table.put_item(Item={
                "PK": pk, "SK": sk,
                **rule,
                "ttl": int(time.time()) + self.ttl_seconds,
            })
            logger.info("acl_cache_populated pk=%s sk=%s", pk, sk)
            return rule
        except Exception as e:
            logger.error("acl_api_error agent=%s scope=%s error=%s", agent_id, scope, e)
            return None

Agent Token Provider

Gerencia o ciclo de vida dos tokens client_credentials que o Supervisor usa para se autenticar com os agentes. O cache é in-memory por scope — um processo ECS não precisa de mais que isso. O buffer de 60s antes da expiração evita usar um token que vai expirar durante a chamada HTTP.

python — auth/tokens.py
import time, logging, requests
from dataclasses import dataclass

logger = logging.getLogger(__name__)


@dataclass
class CachedToken:
    access_token: str
    expires_at:   float  # unix timestamp

    def is_valid(self, buffer_seconds: int = 60) -> bool:
        """Considera inválido 60s antes de expirar."""
        return time.time() < (self.expires_at - buffer_seconds)


class AgentTokenProvider:
    """
    Gerencia tokens client_credentials para o Supervisor.
    Cache in-memory por scope — renovação automática ao expirar.

    Este token representa o SUPERVISOR, não o usuário.
    O contexto do usuário viaja separadamente no payload.
    """

    def __init__(self, tenant_id: str, client_id: str, client_secret: str):
        self.token_url     = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
        self.client_id     = client_id
        self.client_secret = client_secret
        self._cache: dict[str, CachedToken] = {}

    def get_token(self, scope: str) -> str:
        cached = self._cache.get(scope)
        if cached and cached.is_valid():
            logger.debug("agent_token_cache_hit scope=%s", scope)
            return cached.access_token
        return self._fetch_and_cache(scope)

    def _fetch_and_cache(self, scope: str) -> str:
        logger.info("agent_token_refreshing scope=%s", scope)
        try:
            resp = requests.post(self.token_url, data={
                "grant_type":    "client_credentials",
                "client_id":     self.client_id,
                "client_secret": self.client_secret,
                "scope":         scope,
            }, timeout=5)
            resp.raise_for_status()
            data  = resp.json()
            token = CachedToken(
                access_token = data["access_token"],
                expires_at   = time.time() + data["expires_in"],
            )
            self._cache[scope] = token
            logger.info("agent_token_acquired scope=%s expires_in=%ds", scope, data["expires_in"])
            return token.access_token
        except Exception as e:
            logger.error("agent_token_fetch_error scope=%s error=%s", scope, e)
            raise

Nós do Grafo

Cada nó é uma função pura com responsabilidade única. Recebe o state completo e retorna apenas os campos que modificou. O LangGraph faz o merge. Nenhum nó pode reescrever o user_context.

python — graph/nodes.py
import logging, requests
from langchain_core.messages import AIMessage
from supervisor.graph.state import (
    AgentState, UserContext,
    AccessDeniedContext, AccessDeniedReason,
)
from supervisor.acl.cache import ACLCache, AccessScope
from supervisor.auth.tokens import AgentTokenProvider

logger = logging.getLogger(__name__)

# Dependências singleton — injetadas na inicialização
acl_cache:      ACLCache            = None
token_provider: AgentTokenProvider  = None
llm                                  = None
AGENT_ENDPOINTS: dict[str, str]      = {}
AGENT_SCOPES:    dict[str, str]      = {}


# ── Nó 1: parse_user_context ──────────────────────────
# Valida e loga o início do request. Não modifica o state.

def parse_user_context(state: AgentState) -> dict:
    u = state["user_context"]
    logger.info(
        "request_received user_id=%s session=%s thread=%s groups=%s roles=%s",
        u.user_id, u.session_id, u.thread_id, u.groups, u.roles,
    )
    return {}


# ── Nó 2: resolve_intent ──────────────────────────────
# LLM lê a mensagem e retorna o identificador do agente.
# Separado do check de acesso para logar "tentou acessar X".

def resolve_intent(state: AgentState) -> dict:
    u    = state["user_context"]
    msg  = state["messages"][-1].content
    resp = llm.invoke([
        {"role": "system", "content": """
Analise a mensagem e retorne APENAS o identificador do agente:
- financial: questões financeiras, relatórios, pagamentos
- hr: recursos humanos, folha, benefícios
- crm: dados de clientes, accounts, oportunidades
- general: sem agente específico
Retorne só o identificador."""},
        {"role": "user", "content": msg},
    ])
    intent = resp.content.strip().lower()
    logger.info("intent_resolved user=%s session=%s intent=%s", u.user_id, u.session_id, intent)
    return {"intent": intent}


# ── Nó 3: check_agent_access ──────────────────────────
# Primeiro portão de segurança. Popula access_denied ou active_agent.

def check_agent_access(state: AgentState) -> dict:
    u, intent = state["user_context"], state["intent"]

    if not intent or intent == "general":
        return {"active_agent": "general"}

    if not acl_cache.check_access(intent, AccessScope.AGENT, u):
        denied = AccessDeniedContext(
            reason   = AccessDeniedReason.AGENT_RESTRICTED,
            agent_id = intent,
            detail   = f"groups={u.groups} roles={u.roles}",
        )
        _log_access_denied(denied, u)
        return {"access_denied": denied}

    return {"active_agent": intent}


# ── Nó 4: invoke_specialist ───────────────────────────
# Back-to-back: obtém token client_credentials, chama o agente.
# UserContext viaja no payload, não no token.

def invoke_specialist(state: AgentState) -> dict:
    u, agent_id = state["user_context"], state["active_agent"]

    if agent_id == "general":
        return {"specialist_response": {"content": "Posso ajudar com informações gerais."}}

    try:
        token = token_provider.get_token(AGENT_SCOPES[agent_id])
        logger.info("invoking_specialist agent=%s user=%s session=%s thread=%s",
                    agent_id, u.user_id, u.session_id, u.thread_id)

        resp = requests.post(
            AGENT_ENDPOINTS[agent_id],
            headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
            json={
                "user_context": u.to_propagatable_payload(),
                "messages": [{"role": m.type, "content": m.content} for m in state["messages"]],
            },
            timeout=30,
        )
        resp.raise_for_status()
        result = resp.json()

        # Captura erros estruturados de ACL vindos do agente
        if result.get("error_code") in {"CLIENT_DATA_RESTRICTED", "TOOL_RESTRICTED"}:
            denied = AccessDeniedContext(
                reason    = AccessDeniedReason(result["error_code"]),
                agent_id  = agent_id,
                tool_name = result.get("tool_name"),
                client_id = result.get("client_id"),
                detail    = result.get("detail", ""),
            )
            _log_access_denied(denied, u)
            return {"access_denied": denied, "specialist_response": result}

        logger.info("specialist_ok agent=%s user=%s session=%s", agent_id, u.user_id, u.session_id)
        return {"specialist_response": result}

    except requests.RequestException as e:
        logger.error("specialist_error agent=%s user=%s error=%s", agent_id, u.user_id, e)
        return {"specialist_response": {"content": "Não foi possível processar sua solicitação."}}


# ── Nó 5: process_response ────────────────────────────
# Nó terminal. Normaliza qualquer resultado em AIMessage.
# Detalhes técnicos ficam nos logs — nunca expostos ao usuário.

def process_response(state: AgentState) -> dict:
    denied = state.get("access_denied")
    if denied:
        content = _build_denied_message(denied)
    else:
        content = state.get("specialist_response", {}).get("content", "Sem resposta disponível.")
    return {"messages": [AIMessage(content=content)]}


# ── Helpers ───────────────────────────────────────────

def _log_access_denied(denied: AccessDeniedContext, u: UserContext):
    logger.warning(
        "access_denied reason=%s agent=%s tool=%s client=%s user=%s session=%s thread=%s detail=%s",
        denied.reason.value, denied.agent_id, denied.tool_name,
        denied.client_id, u.user_id, u.session_id, u.thread_id, denied.detail,
    )

def _build_denied_message(denied: AccessDeniedContext) -> str:
    return {
        AccessDeniedReason.AGENT_RESTRICTED: (
            "Você não possui acesso ao módulo solicitado. "
            "Entre em contato com o administrador para solicitar permissão."
        ),
        AccessDeniedReason.CLIENT_DATA_RESTRICTED: (
            "Você não possui acesso aos dados deste cliente. "
            "Apenas o account responsável pode consultar essas informações."
        ),
        AccessDeniedReason.TOOL_RESTRICTED: (
            "Você não possui permissão para executar esta operação. "
            "Sua conta permite apenas consultas neste módulo."
        ),
    }.get(denied.reason, "Acesso negado.")

Edges & Builder

As edges condicionais são onde o LangGraph decide qual nó executar a seguir baseado no estado atual. O builder é onde tudo se conecta e o grafo é compilado — uma única vez na inicialização do processo, reutilizado para todos os requests.

python — graph/edges.py
from supervisor.graph.state import AgentState


def after_access_check(state: AgentState) -> str:
    """
    Após check_agent_access:
    - access_denied preenchido → encerra em process_response
    - aprovado                 → vai para invoke_specialist
    """
    if state.get("access_denied"):
        return "process_response"
    return "invoke_specialist"


def after_invoke(state: AgentState) -> str:
    # Sempre normaliza — erros do agente já foram capturados no nó anterior
    return "process_response"
python — graph/builder.py
from langgraph.graph import StateGraph, START, END
from supervisor.graph.state import AgentState
from supervisor.graph.nodes import (
    parse_user_context, resolve_intent,
    check_agent_access, invoke_specialist, process_response,
)
from supervisor.graph.edges import after_access_check


def build_supervisor_graph():
    builder = StateGraph(AgentState)

    # Registra nós
    builder.add_node("parse_user_context", parse_user_context)
    builder.add_node("resolve_intent",     resolve_intent)
    builder.add_node("check_agent_access", check_agent_access)
    builder.add_node("invoke_specialist",  invoke_specialist)
    builder.add_node("process_response",   process_response)

    # Fluxo principal
    builder.add_edge(START,                "parse_user_context")
    builder.add_edge("parse_user_context", "resolve_intent")
    builder.add_edge("resolve_intent",     "check_agent_access")

    # Edge condicional: acesso negado ou aprovado
    builder.add_conditional_edges(
        "check_agent_access",
        after_access_check,
        {
            "invoke_specialist": "invoke_specialist",
            "process_response":  "process_response",
        },
    )

    builder.add_edge("invoke_specialist", "process_response")
    builder.add_edge("process_response",  END)

    return builder.compile()


# Singleton — compilado uma vez, reutilizado em todos os requests
supervisor_graph = build_supervisor_graph()

Entrypoint

O ponto de entrada onde o JWT chega, o UserContext é montado e o grafo é invocado. Toda a segurança começa aqui. O JWT Authorizer do API Gateway já validou assinatura, issuer e audience antes disso — aqui apenas decodificamos as claims sem reverificação de assinatura.

python — main.py
import json, logging
import jwt  # PyJWT
from langchain_core.messages import HumanMessage
from supervisor.graph.builder import supervisor_graph
from supervisor.graph.state import UserContext

logger = logging.getLogger(__name__)


def handler(event: dict, context) -> dict:
    """
    AWS Lambda / ECS handler para requests do API Gateway.
    JWT já validado pelo JWT Authorizer — apenas decodificamos claims.
    """
    try:
        # Extrai claims do JWT (assinatura já validada pelo API GW)
        raw_token = event["headers"].get("Authorization", "").removeprefix("Bearer ")
        claims    = jwt.decode(raw_token, options={"verify_signature": False})

        body       = json.loads(event.get("body", "{}"))
        session_id = body.get("session_id") or event["headers"].get("X-Session-Id")
        thread_id  = body.get("thread_id")  or event["headers"].get("X-Thread-Id")

        if not session_id or not thread_id:
            return _error(400, "session_id e thread_id são obrigatórios")

        # Monta UserContext via allowlist — ponto único de extração do JWT
        user_context = UserContext.from_jwt_claims(claims, session_id, thread_id)

        # Invoca o grafo com state inicial completo
        result = supervisor_graph.invoke({
            "user_context":        user_context,
            "session_id":          session_id,
            "thread_id":           thread_id,
            "messages":            [HumanMessage(content=body["message"])],
            "intent":              None,
            "active_agent":        None,
            "access_denied":       None,
            "specialist_response": None,
        })

        final = result["messages"][-1].content
        return {
            "statusCode": 200,
            "body": json.dumps({
                "message":    final,
                "session_id": session_id,
                "thread_id":  thread_id,
            }),
        }

    except KeyError as e:
        logger.error("missing_field error=%s", e)
        return _error(400, f"Campo obrigatório ausente: {e}")
    except Exception as e:
        logger.error("unhandled_error error=%s", e, exc_info=True)
        return _error(500, "Erro interno")


def _error(status: int, msg: str) -> dict:
    return {"statusCode": status, "body": json.dumps({"error": msg})}

Erros Estruturados

Cada cenário de acesso negado tem um código distinto para rastreabilidade completa. As mensagens exibidas ao usuário são amigáveis e não expõem detalhes internos — esses ficam nos logs com o contexto completo.

AGENT_RESTRICTED
Validado por: Supervisor (check_agent_access) O usuário não tem grupo ou role que permita invocar o agente especialista alvo. O grafo encerra sem chamar o agente.
CLIENT_DATA_RESTRICTED
Validado por: Agente especialista (dentro da tool) O usuário não é account do cliente consultado. A tool chama a API externa com user_id + client_id e recebe negação.
TOOL_RESTRICTED
Validado por: Agente especialista (antes de executar a tool) O usuário tem acesso ao agente mas não a esta operação específica. Ex: pode fazer GET mas não POST em dados de clientes.

Diagrama de Sequência

Client          API Gateway     Supervisor       DynamoDB      ACL API      Agente
  │                   │                 │                  │               │               │
  │── POST /chat ────►│                 │                  │               │               │
  │   Bearer {JWT}    │                 │                  │               │               │
  │                   │─ JWT Authorizer │                  │               │               │
  │                   │  (valida assin, │                  │               │               │
  │                   │   issuer, aud)  │                  │               │               │
  │                   │── request ─────►│                  │               │               │
  │                   │                 │                  │               │               │
  │                   │            parse_user_context         │               │               │
  │                   │            from_jwt_claims (allowlist)             │               │
  │                   │            injeta no AgentState                    │               │
  │                   │                 │                  │               │               │
  │                   │            resolve_intent             │               │               │
  │                   │            LLM → intent="financial"                │               │               │
  │                   │                 │                  │               │               │
  │                   │            check_agent_access         │               │               │
  │                   │                 │── get_item ─────►│               │               │
  │                   │                 │                  │               │               │
  │                   │                 │◄── HIT (TTL ok) ─│               │               │
  │                   │                 │  (ou MISS →)     │──── GET ─────►│               │
  │                   │                 │                  │◄─ regra ──────│               │
  │                   │                 │                  │ put_item      │               │
  │                   │                 │                  │               │               │
  │                   │            intersecção grupos/roles do usuário                     │
  │                   │                 │                  │               │               │
  │                   │            aprovado ─────────────────────────────────────────────►│
  │                   │            invoke_specialist                                       │
  │                   │            client_credentials token                               │
  │                   │            + UserContext no payload                                │
  │                   │                 │                  │               │               │
  │                   │                 │                  │               │  tool executa │
  │                   │                 │                  │               │  valida CLIENT│
  │                   │                 │                  │               │  via API ext. │
  │                   │                 │                  │               │               │
  │                   │            process_response                                        │
  │                   │◄── 200 ─────────│                  │               │               │
  │◄── response ──────│                 │                  │               │               │
  │                   │                 │                  │               │               │
  │                   │       SE access_denied em qualquer nível:               │               │
  │                   │       log warning + mensagem amigável ao usuário                  │
  │◄── 200 ───────────│                 │                  │               │               │

Pontos em Aberto

Checkpointer LangGraph: o thread_id no AgentState deve ser o mesmo usado no RunnableConfig do LangGraph para o checkpointer manter histórico de sessão. A integração do checkpointer com DynamoDB ainda não foi detalhada neste documento.
ACL Client compartilhado: a lógica de ACLCache.check_access será necessária também dentro dos agentes especialistas (para validar TOOL e CLIENT_DATA). Faz sentido extrair um pacote acl-client compartilhado entre Supervisor e agentes.
UserContext no payload MCP: quando os agentes forem formalizados como MCP tools, o user_context entra como metadata do tool call ou como campo do input schema do agente. Esse contrato precisa ser definido antes da implementação dos agentes.
DynamoDB Streams — pronto para ativar: a arquitetura do cache já contempla invalidação via Streams quando uma regra mudar no sistema de parametrização. Sem custo adicional de design — basta ativar o Stream e criar a Lambda de invalidação quando necessário.