mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
Board filter matches re-check the board's access, not only its observer.
card-text-filter failed intermittently in the EVERYTHING runs: after a member's assigned-only restriction was lifted, the text-filter matches never widened again (still one match after 100 s). Instrumented on the polling test server, the failing subscription's board observer delivered its initial add and then nothing - neither the narrowing nor the widening write - while the card observers of the same board kept firing; the narrowing only applied because a card edit happened to trigger a rescan. With nothing after the widening, nothing rescanned. The same stall would also have kept a NARROWED member's old matches, which is an access leak. publishBoardMatches (text matches, movement matches, table pages) now also re-reads the board every 10 s and rescans when its access fields - permission, members and the provider's own board fields - differ from the ones the published result was computed from, unreadable boards included. An unchanged board does not rescan. tests/boardTextPublication.test.cjs widens, narrows and revokes with the observer silent and only the re-check firing; both tests fail on the previous code. card-text-filter passed 3 of 3 in Firefox afterwards. Thanks to xet7 !
This commit is contained in:
1 parent
e7017ca745
commit
479f530da0
2 files changed
+68
-5
No files matched your search
@@ -4,12 +4,25 @@ import Cards from '/models/cards';
|
||||
import { canReadBoard } from '/models/lib/boardVisibility';
|
||||
const { assignedOnlyCardScope } = require('/models/lib/boardCardScope');
|
||||
|
||||
// The board's access fields are also re-read on this interval. The board
|
||||
// observer below is what normally reports a membership change, but on a busy
|
||||
// test server (polling, no oplog) one stalled for good: a member's
|
||||
// assigned-only restriction was lifted and the matches never widened again,
|
||||
// and the same stall would have kept a NARROWED member's old matches. A
|
||||
// missed observer event must not decide what a member may see.
|
||||
const ACCESS_RECHECK_MS = 10000;
|
||||
const accessSignature = (board, boardFields) => JSON.stringify(board ? [board.permission, board.members,
|
||||
...Object.keys(boardFields).sort().map(field => board[field])] : null);
|
||||
|
||||
// Shared authorized, reactive ID-only publication for board filter providers.
|
||||
export async function publishBoardMatches({ publication, boardId, key, identity,
|
||||
collectionName, children, cardFields = {}, boardFields = {}, snapshot = false, prepare, findMatches }) {
|
||||
let stopped = false, initializing = true, running = false, pending = false, revision = 0;
|
||||
const handles = [], published = new Set();
|
||||
let pagePublished = false;
|
||||
// The access fields the published result was computed from.
|
||||
let seenAccess;
|
||||
let accessTimer;
|
||||
const pageCards = new Map();
|
||||
const watches = new Map();
|
||||
const rowId = cardId => JSON.stringify([boardId, key, cardId]);
|
||||
@@ -23,6 +36,7 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
|
||||
};
|
||||
publication.onStop(() => {
|
||||
stopped = true;
|
||||
if (accessTimer) Meteor.clearInterval(accessTimer);
|
||||
for (const handle of handles) handle.stop();
|
||||
for (const { handle } of watches.values()) handle.stop();
|
||||
watches.clear();
|
||||
@@ -47,7 +61,7 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
|
||||
const version = revision;
|
||||
const board = await Boards.findOneAsync(boardId);
|
||||
if (stopped) return;
|
||||
if (!canReadBoard(publication.userId, board)) { clear(); continue; }
|
||||
if (!canReadBoard(publication.userId, board)) { seenAccess = accessSignature(board, boardFields); clear(); continue; }
|
||||
const scope = { boardId, archived: false, ...assignedOnlyCardScope(board, publication.userId) };
|
||||
const isStopped = () => stopped || revision !== version;
|
||||
const prepared = prepare ? await prepare({ scope, board, watch, stopped: isStopped }) : undefined;
|
||||
@@ -59,10 +73,11 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
|
||||
// the subscription starts. A concurrent policy change forces a retry.
|
||||
const latest = await Boards.findOneAsync(boardId);
|
||||
if (stopped) return;
|
||||
if (!canReadBoard(publication.userId, latest)) { clear(); continue; }
|
||||
if (!canReadBoard(publication.userId, latest)) { seenAccess = accessSignature(latest, boardFields); clear(); continue; }
|
||||
if (revision !== version || JSON.stringify(latest.members) !== JSON.stringify(board.members)) {
|
||||
pending = true; continue;
|
||||
}
|
||||
seenAccess = accessSignature(latest, boardFields);
|
||||
if (prepared?.isCurrent && !(await prepared.isCurrent())) { clear(); pending = true; continue; }
|
||||
if (isStopped()) { pending = !stopped; continue; }
|
||||
if (snapshot) {
|
||||
@@ -113,6 +128,13 @@ export async function publishBoardMatches({ publication, boardId, key, identity,
|
||||
}
|
||||
initializing = false;
|
||||
await refresh();
|
||||
if (!stopped) accessTimer = Meteor.setInterval(async () => {
|
||||
if (stopped || running || seenAccess === undefined) return;
|
||||
try {
|
||||
const latest = await Boards.findOneAsync(boardId);
|
||||
if (!stopped && accessSignature(latest, boardFields) !== seenAccess) boardChanged();
|
||||
} catch (error) { /* the next tick, or the observer, tries again */ }
|
||||
}, ACCESS_RECHECK_MS);
|
||||
} catch (error) {
|
||||
if (!stopped) publication.error(new Meteor.Error('card-filter-failed', 'Card filtering failed'));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const vm = require('node:vm');
|
||||
const { assignedOnlyCardScope } = require('../models/lib/boardCardScope');
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
async function fixture(search, snapshot = false, prepare) {
|
||||
const observers = [], rows = new Map();
|
||||
const observers = [], rows = new Map(), intervals = [], cleared = [];
|
||||
let handler, stop, ready = 0;
|
||||
let board = { _id: 'board', permission: 'private', members: [{ userId: 'reader', isActive: true, isReadAssignedOnly: true }] };
|
||||
const model = name => ({ rawCollection: () => name, find: () => ({ observeChangesAsync: async callbacks => {
|
||||
@@ -23,7 +23,9 @@ async function fixture(search, snapshot = false, prepare) {
|
||||
collectionName: 'pages', snapshot: true, cardFields: null, children: [], prepare: prepareFixture,
|
||||
findMatches: boardTextSearch });
|
||||
});` : '';
|
||||
vm.runInNewContext(source + snapshotSource, { Meteor: { publish: (name, fn) => { handler = fn; }, Error }, check() {},
|
||||
vm.runInNewContext(source + snapshotSource, { Meteor: { publish: (name, fn) => { handler = fn; }, Error,
|
||||
setInterval: (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; },
|
||||
clearInterval: id => { cleared.push(id); } }, check() {},
|
||||
Boards, Cards: model('cards'), CardComments: model('comments'), Checklists: model('checklists'), ChecklistItems: model('items'),
|
||||
canReadBoard: (id, doc) => !!doc && (doc.permission === 'public' || doc.members.some(m => m.userId === id && m.isActive)),
|
||||
assignedOnlyCardScope, boardTextSearch: search, prepareFixture: prepare,
|
||||
@@ -33,7 +35,7 @@ async function fixture(search, snapshot = false, prepare) {
|
||||
changed: (collection, id, fields) => rows.set(id, { ...rows.get(id), ...fields }),
|
||||
error: error => { throw error; } };
|
||||
return { observers, rows, context, start: term => handler.call(context, 'board', term),
|
||||
setBoard(value) { board = value; }, stop: () => stop(), ready: () => ready };
|
||||
setBoard(value) { board = value; }, stop: () => stop(), ready: () => ready, intervals, cleared };
|
||||
}
|
||||
test('publication scopes the join, retracts revoked results and cleans every observer', async () => {
|
||||
const scopes = [];
|
||||
@@ -109,3 +111,42 @@ test('invalid or unauthorized requests never search and stopped scans never publ
|
||||
const running = f.start('needle'); await started; f.stop(); finish(['secret']); await running;
|
||||
assert.equal(f.rows.size, 0); assert.ok(f.observers.every(handle => handle.stopped));
|
||||
});
|
||||
|
||||
// The board observer stalled for good on a busy test server (polling, no
|
||||
// oplog): lifting a member's assigned-only restriction never widened the text
|
||||
// matches. The periodic access re-check must catch a change the observer never
|
||||
// reports - in BOTH directions, since a stalled narrowing would leak matches.
|
||||
test('access changes reach the results even when the board observer never reports them', async () => {
|
||||
const scopes = [];
|
||||
const f = await fixture(async args => { scopes.push(args.scope); return args.scope.assignees ? ['assigned'] : ['assigned', 'other']; });
|
||||
await f.start('needle');
|
||||
assert.equal(f.intervals.length, 1, 'one access re-check per subscription');
|
||||
assert.equal(f.intervals[0].ms, 10000);
|
||||
assert.deepEqual([...f.rows.values()].map(row => row.cardId), ['assigned']);
|
||||
// Widen: the restriction is lifted, and NO observer callback fires.
|
||||
f.setBoard({ _id: 'board', permission: 'private', members: [{ userId: 'reader', isActive: true, isReadAssignedOnly: false }] });
|
||||
await f.intervals[0].fn(); await tick(); await tick();
|
||||
assert.deepEqual([...f.rows.values()].map(row => row.cardId).sort(), ['assigned', 'other']);
|
||||
assert.equal(scopes.at(-1).assignees, undefined);
|
||||
// Negative: narrowing again, also without an observer event, retracts.
|
||||
f.setBoard({ _id: 'board', permission: 'private', members: [{ userId: 'reader', isActive: true, isReadAssignedOnly: true }] });
|
||||
await f.intervals[0].fn(); await tick(); await tick();
|
||||
assert.deepEqual([...f.rows.values()].map(row => row.cardId), ['assigned']);
|
||||
// Negative: an unchanged board does not rescan on every tick.
|
||||
const scans = scopes.length;
|
||||
await f.intervals[0].fn(); await tick();
|
||||
assert.equal(scopes.length, scans);
|
||||
f.stop();
|
||||
assert.deepEqual(f.cleared, [1], 'the re-check stops with the subscription');
|
||||
});
|
||||
|
||||
test('revoked access is retracted by the re-check alone', async () => {
|
||||
const f = await fixture(async () => ['assigned']);
|
||||
await f.start('needle');
|
||||
assert.equal(f.rows.size, 1);
|
||||
f.setBoard({ _id: 'board', permission: 'private', members: [] });
|
||||
await f.intervals[0].fn(); await tick(); await tick();
|
||||
assert.equal(f.rows.size, 0);
|
||||
f.stop();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user