From 8f638f27432e77d1d2080aff32a32a225ebda096 Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Thu, 3 Sep 2026 09:40:28 -0300 Subject: [PATCH] =?UTF-8?q?CRUD=20de=20Usu=C3=A1rios=20e=20Pap=C3=A9is=20(?= =?UTF-8?q?role=20weight,=20permiss=C3=B5es,=20gitignore=20de=20worktrees)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 4 + apps/api/src/app.module.ts | 12 +- apps/api/src/auth/auth.module.ts | 5 +- apps/api/src/auth/role-weight.ts | 41 ++++ apps/api/src/auth/super-admin.guard.ts | 23 +++ apps/api/src/roles/dto/create-role.dto.ts | 19 ++ .../src/roles/dto/set-role-permissions.dto.ts | 23 +++ apps/api/src/roles/dto/update-role.dto.ts | 13 ++ apps/api/src/roles/roles.controller.ts | 65 ++++++ apps/api/src/roles/roles.module.ts | 11 + apps/api/src/roles/roles.service.ts | 108 ++++++++++ apps/api/src/users/dto/change-password.dto.ts | 10 + apps/api/src/users/dto/invite-user.dto.ts | 19 ++ apps/api/src/users/dto/update-user.dto.ts | 15 ++ apps/api/src/users/users.controller.ts | 87 ++++++++ apps/api/src/users/users.module.ts | 11 + apps/api/src/users/users.service.ts | 188 ++++++++++++++++++ packages/database/src/repositories/roles.ts | 70 ++++++- packages/database/src/repositories/users.ts | 46 +++++ 19 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/auth/role-weight.ts create mode 100644 apps/api/src/auth/super-admin.guard.ts create mode 100644 apps/api/src/roles/dto/create-role.dto.ts create mode 100644 apps/api/src/roles/dto/set-role-permissions.dto.ts create mode 100644 apps/api/src/roles/dto/update-role.dto.ts create mode 100644 apps/api/src/roles/roles.controller.ts create mode 100644 apps/api/src/roles/roles.module.ts create mode 100644 apps/api/src/roles/roles.service.ts create mode 100644 apps/api/src/users/dto/change-password.dto.ts create mode 100644 apps/api/src/users/dto/invite-user.dto.ts create mode 100644 apps/api/src/users/dto/update-user.dto.ts create mode 100644 apps/api/src/users/users.controller.ts create mode 100644 apps/api/src/users/users.module.ts create mode 100644 apps/api/src/users/users.service.ts diff --git a/.gitignore b/.gitignore index 3dd1183..bce5d58 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ infra/docker/pgdata/ # Claude Code local overrides (personal, never shared) .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/ diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 69fe418..6063c1c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 62c64f4..17b156d 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/role-weight.ts b/apps/api/src/auth/role-weight.ts new file mode 100644 index 0000000..90ff841 --- /dev/null +++ b/apps/api/src/auth/role-weight.ts @@ -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 { + 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 { + 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 { + 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."); + } +} diff --git a/apps/api/src/auth/super-admin.guard.ts b/apps/api/src/auth/super-admin.guard.ts new file mode 100644 index 0000000..eef4bff --- /dev/null +++ b/apps/api/src/auth/super-admin.guard.ts @@ -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(); + if (request.edenUser?.roleKey !== SUPER_ADMIN_ROLE) { + throw new ForbiddenException("Exclusivo do super_admin"); + } + return true; + } +} diff --git a/apps/api/src/roles/dto/create-role.dto.ts b/apps/api/src/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..e95a7f1 --- /dev/null +++ b/apps/api/src/roles/dto/create-role.dto.ts @@ -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; +} diff --git a/apps/api/src/roles/dto/set-role-permissions.dto.ts b/apps/api/src/roles/dto/set-role-permissions.dto.ts new file mode 100644 index 0000000..20c371e --- /dev/null +++ b/apps/api/src/roles/dto/set-role-permissions.dto.ts @@ -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[]; +} diff --git a/apps/api/src/roles/dto/update-role.dto.ts b/apps/api/src/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..2b58707 --- /dev/null +++ b/apps/api/src/roles/dto/update-role.dto.ts @@ -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; +} diff --git a/apps/api/src/roles/roles.controller.ts b/apps/api/src/roles/roles.controller.ts new file mode 100644 index 0000000..2e45e77 --- /dev/null +++ b/apps/api/src/roles/roles.controller.ts @@ -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); + } +} diff --git a/apps/api/src/roles/roles.module.ts b/apps/api/src/roles/roles.module.ts new file mode 100644 index 0000000..c6b49e0 --- /dev/null +++ b/apps/api/src/roles/roles.module.ts @@ -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 {} diff --git a/apps/api/src/roles/roles.service.ts b/apps/api/src/roles/roles.service.ts new file mode 100644 index 0000000..a8fe533 --- /dev/null +++ b/apps/api/src/roles/roles.service.ts @@ -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; + } +} diff --git a/apps/api/src/users/dto/change-password.dto.ts b/apps/api/src/users/dto/change-password.dto.ts new file mode 100644 index 0000000..a309292 --- /dev/null +++ b/apps/api/src/users/dto/change-password.dto.ts @@ -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; +} diff --git a/apps/api/src/users/dto/invite-user.dto.ts b/apps/api/src/users/dto/invite-user.dto.ts new file mode 100644 index 0000000..65246ea --- /dev/null +++ b/apps/api/src/users/dto/invite-user.dto.ts @@ -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">; +} diff --git a/apps/api/src/users/dto/update-user.dto.ts b/apps/api/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..510638a --- /dev/null +++ b/apps/api/src/users/dto/update-user.dto.ts @@ -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; +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..76dab75 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -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, @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); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..4b4e9d9 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -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 {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..3ef144e --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -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 }, + }); + } +} diff --git a/packages/database/src/repositories/roles.ts b/packages/database/src/repositories/roles.ts index 1783828..ec83551 100644 --- a/packages/database/src/repositories/roles.ts +++ b/packages/database/src/repositories/roles.ts @@ -1,4 +1,4 @@ -import { query } from "../pool.js"; +import { query, withTransaction } from "../pool.js"; import type { Role, RolePermission } from "./types.js"; export async function getRoleByKey(key: string): Promise { @@ -18,3 +18,71 @@ export async function getRolePermissions(roleKey: string): Promise { + const { rows } = await query( + `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 { + const { rows } = await query( + `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 { + 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 { + 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 { + 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], + ); + } + }); +} diff --git a/packages/database/src/repositories/users.ts b/packages/database/src/repositories/users.ts index bb3185d..8cb9fbd 100644 --- a/packages/database/src/repositories/users.ts +++ b/packages/database/src/repositories/users.ts @@ -78,3 +78,49 @@ export async function getUserApplications(userId: string): Promise { ); return rows.map((r) => r.application_key); } + +export async function revokeUserApplication( + userId: string, + applicationKey: "core" | "reseller" | "subscriber", +): Promise { + 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 { + const { rows } = await query( + 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 { + const { rows } = await query( + `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; +}