Ir al contenido

RBAC

Una app declara qué roles pueden montarla. El shell declara quién es el usuario actual. wu.mount() rechaza los que no encajan.

Lee esto primero: esta es una imposición del lado del cliente. Se ejecuta en el navegador, en código que controla el usuario. Mejora la corrección y la experiencia de uso —un módulo no autorizado nunca monta, nunca renderiza, nunca emite— pero cualquiera con devtools puede llamar a wu.setPrincipal({ permissions: ['mount:*'] }) y montar lo que le apetezca. La barrera dura es que tu servidor se niegue a servir el bundle y se niegue a atender las llamadas de API que hay detrás. El RBAC de aquí es el segundo cerrojo, no el primero.

Puede ser en la configuración de registro de la app, o en su manifiesto wu.json bajo wu.roles (o un roles de primer nivel):

await wu.init({
apps: [
{ name: 'catalog', url: '...' }, // público
{ name: 'admin', url: '...', roles: ['admin'] }, // restringido
{ name: 'reports', url: '...', roles: ['admin', 'analyst'] },
],
});
{
"name": "admin",
"wu": { "roles": ["admin"] }
}

Una app sin roles —o con un array vacío— es pública y monta siempre. La configuración tiene prioridad sobre el manifiesto.

setPrincipal(principal: {
role?: string;
roles?: string[];
permissions?: string[];
[key: string]: unknown;
} | null): Principal | null

Establece quién está usando el sistema. Devuelve el principal efectivo.

Campo Significado
role Un único rol en forma de cadena.
roles Roles adicionales. role y roles se respetan a la vez.
permissions Concesiones explícitas. mount:* o mount:<appName> saltan la comprobación de roles.

Cualquier otro campo que añadas viaja intacto — útil para un id de usuario o un nombre para mostrar que el shell quiera tener en un solo sitio.

// Después de que tu flujo de autenticación resuelva
wu.setPrincipal({
id: 'u_1138',
role: 'analyst',
roles: ['reporting-beta'],
permissions: ['mount:audit-log'],
});
// Al cerrar sesión
wu.setPrincipal(null);

Casos límite. Nunca lanza. Un argumento falsy se normaliza a null, lo que significa que se deniegan todas las apps restringidas. El principal también se refleja en el store en auth.principal, para que apps y devtools puedan leerlo sin una referencia al framework — esa escritura es de mejor esfuerzo y se traga el error si el store no está disponible. Emite principal:changed con { principal } en el bus de eventos, autenticado con un token interno para que sobreviva al modo estricto.

Asignar un principal no desmonta las apps que ya están montadas. Solo afecta a las llamadas posteriores a mount(). Si una bajada de privilegios de sesión debe desmontar los módulos abiertos, hazlo tú:

wu.setPrincipal(lesserPrincipal);
for (const name of wu.getStats().apps) {
if (!wu.can(name)) await wu.unmount(name, { force: true });
}
getPrincipal(): Principal | null

Devuelve el principal actual, o null si no hay ninguno.

const who = wu.getPrincipal();
console.log(who?.role ?? 'anonymous');

Casos límite. Devuelve el objeto vivo por referencia: mutarlo muta la copia del framework sin emitir principal:changed. Llama de nuevo a setPrincipal() en su lugar.

can(appName: string): boolean

¿Se le permitiría al principal actual montar esta app? Úsalo para decidir qué renderizar antes de intentarlo.

if (wu.can('admin')) {
await wu.mount('admin', '#admin-slot');
} else {
renderAccessDenied();
}

La decisión, en orden:

  1. La app no declara roles → true. Las apps públicas siempre pasan.
  2. No hay principal → false.
  3. permissions incluye mount:* o mount:<appName>true.
  4. Alguno de los roles del principal (de role más roles[]) aparece en los roles requeridos por la app → true.
  5. En cualquier otro caso → false.

Casos límite. Nunca lanza, ni siquiera para un nombre de app que nunca se registró: una app desconocida no tiene roles declarados, así que can() devuelve true para ella. Es una consecuencia deliberada de “público por defecto”, pero implica que can() no es una comprobación de registro. La comparación de roles es una igualdad exacta de cadenas: no hay jerarquía, ni herencia, ni comodines en los nombres de rol. Un rol admin no implica analyst; enumera ambos.

wu.mount() evalúa can() antes que cualquier otra cosa: antes del conteo de referencias, antes del sandbox, antes de cualquier petición de red. Una denegación es un lanzamiento limpio y temprano, sin efectos secundarios.

try {
await wu.mount('admin', '#slot');
} catch (err) {
if (err.code === 'WU_ACCESS_DENIED') {
console.warn(err.appName, 'requiere', err.required);
renderFallback();
} else {
throw err;
}
}

El error lleva:

Propiedad Valor
err.code 'WU_ACCESS_DENIED'
err.appName La app que fue rechazada
err.required Los roles que declara
err.message Legible por humanos, nombrando el rol actual y lo que se requería

También se disparan dos notificaciones, para que un shell pueda renderizar un fallback sin envolver cada montaje en un try:

// En el bus de eventos
wu.on('access:denied', (e) => {
const { appName, role, required } = e.data;
showAccessDenied(appName, required);
});
// En el DOM, para código sin referencia a wu
window.addEventListener('wu:access:denied', (e) => {
console.warn(e.detail);
});
import { setPrincipal, getPrincipal, can } from 'wu-framework';
setPrincipal({ role: 'admin' });
can('admin'); // true
  • Solo del lado del cliente. Se salta trivialmente desde una consola. Combínalo con imposición en el servidor: no sirvas un bundle al que el usuario no tiene derecho.
  • Se impone al montar, no de forma continua. Cambiar el principal no desmonta las apps en marcha, y una app ya montada sigue funcionando.
  • Solo mount() está controlado. wu.update(), el store, el bus de eventos y los contratos de capacidad no tienen comprobaciones de rol. Una app denegada no puede montar, pero cualquier código de la página puede seguir leyendo el store y emitiendo eventos.
  • Roles planos. Comparación exacta de cadenas, sin jerarquía y sin comodines en los nombres de rol (solo en permissions, y solo para el verbo mount:).
  • Las apps desconocidas están permitidas. can('typo') devuelve true, porque una app sin roles declarados es pública.
  • El principal no es una credencial. Es una afirmación local sobre el usuario. Nada lo verifica, y cualquier app de la página puede leerlo en store.get('auth.principal') — así que no metas un token dentro.