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:

  • Diseñar una política de permisos con conditional decisions.

  • Implementar el patrón "los owners pueden editar, los demás solo ver".

  • Integrar el Permission Framework con Open Policy Agent (OPA).

  • Detectar y arreglar los 5 errores más comunes del Permission Framework.

  • Migrar del RBAC legacy al framework moderno de forma incremental.

  • Auditar decisiones de permiso para detectar intentos de acceso indebido.

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 (action, resource) que un usuario quiere ejecutar. Ej: catalog.entity.read, scaffolder.template.execute.

Resource

El objeto sobre el que se ejecuta la acción. Ej: una Component del catalog, un Template del scaffolder.

Policy

Función TypeScript (request: PermissionRequest) ⇒ PolicyDecision. Recibe el permiso, devuelve ALLOW, DENY, o CONDITIONAL.

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 permissions.yaml que exportar y olvidarse. Cada decisión es una función TypeScript que se testea como cualquier otra unidad de código. Esto es deliberado: las decisiones de autorización son lógica de negocio, no configuración.

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

Diagram
Figure 21. Flujo de una decisión de permiso

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 usePermission es opt-in: el Permission Framework no oculta automáticamente los componentes. Cada desarrollador debe llamar usePermission para cada acción que requiera gating. Si no lo hace, el usuario ve el botón, hace click, y recibe un 403. Es UX mala pero no un agujero de seguridad.

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
  1. Permission denied silencioso: devuelves DENY en una policy pero el frontend no llama usePermission para esa acción. El usuario ve el botón, hace click, recibe 403 sin contexto. Fix: siempre emparejar usePermission (frontend) con la policy (backend).

  2. Cache de permisos obsoleto: cacheas decisiones en el frontend y los permisos del usuario cambian pero la cache no se invalida. Fix: usar usePermission (no el hook de cache) o implementar invalidación por evento.

  3. Policy circular: la policy A consulta el catalog para verificar si el usuario es owner, y el catalog consulta a la policy A para verificar si el usuario puede leer. Fix: usar conditional decisions en lugar de lookups síncronos dentro de la policy.

  4. Performance: cada request de permission hace 5 lookups de LDAP, 3 queries SQL, 2 calls a OPA. Latencia de 800ms por click. Fix: cachear decisiones por user+permission, invalidar al cambiar de grupo.

  5. Forget fail-closed: una policy que devuelve ALLOW por defecto y olvida manejar un permission nuevo deja un agujero. Fix: tests que iteren sobre TODOS los permissions conocidos y verifiquen que la policy los maneja explícitamente.

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 usePermission o <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.