Autenticação¶
O serviço autentica duas classes de chamadores, em camadas, no
get_context():
flowchart TB
req[Request com Authorization: Bearer] --> jwt{JWT de usuário?<br/>AUTH_JWKS_URL}
jwt -->|válido| user[ctx.user = User sub=...]
jwt -->|falha| sa{Service account?<br/>SERVICE_ACCOUNT_AUDIENCE}
sa -->|OIDC válido| svc[ctx.service_account = ...]
sa -->|falha| anon[ctx sem identidade<br/>só queries públicas]
1. Usuário final — JWT do Keycloak¶
O portal envia Authorization: Bearer <access_token> (JWT do Keycloak, realm
destaquesgovbr). auth/jwt.py valida contra o JWKS do realm:
AUTH_JWKS_URL→…/realms/destaquesgovbr/protocol/openid-connect/certs(chaves buscadas e cacheadas).- Valida assinatura,
expesubsempre;issquandoAUTH_ISSUERé dado. - O id do usuário é o
subdo token:User(id=payload["sub"], …).
require_claims = ["exp", "sub"]
# ...
return User(id=payload["sub"], email=..., roles=...)
Falha de auth é silenciosa por design
Token ausente/expirado/inválido deixa ctx.user = None — queries públicas
seguem funcionando. Só campos guardados por IsAuthenticated é que falham
com erro de autenticação. Isso mantém o schema público (notícias, temas,
busca, widgets) acessível sem login.
Permissões declarativas¶
Campos autenticados usam permission_classes=[IsAuthenticated]. A autoria é
resolvida por contexto: Clipping.isAuthor compara authorUserId com
ctx.user.id, e resolvers de mutação (editar/excluir clipping) exigem que o
usuário seja o autor.
Resolvers por nível de auth¶
Com o portal consumindo o graphql-api como fonte única, a superfície cresceu. Resumo dos níveis (a referência canônica é o SDL):
| Nível | Resolvers (entre os novos) |
|---|---|
| Público (sem header) | articles, search, themes, agencies, popularTags, relatedArticles, themeArticleCounts, estimateRecorteCount |
| Misto (público se fonte ativa, senão autor/subscriber) | release(id), releaseArticles(id), MarketplaceListing.releases, Clipping.releases |
Usuário (IsAuthenticated) |
clippings, myFollowedListings, currentUserHasTelegramLinked, mutations de clipping/marketplace/push |
Interno (IsInternal, service account) |
similarArticles, newsForTypesense, upsertFeatures, … |
3. Autorização mista (releases)¶
release(id) e releaseArticles(id) não são nem puramente públicos nem
puramente autenticados — a regra espelha MarketplaceListing.releases:
- Público se o listing-fonte do clipping está ativo (um listing ativo já é conteúdo público, então suas entregas também são).
- Caso contrário, só o autor ou um subscriber do clipping podem ver.
- Listing inativo/despublicado nunca expõe releases.
release(id)retornaNoneereleaseArticles(id)retorna[]quando o caller não está autorizado (ou o release não existe) — sem distinguir os dois casos, para não vazar existência.
Só para o caller autorizado, release(id) popula recortes (filtros do clipping
fonte) e marketplaceListingId (id do listing ativo, ou null).
O token no browser (decisão da R1)¶
Para o urql client-side enviar o Bearer, o portal expõe o accessToken na
sessão NextAuth (lido via /api/auth/session). Trade-off aceito: o token fica
legível por JS (um XSS poderia exfiltrá-lo), mitigado por TTL curto (~5 min) +
refresh + CSP. Mitigação futura: proxy GraphQL server-side para manter o Bearer
fora do browser.
CSP do portal precisa liberar este serviço
O browser bloqueia o fetch ao graphql-api se a origin dele não estiver no
connect-src do Content-Security-Policy do portal. CORS aqui não resolve
isso — é política do lado do cliente. Foi a causa-raiz nº 1 da R1: tudo
funcionava via curl (sem CSP) e quebrava no browser. Ver
Subscriptions & SSE e o catálogo de drift.
2. Service account — OIDC do Google¶
Workers de dados (feature-worker, typesense-sync-worker, bronze-writer) chamam as
queries/mutations internas (newsForTypesense, upsertFeatures, …) com um
ID token OIDC do Google no Authorization: Bearer. Quando o JWT de usuário
falha e SERVICE_ACCOUNT_AUDIENCE está configurado, o contexto tenta validar o
token como OIDC e popula ctx.service_account. Os resolvers internos exigem essa
identidade (is_internal).
OIDC de saída (passthrough do agente)¶
Há também OIDC de saída: a subscription generateRecortes faz passthrough do
SSE para o clipping worker, que é protegido por IAM. O serviço obtém um ID
token com audience = URL do worker via lib/oidc.py:
- Em Cloud Run: metadata server da instância.
- Fora do Cloud Run (dev local): fallback via ADC
(
google.oauth2.id_token.fetch_id_token), que exigeroles/run.invokerda identidade ADC no worker. - Audience local (
localhost) → retornaNone(sem header).
Detalhes em Subscriptions & SSE.