(core) fix bug where sharing doc with everyone@ as viewer made it unlisted for site viewers

Summary:
Shares of the same role (e.g. viewer) at different levels could interact for a resource (e.g. a doc) shared with everyone@, potentially blocking the listing of that resource. This diff removes the interaction.

The permission of a user on a resource is calculated by finding all acl rules that link that resource to a group to which the user belongs, or to a group that has a subgroup to which the user belongs, etc, and then bitwise-or-ing the permissions on the acl rules. A later wrinkle was to allow public sharing via special users. A still later wrinkle was to avoid listing resources if they were only shared with the special everyone@ user, while allowing access to them if user has their full link. That wrinkle had a bug, where if e.g. a doc were shared with everyone@ as a viewer, and the org the doc was in was shared with someone@ as a viewer, and the doc inherited the org permissions via a workspace, then that doc would end up not being listed.

The fix is straightforward enough, but needs different code for postgres and sqlite, and is a bit verbose because we unwrap subgroups to a few levels rather than doing recursion (which looks cleaner but was slower in benchmarks).

Test Plan: added test that fails without this fix

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3095
pull/115/head
Paul Fitzpatrick 3 years ago
parent e58df5df5b
commit 35e18cc0ad

@ -28,7 +28,8 @@ import {Workspace} from "app/gen-server/entity/Workspace";
import {Permissions} from 'app/gen-server/lib/Permissions';
import {scrubUserFromOrg} from "app/gen-server/lib/scrubUserFromOrg";
import {applyPatch} from 'app/gen-server/lib/TypeORMPatches';
import {bitOr, getRawAndEntities, now, readJson} from 'app/gen-server/sqlUtils';
import {bitOr, getRawAndEntities, hasAtLeastOneOfTheseIds, hasOnlyTheseIdsOrNull,
now, readJson} from 'app/gen-server/sqlUtils';
import {makeId} from 'app/server/lib/idUtils';
import * as log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
@ -3562,15 +3563,37 @@ export class HomeDBManager extends EventEmitter {
// only to the public flag. So resources available to the user only because
// they are publically available will not be listed. Shares with anon@,
// on the other hand, *are* listed.
const everyoneContribution = accessStyle === 'list' ? Permissions.PUBLIC :
`${Permissions.PUBLIC} | acl_rules.permissions`;
// At this point, we have user ids available for a group associated with the acl
// rule, or a subgroup of that group, of a subgroup of that group, or a subgroup
// of that group (this is enough nesting to support docs in workspaces in orgs,
// with one level of nesting held for future use).
const userIdCols = ['gu0.user_id', 'gu1.user_id', 'gu2.user_id', 'gu3.user_id'];
// If any of the user ids is public (everyone@, anon@), we set the PUBLIC flag.
// This is only advisory, for display in the client - it plays no role in access
// control.
const publicFlagSql = `case when ` +
hasAtLeastOneOfTheseIds(this._dbType, [everyoneId, anonId], userIdCols) +
` then ${Permissions.PUBLIC} else 0 end`;
// The contribution made by the acl rule to overall user permission is contained
// in acl_rules.permissions. BUT if we are listing resources, we discount the
// permission contribution if it is only made with everyone@, and not anon@
// or any of the ids associated with the user. The resource may end up being
// accessible but unlisted for this user.
const contributionSql = accessStyle !== 'list' ? 'acl_rules.permissions' :
`case when ` +
hasOnlyTheseIdsOrNull(this._dbType, [everyoneId], userIdCols) +
` then 0 else acl_rules.permissions end`;
// Finally, if all users are null, the resource is being viewed by the special
// previewer user.
const previewerSql = `case when coalesce(${userIdCols.join(',')}) is null` +
` then acl_rules.permissions else 0 end`;
q = q.select(
bitOr(this._dbType, `(case when ` +
`${everyoneId} IN (gu0.user_id, gu1.user_id, gu2.user_id, gu3.user_id) ` +
`then ${everyoneContribution} else (case when ` +
`${anonId} IN (gu0.user_id, gu1.user_id, gu2.user_id, gu3.user_id) ` +
`then ${Permissions.PUBLIC} | acl_rules.permissions else acl_rules.permissions end) end)`, 8
),
bitOr(this._dbType, `(${publicFlagSql} | ${contributionSql} | ${previewerSql})`, 8),
'permissions'
);
}

@ -42,6 +42,39 @@ export function bitOr(dbType: DatabaseType, column: string, bits: number): strin
}
}
/**
* Checks if a set of columns contains only the given ids (or null).
* Uses array containment operator on postgres (with array_remove to deal with nulls),
* and a clunkier syntax for sqlite.
*/
export function hasOnlyTheseIdsOrNull(dbType: DatabaseType, ids: number[], columns: string[]): string {
switch (dbType) {
case 'postgres':
return `array[${ids.join(',')}] @> array_remove(array[${columns.join(',')}],null)`;
case 'sqlite':
return columns.map(col => `coalesce(${col} in (${ids.join(',')}), true)`).join(' AND ');
default:
throw new Error(`hasOnlyTheseIdsOrNull not implemented for ${dbType}`);
}
}
/**
* Checks if at least one of a set of ids is present in a set of columns.
* There must be at least one id and one column.
* Uses the intersection operator on postgres, and a clunkier syntax for sqlite.
*/
export function hasAtLeastOneOfTheseIds(dbType: DatabaseType, ids: number[], columns: string[]): string {
switch (dbType) {
case 'postgres':
return `array[${ids.join(',')}] && array[${columns.join(',')}]`;
case 'sqlite':
return ids.map(id => `${id} in (${columns.join(',')})`).join(' OR ');
default:
throw new Error(`hasAtLeastOneOfTheseIds not implemented for ${dbType}`);
}
}
/**
* Convert a json value returned by the database into a javascript
* object. For postgres, the value is already unpacked, but for sqlite

Loading…
Cancel
Save