mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
feat: carry the session-limit start and office editor revocations into the shared store on import #1066
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment node
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
import { setStoreForTests } from '@/lib/shared-store';
|
||||
import { resetLocalStoreForTests } from '@/lib/shared-store/local';
|
||||
import { steps } from '@/lib/shared-store/import/auth';
|
||||
import { limitsEnforcedSince, resetLimitsEnforcedSince } from '@/lib/auth/session-age';
|
||||
import { WOPI_TOKEN_TTL_MS, type WopiTokenPayload } from '@/lib/wopi/token';
|
||||
import { isWopiTokenRevoked } from '@/lib/wopi/revocation';
|
||||
import { switchableSharedStore } from './helpers/shared-store-down';
|
||||
|
||||
// Moving a single instance to the shared store (#1066) must carry over the
|
||||
// auth state that outlives a restart, and never touch the local files.
|
||||
|
||||
let stateDir: string;
|
||||
let sharedDir: string;
|
||||
|
||||
const run = async () => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const step of steps) counts[step.name] = await step.run();
|
||||
return counts;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = mkdtempSync(path.join(tmpdir(), 'bw-import-auth-'));
|
||||
sharedDir = mkdtempSync(path.join(tmpdir(), 'bw-import-store-'));
|
||||
vi.stubEnv('ADMIN_STATE_DIR', stateDir);
|
||||
resetLocalStoreForTests();
|
||||
resetLimitsEnforcedSince();
|
||||
delete (globalThis as Record<symbol, unknown>)[Symbol.for('bulwark.wopiRevocations')];
|
||||
setStoreForTests(switchableSharedStore({ sharedDir }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setStoreForTests(null);
|
||||
resetLocalStoreForTests();
|
||||
resetLimitsEnforcedSince();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
rmSync(sharedDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('import steps of the auth stores', () => {
|
||||
it('copies session-limits-since and the WOPI revocations once, leaving the local files alone', async () => {
|
||||
const revokedAt = Date.now() - 60_000;
|
||||
writeFileSync(path.join(stateDir, 'session-limits-since'), '1700000000000');
|
||||
writeFileSync(path.join(stateDir, 'wopi-revocations.json'), JSON.stringify({ 'browser-a:0': revokedAt }));
|
||||
const before = [
|
||||
readFileSync(path.join(stateDir, 'session-limits-since')),
|
||||
readFileSync(path.join(stateDir, 'wopi-revocations.json')),
|
||||
];
|
||||
|
||||
expect(await run()).toEqual({ 'session-limits-since': 1, 'wopi-revocations': 1 });
|
||||
expect(await run()).toEqual({ 'session-limits-since': 0, 'wopi-revocations': 0 });
|
||||
|
||||
expect(readFileSync(path.join(stateDir, 'session-limits-since'))).toEqual(before[0]);
|
||||
expect(readFileSync(path.join(stateDir, 'wopi-revocations.json'))).toEqual(before[1]);
|
||||
|
||||
// What the replicas now read comes from the store, not the old volume.
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
expect(await limitsEnforcedSince()).toBe(1_700_000_000_000);
|
||||
const payload = { bid: 'browser-a', slot: 0, exp: revokedAt - 1000 + WOPI_TOKEN_TTL_MS } as WopiTokenPayload;
|
||||
expect(await isWopiTokenRevoked(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('writes nothing for an install that has neither', async () => {
|
||||
expect(await run()).toEqual({ 'session-limits-since': 0, 'wopi-revocations': 0 });
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getStore, isSharedMode } from '@/lib/shared-store';
|
||||
import { getStore, isSharedMode, type CollectionOptions } from '@/lib/shared-store';
|
||||
|
||||
export { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
|
||||
|
||||
@@ -90,8 +90,12 @@ function memo(): { since: Promise<number> | null } {
|
||||
* The time in the shared store (#1066). Its local layout is today's file in
|
||||
* the state dir, which local mode keeps writing itself (with O_EXCL).
|
||||
*/
|
||||
export function sessionLimitsCollection() {
|
||||
return getStore().collection('session-limits', { local: { dir: () => getStateDir() } });
|
||||
export const SESSION_LIMITS_COLLECTION = 'session-limits';
|
||||
export const SESSION_LIMITS_ID = ENFORCED_SINCE_FILE;
|
||||
export const sessionLimitsLayout: CollectionOptions = { local: { dir: () => getStateDir() } };
|
||||
|
||||
function sessionLimitsCollection() {
|
||||
return getStore().collection(SESSION_LIMITS_COLLECTION, sessionLimitsLayout);
|
||||
}
|
||||
|
||||
function parseTime(text: string): number | null {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SESSION_LIMITS_COLLECTION, SESSION_LIMITS_ID, sessionLimitsLayout } from '@/lib/auth/session-age';
|
||||
import { REVOCATIONS_ID, WOPI_COLLECTION, wopiRevocationsLayout } from '@/lib/wopi/revocation';
|
||||
import { getStore } from '../index';
|
||||
import type { ImportStep } from '../import';
|
||||
import { localCollection } from '../local';
|
||||
import type { CollectionOptions } from '../types';
|
||||
|
||||
/**
|
||||
* Import steps of part `auth` (#1066): the auth state that outlives a
|
||||
* restart.
|
||||
*
|
||||
* - session-limits-since: when the server first enforced the maximum
|
||||
* session length. Without it the shared store would record a new time and
|
||||
* legacy sessions would get a fresh allowance.
|
||||
* - WOPI revocations: office editor tokens revoked at sign-out, for up to
|
||||
* six hours.
|
||||
*
|
||||
* Not imported, because they don't outlive the switch in any way that
|
||||
* matters:
|
||||
* - pairing grants, codes and the hand-off codes: in memory only, gone with
|
||||
* the restart that turns on the shared store, as on any restart (they last
|
||||
* two to five minutes);
|
||||
* - impersonation markers: they refuse a used hand-off link for at most 420
|
||||
* seconds, and the links themselves live five minutes at most. Moving an
|
||||
* install takes the deployment down for longer than that.
|
||||
* - sign-in budgets: counters of 15 minutes to a day; they start empty, as
|
||||
* they do after every restart today.
|
||||
*/
|
||||
|
||||
function copyStep(name: string, collection: string, layout: CollectionOptions, id: string): ImportStep {
|
||||
return {
|
||||
name,
|
||||
async run() {
|
||||
const doc = await localCollection(collection, layout).get(id);
|
||||
if (!doc) return 0;
|
||||
const written = await getStore().collection(collection, layout).put(id, doc.data, { ifVersion: null });
|
||||
return written.ok ? 1 : 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const steps: ImportStep[] = [
|
||||
copyStep('session-limits-since', SESSION_LIMITS_COLLECTION, sessionLimitsLayout, SESSION_LIMITS_ID),
|
||||
copyStep('wopi-revocations', WOPI_COLLECTION, wopiRevocationsLayout, REVOCATIONS_ID),
|
||||
];
|
||||
+14
-11
@@ -4,7 +4,7 @@ import type { cookies } from 'next/headers';
|
||||
import { getStateDir, getStatePath } from '@/lib/admin/paths';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { getStore, isSharedMode } from '@/lib/shared-store';
|
||||
import { getStore, isSharedMode, type CollectionOptions } from '@/lib/shared-store';
|
||||
import { WOPI_TOKEN_TTL_MS, type WopiTokenPayload } from '@/lib/wopi/token';
|
||||
|
||||
/**
|
||||
@@ -29,18 +29,21 @@ type CookieStore = Awaited<ReturnType<typeof cookies>>;
|
||||
|
||||
export const WOPI_BROWSER_COOKIE = 'bulwark_wopi';
|
||||
const REVOCATIONS_FILE = 'wopi-revocations.json';
|
||||
const REVOCATIONS_ID = 'revocations.json';
|
||||
export const WOPI_COLLECTION = 'wopi';
|
||||
export const REVOCATIONS_ID = 'revocations.json';
|
||||
|
||||
/** The revocations document; locally today's file in the state dir. */
|
||||
export function wopiRevocationsCollection() {
|
||||
return getStore().collection('wopi', {
|
||||
local: {
|
||||
dir: () => getStateDir(),
|
||||
file: () => REVOCATIONS_FILE,
|
||||
idOf: (file) => (file === REVOCATIONS_FILE ? REVOCATIONS_ID : null),
|
||||
tmpName: (target) => `${target}.${process.pid}.tmp`,
|
||||
},
|
||||
});
|
||||
export const wopiRevocationsLayout: CollectionOptions = {
|
||||
local: {
|
||||
dir: () => getStateDir(),
|
||||
file: () => REVOCATIONS_FILE,
|
||||
idOf: (file) => (file === REVOCATIONS_FILE ? REVOCATIONS_ID : null),
|
||||
tmpName: (target) => `${target}.${process.pid}.tmp`,
|
||||
},
|
||||
};
|
||||
|
||||
function wopiRevocationsCollection() {
|
||||
return getStore().collection(WOPI_COLLECTION, wopiRevocationsLayout);
|
||||
}
|
||||
|
||||
function browserBinding(browserId: string): string {
|
||||
|
||||
Reference in New Issue
Block a user