Sicurezza e Astrazione Per Design
La domanda che mi sono posto quando ho iniziato a progettare il backend di RAD-System era semplice: qual è il codice che scrivo ogni volta che avvio un nuovo progetto? Autenticazione, campi di audit, operazioni CRUD, controlli di proprietà. Sempre le stesse cose. Sempre con il rischio di sbagliare leggermente una di esse.
La mia risposta è stata scrivere una volta, correttamente, e non scrivere mai più. Questa è l'intera filosofia del backend RAD-System — un livello NestJS in cui il 90% ripetitivo è gestito da due classi astratte, e il tuo lavoro è solo il 10% che è effettivamente specifico del tuo prodotto.
Il codice completo è su GitHub.
1. La Fondazione: BaseEntity e Colonne di Audit
Ogni record in un'applicazione seria deve essere tracciabile — quando è stato creato, quando è stato modificato l'ultima volta, se è stato eliminato. Invece di definire questi campi in ogni entità, ho costruito BaseEntity.
Ogni tabella nel mio database PostgreSQL eredita questo automaticamente, inclusa la soft-delete nativa tramite TypeORM's @DeleteDateColumn. Mantengo la chiave primaria nell'entità concreta in modo da poter scegliere UUID o integer a seconda del progetto.
import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
import { ApiProperty } from '@nestjs/swagger';
import { IBase } from '../interfaces/models.interface';
export abstract class BaseEntity implements IBase {
@ApiProperty()
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date;
@ApiProperty()
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
updatedAt?: Date;
@ApiProperty()
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt?: Date | null;
}
2. Il Motore: BaseService e TypeScript Generics
BaseService è dove avviene il vero risparmio di tempo. Usando i generici TypeScript, questa singola classe astratta fornisce metodi CRUD pronti all'uso per qualsiasi entità. Scrivo la logica una volta; il compilatore applica i tipi ovunque.
Due principi lo guidano:
- DRY — non scrivo mai
findOneofindAlllogica due volte nell'intera applicazione. - Sicurezza dei Tipi — il compilatore sa esattamente che tipo di oggetto sta gestendo ad ogni passo.
export abstract class BaseService<T extends BaseEntity> {
constructor(protected readonly repository: Repository<T>) {}
// Metodo astratto che deve essere implementato dai servizi concreti
protected abstract checkOwnership(id: number, userId: number): Promise<boolean>;
async findAll(options?: FindManyOptions<T>): Promise<T[]> {
return await this.repository.find(options);
}
async findOne(id: number | string, options?: FindOneOptions<T>): Promise<T> {
const entity = await this.repository.findOne({ where: { id } as any, ...(options as any) });
if (!entity) throw new NotFoundException(`Entity with ID ${id} not found`);
return entity;
}
}
3. Sicurezza Nel DNA: Validazione della Proprietà
Ho visto troppe applicazioni in cui la sicurezza è un ripensamento — un livello aggiunto in seguito, pieno di lacune. In RAD-System, la sicurezza è strutturale.
Il metodo checkOwnership() è astratto, il che significa che ogni servizio concreto che estende BaseService è obbligato a implementarlo. Non puoi dimenticartene accidentalmente. Se un utente tenta di accedere a una risorsa che non possiede, il sistema solleva una ForbiddenException al livello core, prima che qualsiasi logica di business venga eseguita.
async findOneByUser(id: number, userId: number, options?: FindOneOptions<T>): Promise<T> {
const hasAccess = await this.checkOwnership(id, userId);
if (!hasAccess) {
throw new ForbiddenException('Access denied');
}
const entity = await this.findOne(id, options);
if (!entity) {
throw new NotFoundException(`Entity not found`);
}
return entity;
}
// Nota: i servizi concreti devono implementare checkOwnership, ad esempio:
// protected async checkOwnership(id: number, userId: number): Promise<boolean> {
// const count = await this.repository.count({ where: { id, userId } as any });
// return count > 0;
// }
4. Il Risultato: Un Nuovo Modulo in Minuti
Questo è ciò che l'astrazione acquisisce in pratica. Quando ho bisogno di aggiungere una nuova funzionalità — Fatture, Attività, Documenti, qualunque cosa — il mio lavoro è:
- Definire l'entità che estende
BaseEntity. - Definire il servizio che estende
BaseService.
Fatto. Ho già endpoint CRUD sicuri e convalidati con soft-delete e traccia di audit completa, pronti per il frontend. Nessun codice ripetuto. Nessun controllo di sicurezza dimenticato.
5. Il Livello Comune: Coerenza Senza Ripetizione
La cartella src/common è la spina dorsale dell'intero backend. Tutto ciò che deve essere coerente tra tutti i moduli vive qui.
Standardizzazione della Risposta
Ogni risposta API passa attraverso TransformInterceptor. Gli oggetti grezzi non lasciano mai il backend — ogni risposta è avvolta in una struttura coerente con dati, timestamp e percorso della richiesta. Le regole di serializzazione @Exclude() da class-transformer vengono applicate automaticamente.
// src/common/interceptors/transform.interceptor.ts
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<unknown>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<unknown>> {
const request = context.switchToHttp().getRequest();
return next.handle().pipe(
map(data => ({
data: instanceToPlain(data), // Applica le regole di serializzazione dell'entità
timestamp: new Date().toISOString(),
path: request.url,
})),
);
}
}
Sicuro Per Default
Il JwtAuthGuard è registrato globalmente. Ogni endpoint richiede autenticazione a meno che non sia esplicitamente contrassegnato come @Public(). Preferisco questo approccio — opt-out della sicurezza piuttosto che opt-in. Dimenticare di proteggere un endpoint è un errore molto più pericoloso che dimenticare di aprire uno.
// src/common/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') implements CanActivate {
constructor(private reflector: Reflector) { super(); }
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
6. Agnosticismo del Database
Ho costruito il sistema per funzionare su PostgreSQL, MySQL/MariaDB, o SQLite senza cambiare una singola riga di logica di business. Il driver TypeORM viene selezionato dinamicamente tramite la variabile di ambiente DB_TYPE in database.config.ts.
Un problema specifico che ho risolto è le differenze di dialetto SQL grezzi — la concatenazione di stringhe è || in PostgreSQL e CONCAT() in MySQL. L'utilità SqlHelper astrae questo in modo che le query personalizzate funzionino ovunque senza branching.
// src/common/utils/sql.helper.ts
export class SqlHelper {
static concat(...values: (string | string[])[]): string {
const flatValues = values.flat();
switch (this.getDbType()) {
case 'postgres':
case 'sqlite':
return flatValues.join(' || '); // SQL standard
case 'mysql':
default:
return `CONCAT(${flatValues.join(', ')})`; // Specifico di MySQL
}
}
}
7. Struttura del Modulo
Ogni funzionalità è autonoma in rad-be/src/modules. I moduli attuali pronti all'uso:
admin/— amministrazione del sistemaauth/— autenticazione JWT, SSO, guardconfig/— gestione della configurazione dinamicadepartments/— struttura organizzativaemail/— notifiche con template Handlebarsusers/— gestione degli utenti e profili
8. Sicurezza Dichiarativa: Decoratori Personalizzati
I controller rimangono puliti. Le decisioni di sicurezza vivono nei decoratori, non sono mescolate nella logica di business.
@Public()— rimuove il guard JWT da un endpoint specifico@User()— inietta l'utente autenticato direttamente nel parametro del metodo, più pulito direq.user@AdminRequired()— applica l'accesso basato sui ruoli a livello di controller
// Esempio di utilizzo in un Controller
@Controller('users')
export class UsersController {
@Get('profile')
getProfile(@User() user: UserEntity) {
return user;
}
@Post()
@AdminRequired() // Solo gli admin possono creare utenti
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
9. Documentazione Auto-Generata
Il sistema genera automaticamente la documentazione OpenAPI 3 ad ogni build. I documenti interattivi sono disponibili su /api-docs/v1, e viene prodotto un file rad-openapi3-spec.json per la generazione dei client frontend — nessuna documentazione manuale da mantenere.