From 2e348a16085879a2da1429d65970bc9887e1e837 Mon Sep 17 00:00:00 2001 From: Hyko Date: Tue, 14 Apr 2026 17:09:27 -0400 Subject: [PATCH] feat(storage): add configurable storage access policies Replace hardcoded `users/` path-based access control with a declarative `storageAccessPolicies` system defined per module via `defineModule()`. - Add `storageAccessPolicies` field to `defineModule()` defaults with support for `owner` and `admin` policy types - Expose `getAllStorageAccessPolicies()` from the modules/storage layer - Refactor `handleGetFile` in `storage/api.js` to resolve access control dynamically from registered policies instead of hardcoded path checks - Add `ZEN_STORAGE_ENDPOINT` env var and update `.env.example` to support S3-compatible backends (Cloudflare R2, Backblaze B2) - Document the env/doc sync convention in `DEV.md` --- .env.example | 9 ++++-- docs/DEV.md | 2 ++ src/core/modules/defineModule.js | 5 +++ src/core/storage/api.js | 38 ++++++++++------------- src/core/storage/index.js | 53 ++++++++++++++++---------------- src/core/storage/utils.js | 33 -------------------- src/features/auth/api.js | 4 ++- src/modules/modules.storage.js | 45 ++++++++++++++++++++++++--- src/modules/posts/api.js | 3 +- 9 files changed, 100 insertions(+), 92 deletions(-) diff --git a/.env.example b/.env.example index e8585c1..ea63b06 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,12 @@ ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev ZEN_DB_SSL_DISABLED=false -# STORAGE (Cloudflare R2 for now) -ZEN_STORAGE_BUCKET=my-bucket-name -ZEN_STORAGE_REGION=your-account-id +# STORAGE (S3-compatible — Cloudflare R2 ou Backblaze B2) +# R2 : ZEN_STORAGE_ENDPOINT=.r2.cloudflarestorage.com ZEN_STORAGE_REGION=auto +# B2 : ZEN_STORAGE_ENDPOINT=s3..backblazeb2.com ZEN_STORAGE_REGION= +ZEN_STORAGE_ENDPOINT= +ZEN_STORAGE_REGION=auto +ZEN_STORAGE_BUCKET= ZEN_STORAGE_ACCESS_KEY= ZEN_STORAGE_SECRET_KEY= diff --git a/docs/DEV.md b/docs/DEV.md index 7440f98..b5b8363 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -30,6 +30,8 @@ Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md). **ESLint passe sans avertissement.** Un warning ignoré aujourd'hui est un bug non détecté demain. +**Les variables d'environnement et la documentation se mettent à jour avec le code.** Toute variable ajoutée, renommée ou supprimée doit être reflétée dans `.env.example`. Toute décision architecturale ou convention nouvelle doit être documentée dans le fichier `docs/` concerné. Le code et sa documentation vieillissent ensemble. + --- ## Build et configuration tsup diff --git a/src/core/modules/defineModule.js b/src/core/modules/defineModule.js index 8a76e63..c5ee3cf 100644 --- a/src/core/modules/defineModule.js +++ b/src/core/modules/defineModule.js @@ -44,6 +44,11 @@ export function defineModule(config) { // Storage prefixes served publicly (no auth). Format: 'posts/blogue', 'assets/docs' storagePublicPrefixes: [], + // Storage access policies for private paths. Each entry: { prefix, type } + // type 'owner' — pathParts[1] must match session.user.id, or role is 'admin' + // type 'admin' — session.user.role must be 'admin' + storageAccessPolicies: [], + // Database (optional) — { createTables, dropTables } db: null, diff --git a/src/core/storage/api.js b/src/core/storage/api.js index c099943..f457d28 100644 --- a/src/core/storage/api.js +++ b/src/core/storage/api.js @@ -16,7 +16,7 @@ import { cookies } from 'next/headers'; import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionResolver } from '../api/router.js'; -import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; +import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; import { getFile } from '@zen/core/storage'; import { fail } from '../../shared/lib/logger.js'; import { defineApiRoutes } from '../api/define.js'; @@ -31,7 +31,7 @@ const COOKIE_NAME = getSessionCookieName(); * @param {{ wildcard: string }} params - wildcard contains the full file key * @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>} */ -async function handleGetFile(request, { wildcard: fileKey }) { +async function handleGetFile(_request, { wildcard: fileKey }) { try { if (!fileKey) { return apiError('Bad Request', 'File path is required'); @@ -40,16 +40,14 @@ async function handleGetFile(request, { wildcard: fileKey }) { // Reject path traversal sequences, empty segments, and null bytes before // passing the key to the storage backend. Next.js decodes percent-encoding // before populating [...path], so '..' and '.' arrive as literal values. - const rawSegments = fileKey.split('/'); + const pathParts = fileKey.split('/'); if ( - rawSegments.some(seg => seg === '..' || seg === '.' || seg === '') || + pathParts.some(seg => seg === '..' || seg === '.' || seg === '') || fileKey.includes('\0') ) { return apiError('Bad Request', 'Invalid file path'); } - const pathParts = rawSegments; - // Public prefixes: declared by each module via defineModule() storagePublicPrefixes. // Files whose path starts with a declared prefix are served without authentication. // The path must have at least two segments beyond the prefix @@ -78,27 +76,23 @@ async function handleGetFile(request, { wildcard: fileKey }) { return apiError('Unauthorized', 'Invalid or expired session'); } - // Path-based access control for authenticated users. - if (pathParts[0] === 'users') { - // User files: users/{userId}/{category}/{filename} - // Users can only access their own files, unless they are admin. - const userId = pathParts[1]; - if (session.user.id !== userId && session.user.role !== 'admin') { + // Path-based access control driven by policies declared in each module. + const policies = getAllStorageAccessPolicies(); + const policy = policies.find(p => pathParts[0] === p.prefix); + + if (!policy) { + return apiError('Forbidden', 'Invalid file path'); + } + + if (policy.type === 'owner') { + // Owner-scoped: pathParts[1] is the resource owner ID (e.g. users/{userId}/...) + if (session.user.id !== pathParts[1] && session.user.role !== 'admin') { return apiError('Forbidden', 'You do not have permission to access this file'); } - } else if (pathParts[0] === 'organizations') { - // Organisation files: admin only. - if (session.user.role !== 'admin') { - return apiError('Forbidden', 'Admin access required for organisation files'); - } - } else if (pathParts[0] === 'posts') { - // Post files not covered by a public prefix: admin only. + } else if (policy.type === 'admin') { if (session.user.role !== 'admin') { return apiError('Forbidden', 'Admin access required for this file'); } - } else { - // Unknown path pattern — deny by default. - return apiError('Forbidden', 'Invalid file path'); } return await fetchFile(fileKey); diff --git a/src/core/storage/index.js b/src/core/storage/index.js index 25fa1c6..0a9cc58 100644 --- a/src/core/storage/index.js +++ b/src/core/storage/index.js @@ -63,8 +63,7 @@ function signingKey(secret, ds, region, service) { * Returns the full URL and the headers object to pass to fetch. */ function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) { - const { accessKeyId, secretAccessKey } = config; - const region = 'auto'; + const { accessKeyId, secretAccessKey, region } = config; const service = 's3'; const ts = amzDate(date); @@ -117,8 +116,7 @@ function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBu * The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time. */ function buildPresignedUrl({ method, host, path, expiresIn, config, date }) { - const { accessKeyId, secretAccessKey } = config; - const region = 'auto'; + const { accessKeyId, secretAccessKey, region } = config; const service = 's3'; const ts = amzDate(date); @@ -156,26 +154,22 @@ function buildPresignedUrl({ method, host, path, expiresIn, config, date }) { // ─── Config ────────────────────────────────────────────────────────────────── function getConfig() { - const region = process.env.ZEN_STORAGE_REGION; + const host = process.env.ZEN_STORAGE_ENDPOINT; + const region = process.env.ZEN_STORAGE_REGION ?? 'auto'; const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY; const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY; const bucket = process.env.ZEN_STORAGE_BUCKET; - if (!region || !accessKeyId || !secretAccessKey) { + if (!host || !accessKeyId || !secretAccessKey) { throw new Error( - 'Storage credentials are not configured. Please set ZEN_STORAGE_REGION, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.' + 'Storage credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.' ); } if (!bucket) { throw new Error('ZEN_STORAGE_BUCKET environment variable is not set'); } - return { - accessKeyId, - secretAccessKey, - bucket, - host: `${region}.r2.cloudflarestorage.com`, - }; + return { accessKeyId, secretAccessKey, bucket, host, region }; } // ─── Minimal XML helpers ───────────────────────────────────────────────────── @@ -557,7 +551,8 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { } /** - * Copy a file within the same bucket + * Copy a file within the same bucket using the native S3 CopyObject API. + * No data is transferred through the server — the provider copies server-side. * @param {Object} options * @param {string} options.sourceKey - Source file path/key * @param {string} options.destinationKey - Destination file path/key @@ -565,21 +560,28 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { */ async function copyFile({ sourceKey, destinationKey }) { try { - const getResult = await getFile(sourceKey); - if (!getResult.success) return getResult; + const config = getConfig(); + const path = `/${config.bucket}/${destinationKey}`; + const date = new Date(); - const uploadResult = await uploadFile({ - key: destinationKey, - body: getResult.data.body, - contentType: getResult.data.contentType, - metadata: getResult.data.metadata, + const { url, headers } = signRequest({ + method: 'PUT', + host: config.host, + path, + extraHeaders: { 'x-amz-copy-source': `/${config.bucket}/${encodePath(sourceKey)}` }, + config, + date, }); - if (uploadResult.success) { - info(`Storage: copied ${sourceKey} → ${destinationKey}`); + const response = await fetch(url, { method: 'PUT', headers }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Copy failed (${response.status}): ${text}`); } - return uploadResult; + info(`Storage: copied ${sourceKey} → ${destinationKey}`); + return { success: true, data: { key: destinationKey, bucket: config.bucket }, error: null }; } catch (error) { fail(`Storage copy failed: ${error.message}`); return { success: false, data: null, error: error.message }; @@ -643,9 +645,6 @@ export { validateFileType, validateFileSize, formatFileSize, - generateUserFilePath, - generateOrgFilePath, - generatePostFilePath, sanitizeFilename, validateImageDimensions, validateUpload, diff --git a/src/core/storage/utils.js b/src/core/storage/utils.js index 6bcc3af..642ceb2 100644 --- a/src/core/storage/utils.js +++ b/src/core/storage/utils.js @@ -136,39 +136,6 @@ export function formatFileSize(bytes, decimals = 2) { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } -/** - * Generate a storage path for a user's file - * @param {string|number} userId - User ID - * @param {string} category - File category (e.g., 'profile', 'documents') - * @param {string} filename - Filename - * @returns {string} Storage path (e.g., 'users/123/profile/filename.jpg') - */ -export function generateUserFilePath(userId, category, filename) { - return `users/${userId}/${category}/${filename}`; -} - -/** - * Generate a storage path for organization/tenant files - * @param {string|number} orgId - Organization/tenant ID - * @param {string} category - File category - * @param {string} filename - Filename - * @returns {string} Storage path - */ -export function generateOrgFilePath(orgId, category, filename) { - return `organizations/${orgId}/${category}/${filename}`; -} - -/** - * Generate a storage path for a post image, scoped by post type. - * @param {string} typeKey - Post type key (e.g. 'blogue', 'cve') - * @param {string|number} postIdOrSlug - Post ID or slug (use timestamp for pre-creation uploads) - * @param {string} filename - Filename - * @returns {string} Storage path (e.g., 'posts/blogue/123/filename.jpg') - */ -export function generatePostFilePath(typeKey, postIdOrSlug, filename) { - return `posts/${typeKey}/${postIdOrSlug}/${filename}`; -} - /** * Sanitize filename by removing special characters * @param {string} filename - Original filename diff --git a/src/features/auth/api.js b/src/features/auth/api.js index c63c34e..947f0ab 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -9,7 +9,9 @@ import { query, updateById } from '@zen/core/database'; import { updateUser } from './lib/auth.js'; -import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; +import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; + +const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`; import { fail, info } from '../../shared/lib/logger.js'; import { defineApiRoutes } from '../../core/api/define.js'; import { apiSuccess, apiError } from '../../core/api/respond.js'; diff --git a/src/modules/modules.storage.js b/src/modules/modules.storage.js index 69bf95e..8f2ca2e 100644 --- a/src/modules/modules.storage.js +++ b/src/modules/modules.storage.js @@ -1,14 +1,14 @@ /** * Module Storage Registry (Server-Side) * - * Aggregates storage public prefixes declared by each module via defineModule(). - * A prefix listed here is served without authentication by the storage handler. + * Aggregates storage public prefixes and private access policies declared by + * each module via defineModule(). * - * Internal modules declare `storagePublicPrefixes` in their defineModule() config. - * External modules registered at runtime are also included automatically. + * Public prefixes are served without authentication. + * Access policies control auth requirements for private paths. * * Usage: - * import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; + * import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; */ import { getModule, getEnabledModules } from '@zen/core/core/modules'; @@ -51,3 +51,38 @@ export function getAllStoragePublicPrefixes() { return [...prefixes]; } + +/** + * Get all storage access policies from every enabled module. + * + * Policies for built-in features (auth, posts) are included directly. + * External modules contribute via their `storageAccessPolicies` defineModule field. + * + * Policy shape: { prefix: string, type: 'owner' | 'admin' } + * 'owner' — pathParts[1] must match session.user.id, or role is 'admin' + * 'admin' — session.user.role must be 'admin' + * + * @returns {{ prefix: string, type: string }[]} + */ +export function getAllStorageAccessPolicies() { + const policies = [ + // Built-in auth feature — user files are owner-scoped + { prefix: 'users', type: 'owner' }, + ]; + + // Posts module — non-public post paths require admin + if (process.env.ZEN_MODULE_POSTS === 'true') { + policies.push({ prefix: 'posts', type: 'admin' }); + } + + // External modules + for (const mod of getEnabledModules()) { + if (!mod.external) continue; + const runtimeConfig = getModule(mod.name); + for (const policy of runtimeConfig?.storageAccessPolicies ?? []) { + policies.push(policy); + } + } + + return policies; +} diff --git a/src/modules/posts/api.js b/src/modules/posts/api.js index 0a81e27..cd68094 100644 --- a/src/modules/posts/api.js +++ b/src/modules/posts/api.js @@ -24,7 +24,6 @@ import { import { uploadImage, deleteFile, - generatePostFilePath, generateUniqueFilename, validateUpload, getFileExtension, @@ -32,6 +31,8 @@ import { FILE_SIZE_LIMITS } from '@zen/core/storage'; +const generatePostFilePath = (typeKey, postIdOrSlug, filename) => `posts/${typeKey}/${postIdOrSlug}/${filename}`; + /** * Extension → MIME type map derived from the validated file extension. * The client-supplied file.type is NEVER trusted — it is an attacker-controlled