Pular para conteúdo

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:

  1. O token tem o scope necessário (ex: tenant:agents.read)
  2. O tenant_id do token bate com o valor do header X-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)