From 9555022143a77791fe5bdc5f2056e44b822547fa Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 29 Apr 2026 15:03:24 +0200 Subject: [PATCH] feat(db): encrypt Profile health fields at rest (ADR 0002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire prisma-field-encryption AES-256-GCM extension on the shared Prisma client and annotate the four sensitive columns on Profile with @encrypted: - weightStartKg / weightCurrentKg / weightGoalKg (Decimal → String) - targetDate (DateTime @db.Date → String, ISO YYYY-MM-DD) Other Profile fields stay in clear text per ADR 0002 (age, gender, heightCm, activityLevel) — they're needed for plan generation and aggregate analytics, and are not strongly identifying on their own. apps/api profile.routes.ts: - serialize() now reads the columns as strings and parses them back to numbers for BMR/TDEE; targetDate is already an ISO string from the DB - the upsert stringifies numeric inputs and slices the date to YYYY-MM-DD Env wiring: - packages/db, apps/api, apps/web .env.example all document PRISMA_FIELD_ENCRYPTION_KEY (k1.aesgcm256.) — must match across every process that hits the DB - key generation snippet documented inline Migration is intentionally NOT in this commit: needs to be created against a live Postgres instance and applied. The fields change Decimal/Date → text so prisma migrate dev will require a USING cast — see the follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/.env.example | 5 +++ .../api/src/modules/profile/profile.routes.ts | 37 ++++++++++-------- apps/web/.env.example | 4 ++ packages/db/.env.example | 3 ++ packages/db/package.json | 3 +- packages/db/prisma/schema.prisma | 12 ++++-- packages/db/src/client.ts | 10 ++++- pnpm-lock.yaml | Bin 291046 -> 294153 bytes 8 files changed, 51 insertions(+), 23 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index 523b11f..3f75a6e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,6 +8,11 @@ CORS_ORIGINS=http://localhost:3000 DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public +# Cifratura at-rest dei campi sanitari (vedi ADR 0002). +# Genera con: node -e "console.log('k1.aesgcm256.'+require('crypto').generateKeySync('aes',{length:256}).export().toString('base64url'))" +# DEVE coincidere con apps/web/.env e con tutti i deploy che leggono il DB. +PRISMA_FIELD_ENCRYPTION_KEY= + # Better Auth — DEVE coincidere con apps/web/.env (sessione condivisa) BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 diff --git a/apps/api/src/modules/profile/profile.routes.ts b/apps/api/src/modules/profile/profile.routes.ts index 55475ba..7acfc58 100644 --- a/apps/api/src/modules/profile/profile.routes.ts +++ b/apps/api/src/modules/profile/profile.routes.ts @@ -10,21 +10,24 @@ import type { FastifyPluginAsync } from 'fastify'; import { requireAuth } from '../../plugins/auth.js'; +// The weight columns are encrypted at rest (ADR 0002) and therefore stored +// as String. The Zod input schema parses numeric strings → numbers, but the +// Prisma row keeps them as strings — so serialize/parse explicitly here. function serialize( profile: { age: number; gender: Gender; heightCm: number; - weightStartKg: { toNumber(): number }; - weightCurrentKg: { toNumber(): number }; - weightGoalKg: { toNumber(): number }; + weightStartKg: string; + weightCurrentKg: string; + weightGoalKg: string; activityLevel: ActivityLevel; - targetDate: Date | null; + targetDate: string | null; currentPhase: string; } | null, ) { if (!profile) return null; - const weightCurrentKg = profile.weightCurrentKg.toNumber(); + const weightCurrentKg = Number(profile.weightCurrentKg); const bmr = calculateBmr({ weightKg: weightCurrentKg, heightCm: profile.heightCm, @@ -36,11 +39,11 @@ function serialize( age: profile.age, gender: profile.gender, heightCm: profile.heightCm, - weightStartKg: profile.weightStartKg.toNumber(), + weightStartKg: Number(profile.weightStartKg), weightCurrentKg, - weightGoalKg: profile.weightGoalKg.toNumber(), + weightGoalKg: Number(profile.weightGoalKg), activityLevel: profile.activityLevel, - targetDate: profile.targetDate?.toISOString() ?? null, + targetDate: profile.targetDate, currentPhase: profile.currentPhase, derived: { bmr: Math.round(bmr), @@ -67,6 +70,8 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { const data = parsed.data; const userId = request.user!.id; + const targetDateStr = data.targetDate ? data.targetDate.toISOString().slice(0, 10) : null; + const profile = await fastify.prisma.profile.upsert({ where: { userId }, create: { @@ -74,21 +79,21 @@ export const profileRoutes: FastifyPluginAsync = async (fastify) => { age: data.age, gender: data.gender, heightCm: data.heightCm, - weightStartKg: data.weightStartKg, - weightCurrentKg: data.weightCurrentKg, - weightGoalKg: data.weightGoalKg, + weightStartKg: String(data.weightStartKg), + weightCurrentKg: String(data.weightCurrentKg), + weightGoalKg: String(data.weightGoalKg), activityLevel: data.activityLevel, - targetDate: data.targetDate ?? null, + targetDate: targetDateStr, }, update: { age: data.age, gender: data.gender, heightCm: data.heightCm, - weightStartKg: data.weightStartKg, - weightCurrentKg: data.weightCurrentKg, - weightGoalKg: data.weightGoalKg, + weightStartKg: String(data.weightStartKg), + weightCurrentKg: String(data.weightCurrentKg), + weightGoalKg: String(data.weightGoalKg), activityLevel: data.activityLevel, - targetDate: data.targetDate ?? null, + targetDate: targetDateStr, }, }); diff --git a/apps/web/.env.example b/apps/web/.env.example index 096a9a4..77bb949 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -12,6 +12,10 @@ BETTER_AUTH_URL=http://localhost:3000 # Database (Better Auth usa @ketopath/db, che legge DATABASE_URL) DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public +# Cifratura at-rest dei campi sanitari (vedi ADR 0002). +# DEVE essere identica a apps/api/.env. +PRISMA_FIELD_ENCRYPTION_KEY= + # Google OAuth (configurato a fine progetto) # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= diff --git a/packages/db/.env.example b/packages/db/.env.example index b9e34d3..a8973fd 100644 --- a/packages/db/.env.example +++ b/packages/db/.env.example @@ -1 +1,4 @@ DATABASE_URL="postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public" + +# Cifratura at-rest (vedi ADR 0002). Stesso valore in apps/api/.env e apps/web/.env. +PRISMA_FIELD_ENCRYPTION_KEY= diff --git a/packages/db/package.json b/packages/db/package.json index 6a34cf6..7eba908 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -19,7 +19,8 @@ "db:seed": "tsx prisma/seed.ts" }, "dependencies": { - "@prisma/client": "^5.18.0" + "@prisma/client": "^5.18.0", + "prisma-field-encryption": "^1.6.0" }, "devDependencies": { "@ketopath/eslint-config": "workspace:*", diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ce968fc..c956aa7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -128,11 +128,15 @@ model Profile { age Int gender Gender heightCm Int @map("height_cm") - weightStartKg Decimal @map("weight_start_kg") @db.Decimal(5, 2) - weightCurrentKg Decimal @map("weight_current_kg") @db.Decimal(5, 2) - weightGoalKg Decimal @map("weight_goal_kg") @db.Decimal(5, 2) + /// @encrypted + weightStartKg String @map("weight_start_kg") + /// @encrypted + weightCurrentKg String @map("weight_current_kg") + /// @encrypted + weightGoalKg String @map("weight_goal_kg") activityLevel ActivityLevel @map("activity_level") - targetDate DateTime? @map("target_date") @db.Date + /// @encrypted + targetDate String? @map("target_date") currentPhase Phase @default(INTENSIVE) @map("current_phase") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 655e39f..6f6e4f3 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,13 +1,19 @@ import { PrismaClient } from '@prisma/client'; +import { fieldEncryptionExtension } from 'prisma-field-encryption'; +// Singleton-friendly storage of the BASE client (so HMR doesn't open a new pool +// every time). The exported `prisma` is the EXTENDED client returned by +// $extends — it wraps the base client with the field-encryption middleware. const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; -export const prisma: PrismaClient = +const baseClient = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], }); if (process.env.NODE_ENV !== 'production') { - globalForPrisma.prisma = prisma; + globalForPrisma.prisma = baseClient; } + +export const prisma = baseClient.$extends(fieldEncryptionExtension()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29dcf4c3c6e4eee50e2c6e08165a394a50538843..929abfab136d9ec4133a9411dabc7c34ef03e563 100644 GIT binary patch delta 2185 zcmb7_Td3oB9mnZq+1;}<$2~K%7e~&VI_lQ3oaWNBO)E!E)AW+`nzp$#4occ4%{^_B z-lEH_J_rheYjFZTxKE1Ui!x{z_C@i*l|eyS1jPqeWCcZG9`<2H#I)z^jz>qqy#9Xq z|9=1PH{Va*dh_6me>?bY_=f-6--n+#IF=MqA03P4&|Xbk#WL3i?|XiYhTk6o*X<9y zu>Re%);;~kwtBoxBT)Rsd;2$keRtm!ZNatdV_DQEqP>3b z>hUM*i|G)f(g5Ym3PQa}@dbh*3M5Wq4Yj&xEKDdPf*GQ}1X3+c1(v0fw8#;2dy2tB zvZ$v9uu;TxIVCbxaFJsxhJpyy@yW@HdwaeBM4r3%;MKR`rw6*ID+6F4IAR9bYWt(V z+_Ip7*`}u5Wp_4A5lW^ZV=KpEi{mt^=CT5<&Wc^$u5=B2*_^s7mry_row>AV78X6e zi7;t{=~{@;VjOJY6_!T=u~4wZ#4AqIniA*-7<`{XZ>d3 z5i@75ga$*wBplE0#e&Bc9ZUb}>(?0vuR{U`(`cEPsM)Sck&We`RVK<>ZI0KdO1s*% z`=VOqWw+k!)>6}|nBfUY02dakwG@GLMtw&wd-f1ySdQeYMwg(dd%yYfrzejK=18

ry?SnDNQtTleJk`aZUiK_UNhxMEqtZgXl~p{8SN>=HaQqW zCw*s@6L2~xJ5F8ovW3}xwp(XJr;LG)M5ED z01Qo42eDtNcGDf&`%|EyuI`n_|r%*HsC&9S?_p8wx zzVg}7(dMnAum)WF4`2mflK;IAg+9uVrCN10O?x0~W|momcBy8@%uXg;snVHd8D-V6 zCbCzmcM5WmG5I+{R&1=L+Z-aWHHA*_?K+ILawVv`^oCuv(9BNQ8s)!)hM)2Geh>ij z=gG+9{tYbj#FMf3Cr)BWG8^AiTH!BUj~)wS%a6g~1np)hKqwcBWEYdtG_7?2wh9_} zl|xGv112ykYf^PkdkLLv6w3M8N>h$Jy zCHysE{{n(x|0{2VkM6w3$zePngOWk2c8R(e@-w#V8Uphk(GXDJe^j@5Juk!OFArrw z*YYvM@4pebL|BHgOIJRICR6WiFtS|HoCjNhLDb(}%h^hmkHN{n+`Ztie z{af#h3(ofUvC#8=`|FXzoAJ|$tvH4NjKzym1-)Cbg68RQ^m<+~g4K5ZZOz((Iu^4A zsyK9aOXXvM7*G;DuXxrdA4?_SbTXUqr@st;eDl%^(W9p?Zw)`)`MuTQz7oE`Vr>g5wHRtlcgcmvrQj81GC{H)0UT9Is+K9Z@cF*mkVhF8M8a^I&hb0 zumcRYFJS@B61S*y0k2rM<;?-|SGSEO0-Z0n5my3$8kfRC1RS?IasqH+li?#5w;$C4 z=VZ5BIs;KIw+m?lMn|`3umh_Fw+*K BJPiN<