Security Architecture
Bilko — Security Architecture Document
Project: Bilko — Balkan Accounting SaaS Version: 1.0 Date: 2026-02-
2523 Author:ALAIComplianceDocumentation TeamArchitect Status:FinalDraftNote:Reviewers:ArchitectureCTO,isDPO,documentedEngineeringforLeadimplementation.Classification:Backend implementation is in progress (Phase 2).Confidential
This
Document document defines the security architecture for Bilko, a financial SaaS handling sensitive accounting data.
Security PrinciplesHistory
Defense in Depth — Multiple layers of security (network, application, database)
Least Privilege — Users and services get minimum necessary permissions
Zero Trust — Verify every request, never assume trust
Encryption Everywhere — Data encrypted in transit and at rest
Immutable Audit Trail — All actions logged, tamper-proof
STRIDE Threat Model
Bilko handles sensitive financial data (tax IDs, IBAN, accounting records) across three jurisdictions. The STRIDE model identifies threats specific to each layer.
Spoofing — Identity Threats
| Initial draft — | ||||
| | |||
Tampering — Data Integrity Threats
| Bilko | |||
|---|---|---|---|
| |||
Repudiation — Non-Traceability Threats
| |||
Information Disclosure — Data Leakage Threats
| |||
Denial of Service — Availability Threats
| |||
Elevation of Privilege — Access Control Threats
| |||
|
1. Security Architecture Overview
Security Owner: Compliance Architect ([email protected]) Last Security Review: 2026-02-23 Next Scheduled Review: 2026-08-23 Compliance Targets: GDPR | Zakon o zaštiti podataka o ličnosti RS (ZZPL) | Zakon o zaštiti ličnih podataka BiH (ZZLP) | GDPR via AZOP (HR) | Zakon o računovodstvu RS/HR/BA | Zakon o PDV RS/BA/HR
Architecture Model: Bilko is a multi-tenant cloud accounting SaaS. Processes invoices, expenses, VAT returns, and financial reports for organizations in Serbia, Bosnia & Herzegovina, and Croatia. Each organization's data strictly isolated by organizationId at the database layer.
Defense-in-Depth Overview
graph TD
CLIENT["Client Browser / PWA"]
subgraph NETWORK["Network Layer"]
CF["Cloudflare WAF\nDDoS Protection\nTLS 1.3 termination\nHSTS"]
end
subgraph APP_LAYER["Application Layer"]
HELMET["Helmet.js\nCSP + X-Frame + HSTS\nno X-Powered-By"]
CORS["CORS Whitelist\nbilko.io only\nno wildcard *"]
RATE["Rate Limiter\nexpress-rate-limit\n5 req/15min auth\n100 req/15min general"]
AUTH_MW["Auth Middleware\nJWT verify (15min access)\norg-scope injection"]
RBAC_MW["RBAC Middleware\nowner / admin / accountant / viewer"]
ZOD["Zod Validation\nall request bodies\ntype-safe parsing"]
end
subgraph DATA_LAYER["Data Layer"]
PRISMA_ORM["Prisma ORM\nparameterized queries\nno raw SQL for user input\norg-scoped WHERE clauses"]
PG_ENC["PostgreSQL (Railway EU West)\nAES-256 disk encryption\nbackup encryption"]
end
subgraph AUDIT["Audit Layer"]
LOG["LoggedAction table\nAPPEND-ONLY\nIP + user + timestamp\nold/new values (changedFields)"]
end
CLIENT --> CF --> HELMET --> CORS --> RATE --> AUTH_MW --> RBAC_MW --> ZOD --> PRISMA_ORM --> PG_ENC
PRISMA_ORM --> LOG
2. Authentication
2.1 Strategy: JWT (JSON Web Tokens)
Why JWT?
- Stateless
(JWT, scaleshorizontally)horizontally WorksonwithRailway.mobile PWAIndustry standard
Token Types
Access Token
tokens Lifetime:(15minutesmin, Storage:memory-only)tokensAuthorization:+Bearerrefresh<token>header(7 Contains:days,UserhttpOnlyID,cookie).organizationRotationID,onroleevery Refresh:refresh.AutomaticRevocation viarefresh token
Refresh Token
Lifetime:7 daysStorage:httpOnly cookie (not accessible to JavaScript)Purpose:Obtain new access tokenRotation:New refreshhashed tokenissued on each refreshRevocation:Storedstorage indatabase, can be invalidated
2.2 JWT Payload Example
{
"sub": "user-uuid",
"org": "org-uuid",
"role": "admin",
"iat": 1640000000,
"exp": 1640000900,
"jti": "unique-token-id"
}
jti(JWT ID) — unique token identifier used to prevent replay attacks and enable server-side token invalidation.
TokenAuth Flow
1.sequenceDiagram
actor User
logsparticipant inFE →as Frontend (bilko.io — Vercel)
participant API as Express API (api.bilko.io — Railway EU)
participant DB as PostgreSQL (Railway EU West)
User->>FE: Enter email + password
FE->>API: POST /api/v1/auth/login
←API->>DB: AccessSELECT tokenuser WHERE email = ? (header)parameterized)
DB-->>API: User record (passwordHash)
API->>API: bcrypt.compare(password, hash) — 12 rounds
alt Password valid
API->>API: jwt.sign({sub, org, role}, JWT_SECRET, 15m)
API->>DB: INSERT refreshToken (hashed, expiresAt)
API-->>FE: 200 { accessToken } + RefreshSet-Cookie: tokenrefreshToken (httpOnlyhttpOnly, cookie)secure, 2.sameSite=strict)
UserFE->>FE: makesStore requestaccessToken →in GETmemory /api/v1/invoicesonly
else Password invalid
API-->>FE: 401 Unauthorized (Authorization:generic Bearer— <no user enumeration)
end
Note over FE,API: 15 minutes later — access>)
← Protected resource
3. Access token expires
(15 min) →FE->>API: POST /api/v1/auth/refresh (httpOnlyCookie: cookie)refreshToken)
←API->>API: NewRotate: accessdelete tokenold, issue new
API-->>FE: 200 { newAccessToken } + NewSet-Cookie: refreshnewRefreshToken
tokenNote 4.over UserUser,DB: logsLogout
out →FE->>API: POST /api/v1/auth/logout
→API->>DB: DeleteDELETE refreshrefreshToken tokenWHERE fromuserId DB= ←?
API-->>FE: 204 No Content
Implementation2.3 (Backend)
import jwt from 'jsonwebtoken'
import bcrypt from 'bcrypt'
// Generate access token
const accessToken = jwt.sign(
{ sub: user.id, org: user.organizationId, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: '15m' },
)
// Generate refresh token
const refreshToken = jwt.sign({ sub: user.id }, process.env.JWT_REFRESH_SECRET!, {
expiresIn: '7d',
})
// Store refresh token in DB (for revocation)
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
})
Token Invalidation Events
Refresh tokens must be revoked server-side on any of these events:
User logoutPassword changeRole change by adminAccount suspensionSuspicious login from unknown IP/country
Password Security
Hashing: bcrypt
Algorithm: bcrypt with 12 salt rounds
Why bcrypt?
Designed for passwords (slow by design, resists brute force)Auto-salted (each password has unique salt)Adaptive (can increase rounds as hardware improves)
Password Requirements
Minimum length:8 charactersComplexity:At least one uppercase, one lowercase, one numberNo common passwords:Check against list of 10K most common passwordsNo reuse:Previous 5 passwords stored (hashed) and blocked
Implementation
import bcrypt from 'bcrypt'
// Hash password (registration)
const passwordHash = await bcrypt.hash(password, 12)
// Verify password (login)
const isValid = await bcrypt.compare(password, user.passwordHash)
Two-Factor Authentication (2FA)
Strategy:
Method: TOTP (Time-basedRFC One-Time6238) Password)—
CompatibleGoogle with:Authenticator, Authy, 1Password
GoogleSetup:AuthenticatorAuthy1PasswordMicrosoft Authenticator
Setup Flow
1. User enables 2FA →POST /api/v1/auth/2fa/setup←→ QR code + base32 secret(base32)
POST /api/v1/auth/2fa/verify { code }Login Flow with 2FA
→1. User logs in → POST /api/v1/auth/login { email, password } ← 200 OK +{ requires2FA: true, tempToken }2. User enters codePOST /api/v1/auth/2fa/login
Backup Codes
Generate
Storedbcrypt-hashed(bcrypt)Used when authenticator unavailableMarked as used after redemption
3. Authorization (RBAC)
Roles
3.1 Permission Matrix
| Action | owner | admin | accountant | viewer |
|---|---|---|---|---|
| Create invoice | ✅ | ✅ | ❌ | ❌ |
| Edit invoice | ✅ | ✅ | ❌ | ❌ |
| Delete invoice | ✅ | ❌ | ❌ | ❌ |
| View invoice | ✅ | ✅ | ✅ | ✅ |
| Approve expense | ✅ | ✅ | ❌ | ❌ |
| Generate report | ✅ | ✅ | ✅ | ❌ |
| Invite user | ✅ | ❌ | ❌ | ❌ |
| Edit org settings | ✅ | ❌ | ❌ | ❌ |
Implementation3.2 Organization Scoping (Middleware)IDOR Prevention)
import { Request, Response, NextFunction } from 'express'
function requireRole(roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' })
}
next()
}
}
// Usage
app.post('/api/v1/invoices', requireRole(['owner', 'admin']), createInvoice)
Data Classification
L4 Restricted fields use a hybrid encryption approach per ADR-014: personal identifiers (JMBG, OIB) receive AES-256-GCM field-level encryption before persistence because they are irrevocable and high-impact on breach. Business tax IDs (PIB, JIB) and IBAN rely on disk-level encryption plus application-layer controls — field-level encryption for publicly available identifiers would be disproportionate to the risk per GDPR Article 32.
Encryption
In Transit: TLS 1.3
All traffic encrypted via HTTPS:
Frontend (Vercel): Automatic HTTPSBackend (Railway): Automatic HTTPSCertificate: Let's Encrypt (auto-renewed)
TLS Configuration:
Minimum version: TLS 1.3Cipher suites: Modern only (no legacy ciphers)HSTS enabled (Strict-Transport-Security header)
At Rest: Database Encryption
PostgreSQL (Railway):
Disk encryption: AES-256 (Railway default)Backup encryption: AES-256Column-level encryption:Hybrid approach per ADR-014— JMBG and OIB fields use AES-256-GCM field-level encryption viaprisma-field-encryption(Tier 1). PIB, JIB, and IBAN rely on disk-level encryption + application controls (Tier 2). Disk-level encryption alone is insufficient for personal identifiers (JMBG/OIB) due to their irrevocability and high breach impact. (See ADR-014)
Cloudflare R2 (Files):
Server-side encryption: AES-256 (default)No client-side encryption needed (files are receipts/invoices, not PII)
Secrets Management
NEVER commit secrets to git:
.envfiles in.gitignoreUse platform-provided secrets (Vercel, Railway)Rotate JWT secrets quarterlyRotate API keys annually
OWASP Top 10 Mitigations
1. Injection (SQL Injection)
Mitigation: Prisma ORM parameterized queries
// SAFEInjected —by Prismaauth auto-escapes
await prisma.invoice.findMany({
where: { customerId: req.params.id },
})
// UNSAFE — Never use raw SQL for user input
await prisma.$queryRaw`SELECT * FROM invoices WHERE customer_id = ${req.params.id}`
2. Broken Authentication
Mitigations:
bcrypt password hashing (12 rounds)JWT with short expiry (15 min)Refresh token rotation2FA (TOTP)Rate limitingmiddleware onauthallendpoints/api/v1/*(5 req/min)
3. Sensitive Data Exposure
Mitigations:
TLS 1.3 in transitAES-256 at restNo PII in JWTs (only user ID)No passwords in logsNo sensitive data in URLs (use POST body)
4. XML External Entities (XXE)
Not applicable — Bilko does not parse XML.
5. Broken Access Control
Mitigations:
RBAC enforced on every endpointOrganization-scoped queries (middleware)No direct object reference (use UUIDs, not auto-increment IDs)
// Organization scoping middlewareroutes
app.use('/api/v1/*', (req, res, next) => {
req.prismaWhere = { organizationId: req.user.organizationId };
next();
});
// ApplyApplied to queriesevery Prisma query
await prisma.invoice.findMany({ where: { ...req.prismaWhere } });
UUID primary keys throughout — no sequential ID enumeration possible.
4. Encryption
6.4.1 SecurityIn MisconfigurationTransit: TLS 1.3
Mitigations:
Helmet.js security headersCORS whitelist (no*in production)Error messages sanitized (no stack traces in production)DisableX-Powered-Byheader
Full Security Headers Configuration
All securitytraffic headersHTTPS. appliedCloudflare viaTLS Helmet.js1.3 onat theedge, Expressre-encrypted API.to TheRailway. Next.js frontend applies equivalent headers viaHSTS: .next.config.jsmax-age=63072000; includeSubDomains; preload
import4.2 helmetAt fromRest: 'helmet'
// Express API — full security headers
app.use(
helmet({
// Content-Security-Policy — prevent XSS
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // No unsafe-inline needed on API
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
frameSrc: ["'none'"], // No iframes from this API
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
useDefaults: false,
},
// Strict-Transport-Security — force HTTPS for 1 year, include subdomains
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true, // Eligible for browser HSTS preload list
},
// X-Frame-Options — prevent clickjacking
frameguard: {
action: 'deny', // DENY: no framing at all
},
// X-Content-Type-Options — prevent MIME sniffing
noSniff: true,
// X-XSS-Protection — legacy header for older browsers
xssFilter: true,
// Referrer-Policy — don't leak URL in Referer header
referrerPolicy: {
policy: 'strict-origin-when-cross-origin',
},
// Permissions-Policy — disable browser features not needed by Bilko
permittedCrossDomainPolicies: { permittedPolicies: 'none' },
// X-DNS-Prefetch-Control
dnsPrefetchControl: { allow: false },
// X-Powered-By removed by default in Helmet
hidePoweredBy: true,
}),
)
// Permissions-Policy header (not yet in Helmet — set manually)
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
)
next()
})
// CORS — whitelist only known origins
app.use(
cors({
origin: (origin, callback) => {
const allowed = [
'https://bilko.io',
'https://www.bilko.io',
'https://app.bilko.io',
'https://staging.bilko.io',
'https://bilko.rs', // Serbia redirect domain
]
if (!origin || allowed.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`CORS: origin ${origin} not allowed`))
}
},
credentials: true, // Required for httpOnly cookie (refresh token)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}),
)
Next.js Frontend Security Headers (next.config.js)
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires these
"style-src 'self' 'unsafe-inline'", // Tailwind requires unsafe-inline
"img-src 'self' data: https:",
"connect-src 'self' https://api.bilko.io wss://api.bilko.io",
"font-src 'self' https://fonts.gstatic.com",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; '),
},
{ key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=()' },
{ key: 'X-DNS-Prefetch-Control', value: 'off' },
]
module.exports = {
headers: async () => [{ source: '/:path*', headers: securityHeaders }],
}
Headers Verification
Use securityheaders.com to verify. Target grade: A+.
PostgreSQL |
||
PostgreSQL backups |
auto-backup |
|
Tax IDs (PIB/JMBG/OIB/JIB), IBAN |
AES-256-GCM field encryption |
|
Cloudflare R2 (receipts, PDFs) |
AES-256 server-side |
4.3 Password Security
bcrypt, 12 salt rounds. Min 8 chars. Block top 10K common passwords. Last 5 hashes retained.
4.4 Financial Data Precision
All monetary amounts: NUMERIC(19,4) — never float. Exchange rates locked at transaction date.
5. OWASP Top 10 Mitigations
| OWASP Risk | Mitigation | Status |
|---|---|---|
| A01: Broken Access Control | RBAC + org-scoped WHERE + UUID PKs | Designed |
A02: Cryptographic Failures |
TLS 1.3 + AES-256 + bcrypt(12) + no PII in JWT |
|
A03: Injection |
||
| A04: Insecure Design | Multi-tenant org isolation at DB layer, immutable audit | Designed |
| A05: Security Misconfiguration | Helmet.js, CORS whitelist (no *), sanitized errors | Designed |
| A06: Vulnerable Components | Dependabot + weekly npm audit + lock file | Planned |
| A07: Auth Failures | Rate limiting + JWT rotation + 2FA + bcrypt(12) | Designed |
| A08: Software Integrity | Signed commits + CI/CD + Dependabot | Planned |
| A09: Logging Failures | Immutable LoggedAction table + Railway logs + Sentry | Designed |
| A10: SSRF | Zod validation + allowlist for SEF/HR-FISK/FINA API | Designed |
7.6. Cross-Site Scripting (XSS)
Mitigations:
React auto-escapes output (default safe)CSP headers (Content-Security-Policy)Sanitize user input (Zod validation)NodangerouslySetInnerHTMLwithout sanitization
// SAFE — React escapes by default
<p>{invoice.description}</p>
// UNSAFE — Only use with sanitized HTML
<div dangerouslySetInnerHTML={{ __html: sanitizedHTML }} />
8. Insecure Deserialization
Not applicable — Bilko does not deserialize untrusted data.
9. Using Components with Known Vulnerabilities
Mitigations:
Dependabot alerts enabled (GitHub)Weeklynpm auditchecksAutomated security updates (Dependabot PRs)Lock file committed (package-lock.json)
10. Insufficient Logging & Monitoring
Mitigations:
Audit trail (LoggedAction table)Error tracking (Sentry recommended)Access logs (Railway built-in)Failed login attempts loggedAnomaly detection (future: alert on 10+ failed logins)
Rate Limiting
Prevent brute force and abuse:
| Endpoint | Limit | Window | ||
|---|---|---|---|---|
POST /api/v1/auth/login |
5 |
| ||
POST /api/v1/ |
| |||
POST /api/v1/auth/refresh |
10 |
15 | ||
GET /api/v1/reports/* |
15 | |||
All other /api/v1/* |
||||
| 15 | |||
|
Implementation
7. Input Validation (Zod)
import rateLimit from 'express-rate-limit'
import RedisStore from 'rate-limit-redis'
// Auth limiter — strict (applied to /auth/login and /auth/register)
const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5,
skipSuccessfulRequests: true, // Don't count successful logins
standardHeaders: true,
legacyHeaders: false,
message: {
error: 'Too many authentication attempts',
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: 60,
},
keyGenerator: (req) => req.ip || 'unknown',
})
// General API limiter (applied to all /api/v1/* routes)
const generalLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
standardHeaders: true,
legacyHeaders: false,
handler: (_req, res) =>
res.status(429).json({
error: 'Too many requests',
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: 60,
}),
})
app.post('/api/v1/auth/login', authLimiter, loginHandler)
app.use('/api/v1/', generalLimiter)
IP Whitelisting for Webhooks
Webhooks from SEF (Serbian e-invoice portal) and FINA (Croatian HR-FISK) must bypass general rate limiting but are restricted to known IP ranges:
// Known webhook source IP ranges
const SEF_WEBHOOK_IPS = [
'185.54.144.0/24', // efaktura.mfin.gov.rs — verify with SEF portal docs
]
const FINA_WEBHOOK_IPS = [
'195.29.61.0/24', // FINA PKI infrastructure — verify with FINA
]
function isWebhookRequest(req: Request): boolean {
const clientIp = req.ip ?? req.socket.remoteAddress
return [...SEF_WEBHOOK_IPS, ...FINA_WEBHOOK_IPS].some((range) => ipRangeContains(range, clientIp))
}
// Webhook endpoint — IP-restricted, no general rate limit
app.post(
'/api/v1/webhooks/sef',
requireWebhookIp(SEF_WEBHOOK_IPS),
verifyWebhookSignature,
handleSefWebhook,
)
app.post(
'/api/v1/webhooks/fina',
requireWebhookIp(FINA_WEBHOOK_IPS),
verifyWebhookSignature,
handleFinaWebhook,
)
function requireWebhookIp(allowedRanges: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const clientIp = req.ip ?? req.socket.remoteAddress
const allowed = allowedRanges.some((range) => ipRangeContains(range, clientIp))
if (!allowed) {
return res.status(403).json({ error: 'Webhook source IP not allowed' })
}
next()
}
}
Note: Confirm exact SEF and FINA IP ranges from their integration documentation before deployment. Update SEF_WEBHOOK_IPS and FINA_WEBHOOK_IPS accordingly.
Input Validation
All inputs validated with Zod schemas:
Example: Invoice Validation
import { z } from 'zod'
const createInvoiceSchema = z.object({
customerId: z.string().uuid(),
invoiceDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
currencyCode: z.enum(['EUR', 'RSD', 'BAM', 'HRK']),
items: z.array(
z.object({
description: z.string().min(1).max(500),
quantity: z.number().positive(),
unitPrice: z.number().nonnegative(),
taxRate: z.number().min(0).max(100),
}),
),
})
// Middleware
function validate(schema: z.ZodSchema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body)
next()
} catch (error) {
res.status(400).json({ error: error.errors })
}
}
}
// Usage
app.post('/api/v1/invoices', validate(createInvoiceSchema), createInvoice);
8. File Upload Security
Allowed File Types
Receipts:Allowed: JPG, PNG,
PDFPDF. - Max
size:10MB per file
Validation
import multer from 'multer'
import path from 'path'
const upload = multer({
limits: { fileSize: 10 *MB. 1024MIME *+ 1024extension },validation. //Stored 10MBin fileFilter:Cloudflare (req,R2 file,EU. cb) => {
const allowedTypes = ['.jpg', '.jpeg', '.png', '.pdf']
const ext = path.extname(file.originalname).toLowerCase()
if (allowedTypes.includes(ext)) {
cb(null, true)
} else {
cb(new Error('Invalid file type'))
}
},
})
Virus Scanning (Planned)
Phase 2: Integrate ClamAV for virus scanning before upload to R2.scanning.
9. Audit Trail
LoggedAction Table (Immutable)
All mutations logged:
Table nameAction (INSERT, UPDATE, DELETE)User IDTimestampOld values (UPDATE/DELETE)New values (INSERT/UPDATE)Client IPSQL query
Example Audit Log Entry
{
"eventId": 12345,
"tableName": "invoices",
"action": "UPDATE",
"userId": "user-uuid",
"actionTimestamp": "2026-02-20T10:30:00Z",
"rowData": { "id": "invoice-uuid", "status": "draft" },
"changedFields": { "status": { "old": "draft", "new": "sent" } },
"clientIp": "192.168.1.10"
}
Audit Queries
// Get user activity
await prisma.loggedAction.findMany({
where: { userId: 'user-uuid' },
orderBy: { actionTimestamp: 'desc' },
take: 100,
})
// Get invoice history
await prisma.loggedAction.findMany({
where: {
tableName: 'invoices',
rowData: { path: ['id'], equals: 'invoice-uuid' },
},
})
Data Retention & Deletion
User Data Deletion (GDPR Right to Erasure)
Process:
User requests deletion → POST /api/v1/account/deleteSoft delete user record (markdeletedAt)Anonymize— LoggedActionentries(replace user ID with "deleted-user")Delete PII (email, name)Keep financial records(required by law, minimum 5 years)
Soft Delete Implementation:
await prisma.user.update({
where: { id: userId },
data: {
email: `deleted-${userId}@example.com`,
fullName: 'Deleted User',
passwordHash: '',
deletedAt: new Date(),
},
})
Security TestingAPPEND-ONLY)
Static Analysis
ESLint:Security rules enabled (no-eval, no-unsafe-regex)TypeScript:Strict mode (catches type errors)
Dependency Scanning
npm audit:Weekly checksDependabot:Automatic PRs for vulnerabilities
Penetration Testing Plan
Frequency: Annual + after significant architecture changes
Provider: External certified firm (OSCP or CREST certified)
Environment: Staging only (staging.bilko.io) — never production without explicit CEO approval
Scope
{ | { ||
Pre-Engagement
On ChecklistGDPR
- erasure:
- userId
Statement→of"deleted-user".WorkFinancial entries retained 11 years (SoW)law).signedLoggedActionwithneverpentest firm Staging environment set up with production-equivalent configurationTest data (fake organizations, fake invoices) loaded —no real customer dataBilko DBA grants read-only DB access to pentest firm for review (not write)Confirm staging SEF/FINA integrations are in test modeLegal: pentest authorization letter from CEO on file
Acceptance Criteria
Zero Critical or High findingsunmitigated before production launchMedium findings: each assessed, documented, and either fixed or accepted with justificationRemediation SLAs:CRITICAL: 48 hoursHIGH: 7 daysMEDIUM: 30 daysLOW: Next sprint boundary
Pentest Report Requirements
The pentest report must include:deleted.
Executive summary (risk level, critical findings)Technical findings: CVSS score, proof of concept, affected endpointsBusiness impact statement for each findingRemediation recommendationsRe-test results (confirm fixes for Critical and High)
Incident10. ResponseSecurity PlanHeaders (Helmet.js)
Detection
| Header | Value |
|---|---|
| Strict-Transport-Security | max-age=63072000; |
| Content-Security-Policy | default-src |
| X-Content-Type-Options | nosniff |
| X-Frame-Options | DENY |
| X-Powered-By | Removed |
11. Pre-Launch Security Checklist (Pre-Launch)
-
JWT secretsJWT_SECRET generated (32+ chars, CSPRNG) — Railway env secret - JWT_REFRESH_SECRET separate key (32+ chars)
-
HTTPSFIELD_ENCRYPTION_KEYenforcedgenerated (no32HTTPbytesallowed)hex) — for PIB/JMBG/OIB/JIB + IBAN -
CORSHTTPSwhitelistenforced -
(noCORS:*)bilko.io only - Rate limiting
enabled (auth endpoints)tested - Helmet.js
securityheadersconfiguredverified - bcrypt
passwordroundshashing=(12rounds) - All Prisma queries
parameterizeduse(noorg-scopedraw SQL)WHERE -
InputZod validation(Zodonschemas)all endpoints -
FileLoggedActionuploadtriggerrestrictionsactive(type,onsize)all Audit trail enabled (LoggedAction)tables- Error
messagesresponses sanitized(no stack traces) - Dependabot alerts enabled
-
BackupRailwaystrategyregiontested= EU West confirmed -
IncidentDPAsresponsesignedplan(Railway,documentedVercel, Cloudflare, SendGrid) -
SecurityDatareviewdeletioncompletedworkflow tested
Related Documents
Compliance:Compliance Framework:COMPLIANCE.compliance-framework.mdDeployment:Data Encryption Policy: data-encryption-policy.md- Key Management Policy: key-management-policy.md
- DPIA: data-protection-impact-assessment.md
- Breach Response: data-breach-response-plan.md
- Security Testing: security-testing-policy.md
- Bilko Security Docs: ../
infrastructure/DEPLOYMENT.md Testing:../testing/TESTING-GUIDE.mdproducts/Bilko/docs/security/
LastApproval
Updated:
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Compliance Architect | 2026-02- |
|
| CTO | |||
| DPO | |||
| Engineering Lead |