CRUD de Usuários e Papéis (role weight, permissões, gitignore de worktrees)
- apps/api/src/users/: invite (senha temporária, mesma entropia do legado), PATCH (role/isActive, com checagem de peso), reset de senha administrativo, troca de senha self-service, grant/revoke de acesso a aplicação — cada ação sensível auditada. - apps/api/src/roles/: CRUD de papéis e gestão de role_permissions, exclusivo super_admin (SuperAdminGuard), mirando o requireSuperAdmin do legado — deliberadamente não generalizado para o modelo de permissão resource+action+scope, por ser a raiz do próprio sistema de autorização (documentado em roles.service.ts). - apps/api/src/auth/role-weight.ts: assertCanAssignRole/assertCanActOnUser (eden.md §1.6) — um ator nunca atribui papel nem mexe em usuário com peso maior que o seu; super_admin sempre passa. - .gitignore: .claude/worktrees/ (worktrees temporários de subagentes paralelos nunca devem virar submódulo do repo principal). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -45,3 +45,7 @@ infra/docker/pgdata/
|
|||||||
|
|
||||||
# Claude Code local overrides (personal, never shared)
|
# Claude Code local overrides (personal, never shared)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Temporary git worktrees created for parallel subagent work — each is its
|
||||||
|
# own nested git checkout; never vendor these into the main repo history.
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
@@ -5,9 +5,19 @@ import { ConfigModule } from "./config/config.module.js";
|
|||||||
import { AuthModule } from "./auth/auth.module.js";
|
import { AuthModule } from "./auth/auth.module.js";
|
||||||
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
|
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
|
||||||
import { CompaniesModule } from "./companies/companies.module.js";
|
import { CompaniesModule } from "./companies/companies.module.js";
|
||||||
|
import { UsersModule } from "./users/users.module.js";
|
||||||
|
import { RolesModule } from "./roles/roles.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule, TerminusModule, AuthModule, BootstrapModule, CompaniesModule],
|
imports: [
|
||||||
|
ConfigModule,
|
||||||
|
TerminusModule,
|
||||||
|
AuthModule,
|
||||||
|
BootstrapModule,
|
||||||
|
CompaniesModule,
|
||||||
|
UsersModule,
|
||||||
|
RolesModule,
|
||||||
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { PasswordService } from "./password.service.js";
|
|||||||
import { TokenService } from "./token.service.js";
|
import { TokenService } from "./token.service.js";
|
||||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||||
import { PermissionGuard } from "./permission.guard.js";
|
import { PermissionGuard } from "./permission.guard.js";
|
||||||
|
import { SuperAdminGuard } from "./super-admin.guard.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
|
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||||
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
|
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
41
apps/api/src/auth/role-weight.ts
Normal file
41
apps/api/src/auth/role-weight.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { ForbiddenException } from "@nestjs/common";
|
||||||
|
import { rolesRepo } from "@eden/database";
|
||||||
|
import { SUPER_ADMIN_ROLE } from "./permissions.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role weight (eden.md §1.6, ADR-0004): an actor can never assign a role
|
||||||
|
* heavier than their own, nor act on a user whose *current* role is
|
||||||
|
* heavier than the actor's — orthogonal to the resource/action/scope
|
||||||
|
* permission check. super_admin (weight 100, hardcoded ceiling) always
|
||||||
|
* passes both checks.
|
||||||
|
*/
|
||||||
|
async function getWeight(roleKey: string): Promise<number> {
|
||||||
|
const role = await rolesRepo.getRoleByKey(roleKey);
|
||||||
|
if (!role) throw new ForbiddenException("Papel inválido");
|
||||||
|
return role.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanAssignRole(actorRoleKey: string, targetRoleKey: string): Promise<void> {
|
||||||
|
if (actorRoleKey === SUPER_ADMIN_ROLE) return;
|
||||||
|
const [actorWeight, targetWeight] = await Promise.all([
|
||||||
|
getWeight(actorRoleKey),
|
||||||
|
getWeight(targetRoleKey),
|
||||||
|
]);
|
||||||
|
if (targetWeight > actorWeight) {
|
||||||
|
throw new ForbiddenException("Você não pode atribuir um papel com peso maior que o seu.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanActOnUser(
|
||||||
|
actorRoleKey: string,
|
||||||
|
targetUserRoleKey: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (actorRoleKey === SUPER_ADMIN_ROLE) return;
|
||||||
|
const [actorWeight, targetWeight] = await Promise.all([
|
||||||
|
getWeight(actorRoleKey),
|
||||||
|
getWeight(targetUserRoleKey),
|
||||||
|
]);
|
||||||
|
if (targetWeight > actorWeight) {
|
||||||
|
throw new ForbiddenException("Você não pode alterar um usuário com papel de peso maior que o seu.");
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/api/src/auth/super-admin.guard.ts
Normal file
23
apps/api/src/auth/super-admin.guard.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { SUPER_ADMIN_ROLE } from "./permissions.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role definition and the permission matrix itself (role_permissions) are
|
||||||
|
* the root of the whole authorization system — deliberately kept
|
||||||
|
* super_admin-only and hardcoded, exactly like the legacy's
|
||||||
|
* `requireSuperAdmin` (eden.md §8), rather than generalized to the
|
||||||
|
* resource/action/scope model. Generalizing config-of-the-authorization-
|
||||||
|
* system-itself would let a misconfigured permission accidentally grant
|
||||||
|
* permission-configuration rights — not worth the flexibility.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SuperAdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
if (request.edenUser?.roleKey !== SUPER_ADMIN_ROLE) {
|
||||||
|
throw new ForbiddenException("Exclusivo do super_admin");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/api/src/roles/dto/create-role.dto.ts
Normal file
19
apps/api/src/roles/dto/create-role.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsInt, IsString, Matches, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
/** Same key format as the legacy: lowercase, starts with a letter, 2-30 chars (eden.md §1.2). */
|
||||||
|
export class CreateRoleDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z][a-z0-9_]{1,29}$/, {
|
||||||
|
message: "key deve começar com letra minúscula e conter apenas a-z, 0-9, _ (2-30 caracteres)",
|
||||||
|
})
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
/** 100 (super_admin) is a fixed ceiling, never assignable here — eden.md §1.6. */
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99)
|
||||||
|
weight!: number;
|
||||||
|
}
|
||||||
23
apps/api/src/roles/dto/set-role-permissions.dto.ts
Normal file
23
apps/api/src/roles/dto/set-role-permissions.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsArray, IsIn, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
const ACTIONS = ["view", "create", "edit", "delete", "approve", "export", "manage"] as const;
|
||||||
|
const SCOPES = ["own", "team", "reseller", "legal_entity", "all"] as const;
|
||||||
|
|
||||||
|
export class RolePermissionEntryDto {
|
||||||
|
@IsString()
|
||||||
|
resource!: string;
|
||||||
|
|
||||||
|
@IsIn(ACTIONS)
|
||||||
|
action!: (typeof ACTIONS)[number];
|
||||||
|
|
||||||
|
@IsIn(SCOPES)
|
||||||
|
scope!: (typeof SCOPES)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetRolePermissionsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => RolePermissionEntryDto)
|
||||||
|
permissions!: RolePermissionEntryDto[];
|
||||||
|
}
|
||||||
13
apps/api/src/roles/dto/update-role.dto.ts
Normal file
13
apps/api/src/roles/dto/update-role.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateRoleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(99)
|
||||||
|
weight?: number;
|
||||||
|
}
|
||||||
65
apps/api/src/roles/roles.controller.ts
Normal file
65
apps/api/src/roles/roles.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { RolesService } from "./roles.service.js";
|
||||||
|
import { CreateRoleDto } from "./dto/create-role.dto.js";
|
||||||
|
import { UpdateRoleDto } from "./dto/update-role.dto.js";
|
||||||
|
import { SetRolePermissionsDto } from "./dto/set-role-permissions.dto.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { SuperAdminGuard } from "../auth/super-admin.guard.js";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every write here is super_admin-only (SuperAdminGuard), mirroring the
|
||||||
|
* legacy's requireSuperAdmin for /api/roles and /api/role-permissions —
|
||||||
|
* see roles.service.ts for why this stays hardcoded instead of using the
|
||||||
|
* generic resource/action/scope model. Listing roles is open to any
|
||||||
|
* authenticated user (the users screen needs it to render a role picker).
|
||||||
|
*/
|
||||||
|
@ApiTags("roles")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller("v1/roles")
|
||||||
|
export class RolesController {
|
||||||
|
constructor(private readonly roles: RolesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.roles.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":key/permissions")
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
getPermissions(@Param("key") key: string) {
|
||||||
|
return this.roles.getPermissions(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
create(@Body() dto: CreateRoleDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.roles.create(dto, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":key")
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
update(@Param("key") key: string, @Body() dto: UpdateRoleDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.roles.update(key, dto, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":key")
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
@HttpCode(204)
|
||||||
|
async delete(@Param("key") key: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
await this.roles.delete(key, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":key/permissions")
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
|
setPermissions(
|
||||||
|
@Param("key") key: string,
|
||||||
|
@Body() dto: SetRolePermissionsDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.roles.setPermissions(key, dto, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/roles/roles.module.ts
Normal file
11
apps/api/src/roles/roles.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { RolesController } from "./roles.controller.js";
|
||||||
|
import { RolesService } from "./roles.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [RolesController],
|
||||||
|
providers: [RolesService],
|
||||||
|
})
|
||||||
|
export class RolesModule {}
|
||||||
108
apps/api/src/roles/roles.service.ts
Normal file
108
apps/api/src/roles/roles.service.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { rolesRepo, auditRepo } from "@eden/database";
|
||||||
|
import { SUPER_ADMIN_ROLE } from "../auth/permissions.js";
|
||||||
|
import type { CreateRoleDto } from "./dto/create-role.dto.js";
|
||||||
|
import type { UpdateRoleDto } from "./dto/update-role.dto.js";
|
||||||
|
import type { SetRolePermissionsDto } from "./dto/set-role-permissions.dto.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesService {
|
||||||
|
list() {
|
||||||
|
return rolesRepo.listRoles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPermissions(roleKey: string) {
|
||||||
|
if (roleKey === SUPER_ADMIN_ROLE) {
|
||||||
|
// super_admin never has rows here — bypass is hardcoded, not data-driven (eden.md §2.1 item 4).
|
||||||
|
throw new BadRequestException("super_admin não usa role_permissions — acesso é total por definição.");
|
||||||
|
}
|
||||||
|
const role = await rolesRepo.getRoleByKey(roleKey);
|
||||||
|
if (!role) throw new NotFoundException("Papel não encontrado");
|
||||||
|
return rolesRepo.getRolePermissions(roleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateRoleDto, actorId: string) {
|
||||||
|
const existing = await rolesRepo.getRoleByKey(dto.key);
|
||||||
|
if (existing) throw new ConflictException("Já existe um papel com essa chave");
|
||||||
|
|
||||||
|
const role = await rolesRepo.createRole({
|
||||||
|
key: dto.key,
|
||||||
|
label: dto.label,
|
||||||
|
weight: dto.weight,
|
||||||
|
createdBy: actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "role.created",
|
||||||
|
resourceType: "role",
|
||||||
|
resourceId: role.key,
|
||||||
|
metadata: { weight: role.weight },
|
||||||
|
});
|
||||||
|
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(key: string, dto: UpdateRoleDto, actorId: string) {
|
||||||
|
const role = await this.assertEditableRole(key);
|
||||||
|
const updated = await rolesRepo.updateRole(key, dto);
|
||||||
|
if (!updated) throw new NotFoundException("Papel não encontrado");
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "role.updated",
|
||||||
|
resourceType: "role",
|
||||||
|
resourceId: key,
|
||||||
|
metadata: { fields: Object.keys(dto), previousWeight: role.weight },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string, actorId: string) {
|
||||||
|
const role = await this.assertEditableRole(key);
|
||||||
|
if (role.is_system) {
|
||||||
|
throw new BadRequestException("Papéis do sistema não podem ser excluídos");
|
||||||
|
}
|
||||||
|
const usersWithRole = await rolesRepo.countUsersWithRole(key);
|
||||||
|
if (usersWithRole > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Não é possível excluir: ${usersWithRole} usuário(s) ainda têm este papel`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await rolesRepo.deleteRole(key);
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "role.deleted",
|
||||||
|
resourceType: "role",
|
||||||
|
resourceId: key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPermissions(key: string, dto: SetRolePermissionsDto, actorId: string) {
|
||||||
|
await this.assertEditableRole(key);
|
||||||
|
await rolesRepo.setRolePermissions(key, dto.permissions, actorId);
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "role.permissions_set",
|
||||||
|
resourceType: "role",
|
||||||
|
resourceId: key,
|
||||||
|
metadata: { count: dto.permissions.length },
|
||||||
|
});
|
||||||
|
return rolesRepo.getRolePermissions(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** super_admin is never editable/deletable via API — always promoted/managed via migration (eden.md §2.1 item 7). */
|
||||||
|
private async assertEditableRole(key: string) {
|
||||||
|
if (key === SUPER_ADMIN_ROLE) {
|
||||||
|
throw new BadRequestException("O papel super_admin não pode ser alterado via API");
|
||||||
|
}
|
||||||
|
const role = await rolesRepo.getRoleByKey(key);
|
||||||
|
if (!role) throw new NotFoundException("Papel não encontrado");
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/users/dto/change-password.dto.ts
Normal file
10
apps/api/src/users/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
currentPassword!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8, { message: "A nova senha precisa ter ao menos 8 caracteres" })
|
||||||
|
newPassword!: string;
|
||||||
|
}
|
||||||
19
apps/api/src/users/dto/invite-user.dto.ts
Normal file
19
apps/api/src/users/dto/invite-user.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ArrayNotEmpty, IsArray, IsEmail, IsIn, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class InviteUserDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
roleKey!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fullName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsIn(["core", "reseller", "subscriber"], { each: true })
|
||||||
|
applications?: Array<"core" | "reseller" | "subscriber">;
|
||||||
|
}
|
||||||
15
apps/api/src/users/dto/update-user.dto.ts
Normal file
15
apps/api/src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
fullName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
roleKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
87
apps/api/src/users/users.controller.ts
Normal file
87
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { UsersService } from "./users.service.js";
|
||||||
|
import { InviteUserDto } from "./dto/invite-user.dto.js";
|
||||||
|
import { UpdateUserDto } from "./dto/update-user.dto.js";
|
||||||
|
import { ChangePasswordDto } from "./dto/change-password.dto.js";
|
||||||
|
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
|
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||||
|
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||||
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static routes (me/*, invite) are declared before the dynamic `:id`
|
||||||
|
* routes — NestJS matches in declaration order, and `me` would otherwise
|
||||||
|
* be captured as an `:id` value (same gotcha the legacy's directory route
|
||||||
|
* had to work around, eden.md §7.1).
|
||||||
|
*/
|
||||||
|
@ApiTags("users")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("v1/users")
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Patch("me")
|
||||||
|
updateSelf(@Body() dto: Pick<UpdateUserDto, "fullName">, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
// Self-service is deliberately narrow — role/isActive are never
|
||||||
|
// touched by this route, only the full CRUD-with-weight-check path
|
||||||
|
// (PATCH /v1/users/:id) can change those.
|
||||||
|
return this.users.update(user.id, { fullName: dto.fullName }, user.roleKey, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("me/change-password")
|
||||||
|
@HttpCode(204)
|
||||||
|
async changeOwnPassword(@Body() dto: ChangePasswordDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
await this.users.changeOwnPassword(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission("users", "view")
|
||||||
|
list() {
|
||||||
|
return this.users.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("invite")
|
||||||
|
@RequirePermission("users", "edit")
|
||||||
|
invite(@Body() dto: InviteUserDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.users.invite(dto, user.roleKey, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@RequirePermission("users", "edit")
|
||||||
|
update(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateUserDto,
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.users.update(id, dto, user.roleKey, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/reset-password")
|
||||||
|
@RequirePermission("users", "edit")
|
||||||
|
resetPassword(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||||
|
return this.users.adminResetPassword(id, user.roleKey, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/applications/:appKey")
|
||||||
|
@RequirePermission("users", "edit")
|
||||||
|
grantApplication(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("appKey") appKey: "core" | "reseller" | "subscriber",
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.users.grantApplication(id, appKey, user.roleKey, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/applications/:appKey/revoke")
|
||||||
|
@RequirePermission("users", "edit")
|
||||||
|
revokeApplication(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Param("appKey") appKey: "core" | "reseller" | "subscriber",
|
||||||
|
@CurrentUser() user: EdenAuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.users.revokeApplication(id, appKey, user.roleKey, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/users/users.module.ts
Normal file
11
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { UsersController } from "./users.controller.js";
|
||||||
|
import { UsersService } from "./users.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
188
apps/api/src/users/users.service.ts
Normal file
188
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
|
||||||
|
import { PasswordService } from "../auth/password.service.js";
|
||||||
|
import { assertCanAssignRole, assertCanActOnUser } from "../auth/role-weight.js";
|
||||||
|
import type { InviteUserDto } from "./dto/invite-user.dto.js";
|
||||||
|
import type { UpdateUserDto } from "./dto/update-user.dto.js";
|
||||||
|
import type { ChangePasswordDto } from "./dto/change-password.dto.js";
|
||||||
|
|
||||||
|
function toPublicUser(user: User) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
fullName: user.full_name,
|
||||||
|
roleKey: user.role_key,
|
||||||
|
isActive: user.is_active,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** crypto.randomBytes(9).toString('base64url') — same entropy/format as the legacy temp password (eden.md §4.7-4.8). */
|
||||||
|
function generateTempPassword(): string {
|
||||||
|
return randomBytes(9).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly passwords: PasswordService) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return usersRepo.listUsers().then((users) => users.map(toPublicUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
async invite(dto: InviteUserDto, actorRoleKey: string, actorId: string) {
|
||||||
|
await assertCanAssignRole(actorRoleKey, dto.roleKey);
|
||||||
|
|
||||||
|
const existing = await usersRepo.findUserByEmail(dto.email);
|
||||||
|
if (existing) throw new ConflictException("E-mail já cadastrado");
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword();
|
||||||
|
const { hash, algoVersion } = await this.passwords.hash(tempPassword);
|
||||||
|
|
||||||
|
const user = await usersRepo.createUser({
|
||||||
|
email: dto.email,
|
||||||
|
passwordHash: hash,
|
||||||
|
passwordAlgoVersion: algoVersion,
|
||||||
|
roleKey: dto.roleKey,
|
||||||
|
fullName: dto.fullName,
|
||||||
|
createdBy: actorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const applications = dto.applications ?? ["core"];
|
||||||
|
await Promise.all(applications.map((app) => usersRepo.grantUserApplication(user.id, app)));
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "user.invited",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: user.id,
|
||||||
|
metadata: { roleKey: dto.roleKey, applications },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email delivery (packages/integrations SMTP adapter) is wired once that
|
||||||
|
// module lands — until then, the temp password is only ever returned in
|
||||||
|
// this response (same dual-delivery fallback philosophy as the legacy,
|
||||||
|
// eden.md §4.8), never logged.
|
||||||
|
return { user: toPublicUser(user), tempPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateUserDto, actorRoleKey: string, actorId: string) {
|
||||||
|
const target = await usersRepo.findUserById(id);
|
||||||
|
if (!target) throw new NotFoundException("Usuário não encontrado");
|
||||||
|
|
||||||
|
await assertCanActOnUser(actorRoleKey, target.role_key);
|
||||||
|
if (dto.roleKey && dto.roleKey !== target.role_key) {
|
||||||
|
await assertCanAssignRole(actorRoleKey, dto.roleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await usersRepo.updateUserProfile(id, {
|
||||||
|
fullName: dto.fullName,
|
||||||
|
roleKey: dto.roleKey,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
});
|
||||||
|
if (!updated) throw new NotFoundException("Usuário não encontrado");
|
||||||
|
|
||||||
|
if (dto.isActive === false) {
|
||||||
|
// Deactivation must also kill any active session immediately —
|
||||||
|
// otherwise a deactivated user keeps working until their access
|
||||||
|
// token happens to expire.
|
||||||
|
await sessionsRepo.revokeAllUserSessions(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "user.updated",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { fields: Object.keys(dto) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return toPublicUser(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminResetPassword(id: string, actorRoleKey: string, actorId: string) {
|
||||||
|
const target = await usersRepo.findUserById(id);
|
||||||
|
if (!target) throw new NotFoundException("Usuário não encontrado");
|
||||||
|
await assertCanActOnUser(actorRoleKey, target.role_key);
|
||||||
|
|
||||||
|
const tempPassword = generateTempPassword();
|
||||||
|
const { hash, algoVersion } = await this.passwords.hash(tempPassword);
|
||||||
|
await usersRepo.updateUserPassword(id, hash, algoVersion);
|
||||||
|
await sessionsRepo.revokeAllUserSessions(id);
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "user.password_reset_by_admin",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tempPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeOwnPassword(userId: string, dto: ChangePasswordDto) {
|
||||||
|
const user = await usersRepo.findUserById(userId);
|
||||||
|
if (!user || !user.password_hash) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
const valid = await this.passwords.verify(user.password_hash, dto.currentPassword);
|
||||||
|
if (!valid) throw new BadRequestException("Senha atual incorreta");
|
||||||
|
|
||||||
|
const { hash, algoVersion } = await this.passwords.hash(dto.newPassword);
|
||||||
|
await usersRepo.updateUserPassword(userId, hash, algoVersion);
|
||||||
|
// Forces re-login everywhere — same "any password change ends every
|
||||||
|
// session" rule as the legacy's token_version (eden.md §7 item 4).
|
||||||
|
await sessionsRepo.revokeAllUserSessions(userId);
|
||||||
|
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId: userId,
|
||||||
|
action: "user.password_changed_self",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async grantApplication(
|
||||||
|
id: string,
|
||||||
|
applicationKey: "core" | "reseller" | "subscriber",
|
||||||
|
actorRoleKey: string,
|
||||||
|
actorId: string,
|
||||||
|
) {
|
||||||
|
const target = await usersRepo.findUserById(id);
|
||||||
|
if (!target) throw new NotFoundException("Usuário não encontrado");
|
||||||
|
await assertCanActOnUser(actorRoleKey, target.role_key);
|
||||||
|
await usersRepo.grantUserApplication(id, applicationKey);
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "user.application_granted",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { applicationKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeApplication(
|
||||||
|
id: string,
|
||||||
|
applicationKey: "core" | "reseller" | "subscriber",
|
||||||
|
actorRoleKey: string,
|
||||||
|
actorId: string,
|
||||||
|
) {
|
||||||
|
const target = await usersRepo.findUserById(id);
|
||||||
|
if (!target) throw new NotFoundException("Usuário não encontrado");
|
||||||
|
await assertCanActOnUser(actorRoleKey, target.role_key);
|
||||||
|
await usersRepo.revokeUserApplication(id, applicationKey);
|
||||||
|
await auditRepo.appendAuditEvent({
|
||||||
|
actorType: "user",
|
||||||
|
actorId,
|
||||||
|
action: "user.application_revoked",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: id,
|
||||||
|
metadata: { applicationKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { query } from "../pool.js";
|
import { query, withTransaction } from "../pool.js";
|
||||||
import type { Role, RolePermission } from "./types.js";
|
import type { Role, RolePermission } from "./types.js";
|
||||||
|
|
||||||
export async function getRoleByKey(key: string): Promise<Role | null> {
|
export async function getRoleByKey(key: string): Promise<Role | null> {
|
||||||
@@ -18,3 +18,71 @@ export async function getRolePermissions(roleKey: string): Promise<RolePermissio
|
|||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleInput {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
createdBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New roles always start with zero permissions (empty row set) — no implicit inheritance, mirrors eden.md §7 item 5. */
|
||||||
|
export async function createRole(input: CreateRoleInput): Promise<Role> {
|
||||||
|
const { rows } = await query<Role>(
|
||||||
|
`INSERT INTO roles (key, label, weight, is_system, created_by)
|
||||||
|
VALUES ($1, $2, $3, false, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[input.key, input.label, input.weight, input.createdBy ?? null],
|
||||||
|
);
|
||||||
|
const role = rows[0];
|
||||||
|
if (!role) throw new Error("failed to create role");
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleInput {
|
||||||
|
label?: string;
|
||||||
|
weight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRole(key: string, input: UpdateRoleInput): Promise<Role | null> {
|
||||||
|
const { rows } = await query<Role>(
|
||||||
|
`UPDATE roles SET label = COALESCE($2, label), weight = COALESCE($3, weight) WHERE key = $1 RETURNING *`,
|
||||||
|
[key, input.label ?? null, input.weight ?? null],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countUsersWithRole(roleKey: string): Promise<number> {
|
||||||
|
const { rows } = await query<{ count: string }>("SELECT count(*) FROM users WHERE role_key = $1", [
|
||||||
|
roleKey,
|
||||||
|
]);
|
||||||
|
return Number(rows[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRole(key: string): Promise<void> {
|
||||||
|
await query("DELETE FROM roles WHERE key = $1", [key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetRolePermissionInput {
|
||||||
|
resource: string;
|
||||||
|
action: RolePermission["action"];
|
||||||
|
scope: RolePermission["scope"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the entire permission set for a role in one transaction — never a partial write. */
|
||||||
|
export async function setRolePermissions(
|
||||||
|
roleKey: string,
|
||||||
|
permissions: SetRolePermissionInput[],
|
||||||
|
actorId?: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await client.query("DELETE FROM role_permissions WHERE role_key = $1", [roleKey]);
|
||||||
|
for (const p of permissions) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO role_permissions (role_key, resource, action, scope, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[roleKey, p.resource, p.action, p.scope, actorId ?? null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,3 +78,49 @@ export async function getUserApplications(userId: string): Promise<string[]> {
|
|||||||
);
|
);
|
||||||
return rows.map((r) => r.application_key);
|
return rows.map((r) => r.application_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function revokeUserApplication(
|
||||||
|
userId: string,
|
||||||
|
applicationKey: "core" | "reseller" | "subscriber",
|
||||||
|
): Promise<void> {
|
||||||
|
await query("DELETE FROM user_applications WHERE user_id = $1 AND application_key = $2", [
|
||||||
|
userId,
|
||||||
|
applicationKey,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListUsersFilter {
|
||||||
|
activeOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Includes inactive users by default (admin screens need to see them to reactivate) — pass activeOnly to filter. */
|
||||||
|
export async function listUsers(filter: ListUsersFilter = {}): Promise<User[]> {
|
||||||
|
const { rows } = await query<User>(
|
||||||
|
filter.activeOnly
|
||||||
|
? "SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC"
|
||||||
|
: "SELECT * FROM users ORDER BY created_at DESC",
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserProfileInput {
|
||||||
|
fullName?: string | null;
|
||||||
|
roleKey?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserProfile(
|
||||||
|
userId: string,
|
||||||
|
input: UpdateUserProfileInput,
|
||||||
|
): Promise<User | null> {
|
||||||
|
const { rows } = await query<User>(
|
||||||
|
`UPDATE users SET
|
||||||
|
full_name = COALESCE($2, full_name),
|
||||||
|
role_key = COALESCE($3, role_key),
|
||||||
|
is_active = COALESCE($4, is_active)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[userId, input.fullName ?? null, input.roleKey ?? null, input.isActive ?? null],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user