O que Autorização Boogy (autorização em serviço) faz?
A autorização no Boogy tem três camadas. Não implemente nenhuma delas manualmente — o SDK emite auxiliares verificados que mantêm as invariantes de segurança intactas.
O modelo de três camadas
- Admissão de ingresso — o modo de ingresso da plataforma decide se uma solicitação chega ao seu serviço. Quando seu código executa, o chamador já é um principal conhecido ou anônimo.
- Guardas protegem rotas — verificações por rota são executadas antes do handler: exigir login, ou exigir propriedade do recurso endereçado.
- Auxiliares escopam linhas — handlers listam/carregam apenas as linhas do chamador.
Resolva o chamador com auth::current_principal() -> Option<String>.
O principal é opaco — nunca analise, remova prefixos ou presuma um UUID;
use-o apenas como valor da coluna de proprietário e como entrada para auth::*.
Ingresso por rota: uma rota pública dentro de um serviço restrito
A camada 1 (ingresso) normalmente é um único mode para todo o serviço. Mas às vezes uma
rota deve ser acessível por chamadores que o resto do serviço rejeita — o
caso canônico é um receptor público /webhook dentro de um serviço que, de outra forma, é authenticated
(ou internal/mixed). Abra uma exceção com substituição por rota;
o resto do serviço permanece restrito:
[ingress]
mode = "authenticated" # padrão para todo o serviço — todo o resto
[[ingress.routes]]
path = "/webhook" # caminho relativo ao serviço; TODOS os métodos
mode = "public" # qualquer um pode acessar /webhook
O contrato de segurança (leia-o — é fail-closed):
| Regra | Por que é importante |
|---|---|
Caminhos literais relativos ao serviço. Exato (/webhook) ou prefixo (/stripe/*, com limite de segmento). Sem captura {param}. | O path é o caminho que seu Router vê, o mesmo contexto que seus handlers. |
| Correspondência mais específica vence; exatamente um modo se aplica. Exato vence prefixo; prefixo mais longo vence mais curto. | Uma sub-rota pública nunca pode alargar uma irmã: /webhook público não afrouxa /data. |
Padrão = o modo de todo o serviço. Um caminho sem correspondência (ou uma tentativa de travessia ..) cai no padrão restrito, nunca na rota mais permissiva. | Esquecer de listar uma rota a deixa protegida, não exposta. |
Cada substituição carrega SEU PRÓPRIO modo + listas de permissão. Uma substituição NÃO herda allowed_agents/allowed_origins. | Liste-os no bloco da rota; a mesma validação não vazia se aplica para que um erro de digitação não possa negar-tudo silenciosamente. |
Por CAMINHO, não por método (hoje). path = "/webhook" se aplica a GET, POST, etc. igualmente. | Um /webhook público é acessível por qualquer verbo — então ainda valide a requisição no handler (um GET indevido deve não fazer nada). |
| Aplicado pelo host na borda. O ingresso executa antes de seu wasm ser instanciado. | Você NÃO autoprotege uma rota pública em código; mas uma rota pública significa que qualquer um a acessa — autentique-a de outra forma (ex.: uma assinatura HMAC; veja boogy:boogy-webhooks). |
| Portão de delegação + limite de taxa permanecem PARA TODO O SERVIÇO. | Uma exceção pública não pode contornar o portão [ingress.delegation] e compartilha o balde de limite de taxa. |
Um manifesto sem [[ingress.routes]] se comporta exatamente como antes — isto é
puramente aditivo.
O inverso: uma subárvore RESTRITA dentro de um serviço aberto (somente /admin do proprietário)
A outra direção — um serviço cujo padrão é aberto (authenticated) mas cuja
subárvore /admin/* é acessível apenas pelo proprietário do serviço (o provisionador).
A armadilha: um módulo provisionável é implantado por qualquer pessoa, então você NÃO deve
codificar uma identidade (@alice, boogy://alice/services/*) no manifesto — esse
proprietário literal está errado para todos os outros provisionadores, e as strings de lista de permissão de ingresso
não são substituídas no momento da implantação. O ingresso não tem um correspondente "mesmo proprietário que eu".
Use a capacidade não portada caller_is_service_owner() — o host atesta
se o chamador é o proprietário DESTE serviço (seu token de agente, resolvido no lado do host
contra o registro de agentes, OU uma de suas próprias cargas de trabalho). Sem ingresso por rota,
sem identidade codificada:
[ingress]
mode = "authenticated" # todas as rotas; o handler protege /admin
// require_operator(): atestado pelo host, nada codificado.
fn require_operator() -> Result<(), ApiError> {
if caller_is_service_owner() { return Ok(()); } // o proprietário: seu agente OU própria carga de trabalho
// OBO: o backend do proprietário agindo por um usuário — o ATOR ATESTADO é a carga de trabalho.
let id = current_identity();
let actor_owner = id.and_then(|i| i.actor).and_then(workload_owner);
if actor_owner == Some(self_identity().owner) { return Ok(()); }
Err(ApiError::forbidden("somente operador"))
}
Por que este é o primitivo correto: o proprietário humano pode acessar /admin diretamente
com seu próprio token (o wasm não pode resolver o handle de um agente, mas o host pode —
é isso que a capacidade faz), E o backend do proprietário funciona como uma carga de trabalho.
Fail-closed: anônimo, um proprietário diferente ou um chamador irresolúvel → false.
(Veja o módulo de catálogo resend-base.) O padrão anterior "internal + carga de trabalho do mesmo proprietário"
também funciona, mas EXCLUI o acesso humano direto — prefira
caller_is_service_owner para superfícies somente do proprietário.
Referência rápida de guardas e auxiliares (verificados)
| Item | Uso |
|---|---|
auth::required() -> Guard | 401 se anônimo. Coloque em rotas de coleção (list, create). |
auth::owns_resource(table, owner_col, id_param) -> Guard | Rotas de item (GET/DELETE /things/{id}). Carrega a linha, 404 se ausente OU não for sua, a armazena em req.ctx. .slot("name") para múltiplos carregamentos. |
auth::find_owned(table, owner_col) -> Result<Vec<Row>, _> | Lista com escopo de principal para endpoints de índice. 401 quando anônimo. |
auth::load_owned(table, owner_col, id) -> Result<Option<Row>, _> | Carregamento único + verificação de propriedade para MCP/JSON-RPC (id no corpo, não no caminho). None = ausente OU não é sua. |
auth::require_scope(scope) -> Guard | Portão de capacidade grosseira: 401 se anônimo, 403 se logado mas sem o escopo. |
A coluna de proprietário é sempre DEFAULT_OWNER_COL ("owner_principal") — nunca
invente owner_id/created_by. Conecte rotas de item como
.group([auth::owns_resource("things", DEFAULT_OWNER_COL, "id")], |g| …).
Handlers atrás desse guarda leem a linha armazenada em ctx — não a busque novamente.
Regra de proprietário a partir do token: ao criar, estampe a coluna de proprietário a partir de
auth::current_principal(). NUNCA leia o proprietário do corpo da requisição
— um cliente poderia reivindicar o id de outro usuário.
Lei de Ferro: negar por máscara de existência
Ausente e existe-mas-não-é-seu ambos retornam 404 — NUNCA 403. Uma invariante de segurança da plataforma, não uma escolha de estilo. Uma divisão 403-vs-404 é um oráculo de enumeração: um usuário autenticado testa ids e descobre quais existem (403) versus quais não existem (404). Os guardas do SDK impõem isso.
"Mas a UI quer dizer ao usuário o porquê." Faça isso no lado do cliente: trate
404 uniformemente como "não disponível para você" e renderize essa mensagem; o
status do fio permanece 404. Não bifurque ou corrija o guarda do SDK para quebrar
a máscara. (O 403 do require_scope é um eixo diferente — capacidade
grosseira, não propriedade de recurso — e está correto.)
Chaves de API para chamadores programáticos (receita verificada)
Invoque api_keys_glue!(bindings) ao lado de wit_glue!, então:
api_key_routes::install_table()eminit_tables.- Monte rotas de gerenciamento via trait de extensão
ApiKeyRoutes:Router::new().with_api_key_routes()(/_keys) ou.with_api_key_routes_at("/admin/keys"). - Proteja suas rotas:
.group([api_key_routes::guard], |g| ...).
| Fato | Detalhe |
|---|---|
| Credencial dupla | guard aceita um bearer de sessão OU uma chave sk_*; ambos se unificam em current_principal(), então owns_resource/find_owned funcionam sem alterações. |
| Gerenciar chaves | Requer uma identidade de sessão; uma chave sk_* não pode criar chaves. |
| Sem escalonamento | Uma chave carrega apenas escopos que o criador já possui (403 caso contrário). |
| Armazenamento | Chaves vivem hashadas no próprio armazenamento do serviço; nunca crie sua própria tabela. |
| Formato | sk_<env>_<…>_<crc>. |
Bandeiras vermelhas
| Pensamento | Realidade |
|---|---|
| "403 'não é seu' diz ao usuário o porquê." | É um oráculo de enumeração de id. Retorne 404 para ambos; explique no lado do cliente. |
| "Vou verificar a propriedade no handler após carregar a linha." | owns_resource faz carregamento + verificação + armazenamento em ctx em um guarda. Leia a linha armazenada; não reimplemente a verificação. |
| "Vou adicionar uma tabela customizada de chaves de API." | api_keys_glue! fornece uma tabela hashada, isolada e ciente de escopo. Use-a. |
| "Leia o id do proprietário do corpo da requisição." | Estampe-o a partir de current_principal(). O corpo é controlado pelo atacante. |
"Uma rota pública [[ingress.routes]] ainda precisa de uma verificação de auth no wasm." | Pública significa que qualquer um a acessa — autentique-a de outra forma (assinatura HMAC para webhooks). A substituição não se autoprotege. |
Integração
← boogy:designing-boogy-services escolhe o modo de ingresso alimentando a camada
boogy:boogy-account-authé de onde vêm os principais (login → o token que você lê). Para agir em nome de um usuário entre serviços, vejaboogy:boogy-obo-delegation. Uma exceção pública por rota para um callback assinado é a metade frontal deboogy:boogy-webhooks.