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:
@@ -5,9 +5,19 @@ import { ConfigModule } from "./config/config.module.js";
|
||||
import { AuthModule } from "./auth/auth.module.js";
|
||||
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
|
||||
import { CompaniesModule } from "./companies/companies.module.js";
|
||||
import { UsersModule } from "./users/users.module.js";
|
||||
import { RolesModule } from "./roles/roles.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, TerminusModule, AuthModule, BootstrapModule, CompaniesModule],
|
||||
imports: [
|
||||
ConfigModule,
|
||||
TerminusModule,
|
||||
AuthModule,
|
||||
BootstrapModule,
|
||||
CompaniesModule,
|
||||
UsersModule,
|
||||
RolesModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { PasswordService } from "./password.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "./permission.guard.js";
|
||||
import { SuperAdminGuard } from "./super-admin.guard.js";
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
|
||||
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
|
||||
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
|
||||
})
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user