Surfaces¶
O Spryx Backend expõe duas surfaces HTTP ativas, uma terceira planejada, e um processo interno assíncrono. Este documento explica por que cada surface existe, como autentica requests, e como as regras de autorização funcionam em cada camada.
Visão geral¶
Surfaces HTTP¶
| Surface | Quem acessa | Identidade | Isolamento |
|---|---|---|---|
| App API | Tenants (usuários do app) | user token com tenant_id |
Tudo escopado ao tenant do token |
| Backoffice API | Staff interno | staff token sem tenant_id |
Sem isolamento — staff vê todos os tenants |
| Client API ⏳ | Desenvolvedores externos | API Key | Por tenant via header |
Processo interno¶
O Worker não é uma surface HTTP — não expõe porta, não aceita tokens. Recebe tasks via Redis (Celery) e as processa chamando Use Cases diretamente. O isolamento de tenant vem dos dados das próprias tasks, não de um mecanismo de auth externo.
Por que surfaces separadas¶
Uma surface não é apenas uma porta diferente — ela é um contrato de segurança distinto. Misturar as regras de auth em uma única surface seria um risco: uma rota de backoffice exposta na App API poderia vazar operações cross-tenant para usuários comuns.
A separação de surfaces força que o compilador (via tipagem de dependências) valide a autenticação correta em cada ponto. Cada surface tem também container, settings e processo próprios — o AppContainer nunca é instanciado pelo processo do backoffice, e vice-versa. Isso torna o raio de explosão de uma configuração errada limitado à surface onde o erro ocorreu.
Modelo de token¶
Ambas as surfaces HTTP ativas usam JWT RS256 com o mesmo par de chaves (AUTHENTICATION__PRIVATE_KEY / PUBLIC_KEY). O que diferencia um token de usuário de um token de staff é o campo type nos claims:
// Token de usuário (App API)
{
"sub": "user_01HQ7Z...",
"type": "user",
"tenant_id": "tenant_01HQ7Z...",
"scopes": ["tenant:agents.read", "tenant:agents.create", ...],
"sid": "session_01HQ7Z..."
}
// Token de staff (Backoffice API)
{
"sub": "staff_01HQ7Z...",
"type": "staff",
"scopes": ["bo:tenants.read", "bo:billing.read", ...],
"sid": "session_01HQ7Z..."
}
A ausência de tenant_id no token de staff não é um erro — é intencional. Staff opera em todos os tenants sem precisar incluir contexto de tenant no token.
Decoder por surface¶
O JWTDecoder é injetado por surface via app.dependency_overrides:
# App API (main.py) — suporta tokens E2E com múltiplas audiences
app.dependency_overrides[get_jwt_decoder] = lambda: get_app_container().jwt_decoder
# Backoffice (main.py) — decoder simples, sem suporte E2E
app.dependency_overrides[get_jwt_decoder] = lambda: get_backoffice_container().jwt_decoder
Isso significa que um token E2E emitido para testes funciona na App API mas é rejeitado no Backoffice.
App API — modelo de auth por categoria de rota¶
A App API divide suas rotas em quatro categorias com regras de auth distintas:
/v1/tenant/* — Operações do tenant¶
Requer JWT + header X-Tenant-Id. A validação faz duas checagens:
- O token tem o scope necessário (ex:
tenant:agents.read) - O
tenant_iddo token bate com o valor do headerX-Tenant-Id
# No router — declaração do guard
@router.get("", dependencies=[Depends(require_tenant_scope("tenant:agents.read"))])
async def list_agents(tenant_id: TenantDep, tx: TransactionDep): ...
O match entre token e header é a garantia de que um usuário autenticado em um tenant não pode operar em outro simplesmente trocando o header. O token foi emitido para um tenant específico na hora do login — não existe token de "múltiplos tenants".
X-Tenant-Id é rejeitado nas rotas core/ — um header X-Tenant-Id em /v1/core/auth/login retorna 400. Isso evita ambiguidade sobre qual contexto está sendo usado em rotas de autenticação.
/v1/core/* — Autenticação e perfil do usuário¶
Requer JWT sem tenant. Cobre login, refresh de token, perfil do usuário, gerenciamento de sessões, convites para tenant.
/v1/public/* — Webhooks de entrada¶
Sem autenticação JWT. Recebe eventos externos: Stripe webhooks, WhatsApp/WAHA webhooks, Google Drive webhooks. Cada webhook tem sua própria validação de assinatura (HMAC, Stripe-Signature, etc.) — não JWT.
/v1/catalog/* — Configuração do produto¶
Sem autenticação. Dados de leitura estática: itens de menu, templates de roles, permissões disponíveis. Servidos sem auth porque são dados de configuração do produto, não dados de tenant.
Backoffice API — modelo de auth¶
Todas as rotas do backoffice exigem um token com type == "staff":
# Qualquer rota backoffice com require_backoffice_scope
if claims.type != "staff":
raise HTTPException(403, "Staff authentication required")
Essa checagem acontece antes da validação de scope. Um token de usuário comum com scope bo:tenants.read seria rejeitado mesmo com o scope correto — o type precisa ser staff.
Scopes do backoffice¶
| Prefixo de scope | Área |
|---|---|
bo:tenants.* |
Gestão de tenants |
bo:billing.* |
Planos, assinaturas, invoices |
bo:staff.* |
Gestão de staff |
bo:audit.* |
Logs de auditoria da plataforma |
tenant-idp:* |
Templates de roles e permissões |
Staff não tem tenant_id no token¶
Staff não está vinculado a nenhum tenant — ele acessa todos. Por isso o token de staff não tem tenant_id e o backoffice não usa o header X-Tenant-Id. Quando um endpoint de backoffice precisa operar em um tenant específico, o tenant_id vem como parâmetro do path ou body (ex: GET /v1/tenants/{tenant_id}/subscription).
Impersonação¶
Staff pode se impersonar como usuário de um tenant para operações de suporte. O token de impersonação tem type == "user" e is_impersonated == True:
{
"sub": "staff_01HQ7Z...",
"type": "user",
"tenant_id": "tenant_01HQ7Z...",
"is_impersonated": true,
"staff_session_id": "session_01HQ7Z...",
"scopes": ["tenant:*"]
}
Na App API, esse token passa pelas mesmas validações de tenant que um token de usuário comum. A diferença é que o audit log registra um ImpersonatedAuditActor em vez de UserAuditActor — preservando rastreabilidade de quem estava impersonando.
Worker — sem surface HTTP¶
O Worker não expõe HTTP. Ele recebe tasks via Redis (Celery) e as processa chamando Use Cases. Não há autenticação de token — o isolamento de tenant vem dos dados das próprias tasks:
@celery_app.task
def index_document_task(document_id: str, tenant_id: str) -> None:
run_async(
container.index_document_uc.execute(
IndexDocumentCommand(
document_id=DocumentID(document_id),
tenant_id=TenantID(tenant_id),
)
)
)
O tenant_id nunca é assumido ou inferido dentro da task — ele sempre vem como argumento explícito.
Documentação da API (Scalar)¶
Ambas as surfaces HTTP ativas expõem o Scalar API Reference em /docs, disponível apenas fora de produção:
| Surface | URL | Status |
|---|---|---|
| App API | http://localhost:8000/docs |
Disponível em dev/staging |
| Backoffice API | http://localhost:8001/docs |
Disponível em dev/staging |
Em produção o endpoint /docs não é registrado — o openapi.json continua acessível mas sem a UI.
Adicionando uma rota: qual surface usar¶
A rota é para um usuário final do app Spryx?
├─ Sim, escopada ao tenant do usuário → /v1/tenant/* (App API)
├─ Sim, mas não precisa de tenant (login, perfil) → /v1/core/* (App API)
└─ Não
A rota é para o time interno da Spryx?
├─ Sim → Backoffice API (bo:* scope)
└─ Não
A rota recebe webhooks externos (Stripe, WhatsApp)?
├─ Sim → /v1/public/* (App API, sem JWT)
└─ Não
A rota é para desenvolvedores externos com API Key?
└─ Client API ⏳ (ainda não existe — ver entrypoints.md)