Roles y permisos sobre @core/auth: comodines, permisos sobre lo propio y concesiones sin escalada
Instalar
genpm add @core/rbacQué obtienes
- Código en src/lib/rbac/, 10 archivos. (20,1 kB)
- Reglas de IA en src/lib/rbac/AGENTS.md, más archivos de reglas para tu IDE.
- Resuelve @core/auth, @core/contracts, @core/db por ti.
README
Este paquete no tiene README.
Esto es exactamente lo que lee tu IA cuando trabaja en src/lib/rbac. No se añade nada más a su contexto.
@core/rbac — rules for AI agents
Purpose
Authorization on top of @core/auth users: roles stored in the database, permissions resource:action with wildcards
(posts:*, *) and own-only grants (posts:update:own), and a role hierarchy (rank) that prevents privilege
escalation. Default roles: owner, admin, editor, support, author. Not authentication, not multi-tenancy.
Map
index.ts— public API:can,assertCan,getGrants,grantRole,revokeRole,bootstrapOwner,defineRole,ensureDefaultRoles.permissions.ts— pure matchergrants()(no DB).defaults.ts— default roles and their permissions.schema.ts— tablesroles,user_roles. Depends on../dband../auth.adapters/hono.ts—requirePermission(perm)middleware.adapters/next.ts—requirePermission(user, perm),permissionResponse(user, perm).admin.ts—usersAdminResourcefor @core/admin (users with roles; grant/revoke actions withusers:manage).
Integration
- Generate and apply migrations (see
src/lib/db/AGENTS.md). - At startup or in a setup script:
await ensureDefaultRoles(). - Make the first user owner once, e.g. in the
onLoginhook of @core/auth:onLogin: async ({ user, isNewUser }) => { if (isNewUser) await bootstrapOwner(user.id); }(only the first call wins). - Hono:
app.use(sessionMiddleware); app.post('/api/posts/:id/publish', requirePermission('posts:publish'), handler). Next.js:const user = await getUser(await cookies()); await requirePermission(user, 'posts:publish'); - Own-only checks:
await assertCan(user.id, 'posts:update', { ownerId: post.authorId }). - Verify: a user without roles gets 403; the owner gets 200.
Conventions
- Permission names: lowercase
resource:action(orders:refund). Actions in use: read, create, update, delete, publish, manage. - Check permissions on the server for every write and every private read; hiding UI is not authorization.
- Load
getGrants(userId)once per request and pass it tocan()when checking several permissions. - Change roles only through
grantRole/revokeRole(they enforce the hierarchy and keep at least one owner).
Don't
- Don't insert into
user_rolesdirectly, and don't add an env flag or "god mode" that bypassescan(). - Don't expose
defineRoleto users without checkingusers:manageand that the new rank is below the actor's. - Don't trust a role or permission sent by the client.
# @core/rbac — rules for AI agents
## Purpose
Authorization on top of @core/auth users: roles stored in the database, permissions `resource:action` with wildcards
(`posts:*`, `*`) and own-only grants (`posts:update:own`), and a role hierarchy (`rank`) that prevents privilege
escalation. Default roles: owner, admin, editor, support, author. Not authentication, not multi-tenancy.
## Map
- `index.ts` — public API: `can`, `assertCan`, `getGrants`, `grantRole`, `revokeRole`, `bootstrapOwner`, `defineRole`, `ensureDefaultRoles`.
- `permissions.ts` — pure matcher `grants()` (no DB).
- `defaults.ts` — default roles and their permissions.
- `schema.ts` — tables `roles`, `user_roles`. Depends on `../db` and `../auth`.
- `adapters/hono.ts` — `requirePermission(perm)` middleware. `adapters/next.ts` — `requirePermission(user, perm)`, `permissionResponse(user, perm)`.
- `admin.ts` — `usersAdminResource` for @core/admin (users with roles; grant/revoke actions with `users:manage`).
## Integration
1. Generate and apply migrations (see `src/lib/db/AGENTS.md`).
2. At startup or in a setup script: `await ensureDefaultRoles()`.
3. Make the first user owner once, e.g. in the `onLogin` hook of @core/auth:
`onLogin: async ({ user, isNewUser }) => { if (isNewUser) await bootstrapOwner(user.id); }` (only the first call wins).
4. Hono: `app.use(sessionMiddleware); app.post('/api/posts/:id/publish', requirePermission('posts:publish'), handler)`.
Next.js: `const user = await getUser(await cookies()); await requirePermission(user, 'posts:publish');`
5. Own-only checks: `await assertCan(user.id, 'posts:update', { ownerId: post.authorId })`.
6. Verify: a user without roles gets 403; the owner gets 200.
## Conventions
- Permission names: lowercase `resource:action` (`orders:refund`). Actions in use: read, create, update, delete, publish, manage.
- Check permissions on the server for every write and every private read; hiding UI is not authorization.
- Load `getGrants(userId)` once per request and pass it to `can()` when checking several permissions.
- Change roles only through `grantRole`/`revokeRole` (they enforce the hierarchy and keep at least one owner).
## Don't
- Don't insert into `user_roles` directly, and don't add an env flag or "god mode" that bypasses `can()`.
- Don't expose `defineRole` to users without checking `users:manage` and that the new rank is below the actor's.
- Don't trust a role or permission sent by the client.
El árbol exacto que se inyectará, tras aplicar .genpmignore. Anclado a
// Roles de usuario en BD y comprobaciones con jerarquía (anti escalada de privilegios).
import { and, count, eq, inArray } from 'drizzle-orm';
import { type Executor, getDb, withTransaction } from '../db/index.ts';
import { DEFAULT_ROLES, OWNER_ROLE, type RoleDefinition } from './defaults.ts';
import { grants, isValidPermission, type PermissionContext } from './permissions.ts';
import { type Role, roles, userRoles } from './schema.ts';
export type RbacErrorCode = 'forbidden' | 'unknown_role' | 'last_owner' | 'invalid_permission' | 'system_role';
export class RbacError extends Error {
constructor(
readonly code: RbacErrorCode,
message: string = code,
) {
super(message);
this.name = 'RbacError';
}
}
/** Permisos efectivos y rango máximo de un usuario. */
export type Grants = { userId: string; roles: string[]; permissions: string[]; rank: number };
export async function getGrants(userId: string, db: Executor = getDb()): Promise<Grants> {
const rows = await db
.select({ key: roles.key, permissions: roles.permissions, rank: roles.rank })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(eq(userRoles.userId, userId));
return {
userId,
roles: rows.map((r) => r.key).sort(),
permissions: [...new Set(rows.flatMap((r) => r.permissions))],
rank: Math.max(0, ...rows.map((r) => r.rank)),
};
}
/** ¿Puede el usuario hacer `permission`? Acepta el id o unos `Grants` ya cargados (evita consultas repetidas). */
export async function can(
user: string | Grants,
permission: string,
ctx: PermissionContext = {},
db: Executor = getDb(),
): Promise<boolean> {
const g = typeof user === 'string' ? await getGrants(user, db) : user;
return grants(g.permissions, permission, g.userId, ctx);
}
/** Como `can`, pero lanza `RbacError('forbidden')`. */
export async function assertCan(
user: string | Grants,
permission: string,
ctx: PermissionContext = {},
db: Executor = getDb(),
): Promise<void> {
if (!(await can(user, permission, ctx, db))) throw new RbacError('forbidden', `missing permission ${permission}`);
}
/** Crea o actualiza un rol (idempotente por `key`). */
export async function defineRole(def: RoleDefinition, db: Executor = getDb()): Promise<Role> {
const bad = def.permissions.find((p) => !isValidPermission(p));
if (bad) throw new RbacError('invalid_permission', `invalid permission ${bad}`);
const system = DEFAULT_ROLES.some((r) => r.key === def.key);
const [row] = await db
.insert(roles)
.values({ ...def, system })
.onConflictDoUpdate({
target: roles.key,
set: { label: def.label, rank: def.rank, permissions: def.permissions },
})
.returning();
return row!;
}
/** Crea los roles por defecto que falten (no pisa los que el usuario haya editado). */
export async function ensureDefaultRoles(db: Executor = getDb()): Promise<void> {
await db
.insert(roles)
.values(DEFAULT_ROLES.map((r) => ({ ...r, permissions: [...r.permissions], system: true })))
.onConflictDoNothing({ target: roles.key });
}
async function roleByKey(key: string, db: Executor): Promise<Role> {
const [role] = await db.select().from(roles).where(eq(roles.key, key));
if (!role) throw new RbacError('unknown_role', `unknown role ${key}`);
return role;
}
/** Comprueba que `actor` puede gestionar `role`: necesita `users:manage` y rango ≥ el del rol (owner solo lo da un owner). */
async function assertCanManage(actorId: string, role: Role, db: Executor): Promise<void> {
const g = await getGrants(actorId, db);
const allowed =
grants(g.permissions, 'users:manage', actorId) &&
g.rank >= role.rank &&
(role.key !== OWNER_ROLE || g.roles.includes(OWNER_ROLE));
if (!allowed) throw new RbacError('forbidden', `cannot manage role ${role.key}`);
}
/** Primer `owner` del sitio (instalación): solo funciona si aún no hay ninguno. */
export async function bootstrapOwner(userId: string, db: Executor = getDb()): Promise<boolean> {
await ensureDefaultRoles(db);
const owner = await roleByKey(OWNER_ROLE, db);
return withTransactionOn(db, async (tx) => {
// Bloquea la fila del rol para serializar dos instalaciones simultáneas.
await tx.select({ id: roles.id }).from(roles).where(eq(roles.id, owner.id)).for('update');
const [n] = await tx.select({ n: count() }).from(userRoles).where(eq(userRoles.roleId, owner.id));
if ((n?.n ?? 0) > 0) return false;
await tx.insert(userRoles).values({ userId, roleId: owner.id });
return true;
});
}
/** `actor` concede `roleKey` a `userId`. Idempotente. */
export async function grantRole(actorId: string, userId: string, roleKey: string, db: Executor = getDb()): Promise<void> {
const role = await roleByKey(roleKey, db);
await assertCanManage(actorId, role, db);
await db.insert(userRoles).values({ userId, roleId: role.id }).onConflictDoNothing();
}
/** `actor` retira `roleKey` a `userId`. Nunca deja el sitio sin `owner`. */
export async function revokeRole(actorId: string, userId: string, roleKey: string, db: Executor = getDb()): Promise<void> {
const role = await roleByKey(roleKey, db);
await assertCanManage(actorId, role, db);
await withTransactionOn(db, async (tx) => {
if (role.key === OWNER_ROLE) {
// Bloquea las filas de owners para que dos retiradas simultáneas no dejen el sitio sin dueño.
const owners = await tx.select().from(userRoles).where(eq(userRoles.roleId, role.id)).for('update');
if (owners.length <= 1 && owners.some((o) => o.userId === userId))
throw new RbacError('last_owner', 'cannot remove the last owner');
}
await tx.delete(userRoles).where(and(eq(userRoles.userId, userId), eq(userRoles.roleId, role.id)));
});
}
/** Borra un rol propio de la app (los de sistema no se borran). */
export async function deleteRole(actorId: string, roleKey: string, db: Executor = getDb()): Promise<void> {
const role = await roleByKey(roleKey, db);
if (role.system) throw new RbacError('system_role', `cannot delete system role ${roleKey}`);
await assertCanManage(actorId, role, db);
await db.delete(roles).where(eq(roles.id, role.id));
}
/** Usuarios con alguno de los roles dados (listados del admin). */
export async function usersWithRoles(roleKeys: string[], db: Executor = getDb()): Promise<string[]> {
if (!roleKeys.length) return [];
const rows = await db
.selectDistinct({ userId: userRoles.userId })
.from(userRoles)
.innerJoin(roles, eq(userRoles.roleId, roles.id))
.where(inArray(roles.key, roleKeys));
return rows.map((r) => r.userId);
}
// withTransaction de @core/db usa getDb(); aquí respetamos el Executor recibido (si ya es una transacción, se reutiliza).
function withTransactionOn<T>(db: Executor, fn: (tx: Executor) => Promise<T>): Promise<T> {
return 'rollback' in db ? fn(db) : withTransaction((tx) => fn(tx), db as Parameters<typeof withTransaction>[1]);
}
Este paquete no declara servidores MCP.
| Versión | Commit | Publicado | Escaneo |
|---|---|---|---|
| 1.0.1 | decbadd | hace 5 horas | escaneo superado |
- npm
- ninguno
- propuesta
- GenPM propone el comando npm y solo lo ejecuta si dices que sí.
- Usado por (1)
- @core/admin ^1.0.0
- escaneo
- escaneo superado · 0 hallazgos
- commit
- v1.0.1 → decbadd748476defda5138e5dc02cd5b7086463b · verificado tras la descarga
- scripts
- Ninguno. GenPM nunca ejecuta código del paquete.
- licencia
- MIT
- Calidad
- 100/100
- Licencia reconocidacumplido
- AGENTS.md explica su propósitocumplido
- AGENTS.md tiene pasos de integracióncumplido
- AGENTS.md lista convenciones o prohibicionescumplido
- Incluye testscumplido
- Escaneo de seguridad superadocumplido
- Publicado en los últimos 6 mesescumplido
- Publicador verificadocumplido
- Resumen y palabras clavecumplido
- reporte
- ¿Ves algo raro?