Keep assigned-only members to their own cards on every read path.

The board publication shows an assigned-only member only the cards assigned
to them, but the attachment API (REST and DDP list, download and info), the
activities publication, Due Cards with all users, Global Search, the
structural move pickers and position history asked only for board membership
and so showed every card of the board. Each now applies the same rule; a
download or info request for another card's attachment is recorded under
AssignedBleed in Admin Panel -> Problems.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-02 11:37:40 +03:00
1 parent fb93c5f430
commit 1a218ead02
9 files changed
+241 -28

No files matched your search

+5 -2
View File
@@ -16,6 +16,7 @@ import RecoveryEvents from '/models/recoveryEvents';
import { recordRecoveryAudit } from '/server/lib/recoveryAudit';
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
import { softDeleteAttachment } from '/server/attachmentSoftDelete';
import { assignedOnlyAttachmentScope, mayReadBoardAttachment } from '/server/lib/assignedOnlyAttachments';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
const HARD_MAX_API_FILE_BYTES = 64 * 1024 * 1024;
@@ -273,7 +274,7 @@ Meteor.methods({
// Check permissions
const board = await ReactiveCache.getBoard(attachment.meta.boardId);
if (!board || !board.hasMember(this.userId)) {
if (!board || !board.hasMember(this.userId) || !(await mayReadBoardAttachment(board, this.userId, attachment))) {
throw new Meteor.Error('not-authorized', 'You do not have permission to access this attachment');
}
@@ -572,6 +573,8 @@ Meteor.methods({
query['meta.cardId'] = cardId;
}
const scope = await assignedOnlyAttachmentScope(board, this.userId);
if (scope) query = { $and: [query, scope] };
const attachments = await ReactiveCache.getAttachments(liveAttachments(query));
const attachmentList = attachments.map(attachment => {
@@ -837,7 +840,7 @@ Meteor.methods({
// Check permissions
const board = await ReactiveCache.getBoard(attachment.meta.boardId);
if (!board || !board.hasMember(this.userId)) {
if (!board || !board.hasMember(this.userId) || !(await mayReadBoardAttachment(board, this.userId, attachment))) {
throw new Meteor.Error('not-authorized', 'You do not have permission to access this attachment');
}
+39
View File
@@ -0,0 +1,39 @@
import { ReactiveCache } from '/imports/reactiveCache';
const { isAssignedOnlyMember } = require('/models/lib/boardCardScope');
// An assigned-only member (isReadAssignedOnly, isNormalAssignedOnly,
// isCommentAssignedOnly) sees only the cards assigned to them, and so only
// those cards' attachments. The board publication applies that; the attachment
// API asked only board.hasMember(), so its list endpoints enumerated - and its
// download and info endpoints served - every attachment on the board.
// The attachment selector clause for this member, or null when unrestricted.
export async function assignedOnlyAttachmentScope(board, userId) {
if (!isAssignedOnlyMember(board, userId)) return null;
const cards = await ReactiveCache.getCards(
{ boardId: board._id, assignees: userId },
{ fields: { _id: 1 } },
);
return { 'meta.cardId': { $in: (cards || []).map(card => card._id) } };
}
// May this member read this attachment of this board? A refusal is an
// attempt - the listings no longer name another card's attachment to this
// member - so it is recorded under AssignedBleed (medium: never disables).
export async function mayReadBoardAttachment(board, userId, attachment) {
if (!isAssignedOnlyMember(board, userId)) return true;
const cardId = attachment && attachment.meta && attachment.meta.cardId;
const card = cardId ? await ReactiveCache.getCard(cardId) : null;
const allowed = !!card && card.boardId === board._id
&& Array.isArray(card.assignees) && card.assignees.includes(userId);
if (!allowed) {
try {
require('/server/lib/securityLog').record({
key: 'authz.assigned', action: 'blocked', source: 'attachment-api:read', userId,
detail: 'Assigned-only member asked for an attachment of a card not assigned to them.',
});
} catch (e) { /* logging must never break the guard */ }
}
return allowed;
}
+23 -4
View File
@@ -7,10 +7,22 @@ import Cards from '/models/cards';
import { ReactiveCache } from '/imports/reactiveCache';
import { canReadBoard } from '/models/lib/boardVisibility';
const { isAssignedOnlyMember, mayCopyFromBoard } = require('/models/lib/boardCardScope');
async function assertCanReadBoard(userId, boardId) {
if (!canReadBoard(userId, await ReactiveCache.getBoard(boardId))) {
const board = await ReactiveCache.getBoard(boardId);
if (!canReadBoard(userId, board)) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return board;
}
// An assigned-only member sees the history of the cards assigned to them, and
// of the board's swimlanes and lists, never of another member's cards.
async function assignedOnlyHistoryScope(userId, board) {
if (!isAssignedOnlyMember(board, userId)) return null;
const cards = await ReactiveCache.getCards({ boardId: board._id, assignees: userId }, { fields: { _id: 1 } });
return { $or: [{ entityType: { $ne: 'card' } }, { entityId: { $in: (cards || []).map(card => card._id) } }] };
}
/**
@@ -252,7 +264,10 @@ Meteor.methods({
throw new Meteor.Error('card-not-found', 'Card not found');
}
await assertCanReadBoard(this.userId, card.boardId);
const cardBoard = await assertCanReadBoard(this.userId, card.boardId);
if (!mayCopyFromBoard(cardBoard, this.userId, card)) {
throw new Meteor.Error('not-authorized', 'You do not have access to this card.');
}
return card.getOriginalPositionDescription();
},
@@ -267,10 +282,12 @@ Meteor.methods({
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
await assertCanReadBoard(this.userId, boardId);
const board = await assertCanReadBoard(this.userId, boardId);
const scope = await assignedOnlyHistoryScope(this.userId, board);
return PositionHistory.find({
boardId: boardId,
...(scope || {}),
}, {
sort: { createdAt: -1 }
}).fetchAsync();
@@ -287,7 +304,8 @@ Meteor.methods({
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
await assertCanReadBoard(this.userId, boardId);
const board = await assertCanReadBoard(this.userId, boardId);
const scope = await assignedOnlyHistoryScope(this.userId, board);
if (!['swimlane', 'list', 'card'].includes(entityType)) {
throw new Meteor.Error('invalid-entity-type', 'Entity type must be swimlane, list, or card');
@@ -296,6 +314,7 @@ Meteor.methods({
return PositionHistory.find({
boardId: boardId,
entityType: entityType,
...(scope || {}),
}, {
sort: { createdAt: -1 }
}).fetchAsync();
+8 -4
View File
@@ -12,6 +12,7 @@ const { columnModifier } = require('/models/lib/boardSettingsColumns');
const { DRAG_SETTINGS, canDragSelection } = require('/models/lib/boardDragging');
import { requireBoardMutation } from '/models/lib/boardMutationGuard';
const { memberCan } = require('/models/lib/boardRoleCapabilities');
const { assignedOnlyCardScope } = require('/models/lib/boardCardScope');
const { selectionRoots, kinds } = require('/models/lib/structuralSelection');
const collections = { swimlane: Swimlanes, list: Lists, card: Cards, checklist: Checklists, item: ChecklistItems };
const targetShape = { boardId: String, swimlaneId: Match.Optional(String), listId: Match.Optional(String), cardId: Match.Optional(String), checklistId: Match.Optional(String), itemId: Match.Optional(String), position: Match.Optional(Match.OneOf('before', 'after')) };
@@ -82,13 +83,16 @@ Meteor.methods({
const boards = await Boards.find({ 'members.userId': this.userId, archived: false }).fetchAsync();
const writable = [];
for (const board of boards) if (memberCan(board.members, this.userId, 'write')) writable.push({ _id: board._id, title: board.title });
await boardAccess(this.userId, target.boardId);
const targetBoard = await boardAccess(this.userId, target.boardId);
// An assigned-only member is offered only the cards they can see.
const cardScope = assignedOnlyCardScope(targetBoard, this.userId) || {};
const projected = async (collection, query) => (await collection.find(query, { sort: { sort: 1 }, fields: { title: 1 } }).fetchAsync());
const result = { boardId: writable, swimlaneId: await projected(Swimlanes, { boardId: target.boardId, archived: false }) };
if (target.swimlaneId) result.listId = await projected(Lists, { boardId: target.boardId, archived: false, $or: [{ swimlaneId: target.swimlaneId }, { swimlaneId: '' }, { swimlaneId: { $exists: false } }] });
if (target.listId) result.cardId = await projected(Cards, { boardId: target.boardId, listId: target.listId, swimlaneId: target.swimlaneId, archived: false });
if (target.cardId && await Cards.findOneAsync({ _id: target.cardId, boardId: target.boardId })) result.checklistId = await projected(Checklists, { cardId: target.cardId });
if (target.checklistId && await Checklists.findOneAsync({ _id: target.checklistId, boardId: target.boardId })) result.itemId = await projected(ChecklistItems, { checklistId: target.checklistId });
if (target.listId) result.cardId = await projected(Cards, { boardId: target.boardId, listId: target.listId, swimlaneId: target.swimlaneId, archived: false, ...cardScope });
if (target.cardId && await Cards.findOneAsync({ _id: target.cardId, boardId: target.boardId, ...cardScope })) result.checklistId = await projected(Checklists, { cardId: target.cardId });
const targetChecklist = target.checklistId && await Checklists.findOneAsync({ _id: target.checklistId, boardId: target.boardId });
if (targetChecklist && await Cards.findOneAsync({ _id: targetChecklist.cardId, boardId: target.boardId, ...cardScope })) result.itemId = await projected(ChecklistItems, { checklistId: target.checklistId });
return result;
},
async moveBoardObjects(sourceBoardId, selection, target, options = {}) {
+21 -6
View File
@@ -1,6 +1,8 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { getFeatureFlags } from '/models/lib/featureFlags';
const { isAssignedOnlyMember, mayCopyFromBoard } = require('/models/lib/boardCardScope');
// We use activities fields at two different places:
// 1. The board sidebar
// 2. The card activity tab
@@ -72,14 +74,27 @@ Meteor.publish('activities', async function(kind, id, limit, showActivities) {
if (!board || !board.isVisibleBy(userForVisibility)) {
return this.ready();
}
// An assigned-only member sees only their assigned cards' history.
if (!mayCopyFromBoard(board, this.userId, card)) {
return this.ready();
}
}
const selector = showActivities
? { [`${kind}Id`]: { $in: linkedElmtId } }
: { $and: [
{ activityType: 'addComment' },
{ [`${kind}Id`]: { $in: linkedElmtId } },
] };
const clauses = [{ [`${kind}Id`]: { $in: linkedElmtId } }];
if (!showActivities) clauses.push({ activityType: 'addComment' });
// On a board an assigned-only member reads the board's own activities that
// concern no card, and those of the cards assigned to them; a linked board's
// activities stay under that board's own visibility check above.
if (kind === 'board' && isAssignedOnlyMember(board, this.userId)) {
const assigned = await ReactiveCache.getCards({ boardId: board._id, assignees: this.userId }, { fields: { _id: 1 } });
clauses.push({ $or: [
{ boardId: { $ne: board._id } },
{ cardId: { $exists: false } },
{ cardId: null },
{ cardId: { $in: (assigned || []).map(c => c._id) } },
] });
}
const selector = clauses.length === 1 ? clauses[0] : { $and: clauses };
const ret = await ReactiveCache.getActivities(selector,
{
+27 -8
View File
@@ -83,7 +83,7 @@ import { CARD_TYPES } from '../../config/const';
import Org from "../../models/org";
import Team from "../../models/team";
import { MATCH_NOTHING, selectorIsInjection } from '/server/lib/selectorGuard';
const { boardCardScope } = require('/models/lib/boardCardScope');
const { boardCardScope, isAssignedOnlyMember } = require('/models/lib/boardCardScope');
const { retainRankedCard } = require('/models/lib/cardSearchRanking');
const { withoutMembershipSelectors } = require('/models/lib/boardPermission');
const {
@@ -357,14 +357,18 @@ Meteor.publish('dueCards', async function(allUsers = false, limit = 200, skip =
// Get user's board memberships for efficient filtering
// #3249: signed in, so 'instance' boards count as well.
const userBoards = (await ReactiveCache.getBoards({
const visibleBoards = await ReactiveCache.getBoards({
$or: [
...withoutMembershipSelectors(true),
{ members: { $elemMatch: { userId, isActive: true } } }
]
})).map(board => board._id);
});
// On a board where this user is an assigned-only member, only the cards
// assigned to them are theirs to see - "all users" included.
const userBoards = visibleBoards.filter(board => !isAssignedOnlyMember(board, userId)).map(board => board._id);
const assignedOnlyBoards = visibleBoards.filter(board => isAssignedOnlyMember(board, userId)).map(board => board._id);
if (userBoards.length === 0) {
if (userBoards.length === 0 && assignedOnlyBoards.length === 0) {
return this.ready();
}
@@ -373,18 +377,21 @@ Meteor.publish('dueCards', async function(allUsers = false, limit = 200, skip =
type: 'cardType-card',
archived: false,
dueAt: { $exists: true, $nin: [null, ''] },
boardId: { $in: userBoards }
$and: [{ $or: [
{ boardId: { $in: userBoards } },
{ boardId: { $in: assignedOnlyBoards }, assignees: userId },
] }],
};
// Add user filtering if not showing all users
if (!allUsers) {
selector.$or = [
selector.$and.push({ $or: [
{ members: userId },
{ assignees: userId },
{ requesters: userId },
{ assigners: userId },
{ userId: userId }
];
] });
}
const options = {
@@ -1346,9 +1353,21 @@ async function findCards(sessionId, query, userId) {
)
? MATCH_NOTHING
: query.selector;
// On a board where the user is an assigned-only member, search finds only
// the cards assigned to them, as the board itself shows.
const assignedOnlyBoardIds = (await ReactiveCache.getBoards(
{ _id: { $in: authorizedBoardIds } },
{ fields: { members: 1 } },
)).filter(board => isAssignedOnlyMember(board, userId)).map(board => board._id);
const boardScope = assignedOnlyBoardIds.length === 0
? { boardId: { $in: authorizedBoardIds } }
: { $or: [
{ boardId: { $in: authorizedBoardIds.filter(id => !assignedOnlyBoardIds.includes(id)) } },
{ boardId: { $in: assignedOnlyBoardIds }, assignees: userId },
] };
const databaseSelector = storedSelector === MATCH_NOTHING
? MATCH_NOTHING
: { $and: [storedSelector, { boardId: { $in: authorizedBoardIds } }] };
: { $and: [storedSelector, boardScope] };
let textMatches = query.getQueryParams().text;
let isTextSearch = !!textMatches;
+7 -3
View File
@@ -19,6 +19,7 @@ import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
import { tripCanary } from '/server/lib/canary';
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
import { softDeleteAttachment } from '/server/attachmentSoftDelete';
import { assignedOnlyAttachmentScope, mayReadBoardAttachment } from '/server/lib/assignedOnlyAttachments';
const HARD_MAX_API_FILE_BYTES = 64 * 1024 * 1024;
const HARD_MAX_API_UPLOAD_BODY_BYTES = 96 * 1024 * 1024;
@@ -593,7 +594,7 @@ WebApp.handlers.use('/api/attachment/upload', async (req, res, next) => {
// Check permissions
const board = await ReactiveCache.getBoard(attachment.meta.boardId);
if (!board || !board.hasMember(userId)) {
if (!board || !board.hasMember(userId) || !(await mayReadBoardAttachment(board, userId, attachment))) {
return sendErrorResponse(res, 403, 'You do not have permission to access this attachment');
}
@@ -705,6 +706,8 @@ WebApp.handlers.use('/api/attachment/upload', async (req, res, next) => {
query['meta.cardId'] = cardId;
}
const scope = await assignedOnlyAttachmentScope(board, userId);
if (scope) query = { $and: [query, scope] };
const attachments = await ReactiveCache.getAttachments(liveAttachments(query));
const attachmentList = attachments.map(attachment => {
@@ -750,7 +753,8 @@ WebApp.handlers.use('/api/boards/:boardId/attachments', async (req, res, next) =
if (!board || !board.hasMember(userId)) {
return sendErrorResponse(res, 403, 'You do not have permission to access this board');
}
const query = liveAttachments({ 'meta.boardId': boardId });
const scope = await assignedOnlyAttachmentScope(board, userId);
const query = scope ? liveAttachments({ $and: [{ 'meta.boardId': boardId }, scope] }) : liveAttachments({ 'meta.boardId': boardId });
const attachments = await ReactiveCache.getAttachments(query);
const attachmentList = attachments.map(attachment => {
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
@@ -1124,7 +1128,7 @@ WebApp.handlers.use('/api/boards/:boardId/attachments', async (req, res, next) =
// Check permissions
const board = await ReactiveCache.getBoard(attachment.meta.boardId);
if (!board || !board.hasMember(userId)) {
if (!board || !board.hasMember(userId) || !(await mayReadBoardAttachment(board, userId, attachment))) {
return sendErrorResponse(res, 403, 'You do not have permission to access this attachment');
}
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// Guard (2026-10-02): an assigned-only member (isReadAssignedOnly,
// isNormalAssignedOnly, isCommentAssignedOnly) sees only the cards assigned to
// them. The board publication enforced that, but several other read paths
// asked only "is this a member of the board?" and so showed that member every
// card of it:
// - the attachment API (REST and DDP): list enumerated every attachment of
// the board, download and info served any of them;
// - the activities publication: a board's whole activity feed, and the
// feed of any card by id;
// - Due Cards with "all users", and Global Search;
// - the structural move dialog's card and checklist-item pickers;
// - position history for the board and a card's original position.
// Run: node tests/assignedOnlyReads.test.cjs
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
const scope = require('../models/lib/boardCardScope');
function loadAttachments(cards) {
const src = read('server/lib/assignedOnlyAttachments.js')
.replace(/^import [^\n]*\n/gm, '').replace(/^const \{ isAssignedOnlyMember \} = require[^\n]*\n/m, '')
.replace(/^export /gm, '');
const ReactiveCache = {
getCards: async selector => cards.filter(c => c.boardId === selector.boardId && c.assignees.includes(selector.assignees)),
getCard: async id => cards.find(c => c._id === id),
};
const lib = { records: [] };
// eslint-disable-next-line no-new-func
new Function('exports', 'ReactiveCache', 'isAssignedOnlyMember', 'require',
`${src}\nexports.assignedOnlyAttachmentScope = assignedOnlyAttachmentScope;\nexports.mayReadBoardAttachment = mayReadBoardAttachment;`)(
lib, ReactiveCache, scope.isAssignedOnlyMember, () => ({ record: r => lib.records.push(r) }));
return lib;
}
const board = {
_id: 'b',
members: [
{ userId: 'restricted', isActive: true, isNormalAssignedOnly: true },
{ userId: 'normal', isActive: true },
],
};
const cards = [
{ _id: 'mine', boardId: 'b', assignees: ['restricted'] },
{ _id: 'theirs', boardId: 'b', assignees: ['normal'] },
{ _id: 'elsewhere', boardId: 'other', assignees: ['restricted'] },
];
test('the reported shape: an assigned-only member reads only their own cards\' attachments', async () => {
const { assignedOnlyAttachmentScope, mayReadBoardAttachment, records } = loadAttachments(cards);
assert.deepEqual(await assignedOnlyAttachmentScope(board, 'restricted'), { 'meta.cardId': { $in: ['mine'] } });
assert.equal(await mayReadBoardAttachment(board, 'restricted', { meta: { cardId: 'theirs' } }), false);
assert.equal(await mayReadBoardAttachment(board, 'restricted', { meta: { cardId: 'elsewhere' } }), false);
assert.equal(await mayReadBoardAttachment(board, 'restricted', { meta: {} }), false);
assert.equal(await mayReadBoardAttachment(board, 'restricted', { meta: { cardId: 'mine' } }), true);
// An ordinary member is not narrowed (negative).
assert.equal(await assignedOnlyAttachmentScope(board, 'normal'), null);
assert.equal(await mayReadBoardAttachment(board, 'normal', { meta: { cardId: 'mine' } }), true);
// The three refusals are in Admin Panel -> Problems, and never disable.
assert.equal(records.length, 3);
assert.deepEqual([...new Set(records.map(r => `${r.key}/${r.action}/${r.userId}`))], ['authz.assigned/blocked/restricted']);
assert.match(read('models/lib/securityCategories.js'), /'authz\.assigned':\s*\{[^}]*severity: 'medium'/);
});
test('negative: every attachment API read applies the assigned-only rule', () => {
for (const file of ['server/attachmentApi.js', 'server/routes/attachmentApi.js']) {
const src = read(file);
// Each "access this attachment" refusal also asks mayReadBoardAttachment.
const refusals = src.split('\n').filter(l => /hasMember\((this\.)?userId\)/.test(l));
for (const line of refusals.filter(l => !/download-background|Background/.test(l))) {
const i = src.indexOf(line);
const next = src.slice(i, src.indexOf('\n', src.indexOf('\n', i) + 1));
if (/access this attachment/.test(next)) assert.match(line, /mayReadBoardAttachment\(/, `${file}: ${line.trim()}`);
}
// Each board-wide listing is narrowed.
let at = src.indexOf("{ 'meta.boardId': boardId }");
assert.ok(at > 0, file);
while (at > 0) {
const window = src.slice(Math.max(0, at - 300), at + 900);
assert.match(window, /assignedOnlyAttachmentScope\(board, (this\.)?userId\)/, `${file} @${at}`);
at = src.indexOf("{ 'meta.boardId': boardId }", at + 1);
}
}
});
test('the other read paths apply the same rule', () => {
const activities = read('server/publications/activities.js');
assert.match(activities, /if \(!mayCopyFromBoard\(board, this\.userId, card\)\) \{\n\s*return this\.ready\(\);/);
assert.match(activities, /if \(kind === 'board' && isAssignedOnlyMember\(board, this\.userId\)\)/);
const cardsPub = read('server/publications/cards.js');
assert.match(cardsPub, /\{ boardId: \{ \$in: assignedOnlyBoards \}, assignees: userId \}/);
assert.match(cardsPub, /\{ boardId: \{ \$in: assignedOnlyBoardIds \}, assignees: userId \}/);
const move = read('server/moveBoardObjects.js');
assert.match(move, /result\.cardId = await projected\(Cards, \{[^}]*\.\.\.cardScope \}\)/);
assert.match(move, /Cards\.findOneAsync\(\{ _id: targetChecklist\.cardId, boardId: target\.boardId, \.\.\.cardScope \}\)\) result\.itemId/);
const history = read('server/methods/positionHistory.js');
assert.equal((history.match(/\.\.\.\(scope \|\| \{\}\),/g) || []).length, 2);
assert.match(history, /if \(!mayCopyFromBoard\(cardBoard, this\.userId, card\)\)/);
});
@@ -49,9 +49,14 @@ assert.match(
);
assert.match(
findCards[0],
/\$and: \[storedSelector, \{ boardId: \{ \$in: authorizedBoardIds \} \}]/,
/\$and: \[storedSelector, boardScope\]/,
'stored selectors from older releases must be constrained to current access',
);
// boardScope is the authorized boards, narrowed to assigned cards on a board
// where the user is an assigned-only member (2026-10-02). Both arms stay
// inside authorizedBoardIds.
assert.match(findCards[0], /\? \{ boardId: \{ \$in: authorizedBoardIds \} \}/);
assert.match(findCards[0], /\{ boardId: \{ \$in: authorizedBoardIds\.filter\(id => !assignedOnlyBoardIds\.includes\(id\)\) \} \},\n\s*\{ boardId: \{ \$in: assignedOnlyBoardIds \}, assignees: userId \},/);
assert.ok(
findCards[0].indexOf('selectorIsInjection(query.selector') <
findCards[0].indexOf('ReactiveCache.getCards(databaseSelector'),