Skip to content

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:

TablaResponsabilidad
authz_outboxCola transaccional de relaciones a sincronizar con OpenFGA

Conceptos del schema OpenFGA:

ConceptoSignificado
TypeTipo de objeto (business, branch, booking...)
RelationRelación entre dos objetos (owner, professional, customer...)
TupleHecho almacenado: business:glamour#owner@account:carlos
ActionAcción compuesta derivada de relaciones (can_cancel, can_manage_bookings)
CheckQuery 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.

TupleSignificadoCuándo se escribeCuándo se borra
account:X#active@account:XCuenta activaAlta de cuenta, reactivaciónSuspensión (directo, no outbox)
account:X#email_verified@account:XEmail verificadoVerificación exitosaNunca (MVP)
business:X#active@business:XNegocio activoCreación del negocioSuspensión (directo, no outbox)
branch:X#active@account:*Sede activaCreación de la sedeSuspensió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:main

Lectura 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_associations y can_respond_branch_reviews sin 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_biz y branch: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_outbox con 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_outbox en la misma transacción DB que muta sus tablas.
  • RF-8 Worker authz_sync consume 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 Check a 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 Check jamás puede bloquearse por fallos del store: si OpenFGA no responde, se responde deny (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) retorna true (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 ni can_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 Forbidden

Flujo 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_at

Flujo 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 hold

Flujo 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) → false

Flujo 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 → activo

8. Dependencias

DominioTipo
Todos los demásConsumen authz.Check
accountsProductor principal de tuples (businesses, branches, membresías)
catalog, scheduling, booking, reviewsProductores 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 dominio messaging.

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)

Documentación interna — BeautyHub