Einstellungen strikt pro Benutzer: kein process.env-Fallback mehr
config.get() ist bei einem nicht gesetzten Schluessel auf process.env zurueckgefallen. Da die Env-Variablen die Konfiguration des Admins enthalten (Docker-Env: MAIL_*, CALDAV_URL, OLLAMA_API_KEY, API_TOKEN), hat damit JEDER neu angelegte Benutzer ohne eigene Einstellungen stillschweigend die Zugangsdaten des Admins geerbt: - /einstellungen zeigte ihm die Zugangsdaten des Admins an. - mailer/caldav isConfigured() war true -> der IMAP-Poller hat fuer den neuen Benutzer das Postfach des Admins abgerufen und dessen E-Mails in sein Konto einsortiert; CalDAV synchronisierte den Kalender des Admins. - Der bezahlte Ollama-Key des Admins wurde mitbenutzt. Jetzt: - config.get() loest ausschliesslich die Zeilen des aktuellen Benutzers auf, sonst den eingebauten Standard (nicht-geheime Werte wie Modell, Host, Ports, Intervalle). Alle Credentials sind bei neuen Benutzern leer, d. h. Ollama/E-Mail/CalDAV/API sind fuer sie aus, bis sie sich selbst etwas eintragen. - Noch per Env gesetzte Konfiguration wird einmalig in die Zeilen des ADMIN uebernommen (importEnvIntoAdmin, Aufruf beim Boot nachdem app_state existiert - in runMigration war das bei Neuinstallationen ein No-op, weil die Tabelle dort noch nicht angelegt ist). - config.ensureLoaded(user.id) beim Aufloesen der Session bzw. des X-API-Key. config.get() ist synchron und liest den Per-User-Cache; ohne Warmladen las ein Web-Request die Werte als "nicht konfiguriert". Das hat bisher der env-Fallback verdeckt (er hielt zufaellig die Werte des Admins) - ohne ihn muss die Config pro Request wirklich geladen werden. Verifiziert gegen eine Kopie der Produktions-DB mit Sentinel-Env-Werten: Admin behaelt seine kompletten Einstellungen, der zweite Benutzer sieht ueberall leere Credentials, Mail/CalDAV sind fuer ihn inaktiv, und der Env-API-Token wird nicht mehr als gueltiger X-API-Key akzeptiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ const blacklist = require('./blacklist');
|
|||||||
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||||
const { normalizeDokumente } = require('./documents');
|
const { normalizeDokumente } = require('./documents');
|
||||||
const { userContext, currentUserId } = require('./context');
|
const { userContext, currentUserId } = require('./context');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
const CONFIG_PREFIX = 'cfg:';
|
const CONFIG_PREFIX = 'cfg:';
|
||||||
|
|
||||||
@@ -76,6 +77,11 @@ function createExternalApi(deps) {
|
|||||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
}
|
}
|
||||||
req.user = user;
|
req.user = user;
|
||||||
|
// Warm this user's cfg rows: config.get() is synchronous and reads from
|
||||||
|
// the per-user cache, so without this an API request could see the user
|
||||||
|
// as unconfigured (e.g. no Ollama key) purely because nothing had loaded
|
||||||
|
// their rows yet in this process.
|
||||||
|
await config.ensureLoaded(user.id);
|
||||||
// Run the remainder of the request inside this user's context so
|
// Run the remainder of the request inside this user's context so
|
||||||
// currentUserId() / config.get() / the scoped helpers all resolve here.
|
// currentUserId() / config.get() / the scoped helpers all resolve here.
|
||||||
userContext.run(user, next);
|
userContext.run(user, next);
|
||||||
|
|||||||
+29
-12
@@ -8,10 +8,15 @@
|
|||||||
// context (lib/context.js). An edit therefore takes effect immediately, scoped
|
// context (lib/context.js). An edit therefore takes effect immediately, scoped
|
||||||
// to the user who made it — no restart, no .env file.
|
// to the user who made it — no restart, no .env file.
|
||||||
//
|
//
|
||||||
// On first start of an install that previously used .env, init() migrates any
|
// On first start of an install that previously used .env (or that still passes
|
||||||
// still-present env value into the *admin* user's config once. After that the
|
// the values as container env vars), the still-present env values are imported
|
||||||
// database is the single source of truth; process.env is only a fallback for keys
|
// into the *admin* user's rows once — see importEnvIntoAdmin(), called from
|
||||||
// that were never saved.
|
// server.js at boot. After that the database is the single source of truth.
|
||||||
|
//
|
||||||
|
// process.env is deliberately NOT a read fallback: env config belongs to the
|
||||||
|
// admin, so falling back to it would hand every freshly created user the admin's
|
||||||
|
// mailbox, calendar, Ollama key and API token. Unset keys resolve to the
|
||||||
|
// built-in defaults instead, which leaves a new user's settings empty.
|
||||||
|
|
||||||
const { currentUser } = require('./context');
|
const { currentUser } = require('./context');
|
||||||
|
|
||||||
@@ -95,9 +100,16 @@ const loaded = new Set(); // userIds whose cfg rows have been read from the DB
|
|||||||
let dbAllFn = null;
|
let dbAllFn = null;
|
||||||
let dbRunFn = null;
|
let dbRunFn = null;
|
||||||
|
|
||||||
function envOrDefault(key) {
|
// The built-in default for a key. Deliberately NOT a process.env lookup: env
|
||||||
const e = process.env[key];
|
// values belong to the admin (they are imported into the admin's rows once at
|
||||||
return e && e.length ? e : DEFAULTS[key];
|
// boot, see importEnvIntoAdmin in lib/migrate-multiuser.js). If get() fell back
|
||||||
|
// to process.env, every user without their own row would silently inherit the
|
||||||
|
// admin's credentials — their own mailbox, calendar, Ollama key and API token.
|
||||||
|
// The defaults below are non-secret standards only (model, host, ports, poll
|
||||||
|
// intervals); every credential defaults to empty, so a new user starts with
|
||||||
|
// Ollama/E-Mail/CalDAV/API switched off until they configure their own.
|
||||||
|
function builtinDefault(key) {
|
||||||
|
return DEFAULTS[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load one user's cfg rows from the DB into the cache. No-op if already loaded.
|
// Load one user's cfg rows from the DB into the cache. No-op if already loaded.
|
||||||
@@ -117,15 +129,20 @@ function invalidate(userId) {
|
|||||||
cache.delete(userId);
|
cache.delete(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Synchronous read for the current user. Falls back to process.env (pre-migration
|
// Synchronous read for the *current user only*. A key the user has not stored
|
||||||
// / never saved) then to the built-in default. Outside a request context (boot)
|
// falls back to the built-in default — never to another user's value and never
|
||||||
// only the env/default fallback applies — callers that need a specific user must
|
// to process.env. A user who has configured nothing therefore reads as "empty"
|
||||||
// run inside the user context (see lib/context.js).
|
// (no mail host, no keys), which is exactly what isolates them: mailer/caldav
|
||||||
|
// isConfigured() turns false and their background jobs stay idle.
|
||||||
|
//
|
||||||
|
// Outside a request context (boot) there is no user, so only the defaults apply.
|
||||||
|
// Callers that need a specific user's values must run inside that user's context
|
||||||
|
// (see lib/context.js) and must have called ensureLoaded(userId) first.
|
||||||
function get(key) {
|
function get(key) {
|
||||||
const u = currentUser();
|
const u = currentUser();
|
||||||
const userObj = u ? cache.get(u.id) : null;
|
const userObj = u ? cache.get(u.id) : null;
|
||||||
if (userObj && userObj[key] !== undefined) return userObj[key];
|
if (userObj && userObj[key] !== undefined) return userObj[key];
|
||||||
return envOrDefault(key);
|
return builtinDefault(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All keys with their effective values for the current user — used by the
|
// All keys with their effective values for the current user — used by the
|
||||||
|
|||||||
@@ -242,12 +242,28 @@ async function runMigration({ db, dbAll, dbGet, dbRun }) {
|
|||||||
// 5. Move on-disk files into a per-user subdirectory for the admin -----
|
// 5. Move on-disk files into a per-user subdirectory for the admin -----
|
||||||
moveFilesIntoUserSubdir(adminId);
|
moveFilesIntoUserSubdir(adminId);
|
||||||
|
|
||||||
// 6. One-time .env -> admin cfg migration ------------------------------
|
// 6. One-time env -> admin cfg import ----------------------------------
|
||||||
|
// On an upgraded install app_state already exists here, so this fills the
|
||||||
|
// admin's rows right away. On a *fresh* install the table is only created
|
||||||
|
// after this migration returns, so this is a no-op — server.js therefore
|
||||||
|
// calls importEnvIntoAdmin() again once the schema is complete. Idempotent
|
||||||
|
// either way (it only fills keys the admin has not stored).
|
||||||
await migrateEnvForAdmin(dbAll, adminId);
|
await migrateEnvForAdmin(dbAll, adminId);
|
||||||
|
|
||||||
return { adminId };
|
return { adminId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import any still-present env config into the admin's rows. Called at boot from
|
||||||
|
// server.js, after initializeDatabase() has created app_state — this is what
|
||||||
|
// makes a deployment that passes its config as container env vars (as ours does)
|
||||||
|
// end up with those values owned by the admin, instead of leaking to every user
|
||||||
|
// through a read-time process.env fallback (which config.get() no longer has).
|
||||||
|
async function importEnvIntoAdmin({ dbAll, dbGet }) {
|
||||||
|
const admin = await dbGet('SELECT id FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
||||||
|
if (!admin) return;
|
||||||
|
await migrateEnvForAdmin(dbAll, admin.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
|
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
|
||||||
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
|
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
|
||||||
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
|
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
|
||||||
@@ -308,4 +324,4 @@ async function migrateEnvForAdmin(dbAll, adminId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runMigration, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };
|
module.exports = { runMigration, importEnvIntoAdmin, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };
|
||||||
@@ -45,7 +45,7 @@ const config = require('./lib/config');
|
|||||||
const { userContext, currentUser, currentUserId } = require('./lib/context');
|
const { userContext, currentUser, currentUserId } = require('./lib/context');
|
||||||
const password = require('./lib/password');
|
const password = require('./lib/password');
|
||||||
const migrate = require('./lib/migrate-multiuser');
|
const migrate = require('./lib/migrate-multiuser');
|
||||||
const { runMigration } = migrate;
|
const { runMigration, importEnvIntoAdmin } = migrate;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -110,6 +110,12 @@ app.use(async (req, res, next) => {
|
|||||||
const user = await loadSessionUser(cookies[SESSION_COOKIE]);
|
const user = await loadSessionUser(cookies[SESSION_COOKIE]);
|
||||||
req.user = user;
|
req.user = user;
|
||||||
res.locals.user = user;
|
res.locals.user = user;
|
||||||
|
// Warm this user's cfg rows before anything reads them: config.get() is
|
||||||
|
// synchronous and answers from the per-user cache, so a user whose rows were
|
||||||
|
// never loaded would silently read as "unconfigured". Until now the process
|
||||||
|
// .env fallback papered over that (it happened to hold the admin's values);
|
||||||
|
// with the fallback gone, the config must actually be loaded per request.
|
||||||
|
if (user) await config.ensureLoaded(user.id);
|
||||||
userContext.run(user, next);
|
userContext.run(user, next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Session-Laden fehlgeschlagen:', e.message);
|
console.error('Session-Laden fehlgeschlagen:', e.message);
|
||||||
@@ -1216,11 +1222,19 @@ async function initializeDatabase() {
|
|||||||
initializeDatabase().then(async () => {
|
initializeDatabase().then(async () => {
|
||||||
console.log('Database initialized successfully');
|
console.log('Database initialized successfully');
|
||||||
|
|
||||||
// Load configuration from the DB (migrates any still-present .env values
|
// Load configuration from the DB. Must run before the boot checks below
|
||||||
// once). Must run before the boot checks below (mailer/caldav configured?) and
|
// (mailer/caldav configured?) and before any route that reads config — values
|
||||||
// before any route that reads config — values live in the DB now, not in .env.
|
// live in the DB now, not in the environment.
|
||||||
await config.init({ dbAll, dbRun });
|
await config.init({ dbAll, dbRun });
|
||||||
|
|
||||||
|
// Import any config still supplied via the environment into the *admin's* rows
|
||||||
|
// (one-time, idempotent). Env config is the admin's: config.get() has no
|
||||||
|
// process.env fallback, precisely so that a newly created user does not
|
||||||
|
// inherit the admin's mailbox, calendar, Ollama key and API token. This runs
|
||||||
|
// here rather than inside runMigration() because on a fresh install app_state
|
||||||
|
// does not exist yet while the migration is running.
|
||||||
|
await importEnvIntoAdmin({ dbAll, dbGet });
|
||||||
|
|
||||||
// Current user's id — set by the auth middleware (lib/context.js). Guaranteed
|
// Current user's id — set by the auth middleware (lib/context.js). Guaranteed
|
||||||
// to be present inside any protected route or background-per-user task.
|
// to be present inside any protected route or background-per-user task.
|
||||||
const uid = () => currentUserId();
|
const uid = () => currentUserId();
|
||||||
|
|||||||
Reference in New Issue
Block a user