Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions backend/src/documents/documents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import * as multer from 'multer';
import { DocumentsService } from './documents.service';
import { DocumentStatus, Document } from './entities/document.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { User } from '../users/entities/user.entity';
import { User, UserRole } from '../users/entities/user.entity';
import { QueueService } from '../queue/queue.service';
import { VerificationService } from '../verification/verification.service';
import { AccessLogsService } from '../access-logs/access-logs.service';
Expand Down Expand Up @@ -159,7 +159,7 @@ export class DocumentsController {
@Get(':id')
@UseGuards(JwtAuthGuard)
async getDocument(
@Param('id') id: string,
@Param('id', ParseUUIDPipe) id: string,
@Req() req: Request & { user?: User },
) {
const user = req.user!;
Expand All @@ -169,25 +169,25 @@ export class DocumentsController {
throw new NotFoundException('Document not found');
}

if (document.ownerId !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Access denied');
}
this.assertOwnerOrAdmin(document, user);

return toDocumentResponse(document);
}

@Post(':id/verify')
@UseGuards(JwtAuthGuard)
async verifyDocument(
@Param('id') id: string,
@Req() req: Request,
@Param('id', ParseUUIDPipe) id: string,
@Req() req: Request & { user?: User },
@Res() res: Response,
) {
const document = await this.documentsService.findById(id);
if (!document) {
throw new NotFoundException('Document not found');
}

this.assertOwnerOrAdmin(document, req.user);

if (document.status === DocumentStatus.VERIFIED) {
throw new ConflictException('Document has already been verified');
}
Expand All @@ -202,12 +202,17 @@ export class DocumentsController {

@Get(':id/verification')
@UseGuards(JwtAuthGuard)
async getVerification(@Param('id') id: string) {
async getVerification(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: Request & { user?: User },
) {
const document = await this.documentsService.findById(id);
if (!document) {
throw new NotFoundException('Document not found');
}

this.assertOwnerOrAdmin(document, req.user);

const record = await this.verificationService.findLatestByDocument(id);
if (!record) {
throw new NotFoundException(
Expand All @@ -229,10 +234,7 @@ export class DocumentsController {
throw new NotFoundException('Document not found');
}

const user = req.user!;
if (document.ownerId !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Access denied');
}
this.assertOwnerOrAdmin(document, req.user);

const updated = await this.documentsService.revokeAccess(id);
return { message: 'Access revoked', documentId: updated?.id, status: updated?.status };
Expand Down Expand Up @@ -286,6 +288,15 @@ export class DocumentsController {
stream.pipe(res);
}

private assertOwnerOrAdmin(document: Document, user?: User): void {
if (!user) {
throw new ForbiddenException('Access denied');
}
if (document.ownerId !== user.id && user.role !== UserRole.ADMIN) {
throw new ForbiddenException('Access denied');
}
}

private safeExtension(mimeType: string): string {
switch (mimeType) {
case 'application/pdf':
Expand Down
7 changes: 7 additions & 0 deletions backend/src/documents/documents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export class DocumentsService {
async findByRiskFilters(filters: {
page: number;
limit: number;
ownerId?: string;
minScore?: number;
maxScore?: number;
startDate?: Date;
Expand All @@ -84,6 +85,12 @@ export class DocumentsService {

qb.where('document.risk_score IS NOT NULL');

if (filters.ownerId) {
qb.andWhere('document.owner_id = :ownerId', {
ownerId: filters.ownerId,
});
}

if (filters.minScore !== undefined) {
qb.andWhere('document.risk_score >= :minScore', {
minScore: filters.minScore,
Expand Down
13 changes: 11 additions & 2 deletions backend/src/risk-assessment/dto/list-risk-assessments.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';

export class ListRiskAssessmentsDto {
@ApiPropertyOptional({ default: 1 })
Expand Down Expand Up @@ -47,4 +47,13 @@ export class ListRiskAssessmentsDto {
@IsOptional()
@IsString()
sortOrder?: 'ASC' | 'DESC' = 'DESC';

@ApiPropertyOptional({
default: false,
description: 'Admins only: include documents from every user',
})
@IsOptional()
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
all?: boolean = false;
}
42 changes: 36 additions & 6 deletions backend/src/risk-assessment/risk-assessment.controller.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,65 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request } from 'express';

import { RiskAssessmentService } from './risk-assessment.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ListRiskAssessmentsDto } from './dto/list-risk-assessments.dto';
import { DocumentsService } from '../documents/documents.service';
import { User, UserRole } from '../users/entities/user.entity';

@ApiTags('documents')
@Controller('documents')
@Controller()
export class RiskAssessmentController {
constructor(
private readonly riskService: RiskAssessmentService,
private readonly documentsService: DocumentsService,
) {}

@Get(':id/risk')
@Get('documents/:id/risk')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Assess the risk score of a document' })
async getRisk(@Param('id') id: string, @Query('lang') lang?: string) {
async getRisk(
@Param('id', ParseUUIDPipe) id: string,
@Query('lang') lang?: string,
) {
return this.riskService.assessDocument(id, lang);
}

@Get('risk-assessments')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'List documents by risk filters (paginated)' })
async listRiskAssessments(@Query() query: ListRiskAssessmentsDto) {
const { page = 1, limit = 20, minScore, maxScore, startDate, endDate, sortOrder = 'DESC' } = query;
async listRiskAssessments(
@Query() query: ListRiskAssessmentsDto,
@Req() req: Request & { user?: User },
) {
const {
page = 1,
limit = 20,
minScore,
maxScore,
startDate,
endDate,
sortOrder = 'DESC',
all = false,
} = query;

const user = req.user!;
// Only admins may explicitly ask for every user's documents.
const ownerId = user.role === UserRole.ADMIN && all ? undefined : user.id;

const result = await this.documentsService.findByRiskFilters({
ownerId,
page,
limit,
minScore,
Expand Down
87 changes: 87 additions & 0 deletions backend/src/users/api-keys.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { createHash, randomBytes } from 'crypto';

import { ApiKey } from './entities/api-key.entity';
import { User } from './entities/user.entity';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { UsersService } from './users.service';

function hashSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}

@Injectable()
export class ApiKeysService {
constructor(
@InjectRepository(ApiKey)
private readonly apiKeyRepository: Repository<ApiKey>,
private readonly usersService: UsersService,
) {}

async create(userId: string, dto: CreateApiKeyDto) {
const secret = `smalda_${randomBytes(24).toString('hex')}`;
const apiKey = await this.apiKeyRepository.save(
this.apiKeyRepository.create({
userId,
name: dto.name,
prefix: secret.slice(0, 14),
keyHash: hashSecret(secret),
scopes: dto.scopes ?? [],
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
}),
);

// The plain-text secret is returned exactly once and never stored.
return {
id: apiKey.id,
name: apiKey.name,
prefix: apiKey.prefix,
scopes: apiKey.scopes,
expiresAt: apiKey.expiresAt,
createdAt: apiKey.createdAt,
key: secret,
};
}

list(userId: string): Promise<ApiKey[]> {
return this.apiKeyRepository.find({
where: { userId, revokedAt: IsNull() },
order: { createdAt: 'DESC' },
});
}

async revoke(userId: string, id: string): Promise<{ message: string }> {
const apiKey = await this.apiKeyRepository.findOne({
where: { id, userId },
});
if (!apiKey) {
throw new NotFoundException('API key not found');
}

if (!apiKey.revokedAt) {
apiKey.revokedAt = new Date();
await this.apiKeyRepository.save(apiKey);
}

return { message: 'API key revoked' };
}

async validateKey(secret: string): Promise<User | null> {
const apiKey = await this.apiKeyRepository.findOne({
where: { keyHash: hashSecret(secret) },
});
if (!apiKey || apiKey.revokedAt) {
return null;
}
if (apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()) {
return null;
}

apiKey.lastUsedAt = new Date();
await this.apiKeyRepository.save(apiKey);

return this.usersService.findById(apiKey.userId);
}
}
7 changes: 7 additions & 0 deletions backend/src/users/dto/admin-update-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { UserRole } from '../entities/user.entity';

export class AdminUpdateUserDto {
@IsEnum(UserRole)
role: UserRole;
}
16 changes: 16 additions & 0 deletions backend/src/users/dto/create-api-key.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IsArray, IsISO8601, IsOptional, IsString, Length } from 'class-validator';

export class CreateApiKeyDto {
@IsString()
@Length(1, 64)
name: string;

@IsOptional()
@IsArray()
@IsString({ each: true })
scopes?: string[];

@IsOptional()
@IsISO8601()
expiresAt?: string;
}
26 changes: 26 additions & 0 deletions backend/src/users/dto/list-users.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { UserRole } from '../entities/user.entity';

export class ListUsersDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;

@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;

@IsOptional()
@IsString()
search?: string;

@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
}
45 changes: 45 additions & 0 deletions backend/src/users/entities/api-key.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
} from 'typeorm';
import { User } from './user.entity';

@Entity('api_keys')
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'user_id' })
userId: string;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
user: User;

@Column()
name: string;

/** Only the short visible prefix is stored; the secret itself is shown once. */
@Column()
prefix: string;

@Column({ name: 'key_hash', unique: true })
keyHash: string;

@Column({ type: 'simple-array', default: '' })
scopes: string[];

@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
lastUsedAt?: Date | null;

@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;

@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt?: Date | null;

@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Loading
Loading