Casi todo sistema de autorización empieza con una sola pregunta: ¿cuál es el rol del usuario? Y funciona, mientras el dominio también sea simple. El problema aparece cuando la misma decisión pasa a depender de cosas de naturaleza distinta y alguien intenta resolverlo todo estirando la misma jerarquía de roles.
Este texto es un recorte de una arquitectura que diseñé para la autorización humana en sistemas multi-tenant. No es Zanzibar entero, no es un motor de políticas genérico y no mete toda la regla dentro de la base de datos. Es una elección deliberada: tomar de cada tradición de control de acceso la abstracción que resuelve bien un problema concreto, y solo esa.
Cuando un role deja de ser suficiente
El comienzo suele ser así:
viewer < editor < admin < superadmin
if user.role.rank >= required_rank:
allow()Parece económico, pero mezcla conceptos. Un administrador puede necesitar dar de alta a personas sin leer contenido sensible. Un analista puede ejecutar una operación, pero solo sobre los datos de su propia unidad. Alguien de otra área puede recibir un informe concreto sin pasar a formar parte de esa área. Y un operador de plataforma puede necesitar atravesar tenants en una emergencia sin que el nombre de su role se convierta en contraseña maestra en cada handler.
No es que falten niveles en la jerarquía. Es que la jerarquía no es la abstracción correcta para todas esas preguntas. Cuando se insiste, aparecen los síntomas de siempre: rank mágico, excepción ad hoc, permission por departamento, if admin esparcido por el código, recursos compartidos que dan más poder del que deberían y un aislamiento entre clientes que depende de la disciplina de cada endpoint.
Las cuatro preguntas
Una decisión de autorización madura se descompone en cuatro dimensiones independientes:
| Pregunta | Mecanismo | Papel |
|---|---|---|
| ¿Qué puede hacer el sujeto? | RBAC con permissions | Capacidad |
| ¿De qué unidades forma parte? | Membership organizacional | Partición |
| ¿Se compartió este objeto con él? | Relación tipada, à la ReBAC | Excepción puntual |
| ¿Esta fila pertenece al tenant? | Row Level Security | Aislamiento |
Las cuatro pueden dar la misma respuesta final, permitir o denegar, pero no tienen la misma semántica. Meterlas todas en una columna role o en una tabla genérica de grants es el camino más corto hacia una política que nadie consigue explicar ni probar.
Una pregunta de autorización, un mecanismo principal.
Hay una forma sencilla de darse cuenta de que los ejes tienen que estar separados: mirar cómo cambia cada uno. La capacidad cambia cuando cambia el puesto de la persona. La partición cambia cuando cambia el organigrama. El acceso compartido cambia por una acción puntual de colaboración. El tenant casi nunca cambia durante la vida de una identidad. Son eventos con frecuencia, dueño, rastro y revocación distintos, y ponerlo todo en el mismo registro obliga al sistema a tratar como iguales cosas que no lo son.
Capacidad: RBAC sin ranks mágicos
El núcleo clásico de RBAC sigue en pie: los usuarios reciben roles, los roles agregan permissions y la autorización consulta el conjunto efectivo. Lo que rechazo es el role como número. Un role es un conjunto declarado. Si uno contiene al otro, es porque se declaró así, y no porque uno se llevó el 3 y el otro el 2.
VIEWER = { profile.manage, content.view }
ANALYST = VIEWER ∪ {
analysis.run, data.query,
content.manage, content.share
}
TENANT_ADMIN = ANALYST ∪ {
users.read, users.write,
authz.roles.read, authz.roles.assign_tenant
}Así el poder se vuelve inspeccionable. Se puede cambiar la composición de un role sin reconstruir la semántica de toda la jerarquía, y el conjunto efectivo pasa a ser una unión simple:
effective(u) =
permissions(base_role)
∪ permissions(roles de u)
∪ grants directos válidos(u)La política es monótona: solo existe concesión, no existe deny explícito. Es una elección, no un olvido. El deny cambia la precedencia y el análisis de todo, y merece tratarse como una evolución de arquitectura, no como una columna más.
Un permission es una capacidad atómica, con el formato dominio.recurso.acción. Antes de que uno entre en el catálogo, pasa por tres preguntas:
- ¿Existe hoy un punto real de enforcement para él?
- ¿Existe un role que lo tiene y otro que no?
- ¿Backend y frontend van a usar exactamente el mismo código?
Si la primera respuesta es no, el permission es documentación sin efecto. Si la segunda es no, la granularidad es teatral. Si la tercera es no, la interfaz y la API van a divergir. El síntoma más común de un catálogo deshonesto es un permission por paso de pantalla, workflow.upload, workflow.preview, workflow.map, que ningún role diferencia. Eso no expresa poder, solo copia la estructura de la UI.
Y no todo permission puede concederse dentro de un tenant. Cosas como platform.tenant.bypass y authz.roles.assign_platform viven al otro lado de una frontera, y el CI verifica que ningún role de tenant contenga ninguno de ellos. Esa propiedad importa más que el nombre de cualquier role.
Partición: el organigrama no es catálogo
Si una empresa tiene decenas de unidades, codificar eso en permissions acaba en content.view.finance, content.view.hr, content.view.branch_001. El organigrama se vuelve código, y cada reorganización se vuelve un deploy. El permission deja de representar una capacidad y pasa a representar una instancia de dato.
La partición es membership, y la membership es dato:
org_unit(id, tenant_id, name)
user_org_unit(user_id, org_unit_id)
resource.org_unit_idUna decisión que parece pequeña y no lo es: el recurso pertenece a la unidad, no a la persona que lo creó. Si el autor cambia de área, puede perder el acceso a lo que produjo. Suena contraintuitivo, pero preserva el principio de que el activo es de la organización.
Y cuidado con el legado. Un sistema en migración siempre tiene datos antiguos sin unidad. Tratar org_unit_id IS NULL como “legado” para siempre es peligroso, porque todo objeto nuevo que nazca sin unidad hereda la misma excepción histórica. El legado tiene que marcarse como legado, en una columna propia, y un objeto nuevo no puede nacer con esa marca.
Relación: compartir es una excepción, no membership
Recibir un informe de otra área no convierte a nadie en miembro de esa área. Es el caso perfecto para una relación tipada, y es la parte de Zanzibar que de verdad uso. No necesito namespace global, zookies ni grafo distribuido. La idea útil es más pequeña:
Una excepción de acceso sobre un objeto concreto debe ser una relación explícita, revocable y auditable.
resource_share(
resource_id,
grantee_user_id,
granted_by_user_id,
granted_at
)Tres reglas acompañan a la relación. El share es intra-tenant por defecto: si compartir entre clientes es un requisito, merece un modelo aparte, y no puede surgir como efecto colateral de una tabla sin constraint. Quien recibe no gana el poder de volver a compartir, porque recibir viewer no es autoridad de concesión. Y compartir exige capacidad y autoridad sobre el objeto, expresadas en permission y predicado, nunca en if user.role == "admin".
can_share(u, r, grantee) :=
has(u, content.share)
AND same_tenant(u, r, grantee)
AND can_view_resource(u, r)
AND ( created_by(r) = u
OR has(u, content.view_all_partitions) )Agregado no es microdato
Esta es la distinción más fácil de perder. Un informe y el dataset que lo originó son objetos de autorización distintos, y cada uno tiene su predicado:
can_view_resource(u, r) :=
same_tenant(u, r)
AND ( in_same_partition(u, r)
OR shared_with(u, r)
OR has_all_partitions_view(u)
OR is_legacy_unpartitioned(r) )
can_access_data_context(u, c) :=
same_tenant(u, c)
AND ( in_same_partition(u, c)
OR is_legacy_unpartitioned(c) )La ausencia de shared_with en el segundo predicado es deliberada. Compartir la presentación no puede convertirse en acceso al dato bruto. Por el mismo motivo, content.view_all_partitions significa ver agregados de todas las unidades, y no consultar los microdatos de todas ellas. Si el negocio necesita las dos cosas, son dos capacidades. El nombre de un permission tiene que reflejar su alcance real.
Y particionar solo el informe es una barrera cosmética. Si la fuente no tiene autorización propia, basta con que alguien descubra el identificador del contexto y llame al endpoint directamente.
Aislamiento: la base de datos garantiza el piso
Row Level Security tiene aquí una responsabilidad estrecha: impedir el acceso cross-tenant. La policy es lo bastante simple como para caber en dos líneas y ser auditada:
tenant_id = current_tenant()
OR current_platform_bypass() = trueLa base de datos no sabe quién es viewer, analyst o dueño de un share. Capacidad, partición y relación se quedan en la aplicación. Duplicarlo todo en el RLS y en el código crea un doble mantenimiento, y una corrección hecha en un lado no llega al otro. Si algún día la partición pasa a la base de datos, que se genere a partir de la misma especificación.
La base de datos garantiza el piso. La aplicación garantiza la política.
Pero el RLS de tenant impide que el tenant A vea al tenant B; no impide que el departamento A vea al departamento B. Una vez que existen particiones, un repositorio que hace get_by_id(id) y confía en el RLS deja de ser seguro. La respuesta aquí es exigir un AccessContext en toda lectura de contenido, dejar los repositorios sensibles privados al módulo de autorización y hacer que el CI falle cuando un router importa uno de ellos directamente. La revisión de PR ayuda, pero no puede ser la única barrera.
Quién puede dar poder a quién
La regla “solo concede quien posee” parece segura y no lo es. Si un admin tiene todos los permissions de admin, el subset check le deja crear otro admin. Al mismo tiempo, obliga a quien solo necesita conceder una capacidad a poseerla. La autoridad de concesión tiene que modelarse directamente, en una matriz explícita:
role_grantable(grantor_role, target_role)
tenant_admin -> viewer, analyst
platform_admin -> tenant_admin, viewer, analystUsar una capacidad y asignársela a otra persona son operaciones distintas. Dar de alta una identidad y conceder autoridad también, así que son endpoints distintos. Escribí más sobre esto en Quién puede dar poder a quién.
Sesión con fecha de caducidad
Casi todo sistema guarda el conjunto efectivo en la sesión para no recalcularlo todo en cada request. Está bien, siempre que la caché sepa cuándo ha envejecido. Y envejece de tres formas: cuando cambia el usuario, cuando cambia el catálogo y cuando el reloj pasa la hora de un grant temporal. Cada una necesita un mecanismo propio, y la última no puede depender de un cron. El detalle está en Los permisos en caché envejecen de tres formas.
El frontend refleja, el backend decide
El navegador está fuera de la frontera de confianza. Ocultar un botón no es autorización, y un claim de role que viene del cliente no es fuente de verdad. El permission tiene el mismo código en los dos extremos, require_permission("analysis.run") en el servidor y hasPermission("analysis.run") en la interfaz, y solo el servidor decide.
| Frontend | Backend | Resultado |
|---|---|---|
| oculta | bloquea | correcto |
| oculta | solo observa | rollout, no enforcement |
| muestra | bloquea | mala UX |
| muestra | permite sin gate | vulnerabilidad |
En una migración, vale la pena ejecutar la autorización en shadow mode: el predicado se evalúa y la decisión se registra, pero la denegación todavía no bloquea. Eso mide el impacto real antes del cutover. Pero shadow no es seguridad, y quedarse ahí indefinidamente es deuda medible. Toda ruta autenticada declara un permission o se clasifica explícitamente como sin gate, y el CI compara el inventario de rutas con el catálogo.
Lo que quedó fuera a propósito
- Todo en ReBAC. Se puede modelar la membership y la herencia como relación, pero, para particiones estables, el coste en grafo, consistencia y tooling no compensa. El rechazo es pragmático, no teórico.
- Un motor ABAC genérico. Uso atributos como
tenant_id,org_unit_idyexpires_at, pero en predicados cerrados y testeables. Un lenguaje de políticas arbitrario es difícil de revisar y de explicar. - Todo en el RLS. El enforcement cerca del dato es excelente para el piso; para las reglas de producto se convierte en un versionado difícil y en duplicación.
- Share como role nuevo. Convierte una excepción sobre un objeto en poder global.
Para cerrar
La autorización madura no es una secuencia de if role == ..., pero tampoco necesita ser un grafo global distribuido. En la mayoría de los sistemas de aplicación, el problema cabe en cuatro preguntas: qué puede hacer la persona, a qué unidad pertenece el dato, si existe una excepción sobre ese objeto y si la fila es del tenant correcto. RBAC, membership, relación tipada y RLS, cada uno en su lugar.
El mejor efecto colateral es la disciplina de evolución. Una unidad nueva no exige un permission nuevo. Un share nuevo no cambia el role de nadie. Una capacidad nueva no toca el tenant. Cada eje evoluciona a su ritmo.
Capacidad no es partición, partición no es compartir, compartir no es tenant, agregado no es microdato e interfaz no es enforcement.