fix: keep the organisation's locks when the policy can't be fetched

This commit is contained in:
Linus Rath
2026-11-01 04:20:02 +01:00
parent 123018f322
commit bdac27367c
4 changed files with 180 additions and 3 deletions
+4
View File
@@ -17,6 +17,7 @@
*/
import { debug } from '@/lib/debug';
import { whenPolicySettled } from '@/stores/policy-store';
// 'plugin-forward': a plugin forwarding a message (api.email.forward).
export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward';
@@ -130,6 +131,9 @@ export function finalizeOutgoing(
* one must not make sending impossible.
*/
export async function runPreSendChecks(context: OutgoingContext): Promise<boolean> {
// The checks and what the composer adds after them (labels, signatures)
// follow the organisation policy: wait for its first fetch.
await whenPolicySettled();
for (const entry of [...checks]) {
try {
if (!(await entry.run(context))) {
+3
View File
@@ -26,6 +26,9 @@ const SIGNED_OUT_STORAGE_KEYS = [
'files-path-stack',
// Names of the people the user gave access to (Settings > Sharing).
'bulwark-delegate-names',
// The last organisation policy (stores/policy-store.ts POLICY_CACHE_KEY):
// its scopes, labels and notices are the signed-in user's.
'bulwark-policy-last',
];
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// A failed policy fetch must not lift the organisation's locks: a refetch
// keeps what the server sent, and a fresh start without the server uses the
// last policy this browser saw instead of the defaults.
const fetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', () => ({ apiFetch: fetchMock }));
import { POLICY_CACHE_KEY, usePolicyStore, whenPolicySettled } from '@/stores/policy-store';
import { DEFAULT_POLICY } from '@/lib/admin/types';
const LOCKED = {
...DEFAULT_POLICY,
restrictions: { externalContentPolicy: { locked: true, value: 'block' } },
};
const ok = (policy: object) => new Response(JSON.stringify(policy));
const locked = () => usePolicyStore.getState().isSettingLocked('externalContentPolicy');
beforeEach(() => {
fetchMock.mockReset();
localStorage.clear();
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, rawPolicy: { ...DEFAULT_POLICY }, loaded: false, fetching: false, source: 'none', partial: false });
});
describe('policy fetch failures', () => {
it('keeps the server policy when a later fetch fails', async () => {
fetchMock.mockResolvedValueOnce(ok(LOCKED));
await usePolicyStore.getState().fetchPolicy();
expect(locked()).toBe(true);
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await usePolicyStore.getState().fetchPolicy();
expect(locked()).toBe(true);
fetchMock.mockResolvedValueOnce(new Response('oops', { status: 502 }));
await usePolicyStore.getState().fetchPolicy();
expect(locked()).toBe(true);
expect(usePolicyStore.getState().source).toBe('server');
});
it('starts from the last policy this browser saw when the server is out of reach', async () => {
fetchMock.mockResolvedValueOnce(ok(LOCKED));
await usePolicyStore.getState().fetchPolicy();
expect(localStorage.getItem(POLICY_CACHE_KEY)).toContain('externalContentPolicy');
// A new page load: nothing in memory, no network.
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, rawPolicy: { ...DEFAULT_POLICY }, loaded: false, source: 'none' });
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await usePolicyStore.getState().fetchPolicy();
expect(usePolicyStore.getState().source).toBe('cache');
expect(locked()).toBe(true);
});
it('uses the defaults only when no policy was ever fetched', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await usePolicyStore.getState().fetchPolicy();
expect(usePolicyStore.getState().source).toBe('fallback');
expect(usePolicyStore.getState().loaded).toBe(true);
expect(locked()).toBe(false);
});
it("doesn't keep the public part a signed-out page gets", async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(DEFAULT_POLICY), { headers: { 'X-Bulwark-Policy-Scope': 'public' } }));
await usePolicyStore.getState().fetchPolicy();
expect(localStorage.getItem(POLICY_CACHE_KEY)).toBeNull();
});
});
describe('whenPolicySettled', () => {
it('waits for the first fetch, then resolves', async () => {
let release!: (r: Response) => void;
fetchMock.mockReturnValueOnce(new Promise<Response>((resolve) => { release = resolve; }));
const fetching = usePolicyStore.getState().fetchPolicy();
let settled = false;
const wait = whenPolicySettled(5000).then(() => { settled = true; });
await Promise.resolve();
expect(settled).toBe(false);
release(ok(LOCKED));
await fetching;
await wait;
expect(settled).toBe(true);
});
it('gives up after its timeout', async () => {
fetchMock.mockReturnValueOnce(new Promise<Response>(() => {}));
void usePolicyStore.getState().fetchPolicy();
const started = Date.now();
await whenPolicySettled(50);
expect(Date.now() - started).toBeGreaterThanOrEqual(40);
});
});
+80 -3
View File
@@ -35,6 +35,14 @@ interface PolicyState {
* things the policy may lock fail closed (hooks/use-third-party-mode.ts).
*/
fetching: boolean;
/**
* Where `rawPolicy` came from: the server, the copy of the last fetched
* policy kept in this browser (the fetch failed), or the built-in
* defaults (nothing was ever fetched). A loaded server policy is never
* replaced by the copy or the defaults, so a network blip can't lift the
* organisation's locks.
*/
source: 'none' | 'server' | 'cache' | 'fallback';
/**
* The server answered with the public subset (not signed in yet); the
* full policy is fetched again once a session exists.
@@ -78,6 +86,55 @@ interface PolicyState {
let policyRequest = 0;
/**
* The last full policy the server sent, kept so a failed fetch (network
* down, a refetch that errors) doesn't fall back to the defaults and lift
* every lock. Removed at full sign-out (lib/sign-out-cleanup.ts).
*/
export const POLICY_CACHE_KEY = 'bulwark-policy-last';
function readCachedPolicy(): Record<string, unknown> | null {
try {
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(POLICY_CACHE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
function writeCachedPolicy(data: unknown): void {
try {
if (typeof localStorage === 'undefined') return;
if (data && typeof data === 'object') localStorage.setItem(POLICY_CACHE_KEY, JSON.stringify(data));
else localStorage.removeItem(POLICY_CACHE_KEY);
} catch {
// Storage full or blocked: the in-memory policy still applies.
}
}
/**
* Resolves once the first policy fetch has settled (or after `timeoutMs`).
* Sending waits for it, so a required label or a send check the policy
* turns on can't be skipped by sending in the first moment after load.
*/
export async function whenPolicySettled(timeoutMs = 5000): Promise<void> {
const settled = () => {
const { fetching, loaded } = usePolicyStore.getState();
return loaded || !fetching;
};
if (settled()) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => { unsubscribe(); resolve(); }, timeoutMs);
const unsubscribe = usePolicyStore.subscribe(() => {
if (!settled()) return;
clearTimeout(timer);
unsubscribe();
resolve();
});
});
}
const COLOR_MODES: readonly ColorMode[] = ['light', 'dark', 'system'];
const sameSubject = (a: PolicySubject | null, b: PolicySubject | null) =>
@@ -96,6 +153,7 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
scopeDomains: null,
loaded: false,
fetching: false,
source: 'none',
partial: false,
demoFeatureOverrides: {},
@@ -110,6 +168,20 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
// for the admin server; missing means defaults, and the gates that need
// the server stay pinned off either way (lib/lite-config.ts).
const fallback = IS_LITE ? { policy: applyLitePolicy({}) } : {};
// The fetch failed: keep a policy the server already gave, else the last
// one this browser saw, else the defaults.
const settleWithoutServer = () => {
if (get().source === 'server') {
set({ loaded: true });
return;
}
const cached = readCachedPolicy();
if (cached) {
set({ ...resolved(IS_LITE ? applyLitePolicy(cached) : (cached as unknown as SettingsPolicy), get().subject), loaded: true, source: 'cache' });
} else {
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true, source: 'fallback' });
}
};
try {
// Stalwart serves bundle files immutably: a build-id URL instead of no-store.
const res = IS_LITE_STALWART
@@ -122,12 +194,17 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public';
const domainsHeader = res.headers?.get?.(POLICY_DOMAINS_HEADER);
const scopeDomains = typeof domainsHeader === 'string' ? domainsHeader.split(',').filter(Boolean) : null;
set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial, scopeDomains });
set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, source: 'server', partial, scopeDomains });
if (!partial) writeCachedPolicy(data);
} else if (IS_LITE && res.status === 404) {
// No policy.json: the deployment set nothing, which is an answer.
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true, source: 'server' });
writeCachedPolicy(null);
} else {
set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true });
settleWithoutServer();
}
} catch {
if (request === policyRequest) set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true });
if (request === policyRequest) settleWithoutServer();
} finally {
if (request === policyRequest) set({ fetching: false });
}