Fluxo 5 - Sincronização de Perfil: Circle → Keycloak
Sincronizar automaticamente alterações de perfil do usuário feitas na comunidade Circle para o Keycloak, mantendo os dados de identidade consistentes entre os dois sistemas. Implementa debounce para evitar race conditions e merge de atributos para não sobrescrever dados existentes.
Objetivo e Contexto
Por que Sincronizar Perfil?
Usuários podem atualizar seus dados em dois lugares: - Circle: Mudar nome, foto, bio, campos customizados - Keycloak: Atributos de identidade, permissões
Sem sincronização → dados divergem:
Cenário problemático:
├─ Usuário muda nome no Circle
├─ Keycloak continua com nome antigo
├─ Automações AC usam nome desatualizado
└─ Emails personalizados ficam com nome errado
Este fluxo garante que todas as mudanças no Circle propagam para Keycloak em tempo real.
Desafios Técnicos Resolvidos
- Race Condition: Múltiplos webhooks do Circle chegam juntos → debounce aguarda 5 segundos
- Atualização Parcial: Keycloak não aceita PATCH → merge com dados existentes
- Divergência de Dados: Não sobrescrever atributos que Circle não conhece
Macro Arquitetura
| Componente | Responsabilidade | Ferramenta |
|---|---|---|
| Webhook | Receber eventos de alteração | Circle (POST → N8N) |
| Normalização | Extrair dados do evento | N8N (Set) |
| Debounce | Evitar processamento simultâneo | Postgres + Wait |
| Busca Circle | Obter estado completo do membro | Circle API |
| Transformação | Converter formato Circle → Keycloak | N8N (JavaScript) |
| Busca Keycloak | Localizar usuário no Keycloak | Keycloak Postgres |
| Merge | Combinar dados sem perder informações | N8N (JavaScript) |
| Atualização | Sincronizar para Keycloak | Keycloak API |
Fluxo Técnico Detalhado
1. Webhook: Receber Eventos Circle
URL de entrada:
POST https://n8n.bonde.org/webhook/{{ webhookId }}
Evento típico (profile_field_updated):
{
"type": "community_member_profile_field_updated",
"data": {
"community_id": 465308,
"community_member_id": 77250,
"profile_field_id": 5222975,
"community_member_profile_field_id": 130882535,
"profile_field_key": "bio",
"profile_field_value": "Desenvolvedor e formador"
}
}
Tipos de eventos suportados:
- community_member_profile_field_updated → Campo de perfil alterado
Estrutura de dados:
| Campo | Tipo | Descrição |
|----|----|----|
| community_id | number | ID da comunidade Circle |
| community_member_id | number | ID único do membro |
| profile_field_id | number | ID do campo no Circle |
| community_member_profile_field_id | number | ID da instância do campo para este membro |
| profile_field_key | string | Nome técnico do campo (ex: bio) |
| profile_field_value | string | Novo valor do campo |
2. Normalização: Extrair Dados Essenciais
O que faz: Padroniza o payload do Circle para formato interno usado pelo debounce
Dados extraídos:
{
"user_id": 77250, // De: data.community_member_id
"event_type": "community_member_profile_field_updated"
}
Por quê normalizar? Porque: - O webhook traz muitos dados que não precisamos agora - O debounce só precisa saber "qual usuário foi alterado" - Reduz tamanho do payload armazenado no controle de debounce - Facilita identificar eventos duplicados do mesmo usuário
3. Debounce: Upsert em Tabela de Controle
Problema: Usuário muda nome + foto + bio = 3 webhooks em <1 segundo
Solução: Postgres UPSERT com timestamp
Tabela:
CREATE TABLE sync_keycloak_circle_control (
user_id BIGINT PRIMARY KEY,
updated_at TIMESTAMP DEFAULT NOW()
);
Query executada:
INSERT INTO sync_keycloak_circle_control (user_id, updated_at)
VALUES ($1, NOW())
ON CONFLICT (user_id)
DO UPDATE SET updated_at = NOW();
Resultado: Sempre há um único registro por usuário com o timestamp do último webhook
4. Wait: Aguardar Debounce
Duração: 5 segundos
Propósito: Permitir que múltiplos webhooks cheguem antes de processar
Comportamento:
t=0s: Webhook 1 chega → Atualiza timestamp
t=0.5s: Webhook 2 chega → Atualiza timestamp
t=1s: Webhook 3 chega → Atualiza timestamp
t=6s: Wait termina → Processa (pega são timestamp mais recente)
5. Verificar se É o Evento Mais Recente
Query:
SELECT *
FROM sync_keycloak_circle_control
WHERE user_id = $1
AND updated_at <= NOW() - INTERVAL '5 seconds';
Lógica: - Se registro foi atualizado há mais de 5 segundos → É o evento mais recente ✅ - Se registro foi atualizado há menos de 5 segundos → Outro webhook chegou depois ❌
Resultado: Apenas 1 de 3 webhooks será processado (o último)
6. Condicional: Processar Ou Ignorar?
IF evento é o mais recente THEN
→ Continuar com sincronização
ELSE
→ Ignorar (outro webhook virá depois)
7. Buscar Dados Completos do Membro Circle
Por quê? O webhook traz apenas UM campo atualizado, mas precisamos do estado completo do perfil.
Endpoint:
GET https://comunidade.bonde.org/api/admin/v2/community_members/{{ member_id }}
Headers:
Authorization: Bearer {{ circle_admin_token }}
Resposta com todos os campos:
{
"id": 77250,
"email": "usuario@example.com",
"name": "João Silva",
"profile_image_url": "https://...",
"username": "joao-silva",
"flattened_profile_fields": {
"bio": "Desenvolvedor e formador",
"empresa": "BONDE Brasil",
"phone": "11999999999",
"website": "https://example.com",
"linkedin": "joaosilva",
"github": "joaosilva"
}
}
Por quê buscar de novo? Porque:
- Webhook traz apenas { profile_field_key: "bio", profile_field_value: "..." }
- Sem buscar, só sincronizaríamos 1 campo e perderia os outros
- Debounce pode agregar múltiplos webhooks de campos diferentes
- Estado completo garante sincronização correta de todo o perfil
8. Transformação: Converter Formato Circle → Keycloak
Contexto: Após buscar dados completos do membro no Circle, recebemos flattened_profile_fields que contém os campos de perfil do usuário.
Problema: Keycloak representa atributos como arrays de strings, Circle como objeto plano
Circle (retorno da API):
{
"flattened_profile_fields": {
"bio": "Desenvolvedor e formador",
"empresa": "BONDE",
"phone": "11999999999"
}
}
Keycloak formato esperado:
{
"attributes": {
"bio": ["Desenvolvedor e formador"],
"empresa": ["BONDE"],
"phone": ["11999999999"],
"circle_community_member_id": ["77250"]
}
}
Transformação (JavaScript):
const attributes = {};
const fields = flattened_profile_fields || {};
// Converte cada campo para array de string (formato Keycloak)
for (const key in fields) {
const value = fields[key];
if (value !== null && value !== undefined) {
attributes[key] = [String(value)];
}
}
// Adiciona sempre o circle_community_member_id (para identificar membro no Circle)
attributes["circle_community_member_id"] = [String(circle_member_id)];
return {
attributes: attributes,
email: email
};
Nota importante: O webhook individual de campo (community_member_profile_field_updated) traz apenas 1 campo, mas ao buscar o membro completo no GET, obtemos todos os flattened_profile_fields no estado atual. Isso garante sincronização mesmo que múltiplos campos sejam alterados rapidamente.
9. Buscar Usuário no Keycloak
Query Postgres (Keycloak DB):
SELECT u.id
FROM user_entity u
JOIN user_attribute a ON u.id = a.user_id
WHERE a.name = 'circle_community_member_id'
AND a.value = $1
LIMIT 1;
Lógica: Usa circle_community_member_id como chave estrangeira para encontrar o usuário
Resultado:
{
"id": "abf27447-cb28-4c67-8094-e1c8beb56dbb"
}
10. Validação: Usuário Existe?
Condicional:
IF user_id EXISTS THEN
→ Continuar com atualização
ELSE
→ Parar (usuário não foi criado no Keycloak ainda)
Cenário de erro: Usuário criado no Circle sem passar pelo Fluxo 2 (assim não tem usuário Keycloak).
11. Obter Token de Admin Keycloak
Endpoint:
POST https://auth.bonde.org/realms/bonde/protocol/openid-connect/token
Credenciais (Client Credentials):
{
"grant_type": "client_credentials",
"client_id": "n8n",
"client_secret": "GZIT95PlJdCe61iV1yivmvMmhqfCtHV9"
}
12. Buscar Atributos Atuais do Usuário Keycloak
Endpoint:
GET https://auth.bonde.org/admin/realms/bonde/users/{{ user_id }}
Headers:
Authorization: Bearer {{ access_token }}
Resposta:
{
"id": "abf27447-cb28-4c67-8094-e1c8beb56dbb",
"email": "usuario@example.com",
"firstName": "João",
"lastName": "Silva",
"enabled": true,
"attributes": {
"circle_community_member_id": ["12345"],
"empresa": ["Empresa Antiga"],
"interno_campo": ["Não sobrescrever isso"]
}
}
Por quê buscar? Porque Keycloak não aceita PATCH → precisamos enviar objeto completo. Se não buscarmos antes, perdemos dados existentes.
13. Merge de Atributos
Problema: Circle envia apenas seus campos. Keycloak pode ter outros campos não gerenciados pelo Circle.
Solução: Merge preservador
JavaScript:
const user = keycloak_user;
const currentAttributes = user.attributes || {};
const incomingAttributes = transform_attributes || {};
// Merge: novos sobrescrevem antigos, mas antigos que não estão em novos são preservados
const mergedAttributes = {
...currentAttributes, // Começa com tudo que já existe
...incomingAttributes // Sobrescreve apenas com novos valores
};
user.attributes = mergedAttributes;
return user;
Exemplo:
Antes (Keycloak):
{
"circle_community_member_id": ["12345"],
"empresa": ["BONDE"],
"departamento": ["Desenvolvimento"],
"interno_id": ["ABC123"]
}
Chegando (Circle):
{
"circle_community_member_id": ["12345"],
"empresa": ["BONDE Novembro"],
"bio": ["Dev Senior"]
}
Resultado (Merge):
{
"circle_community_member_id": ["12345"],
"empresa": ["BONDE Novembro"], // Sobrescrito
"departamento": ["Desenvolvimento"], // Preservado
"interno_id": ["ABC123"], // Preservado
"bio": ["Dev Senior"] // Novo
}
14. Atualizar Usuário Keycloak
Endpoint:
PUT https://auth.bonde.org/admin/realms/bonde/users/{{ user_id }}
Headers:
Authorization: Bearer {{ access_token }}
Content-Type: application/json
Body: Objeto completo do usuário com attributes mesclados
Resposta:
Status: 204 No Content
Resultado Esperado
Estado Antes do Fluxo
- ✓ Usuário atualiza campo de perfil no Circle (ex: bio, empresa)
- Circle envia webhook
community_member_profile_field_updated - ✗ Keycloak ainda tem valor antigo do campo
Estado Depois do Fluxo
- ✅ Keycloak tem dados sincronizados com Circle
- ✅ Perfil completo preservado (todos os campos)
- ✅ Nenhum dado foi perdido
- ✅ Pronto para automações AC usarem dados atualizados
- ✅ Independente de quantos webhooks chegaram, processamento foi feito apenas 1 vez
Exemplo Prático
Usuário executa: 1. Clica em perfil no Circle 2. Muda campo "bio" de "Dev Junior" para "Dev Senior" 3. Muda campo "empresa" de "Anterior" para "BONDE Brasil" 4. Clica "Salvar"
Circle envia 2 webhooks:
t=0s: Webhook 1 → bio mudou
t=0.05s: Webhook 2 → empresa mudou
N8N processa (com debounce):
t=0s: Webhook 1 (bio) → Normaliza → Upsert controle timestamp=0
t=0.05s: Webhook 2 (empresa) → Normaliza → Upsert controle timestamp=0.05
t=6s: Wait termina
→ Verifica: timestamp 0.05 < NOW() - 5s? SIM ✓
→ Processa apenas 1 vez!
→ GET /circle/members/77250
Retorna: { flattened_profile_fields: { bio: "Dev Senior", empresa: "BONDE Brasil", ... } }
→ Transforma atributos
→ Busca usuário Keycloak
→ Busca atributos atuais
→ Merge
→ PUT para atualizar (1 requisição apenas!)
t=6.5s: ActiveCampaign automações usam dados atualizados
Economia: - Sem debounce: 2 buscas Circle + 2 updates Keycloak = 4 requisições - Com debounce: 1 busca Circle + 1 update Keycloak = 2 requisições (50% de redução)
Debounce: Explicação Detalhada
Por que Debounce é Necessário?
Cenário: Usuário atualiza múltiplos campos rapidamente
Sem debounce:
t=0s: Webhook 1 (bio atualizado) chega
→ Normaliza → Busca Circle (estado: bio=antigo)
→ Transforma → Busca Keycloak
→ Update Keycloak
→ Salva timestamp
t=0.1s: Webhook 2 (empresa atualizado) chega
→ Normaliza → Busca Circle (estado: empresa=antigo)
→ Transforma → Busca Keycloak
→ Update Keycloak
→ Salva timestamp (substitui anterior)
t=0.2s: Webhook 3 (phone atualizado) chega
→ Normaliza → Busca Circle (estado: phone=novo)
→ Transforma → Busca Keycloak
→ Update Keycloak
→ Salva timestamp
Resultado: 3 requisições pro Circle, 3 pro Keycloak
Possível desincronização se um falhar
Com debounce:
t=0s: Webhook 1 chega → Upsert timestamp = 0s
t=0.1s: Webhook 2 chega → Upsert timestamp = 0.1s
t=0.2s: Webhook 3 chega → Upsert timestamp = 0.2s
t=6s: Wait termina (5 segundos depois do último webhook)
→ Verifica: timestamp 0.2s < NOW() - 5s? SIM ✓
→ Processa UMA ÚNICA VEZ
→ Busca Circle uma vez (retorna: bio=novo, empresa=novo, phone=novo)
→ Busca Keycloak uma vez
→ Merge uma vez
→ Update Keycloak uma vez
Resultado: 1 requisição pro Circle, 1 pro Keycloak = 50% mais eficiente
Garante estado completo sincronizado
Transformação de Campos
Estrutura de Webhook vs Estado Completo
O webhook community_member_profile_field_updated traz apenas um campo alterado, mas esse não é o estado completo:
Webhook (campo único):
{
"profile_field_key": "bio",
"profile_field_value": "Desenvolvedor e formador"
}
GET /community_members/:id (estado completo):
{
"flattened_profile_fields": {
"bio": "Desenvolvedor e formador",
"empresa": "BONDE",
"phone": "11999999999",
"website": "https://example.com",
"linkedin": "joaosilva"
}
}
Por quê buscar estado completo? - Webhook traz apenas 1 campo - Se ignorarmos os outros, perdemos dados ao fazer merge - Debounce pode ter agregado múltiplos webhooks em 5 segundos - Estado completo garante sincronização correta
Mapeamento de Campos
Campos do Circle em flattened_profile_fields são mapeados diretamente para atributos Keycloak:
| Campo Circle | Tipo | Atributo Keycloak | Exemplo |
|---|---|---|---|
bio |
string | attributes.bio[0] |
"Desenvolvedor senior" |
empresa |
string | attributes.empresa[0] |
"BONDE" |
phone |
string | attributes.phone[0] |
"11999999999" |
website |
string | attributes.website[0] |
"https://..." |
| Qualquer outro | string | Mesmo nome em atributos | Genérico |
Importante: Nenhum campo é renomeado, transformado ou validado. O valor é copiado como string (envolvido em array).
Possíveis Cenários de Erro
| Erro | Causa | Resolução |
|---|---|---|
| Usuário não existe Keycloak | Não passou por Fluxo 2 | N8N para (sticky note TODO) |
| Webhook malformado | Circle enviou JSON inválido | Webhook rejeita |
| Debounce não funciona | Query SQL falha | Verifica DB Postgres |
| Merge sobrescreve dados | Bug em lógica merge | Verificar JS code node |
| Falha ao atualizar Keycloak | Token expirado | Retry automático |
| Circle member não encontrado | ID inválido | Webhook original foi malformado |
Conceitos-Chave
Debounce Pattern
Técnica para processar múltiplos eventos rapidamente = como 1 evento:
E E E E E (5 eventos em < 100ms)
↓ ↓ ↓ (todos salvam timestamp)
WAIT (aguarda intervalo)
✓ (processa 1 vez com estado mais recente)
Casos de uso: - Sincronizações (este fluxo) - Processamento de bulk - Debounce de clicks em UI
Merge Anti-perda
Estratégia para não perder dados ao atualizar:
Cenário perigoso:
Keycloak tem: {A: 1, B: 2, C: 3}
Circle envia: {A: 10}
❌ Sobrescrever direto: {A: 10} ← Perdemos B e C!
Estratégia segura (merge):
Keycloak tem: {A: 1, B: 2, C: 3}
Circle envia: {A: 10}
✅ Merge: {A: 10, B: 2, C: 3} ← Preserva B e C
UPSERT: Update Or Insert
SQL pattern para "atualizar se existe, inserir se não":
INSERT INTO tabela (id, timestamp)
VALUES ($1, NOW())
ON CONFLICT (id)
DO UPDATE SET timestamp = NOW();
Evita: - Checar se existe (SELECT) - Branching logic - Race conditions
Keycloak não aceita PATCH
Limitação importante:
❌ PATCH /users/id
{ "attributes": { "bio": "novo" } }
→ Erro: PATCH não suportado
✅ PUT /users/id
{ "id": "...", "email": "...", "attributes": { "bio": "novo", ... } }
→ Sucesso: PUT substituir tudo
Por isso precisa buscar usuário antes de atualizar.
Fluxos Relacionados
- ✅ Fluxo 2 (Gerenciamento de Acesso): Cria usuário Keycloak que este fluxo sincroniza
- ✅ Fluxo 3 (Ativação): Cria mapping circle_community_member_id usado aqui
- ✅ Fluxo 1 (Enriquecimento AC): AC pode usar atributos sincronizados
Performance e Limitações
Taxa de Sincronização
- Pico: Múltiplos webhooks em paralelo → Debounce agrupa
- Normal: 1 atualização a cada 6 segundos (5s debounce + 1s processamento)
- Máximo: ~10 updates por minuto por usuário
Limites do Circle API
Rate limit: 100 requests/segundo
Timeout: 5 segundos
Retry: Não automático (N8N deve implementar)
Limites do Keycloak API
Rate limit: Ilimitado (local)
Timeout: 5 segundos
Tamanho máximo atributo: 255 caracteres
Máximo atributos por usuário: ~500
Checklist de Implementação
- [ ] Webhook URL configurada no Circle
- [ ] Tabela
sync_keycloak_circle_controlcriada no Postgres - [ ] Circle Admin Token configurado
- [ ] Keycloak Postgres DB acessível
- [ ] N8N client credentials no Keycloak funcionando
- [ ] Teste: Alterar perfil no Circle → dados sincronizados em Keycloak
- [ ] Teste: Múltiplas alterações rápidas → apenas 1 sincronização
- [ ] Monitoramento de erros ativado
- [ ] Alertas configurados para falhas de sincronização
Extensões Futuras
Sincronização Reversa (Keycloak → Circle)
Atualmente é apenas Circle → Keycloak. Para fazer bidirecional:
1. Criar webhook Keycloak para user.updated
2. Seguir padrão similar com debounce
3. Transformar atributos Keycloak → Circle API
4. Enviar PUT /community_members/id ao Circle
⚠️ Cuidado com loops infinitos: A → B → A → B → ...
Solução: Flag de origem no atributo
Manipulação de Campos Ausentes
Sticky note menciona: "Talvez aqui seja interessante criar um fluxo para atualizar o campo caso o valor não exista"
Implementar opcional: - Se campo existe em Keycloak mas não no Circle → deletar - Se campo é NULL → limpar - Se campo muda tipo → validar antes de atualizar