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_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=<accountId>.r2.cloudflarestorage.com ZEN_STORAGE_REGION=auto
# 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_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.
**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
+5
View File
@@ -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,
+16 -22
View File
@@ -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);
+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.
*/
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,
-33
View File
@@ -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
+3 -1
View File
@@ -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';
+40 -5
View File
@@ -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;
}
+2 -1
View File
@@ -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