feat(auth): role-based permissions + user management (plan phase 1)
Adds the RBAC foundation the CRUD phases build on, and the first write module (users). The platform was read-only: every controller was guarded only by AuthenticatedGuard and UserRole was ADMIN|STAFF. The old PHP app stored level+role but enforced neither, so this is a fresh design. Permission model (server-authoritative): - UserRole expanded to an ordered rank ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the read-only role; STAFF+ can write. - auth/abilities.ts: ROLE_RANK + ABILITY_MIN matrix + can()/abilitiesFor(). - @RequireAbility decorator + AbilityGuard enforce it on write routes; reads stay on AuthenticatedGuard so any logged-in user can read. - /auth/login and /auth/me now return the resolved abilities map, so the web gates its UI off one payload instead of duplicating the rules. User management (ADMIN-only, ability "user:manage"): - UsersService gains list/create/update/resetPassword (argon2), never returns passwordHash; blocks self-deactivation and self-demotion; maps duplicate email to 409. - UsersController: GET/POST /users, PATCH /users/:id, POST /users/:id/reset-password. - Every mutation logged via new AuditService over the existing ActivityLog model (global CommonModule). Web: - AuthContext + useAuth/useCan; AppShell provides the user and gates the new "Usuarios" nav entry on user:manage; shows the user's role. - /usuarios admin page: list + create/edit form + password reset + active toggle, Spanish-first, reusing existing card/table/field styles. Schema pushed to dev (enum only, non-destructive). Verified end-to-end against dev: admin CRUD works, VIEWER writes 403 while reads 200, self-lockout guards and duplicate-email 409 all hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
// Server-authoritative permission matrix. Roles form an ordered rank
|
||||
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
|
||||
// write action carries a minimum rank. VIEWER holds rank 0 and is the
|
||||
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
|
||||
// alone, so any logged-in user (including VIEWER) can read.
|
||||
//
|
||||
// This is the single source of truth: the API enforces it via AbilityGuard and
|
||||
// ships the resolved per-user map to the web through /auth/me, so the UI never
|
||||
// keeps its own copy of the rules.
|
||||
|
||||
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||
|
||||
export const ROLE_RANK: Record<Role, number> = {
|
||||
VIEWER: 0,
|
||||
STAFF: 1,
|
||||
MANAGER: 2,
|
||||
ADMIN: 3,
|
||||
};
|
||||
|
||||
export type Ability =
|
||||
| "customer:create"
|
||||
| "customer:update"
|
||||
| "customer:delete"
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
| "ledger:create"
|
||||
| "ledger:void"
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "lookup:manage"
|
||||
| "user:manage";
|
||||
|
||||
/** Minimum role required for each ability. */
|
||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"customer:create": "STAFF",
|
||||
"customer:update": "STAFF",
|
||||
"customer:delete": "ADMIN",
|
||||
"policy:create": "STAFF",
|
||||
"policy:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
"property:create": "STAFF",
|
||||
"property:update": "STAFF",
|
||||
"property:delete": "MANAGER",
|
||||
"ledger:create": "STAFF",
|
||||
"ledger:void": "MANAGER",
|
||||
"bank:create": "STAFF",
|
||||
"bank:void": "MANAGER",
|
||||
"lookup:manage": "MANAGER",
|
||||
"user:manage": "ADMIN",
|
||||
};
|
||||
|
||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||
|
||||
export function can(role: Role, ability: Ability): boolean {
|
||||
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
|
||||
}
|
||||
|
||||
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
|
||||
export function abilitiesFor(role: Role): Record<Ability, boolean> {
|
||||
return Object.fromEntries(
|
||||
ALL_ABILITIES.map((a) => [a, can(role, a)]),
|
||||
) as Record<Ability, boolean>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { ABILITY_KEY } from "./require-ability.decorator";
|
||||
import { Ability, Role, can } from "./abilities";
|
||||
|
||||
/**
|
||||
* Enforces the ability matrix (abilities.ts) against req.user.role. A route
|
||||
* with no @RequireAbility passes through untouched — this guard only gates the
|
||||
* routes that declare one. It does NOT check authentication; always list it
|
||||
* after AuthenticatedGuard so an unauthenticated request is rejected first.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AbilityGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ability = this.reflector.getAllAndOverride<Ability | undefined>(
|
||||
ABILITY_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
if (!ability) return true;
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const user = req.user as { role?: Role } | undefined;
|
||||
if (!user?.role || !can(user.role, ability)) {
|
||||
throw new ForbiddenException("No tiene permisos para esta acción");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,14 @@ import { Request, Response } from "express";
|
||||
import { LocalAuthGuard } from "./local-auth.guard";
|
||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||
import { LoginDto } from "./login.dto";
|
||||
import { abilitiesFor, Role } from "./abilities";
|
||||
|
||||
/** Attach the resolved ability map so the web can gate its UI off one payload. */
|
||||
function withAbilities(user: unknown) {
|
||||
const u = user as { role?: Role } | undefined;
|
||||
if (!u?.role) return u;
|
||||
return { ...u, abilities: abilitiesFor(u.role) };
|
||||
}
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
@@ -13,13 +21,13 @@ export class AuthController {
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
|
||||
return req.user;
|
||||
return withAbilities(req.user);
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@Get("me")
|
||||
me(@Req() req: Request) {
|
||||
return req.user;
|
||||
return withAbilities(req.user);
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import type { Ability } from "./abilities";
|
||||
|
||||
export const ABILITY_KEY = "required_ability";
|
||||
|
||||
/**
|
||||
* Tags a write route with the ability it requires. Pair with
|
||||
* `@UseGuards(AuthenticatedGuard, AbilityGuard)` — AuthenticatedGuard proves
|
||||
* the session, AbilityGuard checks this ability against the user's role.
|
||||
*/
|
||||
export const RequireAbility = (ability: Ability) =>
|
||||
SetMetadata(ABILITY_KEY, ability);
|
||||
Reference in New Issue
Block a user