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
+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