Autorización de Boogy (autorización en el servicio)
La autorización en Boogy consta de tres capas. No implementes ninguna de ellas manualmente: el SDK emite ayudantes verificados que mantienen intactas las invariantes de seguridad.
El modelo de tres capas
- Admisión de ingreso: el modo de ingreso de la plataforma decide si una solicitud llega a tu servicio. Cuando tu código se ejecuta, el solicitante ya es un principal conocido o anónimo.
- Guardias protegen rutas: las comprobaciones por ruta se ejecutan antes del manejador: requieren un inicio de sesión, o requieren la propiedad del recurso abordado.
- Ayudantes limitan filas: los manejadores listan/cargan solo las filas del solicitante.
Resuelve al solicitante con auth::current_principal() -> Option<String>.
El principal es opaco: nunca lo analices, elimines prefijos o asumas un UUID; úsalo solo como valor de tu columna de propietario y como entrada para auth::*.
Anulación de ingreso por ruta: una ruta pública dentro de un servicio restringido
La capa 1 (ingreso) normalmente tiene un solo mode para todo el servicio. Pero a veces una ruta debe ser accesible por solicitantes que el resto del servicio rechaza; el caso canónico es un receptor público /webhook dentro de un servicio que por lo demás es authenticated (o internal/mixed). Exclúyelo con una anulación por ruta; el resto del servicio permanece restringido:
[ingress]
mode = "authenticated" # valor predeterminado para todo el servicio — todo lo demás
[[ingress.routes]]
path = "/webhook" # ruta relativa al servicio; TODOS los métodos
mode = "public" # cualquiera puede acceder a /webhook
El contrato de seguridad (léelo — falla en modo cerrado):
| Regla | Por qué es importante |
|---|---|
Rutas literales relativas al servicio. Exactas (/webhook) o prefijo (/stripe/*, con límite de segmento). Sin captura {param}. | La path es la ruta que ve tu Router, el mismo marco que tus manejadores. |
| Gana la coincidencia más específica; exactamente un modo se aplica. La exacta vence al prefijo; el prefijo más largo vence al más corto. | Una subruta pública nunca puede ampliar una hermana: /webhook pública no afloja /data. |
Predeterminado = el modo de todo el servicio. Una ruta no coincidente (o un intento de traversal ..) cae al predeterminado restringido, nunca a la ruta más permisiva. | Olvidar listar una ruta la deja protegida, no expuesta. |
Cada anulación lleva su PROPIO modo + listas de permitidos. Una anulación NO hereda allowed_agents/allowed_origins. | Enuméralos en el bloque de ruta; se aplica la misma validación de no vacío, por lo que un error tipográfico no puede denegar todo silenciosamente. |
Por RUTA, no por método (por ahora). path = "/webhook" se aplica a GET, POST, etc. por igual. | Un /webhook público es accesible por cualquier verbo, así que aún debes validar la solicitud en el manejador (un GET accidental no debería hacer nada). |
| Aplicado por el host en el borde. El ingreso se ejecuta antes de que tu wasm se instancie. | NO autogestiones una ruta pública en código; pero una ruta pública significa que cualquiera puede acceder a ella — autentícala de alguna otra manera (por ejemplo, una firma HMAC; consulta boogy:boogy-webhooks). |
| La puerta de delegación + el límite de tasa permanecen en TODO EL SERVICIO. | Una exclusión pública no puede eludir la puerta [ingress.delegation] y comparte el depósito de límite de tasa. |
Un manifiesto sin [[ingress.routes]] se comporta exactamente como antes — esto es puramente aditivo.
Lo inverso: un subárbol RESTRINGIDO dentro de un servicio abierto (solo propietario /admin)
La otra dirección: un servicio cuyo valor predeterminado es abierto (authenticated) pero cuyo subárbol /admin/* solo es accesible por el propietario del servicio (el provisionador). La trampa: un módulo provisionable es desplegado por cualquiera, por lo que NO debes codificar una identidad (@alice, boogy://alice/services/*) en el manifiesto — ese propietario literal es incorrecto para cualquier otro provisionador, y las cadenas de la lista de permitidos de ingreso no se sustituyen en el momento del despliegue. El ingreso no tiene un comparador de "mismo propietario que yo".
Usa la capacidad sin puerta caller_is_service_owner() — el host atestigua si el solicitante es el propietario DE ESTE servicio (su token de agente, resuelto en el lado del host contra el registro de agentes, O una de sus propias cargas de trabajo). Sin ingreso por ruta, sin identidad codificada:
[ingress]
mode = "authenticated" # todas las rutas; el manejador protege /admin por sí mismo
// require_operator(): atestiguado por el host, nada codificado.
fn require_operator() -> Result<(), ApiError> {
if caller_is_service_owner() { return Ok(()); } // el propietario: su agente O su propia carga de trabajo
// OBO: el backend del propietario actuando en nombre de un usuario — el actor ATESTIGUADO es la carga de trabajo.
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("solo operador"))
}
Por qué este es el primitivo correcto: el propietario humano puede hacer curl a /admin directamente con su propio token (el wasm no puede resolver el identificador de un agente, pero el host sí — eso es lo que hace la capacidad), Y el backend del propietario funciona como una carga de trabajo. Falla en modo cerrado: anónimo, un propietario diferente o un solicitante irresoluble → false. (Consulta el módulo de catálogo resend-base.) El patrón anterior de "internal + carga de trabajo del mismo propietario" también funciona, pero EXCLUYE el curl humano directo — prefiere caller_is_service_owner para superficies solo del propietario.
Referencia rápida de guardias y ayudantes (verificados)
| Elemento | Uso |
|---|---|
auth::required() -> Guard | 401 si es anónimo. Colócalo en rutas de colección (list, create). |
auth::owns_resource(table, owner_col, id_param) -> Guard | Rutas de elemento (GET/DELETE /things/{id}). Carga la fila, 404 si falta O no es tuya, la guarda en req.ctx. .slot("name") para múltiples cargas. |
auth::find_owned(table, owner_col) -> Result<Vec<Row>, _> | Lista con ámbito de principal para endpoints de índice. 401 cuando es anónimo. |
auth::load_owned(table, owner_col, id) -> Result<Option<Row>, _> | Carga única + comprobación de propiedad para MCP/JSON-RPC (id en el cuerpo, no en la ruta). None = falta O no es tuya. |
auth::require_scope(scope) -> Guard | Puerta de capacidad general: 401 si es anónimo, 403 si ha iniciado sesión pero carece del ámbito. |
La columna de propietario siempre es DEFAULT_OWNER_COL ("owner_principal") — nunca inventes owner_id/created_by. Conecta las rutas de elemento como .group([auth::owns_resource("things", DEFAULT_OWNER_COL, "id")], |g| …). Los manejadores detrás de ese guardia leen la fila guardada en ctx — no vuelvas a buscarla.
Regla de propietario desde el token: al crear, sella la columna de propietario desde auth::current_principal(). NUNCA leas el propietario del cuerpo de la solicitud — un cliente podría reclamar el ID de otro usuario.
Ley de hierro: denegación por máscara de existencia
Falta y existe-pero-no-es-tuya ambas devuelven 404 — NUNCA 403. Es una invariante de seguridad de la plataforma, no una elección de estilo. Una división 403 vs 404 es un oráculo de enumeración: un usuario autenticado prueba IDs y aprende cuáles existen (403) versus cuáles no (404). Los guardias del SDK aplican esto.
"Pero la UI quiere decirle al usuario por qué." Hazlo del lado del cliente: trata 404 de manera uniforme como "no disponible para ti" y muestra ese mensaje; el estado en la red sigue siendo 404. NO bifurques ni parchees el guardia del SDK para dividir la máscara. (El 403 de require_scope es un eje diferente — capacidad general, no propiedad del recurso — y es correcto.)
Claves API para solicitantes programáticos (receta verificada)
Invoca api_keys_glue!(bindings) junto a wit_glue!, luego:
api_key_routes::install_table()eninit_tables.- Monta las rutas de gestión a través del trait de extensión
ApiKeyRoutes:Router::new().with_api_key_routes()(/_keys) o.with_api_key_routes_at("/admin/keys"). - Protege tus rutas:
.group([api_key_routes::guard], |g| ...).
| Hecho | Detalle |
|---|---|
| Credencial dual | guard acepta un bearer de sesión O una clave sk_*; ambas se unifican en current_principal(), por lo que owns_resource/find_owned funcionan sin cambios. |
| Gestión de claves | Requiere una identidad de sesión; una clave sk_* no puede crear claves. |
| Sin escalada | Una clave solo lleva los ámbitos que el creador ya posee (403 en caso contrario). |
| Almacenamiento | Las claves viven hasheadas en el almacén del propio servicio; nunca crees tu propia tabla. |
| Formato | sk_<env>_<…>_<crc>. |
Banderas rojas
| Pensamiento | Realidad |
|---|---|
| "403 'no es tuyo' le dice al usuario por qué." | Es un oráculo de enumeración de IDs. Devuelve 404 para ambos; explica del lado del cliente. |
| "Comprobaré la propiedad en el manejador después de cargar la fila." | owns_resource hace carga + comprobación + guardado en ctx en un solo guardia. Lee la fila guardada; no reimplementes la comprobación. |
| "Agregaré una tabla de api_keys personalizada." | api_keys_glue! proporciona una tabla hasheada, aislada y consciente de ámbitos. Úsala. |
| "Leeré el ID del propietario del cuerpo de la solicitud." | Séllalo desde current_principal(). El cuerpo está controlado por el atacante. |
"Una ruta pública [[ingress.routes]] aún necesita una comprobación de autenticación dentro del wasm." | Pública significa que cualquiera puede acceder a ella — autentícala de otra manera (firma HMAC para webhooks). La anulación no se autoprotege. |
Integración
← boogy:designing-boogy-services elige el modo de ingreso que alimenta la capa 1. boogy:boogy-account-auth es de donde provienen los principales (inicio de sesión → el token que lees). Para actuar en nombre de un usuario a través de servicios, consulta boogy:boogy-obo-delegation. Una exclusión pública por ruta para un callback firmado es la primera mitad de boogy:boogy-webhooks.