PRD — Dominio 02: authz
Autorización. Encapsula OpenFGA como motor. Responde a una sola pregunta para toda la plataforma: ¿el usuario X puede ejecutar la acción Y sobre el recurso Z?
1. Propósito
Proveer un único punto de decisión de autorización para todos los dominios. Todos los checks de permisos se realizan aquí; ningún otro dominio implementa lógica de permisos por su cuenta.
Modelo: ReBAC (Relationship-Based Access Control), implementado con OpenFGA (inspirado en Google Zanzibar, CNCF).
2. Entidades y conceptos
authz no tiene tablas propias en la DB operativa (sus datos viven en OpenFGA). En la DB operativa solo existe:
| Tabla | Responsabilidad |
|---|---|
authz_outbox | Cola transaccional de relaciones a sincronizar con OpenFGA |
Conceptos del schema OpenFGA:
| Concepto | Significado |
|---|---|
| Type | Tipo de objeto (business, branch, booking...) |
| Relation | Relación entre dos objetos (owner, professional, customer...) |
| Tuple | Hecho almacenado: business:glamour#owner@account:carlos |
| Action | Acción compuesta derivada de relaciones (can_cancel, can_manage_bookings) |
| Check | Query binaria: ¿user tiene permission sobre object? |
3. Schema (DSL OpenFGA 1.1)
Patrón: estado como tuple positivo
El estado de cuentas, negocios y sedes se modela como tuples de presencia positiva en OpenFGA — no como atributos en la DB. Ausencia del tuple = entidad inactiva/suspendida.
| Tuple | Significado | Cuándo se escribe | Cuándo se borra |
|---|---|---|---|
account:X#active@account:X | Cuenta activa | Alta de cuenta, reactivación | Suspensión (directo, no outbox) |
account:X#email_verified@account:X | Email verificado | Verificación exitosa | Nunca (MVP) |
business:X#active@business:X | Negocio activo | Creación del negocio | Suspensión (directo, no outbox) |
branch:X#active@account:* | Sede activa | Creación de la sede | Suspensión/archivo (directo, no outbox) |
Excepción al patrón outbox (RN-7): los deletes de estado crítico (suspensiones) llaman a OpenFGA directamente — en la misma transacción DB — para eliminar el lag. El resto de los writes de relación siguen el outbox normal.
OpenFGA 1.1 soporta intersección (and) y exclusión (but not), lo que permite componer estado + relación en una sola acción sin lógica adicional en los handlers.
model
schema 1.1
type platform
relations
define admin: [account]
define moderator: [account] or admin
type account
relations
define self: [account]
define active: [account] # WRITE al crear/reactivar · DELETE al suspender (directo)
define email_verified: [account] # WRITE al verificar email
define can_read_self: self or admin from platform:main
define can_update_self: self and active
define can_transact: self and active and email_verified # prerequisito para reservas y businesses
type business
relations
define owner: [account]
define active: [business] # WRITE al crear · DELETE al suspender (directo)
define billing_viewer: [account] # delegable: acceso a reportes financieros
define can_read: owner or admin from platform:main
define can_update: owner and active
define can_delete: owner and active
define can_view_billing: owner or billing_viewer or admin from platform:main
define can_manage_billing_account: owner
type branch
relations
define parent: [business]
define owner: owner from parent
define active: [account:*] # WRITE al crear (wildcard) · DELETE al suspender/archivar (directo)
define member: [account]
define professional_member: [account]
# Roles delegables
define schedule_manager: [account]
define booking_manager: [account]
define service_manager: [account]
define service_approver: [account]
define associate_inviter: [account]
# Lecturas: sin gate de estado (historial siempre accesible)
define can_read: member or owner or admin from platform:main
define can_read_bookings: owner or booking_manager
# Escrituras: gateadas por active (sede suspendida bloquea mutaciones)
define can_update: owner and active
define can_delete: owner and active
define can_manage_schedule: (owner or schedule_manager) and active
define can_manage_bookings: (owner or booking_manager) and active
define can_create_bookings_for_others: (owner or booking_manager) and active
define can_manage_services: (owner or service_manager) and active
define can_approve_services: (owner or service_approver) and active
define can_invite_professionals: (owner or associate_inviter) and active
# Sin gate: aplican aunque la sede esté suspendida (fin de asociación, respuesta histórica)
define can_end_associations: owner or associate_inviter
define can_respond_branch_reviews: owner
type professional_profile
relations
define owner: [account]
define can_read: [account, account:*]
define can_update: owner
type service_template
relations
define can_read: [account, account:*]
define can_manage: admin from platform:main
type professional_service
relations
define owner: [account]
define can_update: owner
define can_delete: owner
type branch_service
relations
define branch: [branch]
define proposer: [account]
define can_read: [account, account:*]
define can_approve: can_approve_services from branch
define can_manage: can_manage_services from branch or proposer
type booking
relations
define customer: [account]
define located_at: [branch]
define professional: [account]
define can_read: customer or professional or can_read_bookings from located_at
define can_cancel: customer or professional or can_manage_bookings from located_at
define can_confirm: professional or can_manage_bookings from located_at
define can_complete: professional or can_manage_bookings from located_at
define can_create_for_others: can_create_bookings_for_others from located_at
type review
relations
define author: [account]
define professional: [account]
define located_at: [branch]
define can_read: [account, account:*]
define can_update: author
define can_delete: author or moderator from platform:main
define can_respond: professional or can_respond_branch_reviews from located_at
define can_hide: moderator from platform:main
define can_restore: moderator from platform:mainLectura clave del schema
owner: owner from parent→ herencia automática: el dueño del business es dueño de todas sus branches sin replicar la relación.can_transact: self and active and email_verified→ cliente con cuenta suspendida o sin email verificado no puede reservar. Un solo Check lo garantiza en toda la plataforma.active: [account:*]en branch → wildcard: mientras el tuple exista, cualquier cuenta pasa el gate. Al suspender la sede, el DELETE elimina el wildcard y todos los checks de escritura fallan.can_manage_bookings: (owner or booking_manager) and active→ intersección: el manager debe existir Y la sede debe estar activa. Si la sede se suspende, ni el dueño puede mutar bookings (solo leer).can_end_associationsycan_respond_branch_reviewssin gate → decisión explícita: terminar una asociación y responder reviews históricas deben funcionar aunque la sede esté suspendida.- Delegación = crear tuple:
branch:chap#booking_manager@account:laura. Revocar = eliminar. Sin entidad intermedia. - Profesional independiente → al registrarse como profesional se crean
business:maria_biz#active@business:maria_bizybranch:maria_virtual#active@account:*automáticamente.
4. Historias de usuario
- U-1. Como cualquier dominio, quiero preguntar "¿puede este usuario hacer esto?" y recibir
true/false. - U-2. Como dominio
accounts, quiero registrar una nueva relación cuando se crea un business, branch o membresía. - U-3. Como dominio
accounts, quiero revocar una relación cuando termina una membresía o se elimina un recurso. - U-4. Como dueño de un business, quiero delegar permisos específicos a un miembro de mi sede.
- U-5. Como plataforma, quiero auditar todas las relaciones que existen en un momento dado para soporte y cumplimiento.
- U-6. Como app (UI), quiero saber qué acciones puede hacer un usuario sobre un recurso para mostrar/ocultar botones sin romper seguridad.
5. Requerimientos funcionales
- RF-1 API interna:
Check(user, relation, object) → bool. - RF-2 API interna:
Write(tuples[]) → void. Idempotente. - RF-3 API interna:
Delete(tuples[]) → void. - RF-4 API interna:
ListObjects(user, relation, type) → [objectIds](para "¿qué businesses gestiona este usuario?"). - RF-5 API interna:
Expand(relation, object)para debugging. - RF-6 Tabla
authz_outboxcon columnas:id, operation (write/delete), tuple, created_at, processed_at, last_error, retry_count. - RF-7 Cada dominio que muta relaciones (crear business, membresías, delegar permiso) escribe a
authz_outboxen la misma transacción DB que muta sus tablas. - RF-8 Worker
authz_syncconsume el outbox y aplica a OpenFGA. Reintentos con backoff exponencial hasta 5 veces. - RF-9 Endpoint público
GET /authz/my-permissions?resource=...para que las apps pinten UI. Retorna lista de acciones permitidas sobre ese recurso. - RF-10 Contextual tuples: el motor soporta reglas basadas en contexto (útil para futuro: permisos con expiración).
- RF-11 Caché de
Checka nivel de middleware HTTP con TTL corto (5s) para reducir latencia en ráfagas. Comportamiento conocido: una cuenta suspendida puede pasar Checks durante hasta 5s tras la suspensión (ventana del cache). Aceptado — la suspensión ya eliminó el tuple síncronamente en OpenFGA (RN-7); el cache local es el único lag. Para operaciones de seguridad crítica donde el admin necesita efecto inmediato, documentar que el efecto real se propaga en ≤5s.
6. Reglas de negocio
- RN-1 La DB operativa es la fuente de verdad para entidades. OpenFGA es la fuente de verdad para relaciones y estado de autorización.
- RN-2 Toda mutación de relación pasa por el outbox — salvo la excepción de RN-7.
- RN-3 El JWT nunca lleva permisos expandidos. Toda decisión de autorización pasa por
Check. - RN-4 Si el schema cambia, se versiona y se hace deploy coordinado. Cambios solo aditivos en fase MVP.
- RN-5 Un
Checkjamás puede bloquearse por fallos del store: si OpenFGA no responde, se respondedeny(fail closed). - RN-6 El worker de outbox debe alertar si un tuple lleva >5 min sin sincronizar (inconsistencia detectable).
- RN-7 Excepción outbox para suspensiones: los deletes de tuples de estado (
active,email_verified) por suspensión de account, business o branch se ejecutan síncronamente contra OpenFGA dentro de la misma transacción DB. Esto elimina el lag del outbox en eventos de seguridad críticos. Si OpenFGA no responde → la TX hace rollback y la suspensión no se aplica (se reintenta). No se acepta "suspendido en DB pero activo en OpenFGA". - RN-8 Un cliente puede reservar si y solo si
authz.Check(client, can_transact, account:client)retornatrue(activo + email verificado). Este check es prerrequisito de cualquier flujo de booking; los handlers no lo duplican. - RN-9 Una sede suspendida bloquea todas las mutaciones (
can_manage_bookings,can_manage_services, etc.) pero no las lecturas nican_end_associations. El dueño puede terminar asociaciones y responder reviews aunque la sede esté suspendida.
7. Flujos críticos
Flujo de autorización de acción:
Cliente → HTTP request con JWT
↓
Middleware valida JWT → extrae account_id
↓
Handler carga recurso target (si aplica) → obtiene resourceId
↓
Handler llama authz.Check(account_id, action, resource:resourceId)
↓
authz consulta OpenFGA
↓
true → ejecuta handler
false → 403 ForbiddenFlujo de registro de nueva relación (outbox):
Dominio (ej: accounts crea un business)
↓
BEGIN TX
↓
INSERT INTO businesses (...)
INSERT INTO authz_outbox (op=write, tuple=business:X#owner@account:Y)
↓
COMMIT TX
↓
Worker authz_sync lee outbox → OpenFGA.Write(tuple) → marca processed_atFlujo de reserva por cliente (checks de estado + relación):
Sofía → POST /bookings/holds
1. authz.Check(sofia, can_transact, account:sofia)
→ verifica: account:sofia#active@account:sofia EXISTS
AND account:sofia#email_verified@account:sofia EXISTS
→ false → 403 account_not_eligible (suspendida o sin verificar)
2. authz.Check(sofia, active, branch:chap)
→ verifica: branch:chap#active@account:* EXISTS
→ false → 403 branch_not_active
3. Handler continúa → valida slot → crea holdFlujo de suspensión de cuenta (delete directo, no outbox):
Diego (admin) → POST /admin/accounts/{id}/suspend
↓
BEGIN TX
UPDATE accounts SET status='suspended' WHERE id=sofiaID
OpenFGA.Delete(tuple: account:sofia#active@account:sofia) ← SÍNCRONO
Si OpenFGA falla → ROLLBACK (suspensión no aplicada, se reintenta)
COMMIT
→ Sofia queda bloqueada en OpenFGA instantáneamente
→ Próximo authz.Check(sofia, can_transact, account:sofia) → falseFlujo de delegación de permiso:
Dueño Carlos → POST /businesses/X/branches/Y/members/Z/permissions
body: { relations: ["booking_manager", "service_approver"] }
↓
authz.Check(Carlos, can_update, branch:Y) → true
↓
Escribe tuples en outbox:
branch:Y#booking_manager@account:Z
branch:Y#service_approver@account:Z
↓
Worker sincroniza → activo8. Dependencias
| Dominio | Tipo |
|---|---|
| Todos los demás | Consumen authz.Check |
accounts | Productor principal de tuples (businesses, branches, membresías) |
catalog, scheduling, booking, reviews | Productores de tuples específicos de sus recursos |
authz no depende de ningún dominio. Es fundacional.
9. Fuera de alcance (MVP)
- Políticas temporales (expiración automática de tuples) — fase 2.
- Plantillas predefinidas de permisos (rol "recepcionista" = paquete de relaciones) — fase 2 con Colaboradores premium.
- UI de administración de relaciones — la plataforma usa scripts/admin endpoints al inicio.
- Migración histórica/auditoría avanzada — OpenFGA soporta audit log, lo exponemos después.
- Permiso
chat_participant— se agregará cuando entre el dominiomessaging.
10. Métricas
- p99 latencia de
Check< 20ms (local gRPC) - 0 tuples en outbox con retry>5 en producción
- Lag promedio outbox → OpenFGA < 500ms
- 0 falsos positivos de autorización (tests exhaustivos del schema)