petraplatform

SDK Python que transforma qualquer FastAPI em sistema multi-tenant adicionando uma linha por rota. O dev codifica negócio puro. No final, pluga Depends(require_tenant) e a tenancy começa a valer.

🔌

Plug & Play

Sem decorator próprio, sem middleware Starlette. Só Depends do FastAPI.

🛡️

Segurança no SDK

JWT, permissão e RLS vivem dentro do pacote. Camada API fica limpa.

🌐

Master Petra

Claim is_master bypassa RLS — suporte enxerga todos os tenants.

📄

RLS no banco

Filtro por tenant_id aplicado pelo Azure SQL, não pelo código.

Arquitetura macro

Cada sistema pluga o mesmo SDK e fala com seu próprio banco de domínio. Todos compartilham um banco de identidade único: dbpetra (schema platform.*).

flowchart LR subgraph Clientes U[User Browser ou App] end subgraph Sistemas SA[Sistema A API] SB[Sistema B API] SC[Sistema C API] SE[Outros sistemas] end subgraph SDKbox[SDK] PKG[petraplatform pip] end subgraph AzureSQL[Azure SQL] DBP[("dbpetra / platform schema
tenants, users, products, perms")] DBA[("db_app_a / schema do Sistema A")] DBB[("db_app_b / schema do Sistema B")] end U -->|JWT| SA U -->|JWT| SB U -->|JWT| SC SA -.usa.-> PKG SB -.usa.-> PKG SC -.usa.-> PKG SE -.usa.-> PKG SA -->|RLS via SESSION_CONTEXT| DBA SB -->|RLS via SESSION_CONTEXT| DBB SC -->|RLS via SESSION_CONTEXT| DBA PKG -.so no login.-> DBP

Fluxo de autenticação

Em cada request, o middleware do SDK lê o JWT, monta o TenantContext em memória e seta o SESSION_CONTEXT no banco do app pra RLS aplicar. Nenhuma query no banco platform durante a request.

sequenceDiagram autonumber participant U as User participant API as Sistema FastAPI participant SDK as petraplatform participant SEC as itvalleysecurity participant DB as Banco do app U->>API: GET /api/leads + Bearer JWT API->>SDK: Depends(require_tenant) SDK->>SEC: verify_access(token) SEC-->>SDK: claims (sub, tenant_id, products, permissions, is_master) SDK->>SDK: monta TenantContext em memória SDK->>DB: EXEC sp_set_session_context @tenant_id, @is_master SDK-->>API: TenantContext API->>API: Service → Repository API->>DB: SELECT * FROM app_schema.tabela Note over DB: RLS filtra por SESSION_CONTEXT('tenant_id') DB-->>API: só linhas do tenant API-->>U: 200 OK + dados isolados

Dois bancos, duas connection strings

Cada sistema vai ter 2 entradas no .env:

Env varAponta praQuando é usada
APP_SQL_CONNECTION Banco do sistema (cada app define o seu) Toda request — middleware seta SESSION_CONTEXT pra RLS
PLATFORM_SQL_CONNECTION dbpetra (schema platform.*) Só no login e em endpoints de admin (CRUD de tenants/users/products)
Cross-DB JOIN não é necessário. O JWT carrega tudo que a request precisa saber. O dbpetra só é tocado por quem emite o token (login) ou por quem administra a plataforma.

RLS — Row-Level Security

Toda tabela de domínio do sistema (no schema próprio dele) ganha uma coluna tenant_id e uma Security Policy. O Azure SQL filtra automaticamente — o dev não escreve WHERE tenant_id = X.

-- Função aplicada pela Security Policy em toda query
CREATE OR ALTER FUNCTION rls.fn_tenant_filter(@tenant_id NVARCHAR(100))
RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 AS result
WHERE @tenant_id = CAST(SESSION_CONTEXT(N'tenant_id') AS NVARCHAR(100))
   OR CAST(SESSION_CONTEXT(N'is_master') AS BIT) = 1;
flowchart TD Q["SELECT * FROM app_schema.tabela"] POL{Security Policy
fn_tenant_filter} SC["SESSION_CONTEXT
tenant_id = clinica-abc
is_master = 0"] R[Resultado: só linhas
com tenant_id = clinica-abc] Q --> POL SC --> POL POL --> R

Master bypass

Usuários internos da Petra (suporte, admin) recebem JWT com is_master: true. A função RLS tem um OR explícito que libera tudo quando essa claim aparece.

JWTHeader X-Tenant-IdO que enxerga
User comum, tenant=clinica-abc (qualquer coisa — ignorado) Só dados de clinica-abc
Master (ausente) Todos os tenants — visão global
Master clinica-abc Só dados de clinica-abc (modo "ver como cliente")

Schema platform.*

Identidade compartilhada. Vive no banco dbpetra. Todos os 11 sistemas IT Valley se referenciam aqui.

erDiagram TENANTS ||--o{ TENANT_USERS : has USERS ||--o{ TENANT_USERS : assigned TENANTS ||--o{ TENANT_PRODUCTS : subscribes PRODUCTS ||--o{ TENANT_PRODUCTS : in PRODUCTS ||--o{ PERMISSIONS : defines PRODUCTS ||--o{ ROLE_PERMISSIONS : scope PERMISSIONS ||--o{ ROLE_PERMISSIONS : grants TENANTS ||--o{ AUDIT_LOGS : tracked USERS ||--o{ AUDIT_LOGS : did TENANTS { nvarchar id PK nvarchar slug UK nvarchar name nvarchar plan nvarchar status bit is_master } USERS { nvarchar id PK nvarchar email UK nvarchar name nvarchar password_hash bit is_active } TENANT_USERS { nvarchar tenant_id FK nvarchar user_id FK nvarchar role nvarchar status } PRODUCTS { int id PK nvarchar slug UK nvarchar name bit is_active } TENANT_PRODUCTS { nvarchar tenant_id FK int product_id FK nvarchar status datetime expires_at } PERMISSIONS { int id PK int product_id FK nvarchar slug } ROLE_PERMISSIONS { nvarchar role int product_id FK int permission_id FK } AUDIT_LOGS { nvarchar id PK nvarchar tenant_id FK nvarchar user_id FK nvarchar action }

Cada tabela em detalhe

Para cada tabela: o que ela existe pra resolver, um exemplo de linha plausível, e o que NÃO deve entrar (esse último é tão importante quanto o resto — evita o pior tipo de bug arquitetural).

platform.tenants

a empresa cliente

Propósito: cada cliente da Petra (clínica, escritório, consultoria, e-commerce…) é uma linha aqui. 1 linha = 1 organização. O slug é o identificador curto usado em URLs e como chave em outros lugares.

id          = 'clinica-abc'
name        = 'Clínica ABC Saúde'
slug        = 'clinica-abc'
plan        = 'captar'         -- comercial, livre
status      = 'active'
is_master   = 0                -- só Petra tem 1
created_at  = '2026-05-12 14:30:00'
NÃO entra: funcionário da clínica (vai pra users), lead capturado pela clínica via WhatsApp (vai pra shared.contacts ou ao schema do sistema).

platform.users

a pessoa física que LOGA

Propósito: 1 linha = 1 ser humano com email + senha no ecossistema Petra. Pode ser o dono da empresa, funcionário cadastrado por ele, ou consultor que mexe em várias empresas. O email é UNIQUE global — não importa em quantas empresas a pessoa atua, é 1 só.

id            = 'u-001'
email         = 'joao@clinica-abc.com'   -- UNIQUE global
name          = 'João Silva'
password_hash = '$argon2id$v=19$...'
is_active     = 1
NÃO entra: contato/lead do CRM (não loga, vai pra shared.contacts). Empresa cliente (vai pra tenants).

platform.tenant_users

vínculo pessoa ↔ empresa (com role)

Propósito: é aqui que mora "Maria é admin da Clínica ABC". Mesma pessoa pode ter N linhas (uma por empresa onde trabalha). A role determina o que ela pode fazer — resolvida via role_permissions.

tenant_id  = 'clinica-abc'
user_id    = 'u-001'        -- João
role       = 'admin'
status     = 'active'
created_at = '2026-05-12 14:32:00'
NÃO entra: permissões granulares (resolvem em role_permissions). Roles diferentes por produto na mesma empresa (decisão futura via tenant_user_products).

platform.products

catálogo dos sistemas Petra

Propósito: lista canônica dos 5 produtos da Petra. Tabela quase estática — só muda quando lança/retira produto. Slug é usado em JWT, em rotas, em código.

id  | slug      | name      | is_active
1   | genesis   | Genesis   | 1
2   | quanto    | Quanto    | 1
3   | vitrine   | Vitrine   | 1
4   | polaris   | Polaris   | 1
5   | calenda   | Calenda   | 1
NÃO entra: pacotes comerciais (Atrair/Captar/Relacionar/Fidelizar — são marketing, não DDL). Customização por tenant (cada cliente tem sua config no schema do produto).

platform.tenant_products

o que cada empresa contratou

Propósito: a "verdade plana" do que cada empresa pagou pra usar. Vindo de um pacote (4 linhas) ou avulso (1 linha) — pra checagem de permissão é a mesma coisa.

tenant_id   | product_id | status   | expires_at
clinica-abc | 2 (quanto) | active   | NULL
clinica-abc | 5 (calenda)| active   | NULL
NÃO entra: "pacote" (Captar) — quem traduz pacote em N produtos é o admin/script de provisionamento.

platform.permissions

catálogo do que se pode fazer em cada produto

Propósito: lista de ações por produto. Cada produto tem seu set. Slugs em formato recurso.acao ajudam a ler.

id | product_id   | slug          | name
1  | 1 (genesis)  | leads.view    | Ver leads
2  | 1 (genesis)  | leads.edit    | Editar leads
3  | 1 (genesis)  | ai.train      | Treinar IA do agente
4  | 2 (quanto)   | quotes.send   | Enviar cotação ao cliente
5  | 5 (calenda)  | calendar.edit | Editar agenda da equipe
NÃO entra: qual user tem a permissão (resolve via role + role_permissions). Permissões dinâmicas por linha (RLS resolve no SQL, não aqui).

platform.role_permissions

qual role recebe qual permissão por produto

Propósito: mapeia "admin no Genesis tem leads.view + leads.edit + ai.train". Cada role pode ter permissões diferentes em cada produto.

role  | product_id  | permission_id
admin | 1 (genesis) | 1 (leads.view)
admin | 1 (genesis) | 2 (leads.edit)
admin | 1 (genesis) | 3 (ai.train)
user  | 1 (genesis) | 1 (leads.view)    -- user só vê
user  | 2 (quanto)  | 4 (quotes.send)
NÃO entra: permissão por usuário individual (passa por role).

platform.audit_logs

eventos de plataforma (não de negócio)

Propósito: histórico de ações que mexem em identidade/governança — login, criação de tenant, convite de funcionário, mudança de role, troca de plano. tenant_id pode ser NULL pra ações master globais.

id           = 'log-7f3a'
action       = 'user.invited'
tenant_id    = 'clinica-abc'
user_id      = 'u-001'              -- quem fez (João, admin)
entity_type  = 'user'
entity_id    = 'u-099'              -- a pessoa convidada (Ana)
payload_json = '{"role":"user","email":"ana@clinica-abc.com"}'
created_at   = '2026-05-12 14:40:00'
NÃO entra: eventos de negócio (criou lead, fechou deal, enviou cotação) — esses ficam em <sistema>.audit_logs, dentro do schema do produto. Aqui é só identidade/governança.

Cenários de uso — quem grava o quê

Os 4 fluxos mais comuns no ecossistema Petra, mostrados como diagrama. Use isso pra mapear sua feature: "quando o usuário fizer X, o que entra em cada tabela?"

1. Empresa nova contrata o pacote Captar

Clínica ABC compra o pacote Captar (Quanto + Calenda). João é o dono e vai logar como admin.

flowchart TD Start([Clínica ABC compra pacote Captar]) Start --> T1[INSERT platform.tenants
id=clinica-abc
plan=captar] Start --> T2[INSERT platform.users
id=u-001
email=joao@clinica-abc.com] Start --> T3[INSERT platform.tenant_users
tenant=clinica-abc
user=u-001
role=admin] Start --> T4[INSERT platform.tenant_products
tenant=clinica-abc
product=quanto] Start --> T5[INSERT platform.tenant_products
tenant=clinica-abc
product=calenda] T1 --> JWT T2 --> JWT T3 --> JWT T4 --> JWT T5 --> JWT JWT["JWT do João no login
tenant_id=clinica-abc
products=quanto, calenda
permissions=admin x produtos"]

2. Admin cadastra funcionário

João convida Ana (atendente). Se Ana já é cliente Petra noutra empresa, reusa a linha de users.

flowchart TD Start([João admin convida Ana atendente]) Start --> Check{Ana já existe em
platform.users?} Check -->|Não| Create[INSERT platform.users
id=u-099
email=ana@clinica-abc.com] Check -->|Sim, mesma email| Reuse[Reusa linha existente] Create --> Link Reuse --> Link Link[INSERT platform.tenant_users
tenant=clinica-abc
user=u-099
role=user] Link --> Audit[INSERT platform.audit_logs
action=user.invited
user_id=u-001 quem convidou
entity_id=u-099 quem foi convidado] Audit --> Email[Email com link para Ana
definir senha]

3. Maria consultora atende 3 clínicas com o mesmo login

Maria é consultora externa. Trabalha pra 3 clínicas. 1 linha em users, 3 linhas em tenant_users.

flowchart LR Maria([Maria, maria@consult.com]) Maria --> U[platform.users
id=u-150
email=maria@consult.com
1 LINHA SÓ] U --> TU1[tenant_users
tenant=clinica-A
role=user] U --> TU2[tenant_users
tenant=clinica-B
role=user] U --> TU3[tenant_users
tenant=clinica-C
role=admin] TU1 --> Login TU2 --> Login TU3 --> Login Login["Maria loga uma vez
vê seletor de empresa
(3 opções)"] Login --> J["JWT escolhe 1 tenant_id
(ou troca via X-Tenant-Id
se a feature suportar)"]

4. Master Petra dá suporte a uma empresa

Carlos (master) precisa ver dados da Clínica ABC pra debugar. RLS deixa passar pelo claim is_master; com X-Tenant-Id, ele foca naquele tenant.

sequenceDiagram autonumber participant C as Carlos (master) participant API as petraplatform participant DB as Banco do app Note over C: Modo 1: visão global (sem header) C->>API: GET /api/leads
JWT is_master=1 API->>DB: SESSION_CONTEXT is_master=1 DB-->>API: Linhas de TODOS os tenants API-->>C: 200 OK Note over C: Modo 2: foca em 1 tenant (com header) C->>API: GET /api/leads
JWT is_master=1
X-Tenant-Id: clinica-abc API->>DB: SESSION_CONTEXT tenant_id=clinica-abc, is_master=1 DB-->>API: Só linhas da Clínica ABC API-->>C: 200 OK
Regra recorrente em todos os cenários: só pessoas que LOGAM viram linha em platform.users. Lead capturado, paciente, prospect, cliente final — nenhum desses entra aqui. Se entrar, o banco vira lixo em 50 tenants ativos.

Arquitetura em camadas — regra IT Valley

Toda API IT Valley segue 3 camadas. Router → Service → Repository. Não é estilo, é regra de arquitetura. SQL nunca aparece em rota.

flowchart TD R["Router
(@app.get / @app.post)
fino, só Depends"] S["Service
(orquestra negócio)
opaca, sem SQL"] D["Repository / Data
(único lugar com SQL)
SQLAlchemy / queries"] DB[("Banco do app")] R -->|chama via Depends| S S -->|chama| D D -->|SQL| DB
CamadaPodeNUNCA pode
Router Receber request, chamar Service via Depends, retornar JSON SQL, query, conhecer DTO de banco, sessão SQLAlchemy, regra de negócio
Service Orquestrar repositories, aplicar regras de negócio, transformar dados SQL inline, conhecer detalhes do FastAPI (request/response)
Repository SQL, queries, ORM, stored procedures, mapeamento de tabela Lógica de negócio, validação de domínio, conhecer HTTP
Se aparecer SQL dentro de uma função decorada com @app.<verb>, está errado. Não importa quão simples seja o exemplo. O dev novo copia o que vê.

Como o dev vai usar

Padrão IT Valley de 3 camadas — Router fino, Service orquestra, Repository é o único lugar com SQL. Nunca SQL dentro de uma função @app.<verb>.

Antes (fase de dev — zero auth, zero tenancy)

# routers/leads.py
@app.get("/api/leads")
async def list_leads(service: LeadsService = Depends(get_leads_service)):
    return await service.list_all()

# services/leads_service.py
class LeadsService:
    async def list_all(self):
        return await self._repo.find_all()

# data/leads_repository.py
class LeadsRepository:
    async def find_all(self):
        return await self._db.fetch_all("SELECT * FROM <schema>.<tabela>")

Depois (pré-staging — pluga o SDK)

# routers/leads.py — única mudança: dois Depends a mais
from petraplatform import TenantContext, require_permission, require_tenant

@app.get("/api/leads")
async def list_leads(
    tenant: TenantContext = Depends(require_tenant),
    _ = Depends(require_permission("leads")),
    service: LeadsService = Depends(get_leads_service),
):
    return await service.list_all()
# ↑ Service e Repository INTACTOS. Query SQL não muda. RLS filtra no banco.
O router não conhece SQL, não conhece DTO de banco, não conhece sessão SQLAlchemy. Só recebe Depends e devolve o que o Service entrega. O dev nunca escreve: load_dotenv(), WHERE tenant_id = X, if not user.has_permission, decode de JWT, hash de senha.

Configuração — só .env

O dev nunca chama load_dotenv(): o pacote carrega sozinho no import. Só precisa criar o arquivo .env com as variáveis abaixo. Tudo é validado no startup — se faltar algo crítico, a API explode no boot com mensagem clara (não em runtime).

# ===== JWT (vem do itvalleysecurity, embutido) =====
JWT_SECRET_KEY=uma_chave_aleatoria_com_pelo_menos_32_chars_aaaaaaaa
JWT_ISSUER=Petra                   # opcional, default = ITValley
EV_TOKEN_SOURCE=bearer             # bearer | cookie | auto

# ===== Banco do APP (cada sistema escolhe o seu) =====
APP_SQL_CONNECTION=Driver={ODBC Driver 18 for SQL Server};Server=<servidor>.database.windows.net;Database=<seu_db>;Uid=<user>;Pwd=<senha>;Encrypt=yes;TrustServerCertificate=no;

# ===== Banco platform.* (só usado no /login e admin) =====
PLATFORM_SQL_CONNECTION=Driver={ODBC Driver 18 for SQL Server};Server=<servidor>.database.windows.net;Database=dbpetra;Uid=<user>;Pwd=<senha>;Encrypt=yes;TrustServerCertificate=no;

# ===== Contexto de produto =====
PLATFORM_PRODUCT_SLUG=genesis      # qual produto é esse sistema (genesis | quanto | vitrine | polaris | calenda)
PLATFORM_TENANT_HEADER=X-Tenant-Id # opcional, default = X-Tenant-Id
VariávelO que fazObrigatória?
JWT_SECRET_KEY Segredo HMAC que assina/valida o JWT. Mesma chave nos 5 produtos da Petra (pro JWT do Genesis valer no Quanto). ✅ Sim (32+ chars senão explode no boot)
APP_SQL_CONNECTION Banco onde o sistema guarda seus dados (Genesis usa o seu, Quanto o seu, etc.). Toda request seta SESSION_CONTEXT aqui pra RLS aplicar. 🟡 Recomendada (sem ela, RLS não aplica; só faz sentido pular em dev local sem DB)
PLATFORM_SQL_CONNECTION Banco dbpetra com schema platform.*. Lido SÓ no /login e em endpoints admin. Zero round-trip no request normal. 🟡 Recomendada (sem ela, /login não funciona)
PLATFORM_PRODUCT_SLUG O slug do produto desse sistema. require_permission("leads.view") resolve permissão dentro desse produto. ✅ Sim (sem ela, todo require_permission vira 403 silencioso)
JWT_ISSUER Nome do emissor que vai dentro da claim iss. Recomenda-se Petra. ❌ Não (default ITValley)
EV_TOKEN_SOURCE Onde o middleware procura o token: bearer (header), cookie (HttpOnly) ou auto (qualquer um). ❌ Não (default bearer)
PLATFORM_TENANT_HEADER Nome do header que master usa pra escolher tenant alvo. ❌ Não (default X-Tenant-Id)
O JWT_SECRET_KEY tem que ser o mesmo nos 5 produtos da Petra. Senão, JWT emitido pelo Genesis não é aceito pelo Quanto, e a tese da "suite integrada" morre na hora.

De onde vem o JWT

O require_tenant o JWT da request, mas o JWT precisa ter sido emitido em algum lugar antes — o famoso /login. Cada sistema (Genesis, Quanto…) tem o seu /login que consulta o dbpetra uma vez e emite o token.

Anatomia de um /login

# routers/auth.py — exemplo no Genesis
from fastapi import Depends, HTTPException
from itvalleysecurity import issue_pair
from petraplatform.exceptions import InvalidToken
from services.auth_service import AuthService

@app.post("/login")
async def login(body: LoginPayload, service: AuthService = Depends(get_auth_service)):
    user, claims = await service.authenticate(body.email, body.password)
    if not user:
        raise HTTPException(401, "credenciais invalidas")

    # Gera o par access + refresh com TODAS as claims do tenant
    return issue_pair(
        sub=user.id,
        email=user.email,
        tenant_id=claims["tenant_id"],          # de platform.tenant_users
        is_master=claims["is_master"],          # de platform.tenants.is_master
        products=claims["products"],            # de platform.tenant_products
        permissions=claims["permissions"],      # de platform.role_permissions
    )

O que o AuthService.authenticate faz

# services/auth_service.py
class AuthService:
    async def authenticate(self, email: str, password: str):
        # 1. Acha o user em platform.users (UNIQUE email global)
        user = await self._users.find_by_email(email)
        if not user or not verify_argon2(password, user.password_hash):
            return None, {}

        # 2. Pega o(s) tenant(s) que esse user pertence
        memberships = await self._tenant_users.find_by_user(user.id)
        # se tem só 1, escolhe ele; se tem N, pode pedir o tenant alvo
        chosen = memberships[0]

        # 3. Resolve products + permissions desse tenant
        products = await self._tenant_products.list_active(chosen.tenant_id)
        permissions = await self._role_permissions.resolve(
            role=chosen.role, products=[p.slug for p in products]
        )

        # 4. Devolve as claims que vao virar JWT
        return user, {
            "tenant_id": chosen.tenant_id,
            "is_master": chosen.is_master,
            "products": [p.slug for p in products],
            "permissions": permissions,
        }

Claims que o JWT carrega

ClaimDe onde vemUsada por
subplatform.users.ididentificar o user logado
emailplatform.users.emaildisplay, logs
tenant_idplatform.tenant_usersrequire_tenant + RLS
is_masterplatform.tenants.is_masterbypass RLS, suporte
productsplatform.tenant_productsrequire_product()
permissionsrole_permissionsrequire_permission()
expconfig (default 15 min)expiração do access token
Em v0.1: cada sistema tem seu próprio /login (porque os 5 produtos podem ser deployados independentes). Em v0.2: a Petra pode montar um auth.petra.ai central que emite JWT pros 5 produtos — basta compartilhar o mesmo JWT_SECRET_KEY.

CLI petraplatform

1. Inicializar o banco platform (1 vez por ambiente)

# gera o DDL completo + seeds (8 produtos + tenant master)
petraplatform init-platform -o platform_init.sql

# roda no Azure SQL
sqlcmd -S <seu-servidor>.database.windows.net -d dbpetra \
       -U <user> -P $SQL_PWD -i platform_init.sql

2. Aplicar RLS num sistema (1 vez por sistema)

petraplatform generate-rls \
    --schema <seu_schema> \
    --tables <tabela_a>,<tabela_b>,<tabela_c> \
    -o rls_<seu_sistema>.sql

sqlcmd -S <seu-servidor>.database.windows.net -d <seu_db> \
       -U <user> -P $SQL_PWD -i rls_<seu_sistema>.sql

Workflow recomendado

flowchart LR A[1. Dev codifica
negócio puro
SEM Depends] --> B[2. Sistema
estável em dev] B --> C[3. Pluga
Depends require_tenant
nas rotas] C --> D[4. DBA roda
generate-rls
+ aplica SQL] D --> E[5. Sistema
multi-tenant
em staging] E --> F[6. Deploy prod
via GitHub Actions]

FAQ

O sistema novo PRECISA conectar no dbpetra pra rodar?

Não. Em request path normal o middleware lê só o JWT. O dbpetra só é necessário para emitir o JWT (endpoint de login) e para CRUD admin de tenants/users/products. Sistema 100% offline do banco platform funciona perfeitamente em runtime.

Posso começar uni-tenant e migrar pra multi depois?

Sim — é o workflow recomendado. Durante o dev, rotas cruas. No final, adiciona Depends(require_tenant), roda generate-rls, e o sistema vira multi-tenant sem mudar query nenhuma.

Por que banco separado pro platform e não schema dentro de um banco existente?

Backup independente, blast radius reduzido, login SQL dedicado para a plataforma, e zero dependência circular com sistemas legados que já têm tabelas de identidade misturadas com tabelas de domínio.

O master pode ver dados de qualquer tenant sempre?

Sim. Master é IT Valley interno (suporte, debug, ops). Sem header X-Tenant-Id ele vê tudo. Com o header, foca em um tenant específico. Não-master ignora o header sempre.

Por que SQL não pode estar no router?

Regra de arquitetura IT Valley: Router → Service → Repository. O Router só recebe Depends e devolve o que o Service entrega. Service orquestra negócio. Repository é o único lugar com SQL/ORM. Se um exemplo, snippet ou doc mostrar db.query(...) dentro de @app.<verb>, é erro de arquitetura — devs novos copiam o que veem.

Bug do itvalleysecurity que retorna 500?

Token inválido na versão 0.1.0 do itvalleysecurity vaza como 500 em vez de 401. O petraplatform embrulha a exception e devolve 401 corretamente. Testes garantem o comportamento (test_auth_wrapper.py).

O que vem em v0.2?
  • Lookup de tenant em DB com cache Redis
  • tenant.get_resource() — Claude/OpenAI keys por tenant
  • tenant.upload() — Azure Blob com path resolução
  • tenant.track_usage() — quotas por recurso

Arquitetura Azure da Petra — visão

O ecossistema Petra (5 produtos: Genesis, Quanto, Vitrine, Polaris, Calenda) roda inteiro no Azure. Inventário completo e atualizado: 📊 Dashboard Azure.

Estado atual (consolidação 2026-07): depois de 3 rodadas de limpeza (maio→julho), a plataforma roda em 11 plans — 4 pagos (2×B2 + 2×B3, zero P0v3) + 7 F1 grátis. Compute caiu de ~US$300 pra ~US$156/mês. SQL consolidado em 1 servidor (srvmasterclass) com bancos Basic.
Princípio (mantido): 2 ilhas físicas (NONPROD e PROD) com 3 ambientes lógicos (DEV, ACCP, PROD). Cada ambiente tem isolamento por banco, prefixo de storage e fila — mas DEV+ACCP compartilham plan e Service Bus pra economizar.

Ambientes — NONPROD vs PROD

Estado atual (2026-07-21, verificado via az)

RecursoNONPROD (DEV + ACCP)PROD
Plans pagosplan-itvalley-nonprod (B3, 25 apps) + plan-petra-dev (B2, Genesis DEV)plan-prod-canada (B3, 9 apps) + plan-prod-eastus (B2, 6 apps)
Plans grátisplan-free-canada-1..5 + plan-free-eastus + plan-free-bifamilia (F1) — apps leves, ACCP e ferramentas
SQL Serversrvmasterclass (único) — dblumina (Petra API + Genesis) e dbmasterclasse, ambos Basic
Service Busgenesisitvalley Standard (único namespace)
MongoDBAtlas real — cluster1.x6tirkw.mongodb.net (não Cosmos ✅)
EmailACS acs-petra + domínio mail.petrasuite.ai (rg-petra)

Desenho-alvo (quando os produtos escalarem)

RecursoNONPROD (DEV + ACCP)PROD
Planplan-petra-nonprod (B2/B3, compartilhado)plan-petra-prod (P0v3 só quando o volume justificar)
SQL Serversrv-petra-nonprod — bancos por ambiente (dbgenesis-dev, dbgenesis-accp)srv-petra-prod
Service Bussb-petra-nonprod Standard — queues sufixadas .dev / .accpsb-petra-prod — queues .prod
RedisSem Redis em NONPROD (cache em memória)redis-petra-prod Standard C1
Storagesapetranonprod LRS — containers dev/ / accp/sapetraprod GRS
Azure OpenAIaoai-petra-nonprodaoai-petra-prod

Componentes — Frontend / Backend / Worker

Cada produto Petra tem 3 componentes deployáveis:

ComponenteTecnologiaAzure resource
FrontendSvelteKit / VueStatic Web Apps (Free em NONPROD, Standard em PROD)
BackendFastAPI (Python)Web App Linux — usa petraplatform SDK
Worker / EventsFastAPI + Service Bus listenerWeb App Linux (1 worker por ilha, consome várias queues em asyncio)
Decisão Petra: não usar Azure Functions. Worker é Web App Python com listener Service Bus rodando como background task no startup. Mesma stack do backend, mesmo deploy, time não aprende outro modelo.

Service Bus e eventos entre sistemas

Os 5 produtos conversam entre si via eventos, não chamadas diretas. Genesis publica lead.captured, Quanto/Calenda assinam.

Genesis API → grava lead → publish event "lead.captured.prod"
        ↓
Service Bus topic: sb-petra-prod
        ↓
   ├─ Subscriber: Quanto worker  → cria pré-cotação
   ├─ Subscriber: Calenda worker → sugere horário
   ├─ Subscriber: AI brain       → enriquece com knowledge do tenant
   └─ Subscriber: Datalake       → registra no ADLS para BI

Naming das queues: <dominio>.<acao>.<env>. Exemplos: lead.captured.dev, quote.sent.accp, calendar.booked.prod.

Storage e Datalake

Uma conta de Storage por ilha, separação por path prefix com tenant_id — nunca conta por tenant.

sapetraprod/
├── documents/
│   ├── clinica-abc/whatsapp/2026-05-12/foto.jpg
│   └── clinica-xyz/uploads/contrato.pdf
├── ai-knowledge/
│   ├── clinica-abc/embeddings/
│   └── clinica-abc/source-docs/
├── audios/
└── exports/

dlpetraprod/ (ADLS Gen2 — analytics)
└── raw/tenant=clinica-abc/dt=2026-05-12/...

Acesso via SAS token assinado pelo backend — cliente nunca vê a conta de storage diretamente.

Naming convention oficial

RecursoPadrãoExemplo
Resource Grouprg-petra-<env>rg-petra-prod
App Service Planplan-petra-<env>plan-petra-nonprod
Web App backendapp-<produto>-back-<env>app-genesis-back-prod
Web App workerapp-petra-worker-<env> (1 por ilha)app-petra-worker-prod
Static Web Appapp-<produto>-front-<env>app-quanto-front-prod
SQL Serversrv-petra-<env>srv-petra-prod
Banco SQLdb<produto>-<env>dbgenesis-prod
Service Bussb-petra-<env>sb-petra-prod
Storage Accountsapetra<env> (sem hífen)sapetraprod
Redisredis-petra-<env>redis-petra-prod
Azure OpenAIaoai-petra-<env>aoai-petra-prod
Datalake (ADLS)dlpetra<env>dlpetraprod

Custo — estado atual (2026-07-21)

Depois da consolidação (zero P0v3, SQL num servidor só, bancos Basic):

Serviço~US$/mêsOnde
App Service Plans (2×B2 + 2×B3)~$156plan-prod-eastus, plan-petra-dev, plan-prod-canada, plan-itvalley-nonprod
SQL Database (2× Basic)~$10srvmasterclass
MySQL flexible~$14mysql-phoenixx (Academy WP)
Service Bus Standard~$10genesisitvalley
Storage / DNS / misc~$12contas espalhadas — candidatas a limpeza
Total infra~US$ 200≈ CAD$ 270 — era ~CAD$ 405 em maio

MongoDB Atlas e tokens de IA (Anthropic/OpenAI/Groq) são billing separado e variam com uso. O desenho-alvo antigo (~$945 CAD/mês com P0v3 + Redis + 12 bancos) fica como referência de escala futura — só provisionar quando o volume pagar a conta.

Suíte em produção — estado implantado (23/jul/2026)

A Petra Suite está no ar: portal único em suite.petrasuite.ai com login contra o dbpetra (schema platform.*) e SSO por cookie no domínio pai .petrasuite.ai. Abaixo, o que está realmente implantado — não o desenho-alvo.

Portal da suíte

Tenants e produtos (seed)

TenantTipoUsuários
petramastercarlos.viana, henrique.proscholdt
itvalleyclientelaura.lopes
totalelectriqueclientehugo.matias

Produtos cadastrados: genesis, quanto, vitrine, polaris, calenda — cada um com 2 permissions (view / admin).

SSO — cookie petra_sso

No login, o portal grava o cookie petra_sso no domínio pai .petrasuite.ai (Max-Age 8h, Secure, SameSite=Lax). Cada produto lê o cookie e entra sem segunda tela de login. O backend de cada produto valida o JWT com a mesma JWT_SECRET_KEY (petra-jwt-secret-key no Key Vault).

sequenceDiagram autonumber participant U as User participant P as suite.petrasuite.ai participant C as Cookie .petrasuite.ai participant A as quanto / calenda / polaris participant B as Backend do produto U->>P: login (email + senha) P->>P: valida contra dbpetra platform.* P->>C: Set-Cookie petra_sso (Max-Age 8h, Secure, SameSite=Lax) U->>A: abre quanto.petrasuite.ai A->>C: lê petra_sso (mesmo domínio pai) A->>A: auto-login, sem segunda tela A->>B: request + Bearer JWT B->>B: valida JWT (mesma JWT_SECRET_KEY petra-jwt-secret-key do Key Vault) B-->>U: sessão ativa em todos os produtos

Status por produto

ProdutoStatusDetalhe
Calenda (Forms) Multitenant em PROD Tenant vem do JWT; dados existentes marcados itvalley
Polaris Multitenant em PROD dblumina schema social_media + Mongo, colunas tenant_id
Quanto (FlowQuote) Multitenant em PROD Backend novo app-quanto-api, db flowquote-itvalley; dados do TE marcados totalelectrique no db flowquote; TE v1 intocado
Genesis Híbrido Aceita token da suíte + login local; ACCP validado, PROD em deploy
Vitrine Bloqueado por coordenação Main da Polly tem auth/Tenant CRUD próprios — integração pendente

Domínios *.petrasuite.ai

DomínioApp Azure
suite.petrasuite.aiapp-petra-suite-web
suite-api.petrasuite.aiapp-petra-suite-api
genesis.petrasuite.aigenesisfrontend
quanto.petrasuite.aiflowquote-itvalley
vitrine.petrasuite.aiapp-carrossel-frontend
polaris.petrasuite.aiapp-social-media-web
calenda.petrasuite.aiformsitvalleyfrontend
docs.petrasuite.aiapp-petraplatform-docs
Todos os domínios acima têm managed certificate do Azure App Service (HTTPS automático, renovação gerenciada).

Estrutura de repos

Cada produto da Petra tem 3 repos (frontend, backend, worker quando aplicável):

cacaviana/petraplatform        ← SDK compartilhado (este repo)
cacaviana/genesis-backend
cacaviana/genesis-frontend
cacaviana/quanto-backend
cacaviana/quanto-frontend
cacaviana/calenda-backend
cacaviana/calenda-frontend
cacaviana/vitrine-backend
cacaviana/vitrine-frontend
cacaviana/polaris-backend
cacaviana/polaris-frontend
cacaviana/petra-worker         ← 1 worker compartilhado por ilha

Branches longas — main / accp / prod

Em cada repo, 3 branches longas, uma por ambiente:

BranchDeploy automáticoQuem mergeia
mainDEVDev (após PR review)
accpACCPTech lead (PR main → accp)
prodPRODTech lead (PR accp → prod, após tester aprovar)

Fluxo feature → main → accp → prod

flowchart LR F["feature/CU-12345-x"] -->|PR + review| M[main] M -->|deploy DEV auto| D((DEV)) M -->|PR promote| A[accp] A -->|deploy ACCP auto| AC((ACCP)) AC -->|tester aprova| P[prod] P -->|deploy PROD auto| PR((PROD))

Workflows GitHub Actions

Cada repo tem 3 workflows (mesma estrutura, branches diferentes):

.github/workflows/
├── deploy-dev.yml       trigger: push em main
├── deploy-accp.yml      trigger: push em accp
└── deploy-prod.yml      trigger: push em prod

Cada workflow usa azure/webapps-deploy@v3 com publish profile diferente por ambiente (3 secrets: AZURE_PROFILE_DEV, AZURE_PROFILE_ACCP, AZURE_PROFILE_PROD).

Procedimentos — PR, review, hotfix

Por que MongoDB Atlas (não Cosmos DB)

Cada cliente Petra usa combinação diferente de Azure services (um tem OpenAI + Video Indexer, outro tem só Speech-to-Text). Isso é configuração flexível por tenant — SQL relacional não cabe.

Decisão: MongoDB Atlas contratado via Azure Marketplace. Não Cosmos DB. Atlas tem drivers MongoDB nativos, Atlas Vector Search pra RAG da "AI brain" da Petra, backup point-in-time, e billing unificado com Azure.

Modelagem do documento por tenant

// db: petra_configs
// collection: tenant_resources
{
  "_id": "clinica-abc",
  "tenant_id": "clinica-abc",
  "azure_services": {
    "openai": {
      "endpoint": "https://aoai-petra-prod.openai.azure.com/",
      "deployment": "gpt-4o-mini",
      "system_prompt_extra": "Você é o atendente da Clínica ABC..."
    },
    "speech_to_text": {
      "region": "eastus",
      "language": "pt-BR",
      "enabled": true
    },
    "video_indexer": null,              // não contratado
    "blob_storage": {
      "container_prefix": "clinica-abc/",
      "tier": "Hot"
    },
    "datalake": {
      "container": "clinica-abc",
      "retention_days": 90
    }
  },
  "feature_flags": { "ai_brain_v2": true },
  "limits": { "ai_tokens_per_month": 500000, "storage_gb": 50 },
  "updated_at": "2026-05-12T14:30:00Z"
}

Como o sistema descobre o que o cliente tem

O petraplatform SDK v0.2 vai expor:

tenant: TenantContext = Depends(require_tenant)

# Pega config do OpenAI desse tenant
openai_config = tenant.get_service("openai")
if not openai_config:
    raise HTTPException(403, "Tenant não tem OpenAI contratado")

# Usa direto
client = AsyncAzureOpenAI(
    endpoint=openai_config["endpoint"],
    api_key=openai_config["api_key"],
    deployment=openai_config["deployment"],
)

Por baixo, o SDK lê o documento no Mongo Atlas, cacheia por 5min em memória, e devolve só a parte relevante.

Provisionamento (admin liga/desliga service)

Admin da Petra (Carlos) tem um endpoint interno tipo:

POST /api/admin/tenant/clinica-abc/services
{
  "speech_to_text": {"enabled": true, "language": "pt-BR"},
  "video_indexer": null
}

Backend atualiza o documento Mongo. Próximo request do cliente já vê a nova config (cache invalidado).

Estrutura ClickUp da Petra

Workspace ITVS (ID 9007160573). Hoje tem 7 sistemas legados; os 5 produtos da Petra (Genesis/Quanto/Vitrine/Polaris/Calenda) ainda precisam ser criados.

Workspace ITVS
└── Space "Sistemas IT Valley"
    └── Folder = Sistema (ex: "Genesis")
        └── List = Domínio (ex: "Domínio Autenticação", "Domínio Lead", ...)
            └── Task = Entregável

Estado atual (snapshot 2026-05-12): 331 tarefas abertas, 7 sistemas, 56% sem assignee, 83% paradas há mais de 14 dias. Diagnóstico completo no documento de planejamento.

Statuses padronizados (todos os folders)

Hoje só o folder "Sistema de Cobrança" tem pipeline maduro. Vamos replicar em todos:

feito ia  →  backlog  →  em dev  →  revisão dev  →  qa  →  em accp  →  em prod
StatusQuem moveSignifica
feito iaIA gerouTarefa criada por agente IA, ainda precisa triage humano
backlogLaura (GP)Priorizada, atribuída, pronta pra pegar
em devWorkflow (PR merged em main)Em desenvolvimento ativo, branch existe
revisão devWorkflow (PR opened)PR aberto, esperando review
qaDev marcaMergeada em main, pronta pra tester
em accpWorkflow (PR merged em accp)Em homologação, tester validando
em prodWorkflow (PR merged em prod)Mise en prod feita, entregue

Convenção de branches

Toda branch de feature tem o ID da task ClickUp no nome:

feature/CU-86e0y9bt4-gerenciar-avatar-pessoa
hotfix/CU-86e0z1abc-corrigir-bug-login

O ID (86e0y9bt4) vem da URL do task no ClickUp. A integração nativa GitHub ↔ ClickUp detecta automaticamente.

Integração nativa GitHub ↔ ClickUp

ClickUp tem integração oficial com GitHub que faz automaticamente:

Instalação: ClickUp → Settings → Apps → GitHub → Connect. Autoriza acesso ao GitHub cacaviana, seleciona repos. Zero código.

Auto-update Git → ClickUp por ambiente

A integração nativa não distingue merge em main vs accp vs prod. Pra isso, workflow custom complementar:

flowchart LR PR[PR opened] -->|workflow| S1["status: revisao dev"] M[merge em main] -->|workflow| S2["status: em dev"] A[merge em accp] -->|workflow| S3["status: em accp"] P[merge em prod] -->|workflow| S4["status: em prod"]

Workflow lê o ID CU-{id} do nome da branch e chama API ClickUp pra atualizar o status conforme o destino.

Agente IA da Laura

Run diário às 9h. Tools: ClickUp API, GitHub API, email/Slack. Output priorizado, não dump bruto:

🔴 URGENTE (decida hoje):
  • 12 tasks paradas >30 dias sem assignee
  • 5 tasks em "qa" >10 dias com Pollyana

🟡 ACOMPANHAR:
  • 8 PRs abertos no GitHub sem task ClickUp vinculada
  • 3 branches sem PR há >7 dias

🟢 NORMAL:
  • Genesis: 4 tasks avançaram esta semana
  • TCC: backlog drenando, ritmo de 2/dia

Procedimentos da Laura (rotina)