Permisos: la matriz que decide quién cocina qué
Tiempo de lectura: 21 min · Itinerarios: cocinero jefe, CTO, consultor. Lee :cap-14 antes si no conoces la auth básica.
|
Objetivos del capítulo
Al terminar este capítulo vas a poder:
|
Por qué un "allow all" no es buena idea
Por defecto, Backstage aplica una policy de allow all: cualquier usuario autenticado puede hacer cualquier cosa. Es un default razonable para empezar, pero catastrófico en producción.
Una IDP con "allow all" es un restaurante donde cualquiera puede entrar a la cocina, abrir el horno y sazonar la carne. Sí, técnicamente funciona. Pero el día que entre alguien con la piel sensible al cilantro, arderá Troya.
El Permission Framework (introducido en 1.20, GA en 1.24) es la respuesta. No es RBAC tradicional: es un sistema de políticas en TypeScript que devuelve decisiones, no roles.
Conceptos fundamentales
Cuatro conceptos que necesitas dominar:
| Concepto | Definición |
|---|---|
Permission |
Una tupla |
Resource |
El objeto sobre el que se ejecuta la acción. Ej: una |
Policy |
Función TypeScript |
Conditional decision |
Una decisión que depende de información del recurso. El plugin dueño del recurso evalúa la condición. Ej: "allow if user is entity owner". |
|
El framework es code-first, no config-first. No hay un |
El Permission Framework es como una cocina profesional con un jefe que decide qué pasa. El jefe no delega a una hoja de cálculo: mira al cocinero, mira el plato, y decide con su experiencia. Si externalizas la decisión a un config, el día que cambies de jefe necesitarás reescribir la hoja de cálculo.
El flujo de una decisión
|
Decisión y enforcement son dos cosas distintas. La policy decide (ALLOW/DENY/CONDITIONAL). El plugin backend enforces la decisión: si es DENY, devuelve 403. Esto es por diseño: el framework no debería tener que conocer la estructura interna del catalog, scaffolder o techdocs. Cada plugin implementa su propio enforcement. |
Tu primera policy: solo owners pueden editar
Empecemos con el caso más común: un usuario puede ver todas las entidades del catalog, pero solo puede editar las que le pertenecen.
// packages/backend/src/modules/permissionPolicy.ts
import { createBackendModule, coreServices } from '@backstage/backend-plugin-api';
import { PolicyDecision, AuthorizeResult, isPermission } from '@backstage/plugin-permission-common';
import {
catalogConditions,
createCatalogConditionalDecision,
} from '@backstage/plugin-permission-backend-module-catalog';
import {
catalogEntityReadPermission,
catalogEntityUpdatePermission,
catalogEntityDeletePermission,
} from '@backstage/plugin-catalog-backend';
class OwnerBasedPolicy {
async handle(request: PolicyQuery): Promise<PolicyDecision> {
const { user } = request;
// 1. READ: todos los usuarios autenticados pueden ver
if (isPermission(request.permission, catalogEntityReadPermission)) {
return { result: AuthorizeResult.ALLOW };
}
// 2. UPDATE: solo el owner
if (isPermission(request.permission, catalogEntityUpdatePermission)) {
return createCatalogConditionalDecision(
request.permission,
catalogConditions.isEntityOwner({
claims: user?.info.ownershipEntityRefs ?? [],
}),
);
}
// 3. DELETE: solo admins del grupo `platform-admins`
if (isPermission(request.permission, catalogEntityDeletePermission)) {
const isPlatformAdmin = user?.info.memberOf?.some(
(g) => g.split('/').pop() === 'platform-admins',
);
return isPlatformAdmin
? { result: AuthorizeResult.ALLOW }
: { result: AuthorizeResult.DENY };
}
// Por defecto: DENY (fail-closed)
return { result: AuthorizeResult.DENY };
}
}
export default createBackendModule({
pluginId: 'permission',
moduleId: 'owner-based-policy',
register(env) {
env.registerInit({
deps: { logger: coreServices.logger },
async init({ logger }) {
logger.info('Owner-based policy registered');
// Aquí registras la policy en el PermissionBackend
},
});
},
});
|
Fail-closed by default. Si no manejas un permission, devuelve DENY. Si devuelves ALLOW por defecto y olvidas manejar un nuevo permission cuando un plugin lo introduce, queda un agujero de seguridad. La forma de testear esto: añadir un test que itere sobre todos los permissions conocidos y verifique que la policy los maneja. |
Frontend: ocultando lo que el usuario no puede hacer
El backend hace enforcement, pero el frontend debe ocultar las acciones que el usuario no puede ejecutar. La forma estándar es usar el hook usePermission:
// packages/app/src/components/EntityCard.tsx
import { usePermission } from '@backstage/plugin-permission-react';
import { catalogEntityUpdatePermission } from '@backstage/plugin-catalog-backend';
export const EditableEntityCard = ({ entity }: Props) => {
const { allowed, loading } = usePermission({
permission: catalogEntityUpdatePermission,
resourceRef: entity.metadata.name,
});
if (loading) return <CircularProgress />;
if (!allowed) {
// El usuario no puede editar: mostrar vista de solo lectura
return <ReadOnlyEntityCard entity={entity} />;
}
return <EditableEntityCardContent entity={entity} />;
};
|
El hook |
Componente <RequirePermission>
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityUpdatePermission } from '@backstage/plugin-catalog-backend';
<RequirePermission
permission={catalogEntityUpdatePermission}
resourceRef={entity.metadata.name}
errorPage={<NoPermissionMessage />}
>
<EditButton onClick={openEditDialog} />
</RequirePermission>
<RequirePermission> reemplaza el contenido con errorPage si el usuario no tiene permiso. Útil para vistas completas.
Conditional decisions: el patrón elegante
Una conditional decision le dice al Permission Framework: "permite si X es verdad". Pero X no se evalúa en el momento de la decisión: se delega al plugin dueño del recurso.
Una conditional decision es como decirle al jefe de cocina: "este cocinero puede cocinar este plato si él es el chef de esa sección". El jefe no necesita saber qué hace cada sección; pregunta al chef de sección, que sí lo sabe. Si cambias de organización, el chef evalúa en tiempo real.
Ejemplo: "un usuario puede editar un componente si es member del grupo team-X-owners Y el componente pertenece al team X":
// En el catalog processor
const customCondition = createCatalogConditionalDecision(
request.permission,
{
pluginId: 'catalog',
resourceType: 'catalog-entity',
params: {
team: extractTeamFromEntity(entity),
},
// La condición se evalúa en el catalog, no en la policy
evaluate: (params, request) => {
const user = request.user;
const isTeamMember = user?.info.memberOf?.some(
(g) => g === `group:team-${params.team}-owners`,
);
return isTeamMember;
},
},
);
El framework no necesita saber cómo evaluar la condición. El plugin catalog lo hace en su propio database query:
// En el catalog backend
const entities = await db.entities({
filter: { 'metadata.namespace': 'default' },
conditions: { isTeamMember: { team: 'payments' } },
});
|
Por qué conditional decisions son importantes: te permiten mantener la policy simple y empujar la lógica al plugin que tiene el dato. Si añades 50 teams, no tocas la policy; añades condiciones dinámicas en el catalog. |
Integración con Open Policy Agent (OPA)
Para organizaciones que ya tienen políticas en OPA (Kubernetes admins, SRE teams, finanzas), puedes conectar el Permission Framework de Backstage a Rego:
// packages/backend/src/modules/opa-policy.ts
import { createBackendModule } from '@backstage/backend-plugin-api';
import axios from 'axios';
import { AuthorizeResult, PolicyDecision, isPermission } from '@backstage/plugin-permission-common';
interface OpaInput {
user: { name: string; groups: string[] };
permission: { name: string; resource?: string };
resource?: { kind: string; namespace: string; name: string };
}
class OpaPolicy {
async handle(request: PolicyQuery): Promise<PolicyDecision> {
const input: OpaInput = {
user: {
name: request.user?.identity.userEntityRef ?? 'unknown',
groups: (request.user?.info.memberOf ?? []).map((g) => g.split('/').pop()!),
},
permission: {
name: request.permission.name,
resource: 'resourceRef' in request.permission ? request.permission.resourceRef : undefined,
},
resource: this.extractResource(request),
};
const { data } = await axios.post(
'http://opa-server:8181/v1/data/backstage/allow',
{ input },
{ timeout: 200 },
);
if (data.result === true) {
return { result: AuthorizeResult.ALLOW };
}
return { result: AuthorizeResult.DENY };
}
}
Y la política en Rego:
# policies/backstage.rego
package backstage
default allow = false
# Admins pueden hacer todo
allow {
input.user.groups[_] == "platform-admins"
}
# Owners pueden editar sus entidades
allow {
input.permission.name == "catalog.entity.update"
data.entities[input.resource.name].owner == input.user.name
}
# Tech writers pueden leer techdocs
allow {
input.permission.name == "catalog.entity.read"
input.resource.kind == "Component"
input.user.groups[_] == "tech-writers"
}
|
OPA introduce latencia (~50-200ms por request). Cachea las decisiones localmente cuando puedas. Para el catalog, cachea por 5 minutos: las decisiones no cambian en tiempo real. |
RBAC legacy → Permission Framework: migración incremental
Si vienes del RBAC legacy (plugin backstage-plugin-rbac de Roadie o permission-backend-module-rbac del core), no tienes que migrar todo de golpe:
Fase 1: ejecutar ambos en paralelo
// app-config.yaml
permission:
enabled: true
rbac:
pluginsWithPermission: [] # RBAC legacy no se aplica a ningún plugin
policies:
- name: 'legacy-rbac'
policy: './legacy-rbac-policy.ts' # el viejo
- name: 'new-permission'
policy: './new-permission-policy.ts' # el nuevo
Fase 2: traducir policy-by-policy
Empieza con policies simples (ej: "platform-admins puede todo"). Tradúcelas al nuevo formato. Compara decisiones en logs.
Fase 3: cambiar el default
Una vez que el nuevo framework maneja todos los plugins, cambia el default a new-permission y elimina el legacy.
Migrar de RBAC legacy a Permission Framework es como cambiar de una cocina de gas a una de inducción. Puedes hacerlo de un fogón a la vez: la primera semana migras el que más usas, validas que todo funciona, y sigues con los demás. El día que un cocinero se queje de que un fogón no calienta, es porque está mal migrado.
Los 5 errores más comunes
|
Errores típicos
|
Auditar decisiones de permiso
Para detectar intentos de acceso indebido, el Permission Framework emite eventos. Captúralos:
// PermissionEventListener
class AuditListener {
async onEvent(event: PermissionEvent) {
if (event.decision === AuthorizeResult.DENY) {
logger.warn({
msg: 'Permission denied',
user: event.user.identity.userEntityRef,
permission: event.permission.name,
resource: event.resourceRef,
timestamp: new Date().toISOString(),
});
// Enviar al SIEM
await siem.send({
event_type: 'permission_denied',
severity: event.permission.name.includes('delete') ? 'high' : 'medium',
// ...
});
}
}
}
|
Métrica útil: ratio de denegaciones por usuario. Un usuario con 50 denegaciones por hora es probablemente un atacante interno probando permisos. Alerta cuando un usuario supere un umbral razonable (ej: 100/hora). |
Resumen
-
El Permission Framework es code-first, no config-first. Las políticas son funciones TypeScript testeables.
-
Hay tres tipos de decisión: ALLOW, DENY, CONDITIONAL. Conditional delega la evaluación al plugin dueño del recurso.
-
El frontend debe usar
usePermissiono<RequirePermission>para ocultar acciones no permitidas. -
Para equipos grandes, integra con OPA o Casbin para reutilizar políticas existentes.
-
Migración desde RBAC legacy puede ser incremental: corre ambos en paralelo, traduce policy-by-policy.
-
Audita denegaciones: una denegación es información útil para detectar ataques.
Glosario del capítulo
- Permission
-
Tupla
(action, resource)que un usuario quiere ejecutar. - Policy
-
Función TypeScript que devuelve ALLOW, DENY o CONDITIONAL.
- Conditional decision
-
Decisión que depende del estado del recurso, evaluada por el plugin dueño.
- OPA
-
Open Policy Agent, motor de políticas declarativas con Rego.
- Rego
-
Lenguaje de políticas de OPA.
- Fail-closed
-
Comportamiento por defecto: si no manejas un permission, devuelve DENY.
- Permission caching
-
Cachear decisiones de permiso para evitar re-evaluar en cada request.
Próximo capítulo
Capítulo 20 — Software Templates avanzados: el golden path completo — anatomy de un template con multi-step, secretos, conditional execution, custom fields, dry-run, y marketplace interno de templates.