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:
+6
-3
@@ -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=
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user