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`
This commit is contained in:
2026-04-14 17:09:27 -04:00
parent 67de464e1d
commit 2e348a1608
9 changed files with 100 additions and 92 deletions
+6 -3
View File
@@ -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_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
ZEN_DB_SSL_DISABLED=false ZEN_DB_SSL_DISABLED=false
# STORAGE (Cloudflare R2 for now) # STORAGE (S3-compatible — Cloudflare R2 ou Backblaze B2)
ZEN_STORAGE_BUCKET=my-bucket-name # R2 : ZEN_STORAGE_ENDPOINT=<accountId>.r2.cloudflarestorage.com ZEN_STORAGE_REGION=auto
ZEN_STORAGE_REGION=your-account-id # B2 : ZEN_STORAGE_ENDPOINT=s3.<region>.backblazeb2.com ZEN_STORAGE_REGION=<region>
ZEN_STORAGE_ENDPOINT=
ZEN_STORAGE_REGION=auto
ZEN_STORAGE_BUCKET=
ZEN_STORAGE_ACCESS_KEY= ZEN_STORAGE_ACCESS_KEY=
ZEN_STORAGE_SECRET_KEY= ZEN_STORAGE_SECRET_KEY=
+2
View File
@@ -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. **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 ## Build et configuration tsup
+5
View File
@@ -44,6 +44,11 @@ export function defineModule(config) {
// Storage prefixes served publicly (no auth). Format: 'posts/blogue', 'assets/docs' // Storage prefixes served publicly (no auth). Format: 'posts/blogue', 'assets/docs'
storagePublicPrefixes: [], 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 } // Database (optional) — { createTables, dropTables }
db: null, db: null,
+16 -22
View File
@@ -16,7 +16,7 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import { getSessionResolver } from '../api/router.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 { getFile } from '@zen/core/storage';
import { fail } from '../../shared/lib/logger.js'; import { fail } from '../../shared/lib/logger.js';
import { defineApiRoutes } from '../api/define.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 * @param {{ wildcard: string }} params - wildcard contains the full file key
* @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>} * @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>}
*/ */
async function handleGetFile(request, { wildcard: fileKey }) { async function handleGetFile(_request, { wildcard: fileKey }) {
try { try {
if (!fileKey) { if (!fileKey) {
return apiError('Bad Request', 'File path is required'); 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 // Reject path traversal sequences, empty segments, and null bytes before
// passing the key to the storage backend. Next.js decodes percent-encoding // passing the key to the storage backend. Next.js decodes percent-encoding
// before populating [...path], so '..' and '.' arrive as literal values. // before populating [...path], so '..' and '.' arrive as literal values.
const rawSegments = fileKey.split('/'); const pathParts = fileKey.split('/');
if ( if (
rawSegments.some(seg => seg === '..' || seg === '.' || seg === '') || pathParts.some(seg => seg === '..' || seg === '.' || seg === '') ||
fileKey.includes('\0') fileKey.includes('\0')
) { ) {
return apiError('Bad Request', 'Invalid file path'); return apiError('Bad Request', 'Invalid file path');
} }
const pathParts = rawSegments;
// Public prefixes: declared by each module via defineModule() storagePublicPrefixes. // Public prefixes: declared by each module via defineModule() storagePublicPrefixes.
// Files whose path starts with a declared prefix are served without authentication. // Files whose path starts with a declared prefix are served without authentication.
// The path must have at least two segments beyond the prefix // 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'); return apiError('Unauthorized', 'Invalid or expired session');
} }
// Path-based access control for authenticated users. // Path-based access control driven by policies declared in each module.
if (pathParts[0] === 'users') { const policies = getAllStorageAccessPolicies();
// User files: users/{userId}/{category}/{filename} const policy = policies.find(p => pathParts[0] === p.prefix);
// Users can only access their own files, unless they are admin.
const userId = pathParts[1]; if (!policy) {
if (session.user.id !== userId && session.user.role !== 'admin') { 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'); return apiError('Forbidden', 'You do not have permission to access this file');
} }
} else if (pathParts[0] === 'organizations') { } else if (policy.type === 'admin') {
// 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.
if (session.user.role !== 'admin') { if (session.user.role !== 'admin') {
return apiError('Forbidden', 'Admin access required for this file'); 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); return await fetchFile(fileKey);
+26 -27
View File
@@ -63,8 +63,7 @@ function signingKey(secret, ds, region, service) {
* Returns the full URL and the headers object to pass to fetch. * Returns the full URL and the headers object to pass to fetch.
*/ */
function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) { function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
const { accessKeyId, secretAccessKey } = config; const { accessKeyId, secretAccessKey, region } = config;
const region = 'auto';
const service = 's3'; const service = 's3';
const ts = amzDate(date); 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. * The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
*/ */
function buildPresignedUrl({ method, host, path, expiresIn, config, date }) { function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
const { accessKeyId, secretAccessKey } = config; const { accessKeyId, secretAccessKey, region } = config;
const region = 'auto';
const service = 's3'; const service = 's3';
const ts = amzDate(date); const ts = amzDate(date);
@@ -156,26 +154,22 @@ function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
// ─── Config ────────────────────────────────────────────────────────────────── // ─── Config ──────────────────────────────────────────────────────────────────
function getConfig() { 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 accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY; const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
const bucket = process.env.ZEN_STORAGE_BUCKET; const bucket = process.env.ZEN_STORAGE_BUCKET;
if (!region || !accessKeyId || !secretAccessKey) { if (!host || !accessKeyId || !secretAccessKey) {
throw new Error( 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) { if (!bucket) {
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set'); throw new Error('ZEN_STORAGE_BUCKET environment variable is not set');
} }
return { return { accessKeyId, secretAccessKey, bucket, host, region };
accessKeyId,
secretAccessKey,
bucket,
host: `${region}.r2.cloudflarestorage.com`,
};
} }
// ─── Minimal XML helpers ───────────────────────────────────────────────────── // ─── 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 {Object} options
* @param {string} options.sourceKey - Source file path/key * @param {string} options.sourceKey - Source file path/key
* @param {string} options.destinationKey - Destination 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 }) { async function copyFile({ sourceKey, destinationKey }) {
try { try {
const getResult = await getFile(sourceKey); const config = getConfig();
if (!getResult.success) return getResult; const path = `/${config.bucket}/${destinationKey}`;
const date = new Date();
const uploadResult = await uploadFile({ const { url, headers } = signRequest({
key: destinationKey, method: 'PUT',
body: getResult.data.body, host: config.host,
contentType: getResult.data.contentType, path,
metadata: getResult.data.metadata, extraHeaders: { 'x-amz-copy-source': `/${config.bucket}/${encodePath(sourceKey)}` },
config,
date,
}); });
if (uploadResult.success) { const response = await fetch(url, { method: 'PUT', headers });
info(`Storage: copied ${sourceKey}${destinationKey}`);
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) { } catch (error) {
fail(`Storage copy failed: ${error.message}`); fail(`Storage copy failed: ${error.message}`);
return { success: false, data: null, error: error.message }; return { success: false, data: null, error: error.message };
@@ -643,9 +645,6 @@ export {
validateFileType, validateFileType,
validateFileSize, validateFileSize,
formatFileSize, formatFileSize,
generateUserFilePath,
generateOrgFilePath,
generatePostFilePath,
sanitizeFilename, sanitizeFilename,
validateImageDimensions, validateImageDimensions,
validateUpload, validateUpload,
-33
View File
@@ -136,39 +136,6 @@ export function formatFileSize(bytes, decimals = 2) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 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 * Sanitize filename by removing special characters
* @param {string} filename - Original filename * @param {string} filename - Original filename
+3 -1
View File
@@ -9,7 +9,9 @@
import { query, updateById } from '@zen/core/database'; import { query, updateById } from '@zen/core/database';
import { updateUser } from './lib/auth.js'; 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 { fail, info } from '../../shared/lib/logger.js';
import { defineApiRoutes } from '../../core/api/define.js'; import { defineApiRoutes } from '../../core/api/define.js';
import { apiSuccess, apiError } from '../../core/api/respond.js'; import { apiSuccess, apiError } from '../../core/api/respond.js';
+40 -5
View File
@@ -1,14 +1,14 @@
/** /**
* Module Storage Registry (Server-Side) * Module Storage Registry (Server-Side)
* *
* Aggregates storage public prefixes declared by each module via defineModule(). * Aggregates storage public prefixes and private access policies declared by
* A prefix listed here is served without authentication by the storage handler. * each module via defineModule().
* *
* Internal modules declare `storagePublicPrefixes` in their defineModule() config. * Public prefixes are served without authentication.
* External modules registered at runtime are also included automatically. * Access policies control auth requirements for private paths.
* *
* Usage: * Usage:
* import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; * import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage';
*/ */
import { getModule, getEnabledModules } from '@zen/core/core/modules'; import { getModule, getEnabledModules } from '@zen/core/core/modules';
@@ -51,3 +51,38 @@ export function getAllStoragePublicPrefixes() {
return [...prefixes]; 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;
}
+2 -1
View File
@@ -24,7 +24,6 @@ import {
import { import {
uploadImage, uploadImage,
deleteFile, deleteFile,
generatePostFilePath,
generateUniqueFilename, generateUniqueFilename,
validateUpload, validateUpload,
getFileExtension, getFileExtension,
@@ -32,6 +31,8 @@ import {
FILE_SIZE_LIMITS FILE_SIZE_LIMITS
} from '@zen/core/storage'; } from '@zen/core/storage';
const generatePostFilePath = (typeKey, postIdOrSlug, filename) => `posts/${typeKey}/${postIdOrSlug}/${filename}`;
/** /**
* Extension → MIME type map derived from the validated file extension. * Extension → MIME type map derived from the validated file extension.
* The client-supplied file.type is NEVER trusted — it is an attacker-controlled * The client-supplied file.type is NEVER trusted — it is an attacker-controlled