feat: carry the session-limit start and office editor revocations into the shared store on import #1066

This commit is contained in:
Linus Rath
2026-10-31 23:31:00 +01:00
parent 0261c81b16
commit 25c44e01f8
4 changed files with 142 additions and 14 deletions
@@ -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 });
});
});
+7 -3
View File
@@ -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 {
+45
View File
@@ -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
View File
@@ -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 {