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:
2026-09-03 09:40:28 -03:00
parent 8d81cf506b
commit 8f638f2743
19 changed files with 766 additions and 4 deletions

4
.gitignore vendored
View File

@@ -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/

View File

@@ -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 {}

View File

@@ -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 {}

View 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.");
}
}

View 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;
}
}

View 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;
}

View 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[];
}

View 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;
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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">;
}

View 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;
}

View 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);
}
}

View 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 {}

View 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 },
});
}
}

View File

@@ -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],
);
}
});
}

View File

@@ -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;
}