mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
bfd7243fe2
Summary: First iteration for comments system for Grist. - Comments are stored in a generic metatable `_grist_Cells` - Each comment is connected to a particular cell (hence the generic name of the table) - Access level works naturally for records stored in this table -- User can add/read comments for cells he can see -- User can't update/remove comments that he doesn't own, but he can delete them by removing cells (rows/columns) -- Anonymous users can't see comments at all. - Each comment can have replies (but replies can't have more replies) Comments are hidden by default, they can be enabled by COMMENTS=true env variable. Some things for follow-up - Avatars, currently the user's profile image is not shown or retrieved from the server - Virtual rendering for comments list in creator panel. Currently, there is a limit of 200 comments. Test Plan: New and existing tests Reviewers: georgegevoian, paulfitz Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D3509
4455 lines
194 KiB
TypeScript
4455 lines
194 KiB
TypeScript
import {ApiError} from 'app/common/ApiError';
|
|
import {mapGetOrSet, mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
|
import {getDataLimitStatus} from 'app/common/DocLimits';
|
|
import {createEmptyOrgUsageSummary, DocumentUsage, OrgUsageSummary} from 'app/common/DocUsage';
|
|
import {normalizeEmail} from 'app/common/emails';
|
|
import {canAddOrgMembers, Features} from 'app/common/Features';
|
|
import {buildUrlId, MIN_URLID_PREFIX_LENGTH, parseUrlId} from 'app/common/gristUrls';
|
|
import {FullUser, UserProfile} from 'app/common/LoginSessionAPI';
|
|
import {checkSubdomainValidity} from 'app/common/orgNameUtils';
|
|
import {UserOrgPrefs} from 'app/common/Prefs';
|
|
import * as roles from 'app/common/roles';
|
|
import {StringUnion} from 'app/common/StringUnion';
|
|
import {
|
|
ANONYMOUS_USER_EMAIL,
|
|
DocumentProperties,
|
|
EVERYONE_EMAIL,
|
|
getRealAccess,
|
|
ManagerDelta,
|
|
NEW_DOCUMENT_CODE,
|
|
OrganizationProperties,
|
|
Organization as OrgInfo,
|
|
PermissionData,
|
|
PermissionDelta,
|
|
UserAccessData,
|
|
UserOptions,
|
|
WorkspaceProperties
|
|
} from "app/common/UserAPI";
|
|
import {AclRule, AclRuleDoc, AclRuleOrg, AclRuleWs} from "app/gen-server/entity/AclRule";
|
|
import {Alias} from "app/gen-server/entity/Alias";
|
|
import {BillingAccount, ExternalBillingOptions} from "app/gen-server/entity/BillingAccount";
|
|
import {BillingAccountManager} from "app/gen-server/entity/BillingAccountManager";
|
|
import {Document} from "app/gen-server/entity/Document";
|
|
import {Group} from "app/gen-server/entity/Group";
|
|
import {Login} from "app/gen-server/entity/Login";
|
|
import {AccessOption, AccessOptionWithRole, Organization} from "app/gen-server/entity/Organization";
|
|
import {Pref} from "app/gen-server/entity/Pref";
|
|
import {getDefaultProductNames, personalFreeFeatures, Product} from "app/gen-server/entity/Product";
|
|
import {Secret} from "app/gen-server/entity/Secret";
|
|
import {User} from "app/gen-server/entity/User";
|
|
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,
|
|
hasAtLeastOneOfTheseIds,
|
|
hasOnlyTheseIdsOrNull,
|
|
now,
|
|
readJson
|
|
} from 'app/gen-server/sqlUtils';
|
|
import {appSettings} from 'app/server/lib/AppSettings';
|
|
import {getOrCreateConnection} from 'app/server/lib/dbUtils';
|
|
import {makeId} from 'app/server/lib/idUtils';
|
|
import log from 'app/server/lib/log';
|
|
import {Permit} from 'app/server/lib/Permit';
|
|
import {getScope} from 'app/server/lib/requestUtils';
|
|
import {WebHookSecret} from "app/server/lib/Triggers";
|
|
import {EventEmitter} from 'events';
|
|
import {Request} from "express";
|
|
import {
|
|
Brackets,
|
|
Connection,
|
|
DatabaseType,
|
|
EntityManager,
|
|
SelectQueryBuilder,
|
|
WhereExpression
|
|
} from "typeorm";
|
|
import uuidv4 from "uuid/v4";
|
|
import flatten = require('lodash/flatten');
|
|
import pick = require('lodash/pick');
|
|
|
|
// Support transactions in Sqlite in async code. This is a monkey patch, affecting
|
|
// the prototypes of various TypeORM classes.
|
|
// TODO: remove this patch if the issue is ever accepted as a problem in TypeORM and
|
|
// fixed. See https://github.com/typeorm/typeorm/issues/1884#issuecomment-380767213
|
|
applyPatch();
|
|
|
|
export const NotifierEvents = StringUnion(
|
|
'addUser',
|
|
'userChange',
|
|
'firstLogin',
|
|
'addBillingManager',
|
|
'teamCreator',
|
|
'trialPeriodEndingSoon',
|
|
);
|
|
|
|
export type NotifierEvent = typeof NotifierEvents.type;
|
|
|
|
// Nominal email address of a user who can view anything (for thumbnails).
|
|
export const PREVIEWER_EMAIL = 'thumbnail@getgrist.com';
|
|
|
|
// A special user allowed to add/remove the EVERYONE_EMAIL to/from a resource.
|
|
export const SUPPORT_EMAIL = appSettings.section('access').flag('supportEmail').requireString({
|
|
envVar: 'GRIST_SUPPORT_EMAIL',
|
|
defaultValue: 'support@getgrist.com',
|
|
});
|
|
|
|
// A list of emails we don't expect to see logins for.
|
|
const NON_LOGIN_EMAILS = [PREVIEWER_EMAIL, EVERYONE_EMAIL, ANONYMOUS_USER_EMAIL];
|
|
|
|
// Name of a special workspace with examples in it.
|
|
export const EXAMPLE_WORKSPACE_NAME = 'Examples & Templates';
|
|
|
|
// Flag controlling whether sites that are publicly accessible should be listed
|
|
// to the anonymous user. Defaults to not listing such sites.
|
|
const listPublicSites = appSettings.section('access').flag('listPublicSites').readBool({
|
|
envVar: 'GRIST_LIST_PUBLIC_SITES',
|
|
defaultValue: false,
|
|
});
|
|
|
|
// A TTL in milliseconds for caching the result of looking up access level for a doc,
|
|
// which is a burden under heavy traffic.
|
|
const DOC_AUTH_CACHE_TTL = 5000;
|
|
|
|
type Resource = Organization|Workspace|Document;
|
|
|
|
export interface QueryResult<T> {
|
|
status: number;
|
|
data?: T;
|
|
errMessage?: string;
|
|
}
|
|
|
|
// Maps from userId to group name, or null to inherit.
|
|
export interface UserIdDelta {
|
|
[userId: string]: roles.NonGuestRole|null;
|
|
}
|
|
|
|
// A collection of fun facts derived from a PermissionDelta (used to describe
|
|
// a change of users) and a user.
|
|
export interface PermissionDeltaAnalysis {
|
|
userIdDelta: UserIdDelta | null; // New roles for users, indexed by user id.
|
|
permissionThreshold: Permissions; // The permissions needed to make the change.
|
|
// Usually Permissions.ACL_EDIT, but
|
|
// Permissions.ACL_VIEW is enough for a user
|
|
// to removed themselves.
|
|
affectsSelf: boolean; // Flags if the user making the change would
|
|
// be affected by the change.
|
|
}
|
|
|
|
// Options for certain create query helpers private to this file.
|
|
interface QueryOptions {
|
|
manager?: EntityManager;
|
|
markPermissions?: Permissions;
|
|
needRealOrg?: boolean; // Set if pseudo-org should be collapsed to user's personal org
|
|
allowSpecialPermit?: boolean; // Set if specialPermit in Scope object should be respected,
|
|
// potentially overriding markPermissions.
|
|
}
|
|
|
|
interface GroupDescriptor {
|
|
readonly name: roles.Role;
|
|
readonly permissions: number;
|
|
readonly nestParent: boolean;
|
|
readonly orgOnly?: boolean;
|
|
}
|
|
|
|
// Information about a change in billable users.
|
|
export interface UserChange {
|
|
userId: number; // who initiated the change
|
|
org: Organization; // organization changed
|
|
customerId: string|null; // stripe customer id
|
|
countBefore: number; // billable users before change
|
|
countAfter: number; // billable users after change
|
|
membersBefore: Map<roles.NonGuestRole, User[]>;
|
|
membersAfter: Map<roles.NonGuestRole, User[]>;
|
|
}
|
|
|
|
// A specification of the users available during a request. This can be a single
|
|
// user, identified by a user id, or a collection of profiles (typically drawn from
|
|
// the session).
|
|
type AvailableUsers = number | UserProfile[];
|
|
|
|
// A type guard to check for single-user case.
|
|
function isSingleUser(users: AvailableUsers): users is number {
|
|
return typeof users === 'number';
|
|
}
|
|
|
|
// The context in which a query is being made. Includes what we know
|
|
// about the user, and for requests made from pages, the active organization.
|
|
export interface Scope {
|
|
userId: number; // The ID of the user for authentication purposes.
|
|
org?: string; // Org identified in request.
|
|
urlId?: string; // Set when accessing a document. May be a docId.
|
|
users?: AvailableUsers; // Set if available identities.
|
|
includeSupport?: boolean; // When set, include sample resources shared by support to scope.
|
|
showRemoved?: boolean; // When set, query is scoped to removed workspaces/docs.
|
|
showOnlyPinned?: boolean; // When set, query is scoped only to pinned docs.
|
|
showAll?: boolean; // When set, return both removed and regular resources.
|
|
specialPermit?: Permit; // When set, extra rights are granted on a specific resource.
|
|
}
|
|
|
|
// Flag for whether we are listing resources or opening them. This makes a difference
|
|
// for public resources, which we allow users to open but not necessarily list.
|
|
type AccessStyle = 'list' | 'open';
|
|
|
|
// A Scope for documents, with mandatory urlId.
|
|
export interface DocScope extends Scope {
|
|
urlId: string;
|
|
}
|
|
|
|
type NonGuestGroup = Group & { name: roles.NonGuestRole };
|
|
|
|
// Returns whether the given group is a valid non-guest group.
|
|
function isNonGuestGroup(group: Group): group is NonGuestGroup {
|
|
return roles.isNonGuestRole(group.name);
|
|
}
|
|
|
|
export interface UserProfileChange {
|
|
name?: string;
|
|
isFirstTimeUser?: boolean;
|
|
}
|
|
|
|
// Identifies a request to access a document. This combination of values is also used for caching
|
|
// DocAuthResult for DOC_AUTH_CACHE_TTL. Other request scope information is passed along.
|
|
export interface DocAuthKey {
|
|
urlId: string; // May be docId. Must be unambiguous in the context of the org.
|
|
userId: number; // The user accessing this doc. (Could be the ID of Anonymous.)
|
|
org?: string; // Undefined if unknown (e.g. in API calls, but needs unique urlId).
|
|
}
|
|
|
|
// Document auth info. This is the minimum needed to resolve user access checks. For anything else
|
|
// (e.g. doc title), the uncached getDoc() call should be used.
|
|
export interface DocAuthResult {
|
|
docId: string|null; // The unique identifier of the document. Null on error.
|
|
access: roles.Role|null; // The access level for the requesting user. Null on error.
|
|
removed: boolean|null; // Set if the doc is soft-deleted. Users may still have access
|
|
// to removed documents for some purposes. Null on error.
|
|
error?: ApiError;
|
|
cachedDoc?: Document; // For cases where stale info is ok.
|
|
}
|
|
|
|
interface GetUserOptions {
|
|
manager?: EntityManager;
|
|
profile?: UserProfile;
|
|
userOptions?: UserOptions;
|
|
}
|
|
|
|
// Represent a DocAuthKey as a string. The format is "<urlId>:<org> <userId>".
|
|
// flushSingleDocAuthCache() depends on this format.
|
|
function stringifyDocAuthKey(key: DocAuthKey): string {
|
|
return stringifyUrlIdOrg(key.urlId, key.org) + ` ${key.userId}`;
|
|
}
|
|
|
|
function stringifyUrlIdOrg(urlId: string, org?: string): string {
|
|
return `${urlId}:${org}`;
|
|
}
|
|
|
|
export interface DocumentMetadata {
|
|
// ISO 8601 UTC date (e.g. the output of new Date().toISOString()).
|
|
updatedAt?: string;
|
|
usage?: DocumentUsage|null;
|
|
}
|
|
|
|
/**
|
|
* HomeDBManager handles interaction between the ApiServer and the Home database,
|
|
* encapsulating the typeorm logic.
|
|
*/
|
|
export class HomeDBManager extends EventEmitter {
|
|
private _connection: Connection;
|
|
private _dbType: DatabaseType;
|
|
private _specialUserIds: {[name: string]: number} = {}; // id for anonymous user, previewer, etc
|
|
private _exampleWorkspaceId: number;
|
|
private _exampleOrgId: number;
|
|
private _idPrefix: string = ""; // Place this before ids in subdomains, used in routing to
|
|
// deployments on same subdomain.
|
|
|
|
private _docAuthCache = new MapWithTTL<string, Promise<DocAuthResult>>(DOC_AUTH_CACHE_TTL);
|
|
// In restricted mode, documents should be read-only.
|
|
private _restrictedMode: boolean = false;
|
|
|
|
/**
|
|
* Five aclRules, each with one group (with the names 'owners', 'editors', 'viewers',
|
|
* 'guests', and 'members') are created by default on every new entity (Organization,
|
|
* Workspace, Document). These special groups are documented in the _defaultGroups
|
|
* constant below.
|
|
*
|
|
* When a child resource is created under a parent (i.e. when a new Workspace is created
|
|
* under an Organization), special groups with a truthy 'nestParent' property are set up
|
|
* to include in their memberGroups a single group on initialization - the parent's
|
|
* corresponding special group. Special groups with a falsy 'nextParent' property are
|
|
* empty on intialization.
|
|
*
|
|
* NOTE: The groups are ordered from most to least permissive, and should remain that way.
|
|
* TODO: app/common/roles already contains an ordering of the default roles. Usage should
|
|
* be consolidated.
|
|
*/
|
|
private readonly _defaultGroups: GroupDescriptor[] = [{
|
|
name: roles.OWNER,
|
|
permissions: Permissions.OWNER,
|
|
nestParent: true
|
|
}, {
|
|
name: roles.EDITOR,
|
|
permissions: Permissions.EDITOR,
|
|
nestParent: true
|
|
}, {
|
|
name: roles.VIEWER,
|
|
permissions: Permissions.VIEW,
|
|
nestParent: true
|
|
}, {
|
|
name: roles.GUEST,
|
|
permissions: Permissions.VIEW,
|
|
nestParent: false
|
|
}, {
|
|
name: roles.MEMBER,
|
|
permissions: Permissions.VIEW,
|
|
nestParent: false,
|
|
orgOnly: true
|
|
}];
|
|
|
|
public emit(event: NotifierEvent, ...args: any[]): boolean {
|
|
return super.emit(event, ...args);
|
|
}
|
|
|
|
// All groups.
|
|
public get defaultGroups(): GroupDescriptor[] {
|
|
return this._defaultGroups;
|
|
}
|
|
|
|
// Groups whose permissions are inherited from parent resource to child resources.
|
|
public get defaultBasicGroups(): GroupDescriptor[] {
|
|
return this._defaultGroups
|
|
.filter(_grpDesc => _grpDesc.nestParent);
|
|
}
|
|
|
|
// Groups that are common to all resources.
|
|
public get defaultCommonGroups(): GroupDescriptor[] {
|
|
return this._defaultGroups
|
|
.filter(_grpDesc => !_grpDesc.orgOnly);
|
|
}
|
|
|
|
public get defaultGroupNames(): roles.Role[] {
|
|
return this._defaultGroups.map(_grpDesc => _grpDesc.name);
|
|
}
|
|
|
|
public get defaultBasicGroupNames(): roles.BasicRole[] {
|
|
return this.defaultBasicGroups
|
|
.map(_grpDesc => _grpDesc.name) as roles.BasicRole[];
|
|
}
|
|
|
|
public get defaultNonGuestGroupNames(): roles.NonGuestRole[] {
|
|
return this._defaultGroups
|
|
.filter(_grpDesc => _grpDesc.name !== roles.GUEST)
|
|
.map(_grpDesc => _grpDesc.name) as roles.NonGuestRole[];
|
|
}
|
|
|
|
public get defaultCommonGroupNames(): roles.NonMemberRole[] {
|
|
return this.defaultCommonGroups
|
|
.map(_grpDesc => _grpDesc.name) as roles.NonMemberRole[];
|
|
}
|
|
|
|
public setPrefix(prefix: string) {
|
|
this._idPrefix = prefix;
|
|
}
|
|
|
|
public setRestrictedMode(restricted: boolean) {
|
|
this._restrictedMode = restricted;
|
|
}
|
|
|
|
public async connect(): Promise<void> {
|
|
this._connection = await getOrCreateConnection();
|
|
this._dbType = this._connection.driver.options.type;
|
|
}
|
|
|
|
// make sure special users and workspaces are available
|
|
public async initializeSpecialIds(options?: {
|
|
skipWorkspaces?: boolean // if set, skip setting example workspace.
|
|
}): Promise<void> {
|
|
await this._getSpecialUserId({
|
|
email: ANONYMOUS_USER_EMAIL,
|
|
name: "Anonymous"
|
|
});
|
|
await this._getSpecialUserId({
|
|
email: PREVIEWER_EMAIL,
|
|
name: "Preview"
|
|
});
|
|
await this._getSpecialUserId({
|
|
email: EVERYONE_EMAIL,
|
|
name: "Everyone"
|
|
});
|
|
await this._getSpecialUserId({
|
|
email: SUPPORT_EMAIL,
|
|
name: "Support"
|
|
});
|
|
|
|
if (!options?.skipWorkspaces) {
|
|
// Find the example workspace. If there isn't one named just right, take the first workspace
|
|
// belonging to the support user. This shouldn't happen in deployments but could happen
|
|
// in tests.
|
|
// TODO: it should now be possible to remove all this; the only remaining
|
|
// issue is what workspace to associate with documents created by
|
|
// anonymous users.
|
|
const supportWorkspaces = await this._workspaces()
|
|
.leftJoinAndSelect('workspaces.org', 'orgs')
|
|
.where('orgs.owner_id = :userId', { userId: this.getSupportUserId() })
|
|
.orderBy('workspaces.created_at')
|
|
.getMany();
|
|
const exampleWorkspace = supportWorkspaces.find(ws => ws.name === EXAMPLE_WORKSPACE_NAME) || supportWorkspaces[0];
|
|
if (!exampleWorkspace) { throw new Error('No example workspace available'); }
|
|
if (exampleWorkspace.name !== EXAMPLE_WORKSPACE_NAME) {
|
|
log.warn('did not find an appropriately named example workspace in deployment');
|
|
}
|
|
this._exampleWorkspaceId = exampleWorkspace.id;
|
|
this._exampleOrgId = exampleWorkspace.org.id;
|
|
}
|
|
}
|
|
|
|
public get connection() {
|
|
return this._connection;
|
|
}
|
|
|
|
public async testQuery(sql: string, args: any[]): Promise<any> {
|
|
return this._connection.query(sql, args);
|
|
}
|
|
|
|
/**
|
|
* Maps from the name of an entity to its id, for the purposes of
|
|
* unit tests only. It relies on test entities being named
|
|
* distinctly. It just runs through each model in turn by brute
|
|
* force, and returns the id of this first match it finds.
|
|
*/
|
|
public async testGetId(name: string): Promise<number|string> {
|
|
const org = await Organization.findOne({where: {name}});
|
|
if (org) { return org.id; }
|
|
const ws = await Workspace.findOne({where: {name}});
|
|
if (ws) { return ws.id; }
|
|
const doc = await Document.findOne({where: {name}});
|
|
if (doc) { return doc.id; }
|
|
const user = await User.findOne({where: {name}});
|
|
if (user) { return user.id; }
|
|
const product = await Product.findOne({where: {name}});
|
|
if (product) { return product.id; }
|
|
throw new Error(`Cannot testGetId(${name})`);
|
|
}
|
|
|
|
/**
|
|
* For tests only. Get user's unique reference by name.
|
|
*/
|
|
public async testGetRef(name: string): Promise<string> {
|
|
const user = await User.findOne({where: {name}});
|
|
if (user) { return user.ref; }
|
|
throw new Error(`Cannot testGetRef(${name})`);
|
|
}
|
|
|
|
/**
|
|
* Clear all user preferences associated with the given email addresses.
|
|
* For use in tests.
|
|
*/
|
|
public async testClearUserPrefs(emails: string[]) {
|
|
return await this._connection.transaction(async manager => {
|
|
for (const email of emails) {
|
|
const user = await this.getUserByLogin(email, {manager});
|
|
if (user) {
|
|
await manager.delete(Pref, {userId: user.id});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async getUserByKey(apiKey: string): Promise<User|undefined> {
|
|
// Include logins relation for Authorization convenience.
|
|
return await User.findOne({where: {apiKey}, relations: ["logins"]}) || undefined;
|
|
}
|
|
|
|
public async getUser(userId: number): Promise<User|undefined> {
|
|
return await User.findOne({where: {id: userId}, relations: ["logins"]}) || undefined;
|
|
}
|
|
|
|
public async getFullUser(userId: number): Promise<FullUser> {
|
|
const user = await User.findOne({where: {id: userId}, relations: ["logins"]});
|
|
if (!user) { throw new ApiError("unable to find user", 400); }
|
|
return this.makeFullUser(user);
|
|
}
|
|
|
|
/**
|
|
* Convert a user record into the format specified in api.
|
|
*/
|
|
public makeFullUser(user: User): FullUser {
|
|
if (!user.logins?.[0]?.displayEmail) {
|
|
throw new ApiError("unable to find mandatory user email", 400);
|
|
}
|
|
const result: FullUser = {
|
|
id: user.id,
|
|
email: user.logins[0].displayEmail,
|
|
name: user.name,
|
|
picture: user.picture,
|
|
ref: user.ref,
|
|
};
|
|
if (this.getAnonymousUserId() === user.id) {
|
|
result.anonymous = true;
|
|
}
|
|
if (this.getSupportUserId() === user.id) {
|
|
result.isSupport = true;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Ensures that user with external id exists and updates its profile and email if necessary.
|
|
*
|
|
* @param profile External profile
|
|
*/
|
|
public async ensureExternalUser(profile: UserProfile) {
|
|
await this._connection.transaction(async manager => {
|
|
// First find user by the connectId from the profile
|
|
const existing = await manager.findOne(User, {
|
|
where: {connectId: profile.connectId || undefined},
|
|
relations: ["logins"],
|
|
});
|
|
|
|
// If a user does not exist, create it with data from the external profile.
|
|
if (!existing) {
|
|
const newUser = await this.getUserByLoginWithRetry(profile.email, {
|
|
profile,
|
|
manager
|
|
});
|
|
if (!newUser) {
|
|
throw new ApiError("Unable to create user", 500);
|
|
}
|
|
// No need to survey this user.
|
|
newUser.isFirstTimeUser = false;
|
|
await newUser.save();
|
|
} else {
|
|
// Else update profile and login information from external profile.
|
|
let updated = false;
|
|
let login: Login = existing.logins[0]!;
|
|
const properEmail = normalizeEmail(profile.email);
|
|
|
|
if (properEmail !== existing.loginEmail) {
|
|
login = login ?? new Login();
|
|
login.email = properEmail;
|
|
login.displayEmail = profile.email;
|
|
existing.logins.splice(0, 1, login);
|
|
login.user = existing;
|
|
updated = true;
|
|
}
|
|
|
|
if (profile?.name && profile?.name !== existing.name) {
|
|
existing.name = profile.name;
|
|
updated = true;
|
|
}
|
|
|
|
if (profile?.picture && profile?.picture !== existing.picture) {
|
|
existing.picture = profile.picture;
|
|
updated = true;
|
|
}
|
|
|
|
if (updated) {
|
|
await manager.save([existing, login]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public async updateUser(userId: number, props: UserProfileChange): Promise<void> {
|
|
let isWelcomed: boolean = false;
|
|
let user: User|null = null;
|
|
await this._connection.transaction(async manager => {
|
|
user = await manager.findOne(User, {relations: ['logins'],
|
|
where: {id: userId}});
|
|
let needsSave = false;
|
|
if (!user) { throw new ApiError("unable to find user", 400); }
|
|
if (props.name && props.name !== user.name) {
|
|
user.name = props.name;
|
|
needsSave = true;
|
|
}
|
|
if (props.isFirstTimeUser !== undefined && props.isFirstTimeUser !== user.isFirstTimeUser) {
|
|
user.isFirstTimeUser = props.isFirstTimeUser;
|
|
needsSave = true;
|
|
// If we are turning off the isFirstTimeUser flag, then right
|
|
// after this transaction commits is a great time to trigger
|
|
// any automation for first logins
|
|
if (!props.isFirstTimeUser) { isWelcomed = true; }
|
|
}
|
|
if (needsSave) {
|
|
await user.save();
|
|
}
|
|
});
|
|
if (user && isWelcomed) {
|
|
this.emit('firstLogin', this.makeFullUser(user));
|
|
}
|
|
}
|
|
|
|
public async updateUserName(userId: number, name: string) {
|
|
const user = await User.findOne({where: {id: userId}});
|
|
if (!user) { throw new ApiError("unable to find user", 400); }
|
|
user.name = name;
|
|
await user.save();
|
|
}
|
|
|
|
public async updateUserOptions(userId: number, props: Partial<UserOptions>) {
|
|
const user = await User.findOne({where: {id: userId}});
|
|
if (!user) { throw new ApiError("unable to find user", 400); }
|
|
|
|
const newOptions = {...(user.options ?? {}), ...props};
|
|
user.options = newOptions;
|
|
await user.save();
|
|
}
|
|
|
|
// Fetch user from login, creating the user if previously unseen, allowing one retry
|
|
// for an email key conflict failure. This is in case our transaction conflicts with a peer
|
|
// doing the same thing. This is quite likely if the first page visited by a previously
|
|
// unseen user fires off multiple api calls.
|
|
public async getUserByLoginWithRetry(email: string, options: GetUserOptions = {}): Promise<User|undefined> {
|
|
try {
|
|
return await this.getUserByLogin(email, options);
|
|
} catch (e) {
|
|
if (e.name === 'QueryFailedError' && e.detail &&
|
|
e.detail.match(/Key \(email\)=[^ ]+ already exists/)) {
|
|
// This is a postgres-specific error message. This problem cannot arise in sqlite,
|
|
// because we have to serialize sqlite transactions in any case to get around a typeorm
|
|
// limitation.
|
|
return await this.getUserByLogin(email, options);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Fetches a user record based on an email address. If a user record already
|
|
* exists linked to the email address supplied, that is the record returned.
|
|
* Otherwise a fresh record is created, linked to the supplied email address.
|
|
* The supplied `options` are used when creating a fresh record, or updating
|
|
* unset/outdated fields of an existing record.
|
|
*
|
|
*/
|
|
public async getUserByLogin(email: string, options: GetUserOptions = {}): Promise<User|undefined> {
|
|
const {manager: transaction, profile, userOptions} = options;
|
|
const normalizedEmail = normalizeEmail(email);
|
|
const userByLogin = await this._runInTransaction(transaction, async manager => {
|
|
let needUpdate = false;
|
|
const userQuery = manager.createQueryBuilder()
|
|
.select('user')
|
|
.from(User, 'user')
|
|
.leftJoinAndSelect('user.logins', 'logins')
|
|
.leftJoinAndSelect('user.personalOrg', 'personalOrg')
|
|
.where('email = :email', {email: normalizedEmail});
|
|
let user = await userQuery.getOne();
|
|
let login: Login;
|
|
if (!user) {
|
|
user = new User();
|
|
// Special users do not have first time user set so that they don't get redirected to the
|
|
// welcome page.
|
|
user.isFirstTimeUser = !NON_LOGIN_EMAILS.includes(normalizedEmail);
|
|
login = new Login();
|
|
login.email = normalizedEmail;
|
|
login.user = user;
|
|
needUpdate = true;
|
|
} else {
|
|
login = user.logins[0];
|
|
}
|
|
|
|
// Check that user and login records are up to date.
|
|
if (!user.name) {
|
|
// Set the user's name if our provider knows it. Otherwise use their username
|
|
// from email, for lack of something better. If we don't have a profile at this
|
|
// time, then leave the name blank in the hopes of learning it when the user logs in.
|
|
user.name = (profile && (profile.name || email.split('@')[0])) || '';
|
|
needUpdate = true;
|
|
}
|
|
if (profile && !user.firstLoginAt) {
|
|
// set first login time to now (remove milliseconds for compatibility with other
|
|
// timestamps in db set by typeorm, and since second level precision is fine)
|
|
const nowish = new Date();
|
|
nowish.setMilliseconds(0);
|
|
user.firstLoginAt = nowish;
|
|
needUpdate = true;
|
|
}
|
|
if (!user.picture && profile && profile.picture) {
|
|
// Set the user's profile picture if our provider knows it.
|
|
user.picture = profile.picture;
|
|
needUpdate = true;
|
|
}
|
|
if (profile && profile.email && profile.email !== login.displayEmail) {
|
|
// Use provider's version of email address for display.
|
|
login.displayEmail = profile.email;
|
|
needUpdate = true;
|
|
}
|
|
|
|
if (profile?.connectId && profile?.connectId !== user.connectId) {
|
|
user.connectId = profile.connectId;
|
|
needUpdate = true;
|
|
}
|
|
|
|
if (!login.displayEmail) {
|
|
// Save some kind of display email if we don't have anything at all for it yet.
|
|
// This could be coming from how someone wrote it in a UserManager dialog, for
|
|
// instance. It will get overwritten when the user logs in if the provider's
|
|
// version is different.
|
|
login.displayEmail = email;
|
|
needUpdate = true;
|
|
}
|
|
if (!user.options?.authSubject && userOptions?.authSubject) {
|
|
// Link subject from password-based authentication provider if not previously linked.
|
|
user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
|
|
needUpdate = true;
|
|
}
|
|
if (needUpdate) {
|
|
login.user = user;
|
|
await manager.save([user, login]);
|
|
}
|
|
if (!user.personalOrg && !NON_LOGIN_EMAILS.includes(login.email)) {
|
|
// Add a personal organization for this user.
|
|
// We don't add a personal org for anonymous/everyone/previewer "users" as it could
|
|
// get a bit confusing.
|
|
const result = await this.addOrg(user, {name: "Personal"}, {
|
|
setUserAsOwner: true,
|
|
useNewPlan: true
|
|
}, manager);
|
|
if (result.status !== 200) {
|
|
throw new Error(result.errMessage);
|
|
}
|
|
needUpdate = true;
|
|
|
|
// We just created a personal org; set userOrgPrefs that should apply for new users only.
|
|
const userOrgPrefs: UserOrgPrefs = {showGristTour: true};
|
|
const orgId = result.data;
|
|
if (orgId) {
|
|
await this.updateOrg({userId: user.id}, orgId, {userOrgPrefs}, manager);
|
|
}
|
|
}
|
|
if (needUpdate) {
|
|
// We changed the db - reload user in order to give consistent results.
|
|
// In principle this could be optimized, but this is simpler to maintain.
|
|
user = await userQuery.getOne();
|
|
}
|
|
return user;
|
|
});
|
|
return userByLogin;
|
|
}
|
|
|
|
/**
|
|
* Find a user by email. Don't create the user if it doesn't already exist.
|
|
*/
|
|
public async getExistingUserByLogin(
|
|
email: string,
|
|
manager?: EntityManager
|
|
): Promise<User|undefined> {
|
|
const normalizedEmail = normalizeEmail(email);
|
|
return await (manager || this._connection).createQueryBuilder()
|
|
.select('user')
|
|
.from(User, 'user')
|
|
.leftJoinAndSelect('user.logins', 'logins')
|
|
.where('email = :email', {email: normalizedEmail})
|
|
.getOne() || undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the given domain string is available, and false if it is not available.
|
|
* NOTE that the endpoint only checks if the domain string is taken in the database, it does
|
|
* not check whether the string contains invalid characters.
|
|
*/
|
|
public async isDomainAvailable(domain: string): Promise<boolean> {
|
|
let qb = this._orgs();
|
|
qb = this._whereOrg(qb, domain);
|
|
const results = await qb.getRawAndEntities();
|
|
return results.entities.length === 0;
|
|
}
|
|
|
|
/**
|
|
* Returns the number of users in any non-guest role in the given org.
|
|
* Note that this does not require permissions and should not be exposed to the client.
|
|
*
|
|
* If an Organization is provided, all of orgs.acl_rules, orgs.acl_rules.group,
|
|
* and orgs.acl_rules.group.memberUsers should be included.
|
|
*/
|
|
public async getOrgMemberCount(org: string|number|Organization): Promise<number> {
|
|
if (!(org instanceof Organization)) {
|
|
const orgQuery = this._org(null, false, org, {
|
|
needRealOrg: true
|
|
})
|
|
// Join the org's ACL rules (with 1st level groups/users listed).
|
|
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users');
|
|
const result = await orgQuery.getRawAndEntities();
|
|
if (result.entities.length === 0) {
|
|
// If the query for the doc failed, return the failure result.
|
|
throw new ApiError('org not found', 404);
|
|
}
|
|
org = result.entities[0];
|
|
}
|
|
return getResourceUsers(org, this.defaultNonGuestGroupNames).length;
|
|
}
|
|
|
|
/**
|
|
* Deletes a user from the database. For the moment, the only person with the right
|
|
* to delete a user is the user themselves.
|
|
* Users have logins, a personal org, and entries in the group_users table. All are
|
|
* removed together in a transaction. All material in the personal org will be lost.
|
|
*
|
|
* @param scope: request scope, including the id of the user initiating this action
|
|
* @param userIdToDelete: the id of the user to delete from the database
|
|
* @param name: optional cross-check, delete only if user name matches this
|
|
*/
|
|
public async deleteUser(scope: Scope, userIdToDelete: number,
|
|
name?: string): Promise<QueryResult<void>> {
|
|
const userIdDeleting = scope.userId;
|
|
if (userIdDeleting !== userIdToDelete) {
|
|
throw new ApiError('not permitted to delete this user', 403);
|
|
}
|
|
await this._connection.transaction(async manager => {
|
|
const user = await manager.findOne(User, {where: {id: userIdToDelete},
|
|
relations: ["logins", "personalOrg"]});
|
|
if (!user) { throw new ApiError('user not found', 404); }
|
|
if (name) {
|
|
if (user.name !== name) {
|
|
throw new ApiError(`user name did not match ('${name}' vs '${user.name}')`, 400);
|
|
}
|
|
}
|
|
if (user.personalOrg) { await this.deleteOrg(scope, user.personalOrg.id, manager); }
|
|
await manager.remove([...user.logins]);
|
|
// We don't have a GroupUser entity, and adding one tickles lots of TypeOrm quirkiness,
|
|
// so use a plain query to delete entries in the group_users table.
|
|
await manager.createQueryBuilder()
|
|
.delete()
|
|
.from('group_users')
|
|
.where('user_id = :userId', {userId: userIdToDelete})
|
|
.execute();
|
|
await manager.delete(User, userIdToDelete);
|
|
});
|
|
return {
|
|
status: 200
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns a QueryResult for the given organization. The orgKey
|
|
* can be a string (the domain from url) or the id of an org. If it is
|
|
* null, the user's personal organization is returned.
|
|
*/
|
|
public async getOrg(scope: Scope, orgKey: string|number|null,
|
|
transaction?: EntityManager): Promise<QueryResult<Organization>> {
|
|
const {userId} = scope;
|
|
// Anonymous access to the merged org is a special case. We return an
|
|
// empty organization, not backed by the database, and which can contain
|
|
// nothing but the example documents always added to the merged org.
|
|
if (this.isMergedOrg(orgKey) && userId === this.getAnonymousUserId()) {
|
|
const anonOrg: OrgInfo = {
|
|
id: 0,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
domain: this.mergedOrgDomain(),
|
|
name: 'Anonymous',
|
|
owner: this.makeFullUser(this.getAnonymousUser()),
|
|
access: 'viewers',
|
|
billingAccount: {
|
|
id: 0,
|
|
individual: true,
|
|
product: {
|
|
name: 'anonymous',
|
|
features: personalFreeFeatures,
|
|
},
|
|
isManager: false,
|
|
inGoodStanding: true,
|
|
},
|
|
host: null
|
|
};
|
|
return { status: 200, data: anonOrg as any };
|
|
}
|
|
let qb = this.org(scope, orgKey, {
|
|
manager: transaction,
|
|
needRealOrg: true
|
|
});
|
|
qb = this._addBillingAccount(qb, scope.userId);
|
|
let effectiveUserId = scope.userId;
|
|
if (scope.specialPermit && scope.specialPermit.org === orgKey) {
|
|
effectiveUserId = this.getPreviewerUserId();
|
|
}
|
|
qb = this._withAccess(qb, effectiveUserId, 'orgs');
|
|
qb = qb.leftJoinAndSelect('orgs.owner', 'owner');
|
|
// Add preference information that will be relevant for presentation of the org.
|
|
// That includes preference information specific to the site and the user,
|
|
// or specific just to the site, or specific just to the user.
|
|
qb = qb.leftJoinAndMapMany('orgs.prefs', Pref, 'prefs',
|
|
'(prefs.org_id = orgs.id or prefs.org_id IS NULL) AND ' +
|
|
'(prefs.user_id = :userId or prefs.user_id IS NULL)',
|
|
{userId});
|
|
// Apply a particular order (user+org first if present, then org, then user).
|
|
// Slightly round-about syntax because Sqlite and Postgres disagree about NULL
|
|
// ordering (Sqlite does support NULL LAST syntax now, but not on our fork yet).
|
|
qb = qb.addOrderBy('coalesce(prefs.org_id, 0)', 'DESC');
|
|
qb = qb.addOrderBy('coalesce(prefs.user_id, 0)', 'DESC');
|
|
const result = await this._verifyAclPermissions(qb);
|
|
if (result.status === 200) {
|
|
// Return the only org.
|
|
result.data = result.data[0];
|
|
if (this.isMergedOrg(orgKey)) {
|
|
// The merged psuedo-organization is almost, but not quite, the user's personal
|
|
// org. We give it a distinct domain and id.
|
|
result.data.id = 0;
|
|
result.data.domain = this.mergedOrgDomain();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Gets the billing account for the specified org. Will throw errors if the org
|
|
* is not found, or if the user does not have access to its billing account.
|
|
*
|
|
* The special previewer user is given access to billing account information.
|
|
*
|
|
* The billing account includes fields such as stripeCustomerId.
|
|
* To include `managers` and `orgs` fields listing all billing account managers
|
|
* and organizations linked to the account, set `includeOrgsAndManagers`.
|
|
*/
|
|
public async getBillingAccount(scope: Scope, orgKey: string|number,
|
|
includeOrgsAndManagers: boolean,
|
|
transaction?: EntityManager): Promise<BillingAccount> {
|
|
const org = this.unwrapQueryResult(await this.getOrg(scope, orgKey, transaction));
|
|
if (!org.billingAccount.isManager && scope.userId !== this.getPreviewerUserId() &&
|
|
// The special permit (used for the support user) allows access to the billing account.
|
|
scope.specialPermit?.org !== orgKey) {
|
|
throw new ApiError('User does not have access to billing account', 401);
|
|
}
|
|
if (!includeOrgsAndManagers) { return org.billingAccount; }
|
|
|
|
// For full billing account information including all managers
|
|
// (for team accounts) and orgs (for individual accounts), we need
|
|
// to make a different query since what we've got so far is
|
|
// filtered by org and by user for authorization purposes.
|
|
// Also, filling out user information linked to orgs and managers
|
|
// requires a few extra joins.
|
|
return this.getFullBillingAccount(org.billingAccount.id, transaction);
|
|
}
|
|
|
|
/**
|
|
* Gets all information about a billing account, without permission check.
|
|
*/
|
|
public getFullBillingAccount(billingAccountId: number, transaction?: EntityManager): Promise<BillingAccount> {
|
|
return this._runInTransaction(transaction, async tr => {
|
|
let qb = tr.createQueryBuilder()
|
|
.select('billing_accounts')
|
|
.from(BillingAccount, 'billing_accounts')
|
|
.leftJoinAndSelect('billing_accounts.product', 'products')
|
|
.leftJoinAndSelect('billing_accounts.managers', 'managers')
|
|
.leftJoinAndSelect('managers.user', 'manager_users')
|
|
.leftJoinAndSelect('manager_users.logins', 'manager_logins')
|
|
.leftJoinAndSelect('billing_accounts.orgs', 'orgs')
|
|
.leftJoinAndSelect('orgs.owner', 'org_users')
|
|
.leftJoinAndSelect('org_users.logins', 'org_logins')
|
|
.where('billing_accounts.id = :billingAccountId', {billingAccountId});
|
|
qb = this._addBillingAccountCalculatedFields(qb);
|
|
// TODO: should reconcile with isManager field that stripped down results have.
|
|
const results = await qb.getRawAndEntities();
|
|
const resources = this._normalizeQueryResults(results.entities);
|
|
if (!resources[0]) {
|
|
throw new ApiError('Cannot find billing account', 500);
|
|
}
|
|
return resources[0];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Look up an org by an external id. External IDs are used in integrations, and
|
|
* simply offer an alternate way to identify an org.
|
|
*/
|
|
public async getOrgByExternalId(externalId: string): Promise<Organization|undefined> {
|
|
const query = this._orgs()
|
|
.leftJoinAndSelect('orgs.billingAccount', 'billing_accounts')
|
|
.leftJoinAndSelect('billing_accounts.product', 'products')
|
|
.where('external_id = :externalId', {externalId});
|
|
return await query.getOne() || undefined;
|
|
}
|
|
|
|
/**
|
|
* Returns a QueryResult for an organization with nested workspaces.
|
|
*/
|
|
public async getOrgWorkspaces(scope: Scope, orgKey: string|number,
|
|
options: QueryOptions = {}): Promise<QueryResult<Workspace[]>> {
|
|
const query = this._orgWorkspaces(scope, orgKey, options);
|
|
// Allow an empty result for the merged org for the anonymous user. The anonymous user
|
|
// has no home org or workspace. For all other sitations, expect at least one workspace.
|
|
const emptyAllowed = this.isMergedOrg(orgKey) && scope.userId === this.getAnonymousUserId();
|
|
const result = await this._verifyAclPermissions(query, { scope, emptyAllowed });
|
|
// Return the workspaces, not the org(s).
|
|
if (result.status === 200) {
|
|
// Place ownership information in workspaces, available for the merged org.
|
|
for (const o of result.data) {
|
|
for (const ws of o.workspaces) {
|
|
ws.owner = o.owner;
|
|
// Include the org's domain so that the UI can build doc URLs that include the org.
|
|
ws.orgDomain = o.domain;
|
|
}
|
|
}
|
|
// For org-specific requests, we still have the org's workspaces, plus the Samples workspace
|
|
// from the support org.
|
|
result.data = [].concat(...result.data.map((o: Organization) => o.workspaces));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns a QueryResult for the workspace with the given workspace id. The workspace
|
|
* includes nested Docs.
|
|
*/
|
|
public async getWorkspace(scope: Scope, wsId: number): Promise<QueryResult<Workspace>> {
|
|
const {userId} = scope;
|
|
let queryBuilder = this._workspaces()
|
|
.where('workspaces.id = :wsId', {wsId})
|
|
// Nest the docs within the workspace object
|
|
.leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope))
|
|
.leftJoinAndSelect('workspaces.org', 'orgs')
|
|
.leftJoinAndSelect('orgs.owner', 'owner')
|
|
// Define some order (spec doesn't promise anything though)
|
|
.orderBy('workspaces.created_at')
|
|
.addOrderBy('docs.created_at');
|
|
queryBuilder = this._addIsSupportWorkspace(userId, queryBuilder, 'orgs', 'workspaces');
|
|
// Add access information and query limits
|
|
// TODO: allow generic org limit once sample/support workspace is done differently
|
|
queryBuilder = this._applyLimit(queryBuilder, {...scope, org: undefined}, ['workspaces', 'docs'], 'list');
|
|
const result = await this._verifyAclPermissions(queryBuilder, { scope });
|
|
// Return a single workspace.
|
|
if (result.status === 200) {
|
|
result.data = result.data[0];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns an organization's usage summary (e.g. count of documents that are approaching or exceeding
|
|
* limits).
|
|
*/
|
|
public async getOrgUsageSummary(scope: Scope, orgKey: string|number): Promise<OrgUsageSummary> {
|
|
// Check that an owner of the org is making the request.
|
|
const markPermissions = Permissions.OWNER;
|
|
let orgQuery = this.org(scope, orgKey, {
|
|
markPermissions,
|
|
needRealOrg: true
|
|
});
|
|
orgQuery = this._addFeatures(orgQuery);
|
|
const orgQueryResult = await verifyIsPermitted(orgQuery);
|
|
const org: Organization = this.unwrapQueryResult(orgQueryResult);
|
|
const productFeatures = org.billingAccount.product.features;
|
|
|
|
// Grab all the non-removed documents in the org.
|
|
let docsQuery = this._docs()
|
|
.innerJoin('docs.workspace', 'workspaces')
|
|
.innerJoin('workspaces.org', 'orgs')
|
|
.where('docs.workspace_id = workspaces.id')
|
|
.andWhere('workspaces.removed_at IS NULL AND docs.removed_at IS NULL');
|
|
docsQuery = this._whereOrg(docsQuery, orgKey);
|
|
if (this.isMergedOrg(orgKey)) {
|
|
docsQuery = docsQuery.andWhere('orgs.owner_id = :userId', {userId: scope.userId});
|
|
}
|
|
const docsQueryResult = await this._verifyAclPermissions(docsQuery, { scope, emptyAllowed: true });
|
|
const docs: Document[] = this.unwrapQueryResult(docsQueryResult);
|
|
|
|
// Return an aggregate count of documents, grouped by data limit status.
|
|
const summary = createEmptyOrgUsageSummary();
|
|
for (const {usage: docUsage, gracePeriodStart} of docs) {
|
|
const dataLimitStatus = getDataLimitStatus({docUsage, gracePeriodStart, productFeatures});
|
|
if (dataLimitStatus) { summary[dataLimitStatus] += 1; }
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Compute the best access option for an organization, from the
|
|
* users available to the client. If none of the options can access
|
|
* the organization, returns null. If there are equally good
|
|
* options, an arbitrary one is returned.
|
|
*
|
|
* Comparison is made between roles rather than fine-grained
|
|
* permissions, since otherwise the result would not be well defined
|
|
* (permissions could in general overlap without one being a
|
|
* superset of the other). For the acl rules we've used so far,
|
|
* this problem does not arise and reasoning at the level of a
|
|
* hierarchy of roles is adequate.
|
|
*/
|
|
public async getBestUserForOrg(users: AvailableUsers, org: number|string): Promise<AccessOptionWithRole|null> {
|
|
if (this.isMergedOrg(org)) {
|
|
// Don't try to pick a best user for the merged personal org.
|
|
// If this changes in future, be sure to call this._filterByOrgGroups on the query
|
|
// below, otherwise it will include every users' personal org which is wasteful
|
|
// and parsing/mapping the results in TypeORM is slow.
|
|
return null;
|
|
}
|
|
let qb = this._orgs();
|
|
qb = this._whereOrg(qb, org);
|
|
qb = this._withAccess(qb, users, 'orgs');
|
|
const result = await this._verifyAclPermissions(qb, {emptyAllowed: true});
|
|
if (!result.data) {
|
|
throw new ApiError(result.errMessage || 'failed to select user', result.status);
|
|
}
|
|
if (!result.data.length) { return null; }
|
|
const options: AccessOptionWithRole[] = result.data[0].accessOptions;
|
|
if (!options.length) { return null; }
|
|
const role = roles.getStrongestRole(...options.map(option => option.access));
|
|
return options.find(option => option.access === role) || null;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns a SelectQueryBuilder which gives an array of orgs already filtered by
|
|
* the given user' (or users') access.
|
|
* If a domain is specified, only an org matching that domain and accessible by
|
|
* the user or users is returned.
|
|
* The anonymous user is treated specially, to avoid advertising organizations
|
|
* with anonymous access.
|
|
*/
|
|
public async getOrgs(users: AvailableUsers, domain: string|null,
|
|
options?: {ignoreEveryoneShares?: boolean}): Promise<QueryResult<Organization[]>> {
|
|
let queryBuilder = this._orgs()
|
|
.leftJoinAndSelect('orgs.owner', 'users', 'orgs.owner_id = users.id');
|
|
if (isSingleUser(users)) {
|
|
// When querying with a single user in mind, we keep our api promise
|
|
// of returning their personal org first in the list.
|
|
queryBuilder = queryBuilder
|
|
.orderBy('(coalesce(users.id,0) = :userId)', 'DESC')
|
|
.setParameter('userId', users);
|
|
}
|
|
queryBuilder = queryBuilder
|
|
.addOrderBy('users.name')
|
|
.addOrderBy('orgs.name');
|
|
queryBuilder = this._withAccess(queryBuilder, users, 'orgs');
|
|
// Add a direct, efficient filter to remove irrelevant personal orgs from consideration.
|
|
queryBuilder = this._filterByOrgGroups(queryBuilder, users, domain, options);
|
|
if (this._isAnonymousUser(users) && !listPublicSites) {
|
|
// The anonymous user is a special case. It may have access to potentially
|
|
// many orgs, but listing them all would be kind of a misfeature. but reporting
|
|
// nothing would complicate the client. We compromise, and report at most
|
|
// the org of the site the user is on (or nothing when the api is accessed
|
|
// via a url that is unrelated to any particular org).
|
|
// This special processing is only needed for the isSingleUser case. Multiple
|
|
// users can only be presented when the user has proven login access to each.
|
|
if (domain && !this.isMergedOrg(domain)) {
|
|
queryBuilder = this._whereOrg(queryBuilder, domain);
|
|
} else {
|
|
return {status: 200, data: []};
|
|
}
|
|
}
|
|
return this._verifyAclPermissions(queryBuilder, {emptyAllowed: true});
|
|
}
|
|
|
|
// As for getOrgs, but all personal orgs are merged into a single entry.
|
|
public async getMergedOrgs(userId: number, users: AvailableUsers,
|
|
domain: string|null): Promise<QueryResult<Organization[]>> {
|
|
const result = await this.getOrgs(users, domain);
|
|
if (result.status === 200) {
|
|
return {status: 200, data: this._mergePersonalOrgs(userId, result.data!)};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Returns the doc with access information for the calling user only.
|
|
// TODO: The return type of this function includes the workspace and org with the owner
|
|
// properties set, as documented in app/common/UserAPI. The return type of this function
|
|
// should reflect that.
|
|
public async getDocImpl(key: DocAuthKey): Promise<Document> {
|
|
const {userId} = key;
|
|
// Doc permissions of forks are based on the "trunk" document, so make sure
|
|
// we look up permissions of trunk if we are on a fork (we'll fix the permissions
|
|
// up for the fork immediately afterwards).
|
|
const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(key.urlId);
|
|
const urlId = trunkId;
|
|
if (forkId || snapshotId) { key = {...key, urlId}; }
|
|
let doc: Document;
|
|
if (urlId === NEW_DOCUMENT_CODE) {
|
|
if (!forkId) { throw new ApiError('invalid document identifier', 400); }
|
|
// We imagine current user owning trunk if there is no embedded userId, or
|
|
// the embedded userId matches the current user.
|
|
const access = (forkUserId === undefined || forkUserId === userId) ? 'owners' :
|
|
(userId === this.getPreviewerUserId() ? 'viewers' : null);
|
|
if (!access) { throw new ApiError("access denied", 403); }
|
|
doc = {
|
|
name: 'Untitled',
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
id: 'new',
|
|
isPinned: false,
|
|
urlId: null,
|
|
workspace: this.unwrapQueryResult<Workspace>(
|
|
await this.getWorkspace({userId: this.getSupportUserId()},
|
|
this._exampleWorkspaceId)),
|
|
aliases: [],
|
|
access
|
|
} as any;
|
|
} else {
|
|
// We can't delegate filtering of removed documents to the db, since we'll be
|
|
// caching authentication. But we also don't need to delegate filtering, since
|
|
// it is very simple at the single-document level. So we direct the db to include
|
|
// everything with showAll flag, and let the getDoc() wrapper deal with the remaining
|
|
// work.
|
|
let qb = this._doc({...key, showAll: true})
|
|
.leftJoinAndSelect('orgs.owner', 'org_users');
|
|
qb = this._addIsSupportWorkspace(userId, qb, 'orgs', 'workspaces');
|
|
qb = this._addFeatures(qb); // add features to determine whether we've gone readonly
|
|
const docs = this.unwrapQueryResult<Document[]>(await this._verifyAclPermissions(qb));
|
|
if (docs.length === 0) { throw new ApiError('document not found', 404); }
|
|
if (docs.length > 1) { throw new ApiError('ambiguous document request', 400); }
|
|
doc = docs[0];
|
|
const features = doc.workspace.org.billingAccount.product.features;
|
|
if (features.readOnlyDocs || this._restrictedMode) {
|
|
// Don't allow any access to docs that is stronger than "viewers".
|
|
doc.access = roles.getWeakestRole('viewers', doc.access);
|
|
}
|
|
// Place ownership information in the doc's workspace.
|
|
(doc.workspace as any).owner = doc.workspace.org.owner;
|
|
}
|
|
if (forkId || snapshotId) {
|
|
// Fix up our reply to be correct for the fork, rather than the trunk.
|
|
// The "id" and "urlId" fields need updating.
|
|
doc.id = buildUrlId({trunkId: doc.id, forkId, forkUserId, snapshotId});
|
|
if (doc.urlId) {
|
|
doc.urlId = buildUrlId({trunkId: doc.urlId, forkId, forkUserId, snapshotId});
|
|
}
|
|
|
|
// Set trunkAccess field.
|
|
doc.trunkAccess = doc.access;
|
|
|
|
// Update access for fork.
|
|
this._setForkAccess({userId, forkUserId, snapshotId}, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
// Calls getDocImpl() and returns the Document from that, caching a fresh DocAuthResult along
|
|
// the way. Note that we only cache the access level, not Document itself.
|
|
public async getDoc(reqOrScope: Request | Scope): Promise<Document> {
|
|
const scope = "params" in reqOrScope ? getScope(reqOrScope) : reqOrScope;
|
|
const key = getDocAuthKeyFromScope(scope);
|
|
const promise = this.getDocImpl(key);
|
|
await mapSetOrClear(this._docAuthCache, stringifyDocAuthKey(key), makeDocAuthResult(promise));
|
|
const doc = await promise;
|
|
// Filter the result for removed / non-removed documents.
|
|
if (!scope.showAll && scope.showRemoved ?
|
|
(doc.removedAt === null && doc.workspace.removedAt === null) :
|
|
(doc.removedAt || doc.workspace.removedAt)) {
|
|
throw new ApiError('document not found', 404);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
public async getRawDocById(docId: string) {
|
|
return await this.getDoc({urlId: docId, userId: this.getPreviewerUserId(), showAll: true});
|
|
}
|
|
|
|
// Returns access info for the given doc and user, caching the results for DOC_AUTH_CACHE_TTL
|
|
// ms. This helps reduce database load created by liberal authorization requests.
|
|
public async getDocAuthCached(key: DocAuthKey): Promise<DocAuthResult> {
|
|
return mapGetOrSet(this._docAuthCache, stringifyDocAuthKey(key),
|
|
() => makeDocAuthResult(this.getDocImpl(key)));
|
|
}
|
|
|
|
// Used in tests, and to clear all timeouts when exiting.
|
|
public flushDocAuthCache() {
|
|
this._docAuthCache.clear();
|
|
}
|
|
|
|
// Flush cached access information about a specific document
|
|
// (identified specifically by a docId, not a urlId). Any cached
|
|
// information under an alias will also be flushed.
|
|
// TODO: make a more efficient implementation if needed.
|
|
public async flushSingleDocAuthCache(scope: DocScope, docId: string) {
|
|
// Get all aliases of this document.
|
|
const aliases = await this._connection.manager.find(Alias, {where: {docId}});
|
|
// Construct a set of possible prefixes for cache keys.
|
|
const names = new Set(aliases.map(a => stringifyUrlIdOrg(a.urlId, scope.org)));
|
|
names.add(stringifyUrlIdOrg(docId, scope.org));
|
|
// Remove any cache keys that start with any of the prefixes.
|
|
for (const key of this._docAuthCache.keys()) {
|
|
const name = key.split(' ', 1)[0];
|
|
if (names.has(name)) { this._docAuthCache.delete(key); }
|
|
}
|
|
}
|
|
|
|
// Find a document by name. Limit name search to a specific organization.
|
|
// It is possible to hit ambiguities, e.g. with the same name of a doc
|
|
// in multiple workspaces, so this is not a general-purpose method. It
|
|
// is here to facilitate V0 -> V1 migration, so existing links to docs continue
|
|
// to work.
|
|
public async getDocByName(userId: number, orgId: number, docName: string): Promise<QueryResult<Document>> {
|
|
let qb = this._docs()
|
|
.innerJoin('docs.workspace', 'workspace')
|
|
.innerJoin('workspace.org', 'org')
|
|
.where('docs.name = :docName', {docName})
|
|
.andWhere('org.id = :orgId', {orgId});
|
|
qb = this._withAccess(qb, userId, 'docs');
|
|
return this._single(await this._verifyAclPermissions(qb));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Adds an org with the given name. Returns a query result with the id of the added org.
|
|
*
|
|
* @param user: user doing the adding
|
|
* @param name: desired org name
|
|
* @param domain: desired org domain, or null not to set a domain
|
|
* @param setUserAsOwner: if this is the user's personal org (they will be made an
|
|
* owner in the ACL sense in any case)
|
|
* @param useNewPlan: by default, the individual billing account associated with the
|
|
* user's personal org will be used for all other orgs they create. Set useNewPlan
|
|
* to force a distinct non-individual billing account to be used for this org.
|
|
* NOTE: Currently it is always a true - billing account is one to one with org.
|
|
* @param planType: if set, controls the type of plan used for the org. Only
|
|
* meaningful for team sites currently.
|
|
*
|
|
*/
|
|
public async addOrg(user: User, props: Partial<OrganizationProperties>,
|
|
options: { setUserAsOwner: boolean,
|
|
useNewPlan: boolean,
|
|
planType?: string,
|
|
externalId?: string,
|
|
externalOptions?: ExternalBillingOptions },
|
|
transaction?: EntityManager): Promise<QueryResult<number>> {
|
|
const notifications: Array<() => void> = [];
|
|
const name = props.name;
|
|
const domain = props.domain;
|
|
if (!name) {
|
|
return {
|
|
status: 400,
|
|
errMessage: 'Bad request: name required'
|
|
};
|
|
}
|
|
const orgResult = await this._runInTransaction(transaction, async manager => {
|
|
if (domain) {
|
|
try {
|
|
checkSubdomainValidity(domain);
|
|
} catch (e) {
|
|
return {
|
|
status: 400,
|
|
errMessage: `Domain is not permitted: ${e.message}`
|
|
};
|
|
}
|
|
}
|
|
// Create or find a billing account to associate with this org.
|
|
const billingAccountEntities = [];
|
|
let billingAccount;
|
|
if (options.useNewPlan) { // use separate billing account (currently yes)
|
|
const productNames = getDefaultProductNames();
|
|
let productName = options.setUserAsOwner ? productNames.personal :
|
|
options.planType === productNames.teamFree ? productNames.teamFree : productNames.teamInitial;
|
|
// A bit fragile: this is called during creation of support@ user, before
|
|
// getSupportUserId() is available, but with setUserAsOwner of true.
|
|
if (!options.setUserAsOwner
|
|
&& user.id === this.getSupportUserId()
|
|
&& options.planType !== productNames.teamFree) {
|
|
// For teams created by support@getgrist.com, set the product to something
|
|
// good so payment not needed. This is useful for testing.
|
|
productName = productNames.team;
|
|
}
|
|
billingAccount = new BillingAccount();
|
|
billingAccount.individual = options.setUserAsOwner;
|
|
const dbProduct = await manager.findOne(Product, {where: {name: productName}});
|
|
if (!dbProduct) {
|
|
throw new Error('Cannot find product for new organization');
|
|
}
|
|
billingAccount.product = dbProduct;
|
|
billingAccountEntities.push(billingAccount);
|
|
const billingAccountManager = new BillingAccountManager();
|
|
billingAccountManager.user = user;
|
|
billingAccountManager.billingAccount = billingAccount;
|
|
billingAccountEntities.push(billingAccountManager);
|
|
if (options.externalId) {
|
|
// save will fail if externalId is a duplicate.
|
|
billingAccount.externalId = options.externalId;
|
|
}
|
|
if (options.externalOptions) {
|
|
billingAccount.externalOptions = options.externalOptions;
|
|
}
|
|
} else {
|
|
log.warn("Creating org with shared billing account");
|
|
// Use the billing account from the user's personal org to start with.
|
|
billingAccount = await manager.createQueryBuilder()
|
|
.select('billing_accounts')
|
|
.from(BillingAccount, 'billing_accounts')
|
|
.leftJoinAndSelect('billing_accounts.orgs', 'orgs')
|
|
.where('orgs.owner_id = :userId', {userId: user.id})
|
|
.getOne();
|
|
if (options.externalId && billingAccount?.externalId !== options.externalId) {
|
|
throw new ApiError('Conflicting external identifier', 400);
|
|
}
|
|
if (!billingAccount) {
|
|
throw new ApiError('Cannot find an initial plan for organization', 500);
|
|
}
|
|
}
|
|
// Create a new org.
|
|
const org = new Organization();
|
|
org.checkProperties(props);
|
|
org.updateFromProperties(props);
|
|
org.billingAccount = billingAccount;
|
|
if (domain) {
|
|
org.domain = domain;
|
|
}
|
|
if (options.setUserAsOwner) {
|
|
org.owner = user;
|
|
}
|
|
// Create the special initial permission groups for the new org.
|
|
const groupMap = this._createGroups();
|
|
org.aclRules = this.defaultGroups.map(_grpDesc => {
|
|
// Get the special group with the name needed for this ACL Rule
|
|
const group = groupMap[_grpDesc.name];
|
|
// Note that the user is added to the owners group of an org when it is created.
|
|
if (_grpDesc.name === roles.OWNER) {
|
|
group.memberUsers = [user];
|
|
}
|
|
// Add each of the special groups to the new workspace.
|
|
const aclRuleOrg = new AclRuleOrg();
|
|
aclRuleOrg.permissions = _grpDesc.permissions;
|
|
aclRuleOrg.group = group;
|
|
aclRuleOrg.organization = org;
|
|
return aclRuleOrg;
|
|
});
|
|
// Saves the workspace as well as its new ACL Rules and Group.
|
|
const groups = org.aclRules.map(rule => rule.group);
|
|
let savedOrg: Organization;
|
|
try {
|
|
const result = await manager.save([org, ...org.aclRules, ...groups, ...billingAccountEntities]);
|
|
savedOrg = result[0] as Organization;
|
|
} catch (e) {
|
|
if (e.name === 'QueryFailedError' && e.message &&
|
|
e.message.match(/unique constraint/i)) {
|
|
throw new ApiError('Domain already in use', 400);
|
|
}
|
|
throw e;
|
|
}
|
|
// Add a starter workspace to the org. Any limits on org workspace
|
|
// count are not checked, this will succeed unconditionally.
|
|
await this._doAddWorkspace(savedOrg, {name: 'Home'}, manager);
|
|
|
|
if (!options.setUserAsOwner) {
|
|
// This user just made a team site (once this transaction is applied).
|
|
// Emit a notification.
|
|
notifications.push(this._teamCreatorNotification(user.id));
|
|
}
|
|
return {
|
|
status: 200,
|
|
data: savedOrg.id
|
|
};
|
|
});
|
|
for (const notification of notifications) { notification(); }
|
|
return orgResult;
|
|
}
|
|
|
|
// If setting anything more than prefs:
|
|
// Checks that the user has UPDATE permissions to the given org. If not, throws an
|
|
// error. Otherwise updates the given org with the given name. Returns an empty
|
|
// query result with status 200 on success.
|
|
// For setting userPrefs or userOrgPrefs:
|
|
// These are user-specific setting, so are allowed with VIEW access (that includes
|
|
// guests). Prefs are replaced in their entirety, not merged.
|
|
// For setting orgPrefs:
|
|
// These are not user-specific, so require UPDATE permissions.
|
|
public async updateOrg(
|
|
scope: Scope,
|
|
orgKey: string|number,
|
|
props: Partial<OrganizationProperties>,
|
|
transaction?: EntityManager,
|
|
): Promise<QueryResult<number>> {
|
|
|
|
// Check the scope of the modifications.
|
|
let markPermissions: number = Permissions.VIEW;
|
|
let modifyOrg: boolean = false;
|
|
let modifyPrefs: boolean = false;
|
|
for (const key of Object.keys(props)) {
|
|
if (key === 'orgPrefs') {
|
|
// If setting orgPrefs, make sure we have UPDATE rights since this
|
|
// will affect other users.
|
|
markPermissions = Permissions.UPDATE;
|
|
modifyPrefs = true;
|
|
} else if (key === 'userPrefs' || key === 'userOrgPrefs') {
|
|
// These keys only affect the current user.
|
|
modifyPrefs = true;
|
|
} else {
|
|
markPermissions = Permissions.UPDATE;
|
|
modifyOrg = true;
|
|
}
|
|
}
|
|
|
|
// TODO: Unsetting a domain will likely have to be supported; also possibly prefs.
|
|
return await this._runInTransaction(transaction, async manager => {
|
|
const orgQuery = this.org(scope, orgKey, {
|
|
manager,
|
|
markPermissions,
|
|
needRealOrg: true
|
|
});
|
|
const queryResult = await verifyIsPermitted(orgQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the workspace failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
// Update the fields and save.
|
|
const org: Organization = queryResult.data;
|
|
org.checkProperties(props);
|
|
if (modifyOrg) {
|
|
if (props.domain) {
|
|
if (org.owner) {
|
|
throw new ApiError('Cannot set a domain for a personal organization', 400);
|
|
}
|
|
try {
|
|
checkSubdomainValidity(props.domain);
|
|
} catch (e) {
|
|
return {
|
|
status: 400,
|
|
errMessage: `Domain is not permitted: ${e.message}`
|
|
};
|
|
}
|
|
}
|
|
org.updateFromProperties(props);
|
|
await manager.save(org);
|
|
}
|
|
if (modifyPrefs) {
|
|
for (const flavor of ['orgPrefs', 'userOrgPrefs', 'userPrefs'] as const) {
|
|
const prefs = props[flavor];
|
|
if (prefs === undefined) { continue; }
|
|
const orgId = ['orgPrefs', 'userOrgPrefs'].includes(flavor) ? org.id : null;
|
|
const userId = ['userOrgPrefs', 'userPrefs'].includes(flavor) ? scope.userId : null;
|
|
await manager.createQueryBuilder()
|
|
.insert()
|
|
// if pref flavor has been set before, update it
|
|
.onConflict('(COALESCE(org_id,0), COALESCE(user_id,0)) DO UPDATE SET prefs = :prefs')
|
|
// TypeORM muddles JSON handling a bit here
|
|
.setParameters({prefs: JSON.stringify(prefs)})
|
|
.into(Pref)
|
|
.values({orgId, userId, prefs})
|
|
.execute();
|
|
}
|
|
}
|
|
return {status: 200};
|
|
});
|
|
}
|
|
|
|
// Checks that the user has REMOVE permissions to the given org. If not, throws an
|
|
// error. Otherwise deletes the given org. Returns an empty query result with
|
|
// status 200 on success.
|
|
public async deleteOrg(scope: Scope, orgKey: string|number,
|
|
transaction?: EntityManager): Promise<QueryResult<number>> {
|
|
return await this._runInTransaction(transaction, async manager => {
|
|
const orgQuery = this.org(scope, orgKey, {
|
|
manager,
|
|
markPermissions: Permissions.REMOVE,
|
|
allowSpecialPermit: true
|
|
})
|
|
// Join the org's workspaces (with ACLs and groups), docs (with ACLs and groups)
|
|
// and ACLs and groups so we can remove them.
|
|
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'groups')
|
|
.leftJoinAndSelect('orgs.workspaces', 'workspaces')
|
|
.leftJoinAndSelect('workspaces.aclRules', 'workspace_acl_rules')
|
|
.leftJoinAndSelect('workspace_acl_rules.group', 'workspace_group')
|
|
.leftJoinAndSelect('workspaces.docs', 'docs')
|
|
.leftJoinAndSelect('docs.aclRules', 'doc_acl_rules')
|
|
.leftJoinAndSelect('doc_acl_rules.group', 'doc_group')
|
|
.leftJoinAndSelect('orgs.billingAccount', 'billing_accounts');
|
|
const queryResult = await verifyIsPermitted(orgQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the org failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const org: Organization = queryResult.data;
|
|
// Delete the org, org ACLs/groups, workspaces, workspace ACLs/groups, workspace docs
|
|
// and doc ACLs/groups.
|
|
const orgGroups = org.aclRules.map(orgAcl => orgAcl.group);
|
|
const wsAcls = ([] as AclRule[]).concat(...org.workspaces.map(ws => ws.aclRules));
|
|
const wsGroups = wsAcls.map(wsAcl => wsAcl.group);
|
|
const docs = ([] as Document[]).concat(...org.workspaces.map(ws => ws.docs));
|
|
const docAcls = ([] as AclRule[]).concat(...docs.map(doc => doc.aclRules));
|
|
const docGroups = docAcls.map(docAcl => docAcl.group);
|
|
await manager.remove([org, ...org.aclRules, ...orgGroups, ...org.workspaces,
|
|
...wsAcls, ...wsGroups, ...docs, ...docAcls, ...docGroups]);
|
|
|
|
// Delete billing account if this was the last org using it.
|
|
const billingAccount = await manager.findOne(BillingAccount, {
|
|
where: {id: org.billingAccountId},
|
|
relations: ['orgs'],
|
|
});
|
|
if (billingAccount && billingAccount.orgs.length === 0) {
|
|
await manager.remove([billingAccount]);
|
|
}
|
|
return {status: 200};
|
|
});
|
|
}
|
|
|
|
// Checks that the user has ADD permissions to the given org. If not, throws an error.
|
|
// Otherwise adds a workspace with the given name. Returns a query result with the id
|
|
// of the added workspace.
|
|
public async addWorkspace(scope: Scope, orgKey: string|number,
|
|
props: Partial<WorkspaceProperties>): Promise<QueryResult<number>> {
|
|
const name = props.name;
|
|
if (!name) {
|
|
return {
|
|
status: 400,
|
|
errMessage: 'Bad request: name required'
|
|
};
|
|
}
|
|
return await this._connection.transaction(async manager => {
|
|
let orgQuery = this.org(scope, orgKey, {
|
|
manager,
|
|
markPermissions: Permissions.ADD,
|
|
needRealOrg: true
|
|
})
|
|
// Join the org's ACL rules (with 1st level groups listed) so we can include them in the
|
|
// workspace.
|
|
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'org_group')
|
|
.leftJoinAndSelect('orgs.workspaces', 'workspaces'); // we may want to count workspaces.
|
|
orgQuery = this._addFeatures(orgQuery); // add features to access optional workspace limit.
|
|
const queryResult = await verifyIsPermitted(orgQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the organization failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const org: Organization = queryResult.data;
|
|
const features = org.billingAccount.product.features;
|
|
if (features.maxWorkspacesPerOrg !== undefined) {
|
|
// we need to count how many workspaces are in the current org, and if we
|
|
// are already at or above the limit, then fail.
|
|
const count = org.workspaces.length;
|
|
if (count >= features.maxWorkspacesPerOrg) {
|
|
throw new ApiError('No more workspaces permitted', 403, {
|
|
limit: {
|
|
quantity: 'workspaces',
|
|
maximum: features.maxWorkspacesPerOrg,
|
|
value: count,
|
|
projectedValue: count + 1
|
|
}
|
|
});
|
|
}
|
|
}
|
|
const workspace = await this._doAddWorkspace(org, props, manager);
|
|
return {
|
|
status: 200,
|
|
data: workspace.id
|
|
};
|
|
});
|
|
}
|
|
|
|
// Checks that the user has UPDATE permissions to the given workspace. If not, throws an
|
|
// error. Otherwise updates the given workspace with the given name. Returns an empty
|
|
// query result with status 200 on success.
|
|
public async updateWorkspace(scope: Scope, wsId: number,
|
|
props: Partial<WorkspaceProperties>): Promise<QueryResult<number>> {
|
|
return await this._connection.transaction(async manager => {
|
|
const wsQuery = this._workspace(scope, wsId, {
|
|
manager,
|
|
markPermissions: Permissions.UPDATE
|
|
});
|
|
const queryResult = await verifyIsPermitted(wsQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the workspace failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
// Update the name and save.
|
|
const workspace: Workspace = queryResult.data;
|
|
workspace.checkProperties(props);
|
|
workspace.updateFromProperties(props);
|
|
await manager.save(workspace);
|
|
return {status: 200};
|
|
});
|
|
}
|
|
|
|
// Checks that the user has REMOVE permissions to the given workspace. If not, throws an
|
|
// error. Otherwise deletes the given workspace. Returns an empty query result with
|
|
// status 200 on success.
|
|
public async deleteWorkspace(scope: Scope, wsId: number): Promise<QueryResult<number>> {
|
|
return await this._connection.transaction(async manager => {
|
|
const wsQuery = this._workspace(scope, wsId, {
|
|
manager,
|
|
markPermissions: Permissions.REMOVE,
|
|
allowSpecialPermit: true
|
|
})
|
|
// Join the workspace's docs (with ACLs and groups) and ACLs and groups so we can
|
|
// remove them. Also join the org to get the orgId.
|
|
.leftJoinAndSelect('workspaces.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'groups')
|
|
.leftJoinAndSelect('workspaces.docs', 'docs')
|
|
.leftJoinAndSelect('docs.aclRules', 'doc_acl_rules')
|
|
.leftJoinAndSelect('doc_acl_rules.group', 'doc_groups')
|
|
.leftJoinAndSelect('workspaces.org', 'orgs');
|
|
const queryResult = await verifyIsPermitted(wsQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the workspace failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const workspace: Workspace = queryResult.data;
|
|
// Delete the workspace, workspace docs, doc ACLs/groups and workspace ACLs/groups.
|
|
const wsGroups = workspace.aclRules.map(wsAcl => wsAcl.group);
|
|
const docAcls = ([] as AclRule[]).concat(...workspace.docs.map(doc => doc.aclRules));
|
|
const docGroups = docAcls.map(docAcl => docAcl.group);
|
|
await manager.remove([workspace, ...wsGroups, ...docAcls, ...workspace.docs,
|
|
...workspace.aclRules, ...docGroups]);
|
|
// Update the guests in the org after removing this workspace.
|
|
await this._repairOrgGuests(scope, workspace.org.id, manager);
|
|
return {status: 200};
|
|
});
|
|
}
|
|
|
|
public softDeleteWorkspace(scope: Scope, wsId: number): Promise<void> {
|
|
return this._setWorkspaceRemovedAt(scope, wsId, new Date());
|
|
}
|
|
|
|
public async undeleteWorkspace(scope: Scope, wsId: number): Promise<void> {
|
|
return this._setWorkspaceRemovedAt(scope, wsId, null);
|
|
}
|
|
|
|
// Checks that the user has ADD permissions to the given workspace. If not, throws an
|
|
// error. Otherwise adds a doc with the given name. Returns a query result with the id
|
|
// of the added doc.
|
|
// The desired docId may be passed in. If passed in, it should have been generated
|
|
// by makeId(). The client should not be given control of the choice of docId.
|
|
// This option is used during imports, where it is convenient not to add a row to the
|
|
// document database until the document has actually been imported.
|
|
public async addDocument(scope: Scope, wsId: number, props: Partial<DocumentProperties>,
|
|
docId?: string): Promise<QueryResult<string>> {
|
|
const name = props.name;
|
|
if (!name) {
|
|
return {
|
|
status: 400,
|
|
errMessage: 'Bad request: name required'
|
|
};
|
|
}
|
|
return await this._connection.transaction(async manager => {
|
|
let wsQuery = this._workspace(scope, wsId, {
|
|
manager,
|
|
markPermissions: Permissions.ADD
|
|
})
|
|
.leftJoinAndSelect('workspaces.org', 'orgs')
|
|
// Join the workspaces's ACL rules (with 1st level groups listed) so we can include
|
|
// them in the doc.
|
|
.leftJoinAndSelect('workspaces.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'workspace_group');
|
|
wsQuery = this._addFeatures(wsQuery);
|
|
const queryResult = await verifyIsPermitted(wsQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the organization failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const workspace: Workspace = queryResult.data;
|
|
await this._checkRoomForAnotherDoc(workspace, manager);
|
|
// Create a new document.
|
|
const doc = new Document();
|
|
doc.id = docId || makeId();
|
|
doc.checkProperties(props);
|
|
doc.updateFromProperties(props);
|
|
// For some reason, isPinned defaulting to null, not false,
|
|
// for some typeorm/postgres combination? That causes a
|
|
// constraint violation.
|
|
if (!doc.isPinned) {
|
|
doc.isPinned = false;
|
|
}
|
|
// By default, assign a urlId that is a prefix of the docId.
|
|
// The urlId should be unique across all existing documents.
|
|
if (!doc.urlId) {
|
|
for (let i = MIN_URLID_PREFIX_LENGTH; i <= doc.id.length; i++) {
|
|
const candidate = doc.id.substr(0, i);
|
|
if (!await manager.findOne(Alias, {where: {urlId: candidate}})) {
|
|
doc.urlId = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (!doc.urlId) {
|
|
// This should happen only if UUIDs collide.
|
|
throw new Error('Could not find a free identifier for document');
|
|
}
|
|
}
|
|
if (doc.urlId) {
|
|
await this._checkForUrlIdConflict(manager, workspace.org, doc.urlId);
|
|
const alias = new Alias();
|
|
doc.aliases = [alias];
|
|
alias.urlId = doc.urlId;
|
|
alias.orgId = workspace.org.id;
|
|
} else {
|
|
doc.aliases = [];
|
|
}
|
|
doc.workspace = workspace;
|
|
// Create the special initial permission groups for the new workspace.
|
|
const groupMap = this._createGroups(workspace);
|
|
doc.aclRules = this.defaultCommonGroups.map(_grpDesc => {
|
|
// Get the special group with the name needed for this ACL Rule
|
|
const group = groupMap[_grpDesc.name];
|
|
// Add each of the special groups to the new doc.
|
|
const aclRuleDoc = new AclRuleDoc();
|
|
aclRuleDoc.permissions = _grpDesc.permissions;
|
|
aclRuleDoc.group = group;
|
|
aclRuleDoc.document = doc;
|
|
return aclRuleDoc;
|
|
});
|
|
// Saves the document as well as its new ACL Rules and Group.
|
|
const groups = doc.aclRules.map(rule => rule.group);
|
|
const result = await manager.save([doc, ...doc.aclRules, ...doc.aliases, ...groups]);
|
|
return {
|
|
status: 200,
|
|
data: (result[0] as Document).id
|
|
};
|
|
});
|
|
}
|
|
|
|
public addSecret(value: string, docId: string): Promise<Secret> {
|
|
return this._connection.transaction(async manager => {
|
|
const secret = new Secret();
|
|
secret.id = uuidv4();
|
|
secret.value = value;
|
|
secret.doc = {id: docId} as any;
|
|
await manager.save([secret]);
|
|
return secret;
|
|
});
|
|
}
|
|
|
|
public async getSecret(id: string, docId: string, manager?: EntityManager): Promise<string | undefined> {
|
|
const secret = await (manager || this._connection).createQueryBuilder()
|
|
.select('secrets')
|
|
.from(Secret, 'secrets')
|
|
.where('id = :id AND doc_id = :docId', {id, docId})
|
|
.getOne();
|
|
return secret?.value;
|
|
}
|
|
|
|
public async removeWebhook(id: string, docId: string, unsubscribeKey: string): Promise<void> {
|
|
if (!(id && unsubscribeKey)) {
|
|
throw new ApiError('Bad request: id and unsubscribeKey both required', 400);
|
|
}
|
|
return await this._connection.transaction(async manager => {
|
|
const secret = await this.getSecret(id, docId, manager);
|
|
if (!secret) {
|
|
throw new ApiError('Webhook with given id not found', 404);
|
|
}
|
|
const webhook = JSON.parse(secret) as WebHookSecret;
|
|
if (webhook.unsubscribeKey !== unsubscribeKey) {
|
|
throw new ApiError('Wrong unsubscribeKey', 401);
|
|
}
|
|
await manager.createQueryBuilder()
|
|
.delete()
|
|
.from(Secret)
|
|
.where('id = :id', {id})
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
// Checks that the user has UPDATE permissions to the given doc. If not, throws an
|
|
// error. Otherwise updates the given doc with the given name. Returns an empty
|
|
// query result with status 200 on success.
|
|
// NOTE: This does not update the updateAt date indicating the last modified time of the doc.
|
|
// We may want to make it do so.
|
|
public async updateDocument(scope: DocScope,
|
|
props: Partial<DocumentProperties>): Promise<QueryResult<number>> {
|
|
return await this._connection.transaction(async manager => {
|
|
const docQuery = this._doc(scope, {
|
|
manager,
|
|
markPermissions: Permissions.UPDATE
|
|
});
|
|
|
|
const queryResult = await verifyIsPermitted(docQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the workspace failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
// Update the name and save.
|
|
const doc: Document = queryResult.data;
|
|
doc.checkProperties(props);
|
|
doc.updateFromProperties(props);
|
|
// Forcibly remove the aliases relation from the document object, so that TypeORM
|
|
// doesn't try to save it. It isn't safe to do that because it was filtered by
|
|
// a where clause.
|
|
// TODO: refactor to avoid using TypeORM's save method.
|
|
doc.aliases = undefined as any;
|
|
// TODO: if pinning does anything special in future, like triggering thumbnail
|
|
// processing, then we should probably call pinDoc.
|
|
await manager.save(doc);
|
|
if (props.urlId) {
|
|
// We accumulate old urlIds in order to correctly redirect them, so we need
|
|
// to do some extra bookwork when a doc's urlId is changed. First, throw
|
|
// an error if urlId is already in use by this org.
|
|
await this._checkForUrlIdConflict(manager, doc.workspace.org, props.urlId, doc.id);
|
|
// Otherwise, add an alias entry for this document.
|
|
await manager.createQueryBuilder()
|
|
.insert()
|
|
// if urlId has been used before, update it
|
|
.onConflict(`(org_id, url_id) DO UPDATE SET doc_id = :docId, created_at = ${now(this._dbType)}`)
|
|
.setParameter('docId', doc.id)
|
|
.into(Alias)
|
|
.values({orgId: doc.workspace.org.id, urlId: props.urlId, doc})
|
|
.execute();
|
|
// TODO: we could limit the max number of aliases stored per document.
|
|
}
|
|
return {status: 200};
|
|
});
|
|
}
|
|
|
|
// Checks that the user has REMOVE permissions to the given document. If not, throws an
|
|
// error. Otherwise deletes the given document. Returns an empty query result with
|
|
// status 200 on success.
|
|
public async deleteDocument(scope: DocScope): Promise<QueryResult<number>> {
|
|
return await this._connection.transaction(async manager => {
|
|
const docQuery = this._doc(scope, {
|
|
manager,
|
|
markPermissions: Permissions.REMOVE,
|
|
allowSpecialPermit: true
|
|
})
|
|
// Join the docs's ACLs and groups so we can remove them.
|
|
// Join the workspace and org to get their ids.
|
|
.leftJoinAndSelect('docs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'groups');
|
|
const queryResult = await verifyIsPermitted(docQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the workspace failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const doc: Document = queryResult.data;
|
|
// Delete the doc and doc ACLs/groups.
|
|
const docGroups = doc.aclRules.map(docAcl => docAcl.group);
|
|
await manager.remove([doc, ...docGroups, ...doc.aclRules]);
|
|
// Update guests of the workspace and org after removing this doc.
|
|
await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);
|
|
await this._repairOrgGuests(scope, doc.workspace.org.id, manager);
|
|
return {status: 200};
|
|
});
|
|
}
|
|
|
|
public softDeleteDocument(scope: DocScope): Promise<void> {
|
|
return this._setDocumentRemovedAt(scope, new Date());
|
|
}
|
|
|
|
public async undeleteDocument(scope: DocScope): Promise<void> {
|
|
return this._setDocumentRemovedAt(scope, null);
|
|
}
|
|
|
|
// Fetches and provides a callback with the billingAccount so it may be updated within
|
|
// a transaction. The billingAccount is saved after any changes applied in the callback.
|
|
// Will throw an error if the user does not have access to the org's billingAccount.
|
|
//
|
|
// Only certain properties of the billingAccount may be changed:
|
|
// 'inGoodStanding', 'status', 'stripeCustomerId','stripeSubscriptionId', 'stripePlanId'
|
|
//
|
|
// Returns an empty query result with status 200 on success.
|
|
public async updateBillingAccount(
|
|
userId: number,
|
|
orgKey: string|number,
|
|
callback: (billingAccount: BillingAccount, transaction: EntityManager) => void|Promise<void>
|
|
): Promise<QueryResult<void>> {
|
|
return await this._connection.transaction(async transaction => {
|
|
const billingAccount = await this.getBillingAccount({userId}, orgKey, false, transaction);
|
|
const billingAccountCopy = Object.assign({}, billingAccount);
|
|
await callback(billingAccountCopy, transaction);
|
|
// Pick out properties that are allowed to be changed, to prevent accidental updating
|
|
// of other information.
|
|
const updated = pick(billingAccountCopy, 'inGoodStanding', 'status', 'stripeCustomerId',
|
|
'stripeSubscriptionId', 'stripePlanId', 'product', 'externalId',
|
|
'externalOptions');
|
|
billingAccount.paid = undefined; // workaround for a typeorm bug fixed upstream in
|
|
// https://github.com/typeorm/typeorm/pull/4035
|
|
await transaction.save(Object.assign(billingAccount, updated));
|
|
return { status: 200 };
|
|
});
|
|
}
|
|
|
|
// Updates the managers of a billing account. Returns an empty query result with
|
|
// status 200 on success.
|
|
public async updateBillingAccountManagers(userId: number, orgKey: string|number,
|
|
delta: ManagerDelta): Promise<QueryResult<void>> {
|
|
const notifications: Array<() => void> = [];
|
|
// Translate our ManagerDelta to a PermissionDelta so that we can reuse existing
|
|
// methods for normalizing/merging emails and finding the user ids.
|
|
const permissionDelta: PermissionDelta = {users: {}};
|
|
for (const key of Object.keys(delta.users)) {
|
|
const target = delta.users[key];
|
|
if (target !== null && target !== 'managers') {
|
|
throw new ApiError("Only valid settings for billing account managers are 'managers' or null", 400);
|
|
}
|
|
permissionDelta.users![key] = delta.users[key] ? 'owners' : null;
|
|
}
|
|
|
|
return await this._connection.transaction(async transaction => {
|
|
const billingAccount = await this.getBillingAccount({userId}, orgKey, true, transaction);
|
|
// At this point, we'll have thrown an error if userId is not a billing account manager.
|
|
// Now check if the billing account has mutable managers (individual account does not).
|
|
if (billingAccount.individual) {
|
|
throw new ApiError('billing account managers cannot be added/removed for individual billing accounts', 400);
|
|
}
|
|
// Get the ids of users to update.
|
|
const billingAccountId = billingAccount.id;
|
|
const analysis = await this._verifyAndLookupDeltaEmails(userId, permissionDelta, true, transaction);
|
|
this._failIfPowerfulAndChangingSelf(analysis);
|
|
const {userIdDelta} = analysis;
|
|
if (!userIdDelta) { throw new ApiError('No userIdDelta', 500); }
|
|
// Any duplicated emails have been merged, and userIdDelta is now keyed by user ids.
|
|
// Now we iterate over users and add/remove them as managers.
|
|
for (const memberUserIdStr of Object.keys(userIdDelta)) {
|
|
const memberUserId = parseInt(memberUserIdStr, 10);
|
|
const add = Boolean(userIdDelta[memberUserIdStr]);
|
|
const manager = await transaction.findOne(BillingAccountManager, {where: {userId: memberUserId,
|
|
billingAccountId}});
|
|
if (add) {
|
|
// Skip adding user if they are already a manager.
|
|
if (!manager) {
|
|
const newManager = new BillingAccountManager();
|
|
newManager.userId = memberUserId;
|
|
newManager.billingAccountId = billingAccountId;
|
|
await transaction.save(newManager);
|
|
notifications.push(this._billingManagerNotification(userId, memberUserId,
|
|
billingAccount.orgs));
|
|
}
|
|
} else {
|
|
if (manager) {
|
|
// Don't allow a user to remove themselves as a manager, to be consistent
|
|
// with ACL behavior.
|
|
if (memberUserId === userId) {
|
|
throw new ApiError('Users cannot remove themselves as billing managers', 400);
|
|
}
|
|
await transaction.remove(manager);
|
|
}
|
|
}
|
|
}
|
|
for (const notification of notifications) { notification(); }
|
|
return { status: 200 };
|
|
});
|
|
}
|
|
|
|
// Updates the permissions of users on the given org according to the PermissionDelta.
|
|
public async updateOrgPermissions(
|
|
scope: Scope,
|
|
orgKey: string|number,
|
|
delta: PermissionDelta
|
|
): Promise<QueryResult<void>> {
|
|
const {userId} = scope;
|
|
const notifications: Array<() => void> = [];
|
|
const result = await this._connection.transaction(async manager => {
|
|
const analysis = await this._verifyAndLookupDeltaEmails(userId, delta, true, manager);
|
|
const {userIdDelta} = analysis;
|
|
let orgQuery = this.org(scope, orgKey, {
|
|
manager,
|
|
markPermissions: analysis.permissionThreshold,
|
|
needRealOrg: true
|
|
})
|
|
// Join the org's ACL rules (with 1st level groups/users listed) so we can edit them.
|
|
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users');
|
|
orgQuery = this._addFeatures(orgQuery);
|
|
orgQuery = this._withAccess(orgQuery, userId, 'orgs');
|
|
const queryResult = await verifyIsPermitted(orgQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the organization failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
this._failIfPowerfulAndChangingSelf(analysis, queryResult);
|
|
const org: Organization = queryResult.data;
|
|
const groups = getNonGuestGroups(org);
|
|
if (userIdDelta) {
|
|
const membersBefore = getUsersWithRole(groups, this.getExcludedUserIds());
|
|
const countBefore = removeRole(membersBefore).length;
|
|
await this._updateUserPermissions(groups, userIdDelta, manager);
|
|
this._checkUserChangeAllowed(userId, groups);
|
|
await manager.save(groups);
|
|
// Fully remove any users being removed from the org.
|
|
for (const deltaUser in userIdDelta) {
|
|
// Any users removed from the org should be removed from everything in the org.
|
|
if (userIdDelta[deltaUser] === null) {
|
|
await scrubUserFromOrg(org.id, parseInt(deltaUser, 10), userId, manager);
|
|
}
|
|
}
|
|
// Emit an event if the number of org users is changing.
|
|
const membersAfter = getUsersWithRole(groups, this.getExcludedUserIds());
|
|
const countAfter = removeRole(membersAfter).length;
|
|
notifications.push(this._userChangeNotification(userId, org, countBefore, countAfter,
|
|
membersBefore, membersAfter));
|
|
// Notify any added users that they've been added to this resource.
|
|
notifications.push(this._inviteNotification(userId, org, userIdDelta, membersBefore));
|
|
}
|
|
return {status: 200};
|
|
});
|
|
for (const notification of notifications) { notification(); }
|
|
return result;
|
|
}
|
|
|
|
// Updates the permissions of users on the given workspace according to the PermissionDelta.
|
|
public async updateWorkspacePermissions(
|
|
scope: Scope,
|
|
wsId: number,
|
|
delta: PermissionDelta
|
|
): Promise<QueryResult<void>> {
|
|
const {userId} = scope;
|
|
const notifications: Array<() => void> = [];
|
|
const result = await this._connection.transaction(async manager => {
|
|
const analysis = await this._verifyAndLookupDeltaEmails(userId, delta, false, manager);
|
|
let {userIdDelta} = analysis;
|
|
let wsQuery = this._workspace(scope, wsId, {
|
|
manager,
|
|
markPermissions: analysis.permissionThreshold,
|
|
})
|
|
// Join the workspace's ACL rules and groups/users so we can edit them.
|
|
.leftJoinAndSelect('workspaces.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'workspace_groups')
|
|
.leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_users')
|
|
// Join the workspace's org and org member groups so we know what should be inherited.
|
|
.leftJoinAndSelect('workspaces.org', 'org')
|
|
.leftJoinAndSelect('org.aclRules', 'org_acl_rules')
|
|
.leftJoinAndSelect('org_acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_users');
|
|
wsQuery = this._addFeatures(wsQuery, 'org');
|
|
wsQuery = this._withAccess(wsQuery, userId, 'workspaces');
|
|
const queryResult = await verifyIsPermitted(wsQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the workspace failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
this._failIfPowerfulAndChangingSelf(analysis, queryResult);
|
|
const ws: Workspace = queryResult.data;
|
|
// Get all the non-guest groups on the org.
|
|
const orgGroups = getNonGuestGroups(ws.org);
|
|
// Get all the non-guest groups to be updated by the delta.
|
|
const groups = getNonGuestGroups(ws);
|
|
if ('maxInheritedRole' in delta) {
|
|
// Honor the maxInheritedGroups delta setting.
|
|
this._moveInheritedGroups(groups, orgGroups, delta.maxInheritedRole);
|
|
if (delta.maxInheritedRole !== roles.OWNER) {
|
|
// If the maxInheritedRole was lowered from 'owners', add the calling user
|
|
// back as an owner so that their acl edit access is not revoked.
|
|
userIdDelta = userIdDelta || {};
|
|
userIdDelta[userId] = roles.OWNER;
|
|
}
|
|
}
|
|
const membersBefore = this._withoutExcludedUsers(new Map(groups.map(grp => [grp.name, grp.memberUsers])));
|
|
if (userIdDelta) {
|
|
// To check limits on shares, we track group members before and after call
|
|
// to _updateUserPermissions. Careful, that method mutates groups.
|
|
const nonOrgMembersBefore = this._getUserDifference(groups, orgGroups);
|
|
await this._updateUserPermissions(groups, userIdDelta, manager);
|
|
this._checkUserChangeAllowed(userId, groups);
|
|
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
|
|
const features = ws.org.billingAccount.product.features;
|
|
const limit = features.maxSharesPerWorkspace;
|
|
if (limit !== undefined) {
|
|
this._restrictShares(null, limit, removeRole(nonOrgMembersBefore),
|
|
removeRole(nonOrgMembersAfter), true, 'workspace', features);
|
|
}
|
|
}
|
|
await manager.save(groups);
|
|
// If the users in workspace were changed, make a call to repair the guests in the org.
|
|
if (userIdDelta) {
|
|
await this._repairOrgGuests(scope, ws.org.id, manager);
|
|
notifications.push(this._inviteNotification(userId, ws, userIdDelta, membersBefore));
|
|
}
|
|
return {status: 200};
|
|
});
|
|
for (const notification of notifications) { notification(); }
|
|
return result;
|
|
}
|
|
|
|
// Updates the permissions of users on the given doc according to the PermissionDelta.
|
|
public async updateDocPermissions(
|
|
scope: DocScope,
|
|
delta: PermissionDelta
|
|
): Promise<QueryResult<void>> {
|
|
const notifications: Array<() => void> = [];
|
|
const result = await this._connection.transaction(async manager => {
|
|
const {userId} = scope;
|
|
const analysis = await this._verifyAndLookupDeltaEmails(userId, delta, false, manager);
|
|
let {userIdDelta} = analysis;
|
|
const doc = await this._loadDocAccess(scope, analysis.permissionThreshold, manager);
|
|
this._failIfPowerfulAndChangingSelf(analysis, {data: doc, status: 200});
|
|
// Get all the non-guest doc groups to be updated by the delta.
|
|
const groups = getNonGuestGroups(doc);
|
|
if ('maxInheritedRole' in delta) {
|
|
const wsGroups = getNonGuestGroups(doc.workspace);
|
|
// Honor the maxInheritedGroups delta setting.
|
|
this._moveInheritedGroups(groups, wsGroups, delta.maxInheritedRole);
|
|
if (delta.maxInheritedRole !== roles.OWNER) {
|
|
// If the maxInheritedRole was lowered from 'owners', add the calling user
|
|
// back as an owner so that their acl edit access is not revoked.
|
|
userIdDelta = userIdDelta || {};
|
|
userIdDelta[userId] = roles.OWNER;
|
|
}
|
|
}
|
|
const membersBefore = new Map(groups.map(grp => [grp.name, grp.memberUsers]));
|
|
if (userIdDelta) {
|
|
// To check limits on shares, we track group members before and after call
|
|
// to _updateUserPermissions. Careful, that method mutates groups.
|
|
const org = doc.workspace.org;
|
|
const orgGroups = getNonGuestGroups(org);
|
|
const nonOrgMembersBefore = this._getUserDifference(groups, orgGroups);
|
|
await this._updateUserPermissions(groups, userIdDelta, manager);
|
|
this._checkUserChangeAllowed(userId, groups);
|
|
const nonOrgMembersAfter = this._getUserDifference(groups, orgGroups);
|
|
const features = org.billingAccount.product.features;
|
|
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter);
|
|
}
|
|
await manager.save(groups);
|
|
if (userIdDelta) {
|
|
// If the users in the doc were changed, make calls to repair workspace then org guests.
|
|
await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);
|
|
await this._repairOrgGuests(scope, doc.workspace.org.id, manager);
|
|
notifications.push(this._inviteNotification(userId, doc, userIdDelta, membersBefore));
|
|
}
|
|
return {status: 200};
|
|
});
|
|
for (const notification of notifications) { notification(); }
|
|
return result;
|
|
}
|
|
|
|
// Returns UserAccessData for all users with any permissions on the org.
|
|
public async getOrgAccess(scope: Scope, orgKey: string|number): Promise<QueryResult<PermissionData>> {
|
|
const orgQuery = this.org(scope, orgKey, {
|
|
markPermissions: Permissions.VIEW,
|
|
needRealOrg: true,
|
|
allowSpecialPermit: true
|
|
})
|
|
// Join the org's ACL rules (with 1st level groups/users listed).
|
|
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_member_users')
|
|
.leftJoinAndSelect('org_member_users.logins', 'user_logins');
|
|
const queryResult = await verifyIsPermitted(orgQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the doc failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const org: Organization = queryResult.data;
|
|
const userRoleMap = getMemberUserRoles(org, this.defaultGroupNames);
|
|
const users = getResourceUsers(org).filter(u => userRoleMap[u.id]).map(u => ({
|
|
...this.makeFullUser(u),
|
|
access: userRoleMap[u.id]
|
|
}));
|
|
const personal = this._filterAccessData(scope, users, null);
|
|
return {
|
|
status: 200,
|
|
data: {
|
|
...personal,
|
|
users
|
|
}
|
|
};
|
|
}
|
|
|
|
// Returns UserAccessData for all users with any permissions on the ORG, as well as the
|
|
// maxInheritedRole set on the workspace. Note that information for all users in the org
|
|
// is given to indicate which users have access to the org but not to this particular workspace.
|
|
public async getWorkspaceAccess(scope: Scope, wsId: number): Promise<QueryResult<PermissionData>> {
|
|
const wsQuery = this._workspace(scope, wsId, {
|
|
markPermissions: Permissions.VIEW
|
|
})
|
|
// Join the workspace's ACL rules (with 1st level groups/users listed).
|
|
.leftJoinAndSelect('workspaces.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'workspace_groups')
|
|
.leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_group_users')
|
|
.leftJoinAndSelect('workspace_groups.memberGroups', 'workspace_group_groups')
|
|
.leftJoinAndSelect('workspace_group_users.logins', 'workspace_user_logins')
|
|
// Join the org and groups/users.
|
|
.leftJoinAndSelect('workspaces.org', 'org')
|
|
.leftJoinAndSelect('org.aclRules', 'org_acl_rules')
|
|
.leftJoinAndSelect('org_acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_group_users')
|
|
.leftJoinAndSelect('org_group_users.logins', 'org_user_logins');
|
|
const queryResult = await verifyIsPermitted(wsQuery);
|
|
if (queryResult.status !== 200) {
|
|
// If the query for the doc failed, return the failure result.
|
|
return queryResult;
|
|
}
|
|
const workspace: Workspace = queryResult.data;
|
|
const wsMap = getMemberUserRoles(workspace, this.defaultCommonGroupNames);
|
|
// The orgMap gives the org access inherited by each user.
|
|
const orgMap = getMemberUserRoles(workspace.org, this.defaultBasicGroupNames);
|
|
// Iterate through the org since all users will be in the org.
|
|
const users: UserAccessData[] = getResourceUsers([workspace, workspace.org]).map(u => {
|
|
return {
|
|
...this.makeFullUser(u),
|
|
access: wsMap[u.id] || null,
|
|
parentAccess: roles.getEffectiveRole(orgMap[u.id] || null)
|
|
};
|
|
});
|
|
const maxInheritedRole = this._getMaxInheritedRole(workspace);
|
|
const personal = this._filterAccessData(scope, users, maxInheritedRole);
|
|
return {
|
|
status: 200,
|
|
data: {
|
|
...personal,
|
|
maxInheritedRole,
|
|
users
|
|
}
|
|
};
|
|
}
|
|
|
|
// Returns UserAccessData for all users with any permissions on the ORG, as well as the
|
|
// maxInheritedRole set on the doc. Note that information for all users in the org is given
|
|
// to indicate which users have access to the org but not to this particular doc.
|
|
// TODO: Consider updating to traverse through the doc groups and their nested groups for
|
|
// a more straightforward way of determining inheritance. The difficulty here is that all users
|
|
// in the org and their logins are needed for inclusion in the result, which would require an
|
|
// extra lookup step when traversing from the doc.
|
|
//
|
|
// If the user is not an owner of the document, only that user (at most) will be mentioned
|
|
// in the result.
|
|
//
|
|
// Optionally, the results can be flattened, removing all information about inheritance and
|
|
// parents, and just giving the effective access level of each user (frankly, the default
|
|
// output of this method is quite confusing).
|
|
//
|
|
// Optionally, users without access to the document can be removed from the results
|
|
// (I believe they are included in order to one day facilitate auto-completion in the client?).
|
|
public async getDocAccess(scope: DocScope, options?: {
|
|
flatten?: boolean,
|
|
excludeUsersWithoutAccess?: boolean,
|
|
}): Promise<QueryResult<PermissionData>> {
|
|
// Doc permissions of forks are based on the "trunk" document, so make sure
|
|
// we look up permissions of trunk if we are on a fork (we'll fix the permissions
|
|
// up for the fork immediately afterwards).
|
|
const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(scope.urlId);
|
|
|
|
const doc = await this._loadDocAccess({...scope, urlId: trunkId}, Permissions.VIEW);
|
|
const docMap = getMemberUserRoles(doc, this.defaultCommonGroupNames);
|
|
// The wsMap gives the ws access inherited by each user.
|
|
const wsMap = getMemberUserRoles(doc.workspace, this.defaultBasicGroupNames);
|
|
// The orgMap gives the org access inherited by each user.
|
|
const orgMap = getMemberUserRoles(doc.workspace.org, this.defaultBasicGroupNames);
|
|
// The orgMapWithMembership gives the full access to the org for each user, including
|
|
// the "members" level, which grants no default inheritable access but allows the user
|
|
// to be added freely to workspaces and documents.
|
|
const orgMapWithMembership = getMemberUserRoles(doc.workspace.org, this.defaultGroupNames);
|
|
const wsMaxInheritedRole = this._getMaxInheritedRole(doc.workspace);
|
|
// Iterate through the org since all users will be in the org.
|
|
let users: UserAccessData[] = getResourceUsers([doc, doc.workspace, doc.workspace.org]).map(u => {
|
|
// Merge the strongest roles from the resource and parent resources. Note that the parent
|
|
// resource access levels must be tempered by the maxInheritedRole values of their children.
|
|
const inheritFromOrg = roles.getWeakestRole(orgMap[u.id] || null, wsMaxInheritedRole);
|
|
const orgAccess = orgMapWithMembership[u.id] || null;
|
|
return {
|
|
...this.makeFullUser(u),
|
|
access: docMap[u.id] || null,
|
|
parentAccess: roles.getEffectiveRole(
|
|
roles.getStrongestRole(wsMap[u.id] || null, inheritFromOrg)
|
|
),
|
|
isMember: orgAccess !== 'guests' && orgAccess !== null,
|
|
isSupport: u.id === this.getSupportUserId() ? true : undefined,
|
|
};
|
|
});
|
|
let maxInheritedRole = this._getMaxInheritedRole(doc);
|
|
|
|
if (options?.excludeUsersWithoutAccess) {
|
|
users = users.filter(user => {
|
|
const access = getRealAccess(user, { maxInheritedRole, users });
|
|
return roles.canView(access);
|
|
});
|
|
}
|
|
|
|
if (forkId || snapshotId || options?.flatten) {
|
|
for (const user of users) {
|
|
const access = getRealAccess(user, { maxInheritedRole, users });
|
|
user.access = access;
|
|
user.parentAccess = undefined;
|
|
}
|
|
maxInheritedRole = null;
|
|
}
|
|
|
|
const personal = this._filterAccessData(scope, users, maxInheritedRole, doc.id);
|
|
|
|
// If we are on a fork, make any access changes needed. Assumes results
|
|
// have been flattened.
|
|
if (forkId || snapshotId) {
|
|
for (const user of users) {
|
|
this._setForkAccess({userId: user.id, forkUserId, snapshotId}, user);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
data: {
|
|
...personal,
|
|
maxInheritedRole,
|
|
users
|
|
}
|
|
};
|
|
}
|
|
|
|
public async moveDoc(
|
|
scope: DocScope,
|
|
wsId: number
|
|
): Promise<QueryResult<void>> {
|
|
return await this._connection.transaction(async manager => {
|
|
// Get the doc
|
|
const docQuery = this._doc(scope, {
|
|
manager,
|
|
markPermissions: Permissions.OWNER
|
|
})
|
|
.leftJoinAndSelect('docs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'doc_groups')
|
|
.leftJoinAndSelect('doc_groups.memberUsers', 'doc_users')
|
|
.leftJoinAndSelect('workspaces.aclRules', 'workspace_acl_rules')
|
|
.leftJoinAndSelect('workspace_acl_rules.group', 'workspace_groups')
|
|
.leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_users')
|
|
.leftJoinAndSelect('orgs.aclRules', 'org_acl_rules')
|
|
.leftJoinAndSelect('org_acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_users');
|
|
const docQueryResult = await verifyIsPermitted(docQuery);
|
|
if (docQueryResult.status !== 200) {
|
|
// If the query for the doc failed, return the failure result.
|
|
return docQueryResult;
|
|
}
|
|
const doc: Document = docQueryResult.data;
|
|
if (doc.workspace.id === wsId) {
|
|
return {
|
|
status: 400,
|
|
errMessage: `Bad request: doc is already in destination workspace`
|
|
};
|
|
}
|
|
// Get the destination workspace
|
|
let wsQuery = this._workspace(scope, wsId, {
|
|
manager,
|
|
markPermissions: Permissions.ADD
|
|
})
|
|
// Join the workspaces's ACL rules (with 1st level groups listed) so we can include
|
|
// them in the doc.
|
|
.leftJoinAndSelect('workspaces.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'workspace_groups')
|
|
.leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_users')
|
|
.leftJoinAndSelect('workspaces.org', 'orgs')
|
|
.leftJoinAndSelect('orgs.aclRules', 'org_acl_rules')
|
|
.leftJoinAndSelect('org_acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_users');
|
|
wsQuery = this._addFeatures(wsQuery);
|
|
const wsQueryResult = await verifyIsPermitted(wsQuery);
|
|
if (wsQueryResult.status !== 200) {
|
|
// If the query for the organization failed, return the failure result.
|
|
return wsQueryResult;
|
|
}
|
|
const workspace: Workspace = wsQueryResult.data;
|
|
// Collect all first-level users of the doc being moved.
|
|
const firstLevelUsers = getResourceUsers(doc);
|
|
const docGroups = doc.aclRules.map(rule => rule.group);
|
|
if (doc.workspace.org.id !== workspace.org.id) {
|
|
// Doc is going to a new org. Check that there is room for it there.
|
|
await this._checkRoomForAnotherDoc(workspace, manager);
|
|
// Check also that doc doesn't have too many shares.
|
|
if (firstLevelUsers.length > 0) {
|
|
const sourceOrg = doc.workspace.org;
|
|
const sourceOrgGroups = getNonGuestGroups(sourceOrg);
|
|
const destOrg = workspace.org;
|
|
const destOrgGroups = getNonGuestGroups(destOrg);
|
|
const nonOrgMembersBefore = this._getUserDifference(docGroups, sourceOrgGroups);
|
|
const nonOrgMembersAfter = this._getUserDifference(docGroups, destOrgGroups);
|
|
const features = destOrg.billingAccount.product.features;
|
|
this._restrictAllDocShares(features, nonOrgMembersBefore, nonOrgMembersAfter, false);
|
|
}
|
|
}
|
|
// Update the doc workspace.
|
|
const oldWs = doc.workspace;
|
|
doc.workspace = workspace;
|
|
// The doc should have groups which properly inherit the permissions of the
|
|
// new workspace after it is moved.
|
|
// Update the doc groups to inherit the groups in the new workspace/org.
|
|
// Any previously custom added members remain in the doc groups.
|
|
doc.aclRules.forEach(aclRule => {
|
|
this._setInheritance(aclRule.group, workspace);
|
|
});
|
|
// If the org is changing, remove all urlIds for this doc, since there could be
|
|
// conflicts in the new org.
|
|
// TODO: could try recreating/keeping the urlIds in the new org if there is in fact
|
|
// no conflict. Be careful about the merged personal org.
|
|
if (oldWs.org.id !== doc.workspace.org.id) {
|
|
doc.urlId = null;
|
|
await manager.delete(Alias, { doc: doc.id });
|
|
}
|
|
// Forcibly remove the aliases relation from the document object, so that TypeORM
|
|
// doesn't try to save it. It isn't safe to do that because it was filtered by
|
|
// a where clause.
|
|
doc.aliases = undefined as any;
|
|
// Saves the document as well as its new ACL Rules and Groups and the
|
|
// updated guest group in the workspace.
|
|
await manager.save([doc, ...doc.aclRules, ...docGroups]);
|
|
if (firstLevelUsers.length > 0) {
|
|
// If the doc has first-level users, update the source and destination workspaces.
|
|
await this._repairWorkspaceGuests(scope, oldWs.id, manager);
|
|
await this._repairWorkspaceGuests(scope, doc.workspace.id, manager);
|
|
if (oldWs.org.id !== doc.workspace.org.id) {
|
|
// Also if the org changed, update the source and destination org guest groups.
|
|
await this._repairOrgGuests(scope, oldWs.org.id, manager);
|
|
await this._repairOrgGuests(scope, doc.workspace.org.id, manager);
|
|
}
|
|
}
|
|
return {
|
|
status: 200
|
|
};
|
|
});
|
|
}
|
|
|
|
// Pin or unpin a doc.
|
|
public async pinDoc(
|
|
scope: DocScope,
|
|
setPinned: boolean
|
|
): Promise<QueryResult<void>> {
|
|
return await this._connection.transaction(async manager => {
|
|
// Find the doc to assert that it exists. Assert that the user has edit access to the
|
|
// parent org.
|
|
const permissions = Permissions.EDITOR;
|
|
const docQuery = this._doc(scope, {
|
|
manager
|
|
})
|
|
.addSelect(this._markIsPermitted('orgs', scope.userId, 'open', permissions), 'is_permitted');
|
|
const docQueryResult = await verifyIsPermitted(docQuery);
|
|
if (docQueryResult.status !== 200) {
|
|
// If the query for the doc failed, return the failure result.
|
|
return docQueryResult;
|
|
}
|
|
const doc: Document = docQueryResult.data;
|
|
if (doc.isPinned !== setPinned) {
|
|
doc.isPinned = setPinned;
|
|
// Forcibly remove the aliases relation from the document object, so that TypeORM
|
|
// doesn't try to save it. It isn't safe to do that because it was filtered by
|
|
// a where clause.
|
|
doc.aliases = undefined as any;
|
|
// Save and return success status.
|
|
await manager.save(doc);
|
|
}
|
|
return { status: 200 };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates the updatedAt and usage values for several docs. Takes a map where each entry maps
|
|
* a docId to a metadata object containing the updatedAt and/or usage values. This is not a part
|
|
* of the API, it should be called only by the HostedMetadataManager when a change is made to a
|
|
* doc.
|
|
*/
|
|
public async setDocsMetadata(
|
|
docUpdateMap: {[docId: string]: DocumentMetadata}
|
|
): Promise<QueryResult<void>> {
|
|
if (!docUpdateMap || Object.keys(docUpdateMap).length === 0) {
|
|
return {
|
|
status: 400,
|
|
errMessage: `Bad request: missing argument`
|
|
};
|
|
}
|
|
const docIds = Object.keys(docUpdateMap);
|
|
return this._connection.transaction(async manager => {
|
|
const updateTasks = docIds.map(docId => {
|
|
return manager.createQueryBuilder()
|
|
.update(Document)
|
|
.set(docUpdateMap[docId])
|
|
.where("id = :docId", {docId})
|
|
.execute();
|
|
});
|
|
await Promise.all(updateTasks);
|
|
return { status: 200 };
|
|
});
|
|
}
|
|
|
|
public async setDocGracePeriodStart(docId: string, gracePeriodStart: Date | null) {
|
|
return await this._connection.createQueryBuilder()
|
|
.update(Document)
|
|
.set({gracePeriodStart})
|
|
.where({id: docId})
|
|
.execute();
|
|
}
|
|
|
|
public async getDocProduct(docId: string): Promise<Product | undefined> {
|
|
return await this._connection.createQueryBuilder()
|
|
.select('product')
|
|
.from(Product, 'product')
|
|
.leftJoinAndSelect('product.accounts', 'account')
|
|
.leftJoinAndSelect('account.orgs', 'org')
|
|
.leftJoinAndSelect('org.workspaces', 'workspace')
|
|
.leftJoinAndSelect('workspace.docs', 'doc')
|
|
.where('doc.id = :docId', {docId})
|
|
.getOne() || undefined;
|
|
}
|
|
|
|
/**
|
|
* Get the anonymous user, as a constructed object rather than a database lookup.
|
|
*/
|
|
public getAnonymousUser(): User {
|
|
const user = new User();
|
|
user.id = this.getAnonymousUserId();
|
|
user.name = "Anonymous";
|
|
user.isFirstTimeUser = false;
|
|
const login = new Login();
|
|
login.displayEmail = login.email = ANONYMOUS_USER_EMAIL;
|
|
user.logins = [login];
|
|
user.ref = '';
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Get the id of the anonymous user.
|
|
*
|
|
*/
|
|
public getAnonymousUserId(): number {
|
|
const id = this._specialUserIds[ANONYMOUS_USER_EMAIL];
|
|
if (!id) { throw new Error("Anonymous user not available"); }
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Get the id of the thumbnail user.
|
|
*/
|
|
public getPreviewerUserId(): number {
|
|
const id = this._specialUserIds[PREVIEWER_EMAIL];
|
|
if (!id) { throw new Error("Previewer user not available"); }
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Get the id of the 'everyone' user.
|
|
*/
|
|
public getEveryoneUserId(): number {
|
|
const id = this._specialUserIds[EVERYONE_EMAIL];
|
|
if (!id) { throw new Error("'everyone' user not available"); }
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Get the id of the 'support' user.
|
|
*/
|
|
public getSupportUserId(): number {
|
|
const id = this._specialUserIds[SUPPORT_EMAIL];
|
|
if (!id) { throw new Error("'support' user not available"); }
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Get ids of users to be excluded from member counts and emails.
|
|
*/
|
|
public getExcludedUserIds(): number[] {
|
|
return [this.getSupportUserId(), this.getAnonymousUserId(), this.getEveryoneUserId()];
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Take a list of user profiles coming from the client's session, correlate
|
|
* them with Users and Logins in the database, and construct full profiles
|
|
* with user ids, standardized display emails, pictures, and anonymous flags.
|
|
*
|
|
*/
|
|
public async completeProfiles(profiles: UserProfile[]): Promise<FullUser[]> {
|
|
if (profiles.length === 0) { return []; }
|
|
const qb = this._connection.createQueryBuilder()
|
|
.select('logins')
|
|
.from(Login, 'logins')
|
|
.leftJoinAndSelect('logins.user', 'user')
|
|
.where('logins.email in (:...emails)', {emails: profiles.map(profile => normalizeEmail(profile.email))});
|
|
const completedProfiles: {[email: string]: FullUser} = {};
|
|
for (const login of await qb.getMany()) {
|
|
completedProfiles[login.email] = {
|
|
id: login.user.id,
|
|
email: login.displayEmail,
|
|
name: login.user.name,
|
|
picture: login.user.picture,
|
|
anonymous: login.user.id === this.getAnonymousUserId()
|
|
};
|
|
}
|
|
return profiles.map(profile => completedProfiles[normalizeEmail(profile.email)])
|
|
.filter(profile => profile);
|
|
}
|
|
|
|
/**
|
|
* Calculate the public-facing subdomain for an org.
|
|
*
|
|
* If the domain is a personal org, the public-facing subdomain will
|
|
* be docs/docs-s (if `mergePersonalOrgs` is set), or docs-[s]NNN where NNN
|
|
* is the user id (if `mergePersonalOrgs` is not set).
|
|
*
|
|
* If a domain is set in the database, and `suppressDomain` is not
|
|
* set, we report that domain verbatim. The `suppressDomain` may
|
|
* be set in some key endpoints in order to enforce a `vanityDomain`
|
|
* feature flag.
|
|
*
|
|
* Otherwise, we report o-NNN (or o-sNNN in staging) where NNN is
|
|
* the org id.
|
|
*/
|
|
public normalizeOrgDomain(orgId: number, domain: string|null,
|
|
ownerId: number|undefined, mergePersonalOrgs: boolean = true,
|
|
suppressDomain: boolean = false): string {
|
|
if (ownerId) {
|
|
// An org with an ownerId set is a personal org. Historically, those orgs
|
|
// have a subdomain like docs-NN where NN is the user ID.
|
|
const personalDomain = `docs-${this._idPrefix}${ownerId}`;
|
|
// In most cases now we pool all personal orgs as a single virtual org.
|
|
// So when mergePersonalOrgs is on, and the subdomain is either not set
|
|
// (as it is in the database for personal orgs) or set to something
|
|
// like docs-NN (as it is in the API), normalization should just return the
|
|
// single merged org ("docs" or "docs-s").
|
|
if (mergePersonalOrgs && (!domain || domain === personalDomain)) {
|
|
domain = this.mergedOrgDomain();
|
|
}
|
|
if (!domain) {
|
|
domain = personalDomain;
|
|
}
|
|
} else if (suppressDomain || !domain) {
|
|
// If no subdomain is set, or custom subdomains or forbidden, return something
|
|
// uninspiring but unique, like o-NN where NN is the org ID.
|
|
domain = `o-${this._idPrefix}${orgId}`;
|
|
}
|
|
return domain;
|
|
}
|
|
|
|
// Throw an error for query results that represent errors or have no data; otherwise unwrap
|
|
// the valid result it contains.
|
|
public unwrapQueryResult<T>(qr: QueryResult<T>): T {
|
|
if (qr.data) { return qr.data; }
|
|
throw new ApiError(qr.errMessage || 'an error occurred', qr.status);
|
|
}
|
|
|
|
// Throw an error for query results that represent errors
|
|
public checkQueryResult<T>(qr: QueryResult<T>) {
|
|
if (qr.status !== 200) {
|
|
throw new ApiError(qr.errMessage || 'an error occurred', qr.status);
|
|
}
|
|
}
|
|
|
|
// Get the domain name for the merged organization. In production, this is 'docs',
|
|
// in staging, it is 'docs-s'.
|
|
public mergedOrgDomain() {
|
|
if (this._idPrefix) {
|
|
return `docs-${this._idPrefix}`;
|
|
}
|
|
return 'docs';
|
|
}
|
|
|
|
// The merged organization is a special pseudo-organization
|
|
// patched together from all the material a given user has access
|
|
// to. The result is approximately, but not exactly, an organization,
|
|
// and so it treated a bit differently.
|
|
public isMergedOrg(orgKey: string|number|null) {
|
|
return orgKey === this.mergedOrgDomain() || orgKey === 0;
|
|
}
|
|
|
|
/**
|
|
* Construct a QueryBuilder for a select query on a specific org given by orgId.
|
|
* Provides options for running in a transaction and adding permission info.
|
|
* See QueryOptions documentation above.
|
|
*/
|
|
public org(scope: Scope, org: string|number|null,
|
|
options: QueryOptions = {}): SelectQueryBuilder<Organization> {
|
|
return this._org(scope, scope.includeSupport || false, org, options);
|
|
}
|
|
|
|
private _org(scope: Scope|null, includeSupport: boolean, org: string|number|null,
|
|
options: QueryOptions = {}): SelectQueryBuilder<Organization> {
|
|
let query = this._orgs(options.manager);
|
|
// merged pseudo-org must become personal org.
|
|
if (org === null || (options.needRealOrg && this.isMergedOrg(org))) {
|
|
if (!scope || !scope.userId) { throw new Error('_org: requires userId'); }
|
|
query = query.where('orgs.owner_id = :userId', {userId: scope.userId});
|
|
} else {
|
|
query = this._whereOrg(query, org, includeSupport);
|
|
}
|
|
if (options.markPermissions) {
|
|
if (!scope || !scope.userId) {
|
|
throw new Error(`_orgQuery error: userId must be set to mark permissions`);
|
|
}
|
|
let effectiveUserId = scope.userId;
|
|
let threshold = options.markPermissions;
|
|
// TODO If the specialPermit is used across the network, requests could refer to orgs in
|
|
// different ways (number vs string), causing this comparison to fail.
|
|
if (options.allowSpecialPermit && scope.specialPermit && scope.specialPermit.org === org) {
|
|
effectiveUserId = this.getPreviewerUserId();
|
|
threshold = Permissions.VIEW;
|
|
}
|
|
// Compute whether we have access to the doc
|
|
query = query.addSelect(
|
|
this._markIsPermitted('orgs', effectiveUserId, 'open', threshold),
|
|
'is_permitted'
|
|
);
|
|
}
|
|
return query;
|
|
}
|
|
|
|
/**
|
|
* Construct a QueryBuilder for a select query on a specific org's workspaces given by orgId.
|
|
* Provides options for running in a transaction and adding permission info.
|
|
* See QueryOptions documentation above.
|
|
*/
|
|
private _orgWorkspaces(scope: Scope, org: string|number|null,
|
|
options: QueryOptions = {}): SelectQueryBuilder<Organization> {
|
|
const {userId} = scope;
|
|
const supportId = this._specialUserIds[SUPPORT_EMAIL];
|
|
let query = this.org(scope, org, options)
|
|
.leftJoinAndSelect('orgs.workspaces', 'workspaces')
|
|
.leftJoinAndSelect('workspaces.docs', 'docs', this._onDoc(scope))
|
|
.leftJoin('orgs.billingAccount', 'account')
|
|
.leftJoin('account.product', 'product')
|
|
.addSelect('product.features')
|
|
.addSelect('product.id')
|
|
.addSelect('account.id')
|
|
// order the support org (aka Samples/Examples) after other ones.
|
|
.orderBy('coalesce(orgs.owner_id = :supportId, false)')
|
|
.setParameter('supportId', supportId)
|
|
.addOrderBy('(orgs.owner_id = :userId)', 'DESC')
|
|
.setParameter('userId', userId)
|
|
// For consistency of results, particularly in tests, order workspaces by name.
|
|
.addOrderBy('workspaces.name')
|
|
.addOrderBy('docs.created_at')
|
|
.leftJoinAndSelect('orgs.owner', 'org_users');
|
|
|
|
// If merged org, we need to take some special steps.
|
|
if (this.isMergedOrg(org)) {
|
|
// Add information about owners of personal orgs.
|
|
query = query.leftJoinAndSelect('org_users.logins', 'org_logins');
|
|
// Add a direct, efficient filter to remove irrelevant personal orgs from consideration.
|
|
query = this._filterByOrgGroups(query, userId, null);
|
|
// The anonymous user is a special case; include only examples from support user.
|
|
if (userId === this.getAnonymousUserId()) {
|
|
query = query.andWhere('orgs.owner_id = :supportId', { supportId });
|
|
}
|
|
}
|
|
query = this._addIsSupportWorkspace(userId, query, 'orgs', 'workspaces');
|
|
// Add access information and query limits
|
|
// TODO: allow generic org limit once sample/support workspace is done differently
|
|
query = this._applyLimit(query, {...scope, org: undefined}, ['orgs', 'workspaces', 'docs'], 'list');
|
|
return query;
|
|
}
|
|
|
|
/**
|
|
* Check if urlId is already in use in the given org, and throw an error if so.
|
|
* If the org is a personal org, we check for use of the urlId in any personal org.
|
|
* If docId is set, we permit the urlId to be in use by that doc.
|
|
*/
|
|
private async _checkForUrlIdConflict(manager: EntityManager, org: Organization, urlId: string, docId?: string) {
|
|
// Prepare a query to see if there is an existing conflicting urlId.
|
|
let aliasQuery = this._docs(manager)
|
|
.leftJoinAndSelect('docs.aliases', 'aliases')
|
|
.leftJoinAndSelect('aliases.org', 'orgs')
|
|
.where('docs.urlId = :urlId', {urlId}); // Place restriction on active urlIds only.
|
|
// Older urlIds are best-effort, and subject to
|
|
// reuse (currently).
|
|
if (org.ownerId === this.getSupportUserId()) {
|
|
// This is the support user. Some of their documents end up as examples on team sites.
|
|
// so urlIds need to be checked globally, which corresponds to placing no extra where
|
|
// clause here.
|
|
} else if (org.ownerId) {
|
|
// This is a personal org, so look for conflicts in any personal org
|
|
// (needed to ensure consistency in merged personal org).
|
|
// We don't need to do anything special about examples since they are stored in a personal
|
|
// org.
|
|
aliasQuery = aliasQuery.andWhere('orgs.owner_id is not null');
|
|
} else {
|
|
// For team sites, just check within the team site.
|
|
// We also need to check within the support@ org for conflict with examples, which
|
|
// currently have an existence within team sites.
|
|
aliasQuery = aliasQuery.andWhere('(aliases.orgId = :orgId OR aliases.orgId = :exampleOrgId)',
|
|
{orgId: org.id, exampleOrgId: this._exampleOrgId});
|
|
}
|
|
if (docId) {
|
|
aliasQuery = aliasQuery.andWhere('docs.id <> :docId', {docId});
|
|
}
|
|
if (await aliasQuery.getOne()) {
|
|
throw new ApiError('urlId already in use', 400);
|
|
}
|
|
// Also forbid any urlId that would match an existing docId, that is a recipe for confusion
|
|
// and mischief.
|
|
if (await this._docs(manager).where('docs.id = :urlId', {urlId}).getOne()) {
|
|
throw new ApiError('urlId already in use as document id', 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the workspace guests with any first-level users of docs inside the workspace.
|
|
*/
|
|
private async _repairWorkspaceGuests(scope: Scope, wsId: number, transaction?: EntityManager): Promise<void> {
|
|
return await this._runInTransaction(transaction, async manager => {
|
|
// Get guest group for workspace.
|
|
const wsQuery = this._workspace(scope, wsId, {manager})
|
|
.leftJoinAndSelect('workspaces.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'groups')
|
|
.leftJoinAndSelect('groups.memberUsers', 'users');
|
|
const workspace: Workspace = (await wsQuery.getOne())!;
|
|
const wsGuestGroup = workspace.aclRules.map(aclRule => aclRule.group)
|
|
.find(_grp => _grp.name === roles.GUEST);
|
|
if (!wsGuestGroup) {
|
|
throw new Error(`_repairWorkspaceGuests error: could not find ${roles.GUEST} ACL group`);
|
|
}
|
|
|
|
// Get explicitly added users of docs inside the workspace, as a separate query
|
|
// to avoid multiplying rows and to allow filtering the result in sql.
|
|
const wsWithDocsQuery = this._workspace(scope, wsId, {manager})
|
|
.leftJoinAndSelect('workspaces.docs', 'docs')
|
|
.leftJoinAndSelect('docs.aclRules', 'doc_acl_rules')
|
|
.leftJoinAndSelect('doc_acl_rules.group', 'doc_groups')
|
|
.leftJoinAndSelect('doc_groups.memberUsers', 'doc_users')
|
|
.andWhere('doc_users.id is not null');
|
|
const wsWithDocs = await wsWithDocsQuery.getOne();
|
|
await this._setGroupUsers(manager, wsGuestGroup.id, wsGuestGroup.memberUsers,
|
|
this._filterEveryone(getResourceUsers(wsWithDocs?.docs || [])));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Updates the org guests with any first-level users of workspaces inside the org.
|
|
* NOTE: If repairing both workspace and org guests, this should always be called AFTER
|
|
* _repairWorkspaceGuests.
|
|
*/
|
|
private async _repairOrgGuests(scope: Scope, orgKey: string|number, transaction?: EntityManager): Promise<void> {
|
|
return await this._runInTransaction(transaction, async manager => {
|
|
const orgQuery = this.org(scope, orgKey, {manager})
|
|
.leftJoinAndSelect('orgs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'groups')
|
|
.leftJoinAndSelect('groups.memberUsers', 'users')
|
|
.andWhere('groups.name = :role', {role: roles.GUEST});
|
|
const org = await orgQuery.getOne();
|
|
if (!org) { throw new Error('cannot find org'); }
|
|
const workspaceQuery = this._workspaces(manager)
|
|
.where('workspaces.org_id = :orgId', {orgId: org.id})
|
|
.leftJoinAndSelect('workspaces.aclRules', 'workspace_acl_rules')
|
|
.leftJoinAndSelect('workspace_acl_rules.group', 'workspace_group')
|
|
.leftJoinAndSelect('workspace_group.memberUsers', 'workspace_users')
|
|
.leftJoinAndSelect('workspaces.org', 'org');
|
|
org.workspaces = await workspaceQuery.getMany();
|
|
const orgGroups = org.aclRules.map(aclRule => aclRule.group);
|
|
if (orgGroups.length !== 1) {
|
|
throw new Error(`_repairOrgGuests error: found ${orgGroups.length} ${roles.GUEST} ACL group(s)`);
|
|
}
|
|
const orgGuestGroup = orgGroups[0]!;
|
|
await this._setGroupUsers(manager, orgGuestGroup.id, orgGuestGroup.memberUsers,
|
|
this._filterEveryone(getResourceUsers(org.workspaces)));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update the set of users in a group. TypeORM's .save() method appears to be
|
|
* unreliable for a ManyToMany relation with a table with a multi-column primary
|
|
* key, so we make the update using explicit deletes and inserts.
|
|
*/
|
|
private async _setGroupUsers(manager: EntityManager, groupId: number, usersBefore: User[],
|
|
usersAfter: User[]) {
|
|
const userIdsBefore = new Set(usersBefore.map(u => u.id));
|
|
const userIdsAfter = new Set(usersAfter.map(u => u.id));
|
|
const toDelete = [...userIdsBefore].filter(id => !userIdsAfter.has(id));
|
|
const toAdd = [...userIdsAfter].filter(id => !userIdsBefore.has(id));
|
|
if (toDelete.length > 0) {
|
|
await manager.createQueryBuilder()
|
|
.delete()
|
|
.from('group_users')
|
|
.whereInIds(toDelete.map(id => ({user_id: id, group_id: groupId})))
|
|
.execute();
|
|
}
|
|
if (toAdd.length > 0) {
|
|
await manager.createQueryBuilder()
|
|
.insert()
|
|
.into('group_users')
|
|
.values(toAdd.map(id => ({user_id: id, group_id: groupId})))
|
|
.execute();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Don't add everyone@ as a guest, unless also sharing with anon@.
|
|
* This means that material shared with everyone@ doesn't become
|
|
* listable/discoverable by default.
|
|
*
|
|
* This is a HACK to allow existing example doc setup to continue to
|
|
* work. It could be removed if we are willing to share the entire
|
|
* support org with users. E.g. move any material we don't want to
|
|
* share into a workspace that doesn't inherit ACLs. TODO: remove
|
|
* this hack, or enhance it up as a way to support discoverability /
|
|
* listing. It has the advantage of cloning well.
|
|
*/
|
|
private _filterEveryone(users: User[]): User[] {
|
|
const everyone = this.getEveryoneUserId();
|
|
const anon = this.getAnonymousUserId();
|
|
if (users.find(u => u.id === anon)) { return users; }
|
|
return users.filter(u => u.id !== everyone);
|
|
}
|
|
|
|
/**
|
|
* Creates, initializes and saves a workspace in the given org with the given properties.
|
|
* Product limits on number of workspaces allowed in org are not checked.
|
|
*/
|
|
private async _doAddWorkspace(org: Organization, props: Partial<WorkspaceProperties>,
|
|
transaction?: EntityManager): Promise<Workspace> {
|
|
if (!props.name) { throw new ApiError('Bad request: name required', 400); }
|
|
return await this._runInTransaction(transaction, async manager => {
|
|
// Create a new workspace.
|
|
const workspace = new Workspace();
|
|
workspace.checkProperties(props);
|
|
workspace.updateFromProperties(props);
|
|
workspace.org = org;
|
|
// Create the special initial permission groups for the new workspace.
|
|
const groupMap = this._createGroups(org);
|
|
workspace.aclRules = this.defaultCommonGroups.map(_grpDesc => {
|
|
// Get the special group with the name needed for this ACL Rule
|
|
const group = groupMap[_grpDesc.name];
|
|
// Add each of the special groups to the new workspace.
|
|
const aclRuleWs = new AclRuleWs();
|
|
aclRuleWs.permissions = _grpDesc.permissions;
|
|
aclRuleWs.group = group;
|
|
aclRuleWs.workspace = workspace;
|
|
return aclRuleWs;
|
|
});
|
|
// Saves the workspace as well as its new ACL Rules and Group.
|
|
const groups = workspace.aclRules.map(rule => rule.group);
|
|
const result = await manager.save([workspace, ...workspace.aclRules, ...groups]);
|
|
return result[0];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* If the user is a manager of the billing account associated with
|
|
* the domain, an extra `billingAccount` field is returned,
|
|
* containing a `inGoodStanding` flag, a `status` json field, and a
|
|
* `product.paid` flag which is true if on a paid plan or false
|
|
* otherwise. Other `billingAccount` fields are included (stripe ids in
|
|
* particular) but these will not be reported across the API.
|
|
*/
|
|
private _addBillingAccount(qb: SelectQueryBuilder<Organization>, userId: number) {
|
|
qb = qb.leftJoinAndSelect('orgs.billingAccount', 'billing_accounts');
|
|
qb = qb.leftJoinAndSelect('billing_accounts.product', 'products');
|
|
qb = qb.leftJoinAndSelect('billing_accounts.managers', 'managers',
|
|
'managers.billing_account_id = billing_accounts.id and ' +
|
|
'managers.user_id = :userId');
|
|
qb = qb.setParameter('userId', userId);
|
|
qb = this._addBillingAccountCalculatedFields(qb);
|
|
return qb;
|
|
}
|
|
|
|
/**
|
|
* Adds any calculated fields related to billing accounts - currently just
|
|
* products.paid.
|
|
*/
|
|
private _addBillingAccountCalculatedFields<T>(qb: SelectQueryBuilder<T>) {
|
|
// We need to sum up whether the account is paid or not, so that UI can provide
|
|
// a "billing" vs "upgrade" link. For the moment, we just check if there is
|
|
// a subscription id. TODO: make sure this is correct in case of free plans.
|
|
qb = qb.addSelect(`(billing_accounts.stripe_subscription_id is not null)`, 'billing_accounts_paid');
|
|
return qb;
|
|
}
|
|
|
|
/**
|
|
* Makes sure that product features for orgs are available in query result.
|
|
*/
|
|
private _addFeatures<T>(qb: SelectQueryBuilder<T>, orgAlias: string = 'orgs') {
|
|
qb = qb.leftJoinAndSelect(`${orgAlias}.billingAccount`, 'billing_accounts');
|
|
qb = qb.leftJoinAndSelect('billing_accounts.product', 'products');
|
|
// orgAlias.billingAccount.product.features should now be available
|
|
return qb;
|
|
}
|
|
|
|
private _addIsSupportWorkspace<T>(users: AvailableUsers, qb: SelectQueryBuilder<T>,
|
|
orgAlias: string, workspaceAlias: string) {
|
|
const supportId = this._specialUserIds[SUPPORT_EMAIL];
|
|
|
|
// We'll be selecting a boolean and naming it as *_support. This matches the
|
|
// SQL name `support` of a column in the Workspace entity whose javascript
|
|
// name is `isSupportWorkspace`.
|
|
const alias = `${workspaceAlias}_support`;
|
|
|
|
// If we happen to be the support user, don't treat our workspaces as anything
|
|
// special, so we can work with them in the ordinary way.
|
|
if (isSingleUser(users) && users === supportId) { return qb.addSelect('false', alias); }
|
|
|
|
// Otherwise, treat workspaces owned by support as special.
|
|
return qb.addSelect(`coalesce(${orgAlias}.owner_id = ${supportId}, false)`, alias);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* Get the id of a special user, creating that user if it is not already present.
|
|
*
|
|
*/
|
|
private async _getSpecialUserId(profile: UserProfile) {
|
|
let id = this._specialUserIds[profile.email];
|
|
if (!id) {
|
|
// get or create user - with retry, since there'll be a race to create the
|
|
// user if a bunch of servers start simultaneously and the user doesn't exist
|
|
// yet.
|
|
const user = await this.getUserByLoginWithRetry(profile.email, {profile});
|
|
if (user) { id = this._specialUserIds[profile.email] = user.id; }
|
|
}
|
|
if (!id) { throw new Error(`Could not find or create user ${profile.email}`); }
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Modify an access level when the document is a fork. Here are the rules, as they
|
|
* have evolved (the main constraint is that currently forks have no access info of
|
|
* their own in the db).
|
|
* - If fork is a snapshot, all users are at most viewers. Else:
|
|
* - If there is no ~USERID in fork id, then all viewers of trunk are owners of the fork.
|
|
* - If there is a ~USERID in fork id, that user is owner, all others are at most viewers.
|
|
*/
|
|
private _setForkAccess(ids: {userId: number, forkUserId?: number, snapshotId?: string},
|
|
res: {access: roles.Role|null}) {
|
|
// Forks without a user id are editable by anyone with view access to the trunk.
|
|
if (ids.forkUserId === undefined && roles.canView(res.access)) { res.access = 'owners'; }
|
|
if (ids.forkUserId !== undefined) {
|
|
// A fork user id is known, so only that user should get to edit the fork.
|
|
if (ids.userId === ids.forkUserId) {
|
|
if (roles.canView(res.access)) { res.access = 'owners'; }
|
|
} else {
|
|
// reduce to viewer if not already viewer
|
|
res.access = roles.getWeakestRole('viewers', res.access);
|
|
}
|
|
}
|
|
// Finally, if we are viewing a snapshot, we can't edit it.
|
|
if (ids.snapshotId) {
|
|
res.access = roles.getWeakestRole('viewers', res.access);
|
|
}
|
|
}
|
|
|
|
// This deals with the problem posed by receiving a PermissionDelta specifying a
|
|
// role for both alice@x and Alice@x. We do not distinguish between such emails.
|
|
// If there are multiple indistinguishabe emails, we preserve just one of them,
|
|
// assigning it the most powerful permission specified. The email variant perserved
|
|
// is the earliest alphabetically.
|
|
private _mergeIndistinguishableEmails(delta: PermissionDelta) {
|
|
if (!delta.users) { return; }
|
|
// We normalize emails for comparison, but track how they were capitalized
|
|
// in order to preserve it. This is worth doing since for the common case
|
|
// of a user being added to a resource prior to ever logging in, their
|
|
// displayEmail will be seeded from this value.
|
|
const displayEmails: {[email: string]: string} = {};
|
|
// This will be our output.
|
|
const users: {[email: string]: roles.NonGuestRole|null} = {};
|
|
for (const displayEmail of Object.keys(delta.users).sort()) {
|
|
const email = normalizeEmail(displayEmail);
|
|
const role = delta.users[displayEmail];
|
|
const key = displayEmails[email] = displayEmails[email] || displayEmail;
|
|
users[key] = users[key] ? roles.getStrongestRole(users[key], role) : role;
|
|
}
|
|
delta.users = users;
|
|
}
|
|
|
|
// Looks up the emails in the permission delta and adds them to the users map in
|
|
// the delta object.
|
|
// Returns a QueryResult based on the validity of the passed in PermissionDelta object.
|
|
private async _verifyAndLookupDeltaEmails(
|
|
userId: number,
|
|
delta: PermissionDelta,
|
|
isOrg: boolean = false,
|
|
transaction?: EntityManager
|
|
): Promise<PermissionDeltaAnalysis> {
|
|
if (!delta) {
|
|
throw new ApiError('Bad request: missing permission delta', 400);
|
|
}
|
|
this._mergeIndistinguishableEmails(delta);
|
|
const hasInherit = 'maxInheritedRole' in delta;
|
|
const hasUsers = delta.users; // allow zero actual changes; useful to reduce special
|
|
// cases in scripts
|
|
if ((isOrg && (hasInherit || !hasUsers)) || (!isOrg && !hasInherit && !hasUsers)) {
|
|
throw new ApiError('Bad request: invalid permission delta', 400);
|
|
}
|
|
// Lookup the email access changes and move them to the users object.
|
|
const userIdMap: {[userId: string]: roles.NonGuestRole|null} = {};
|
|
if (hasInherit) {
|
|
// Verify maxInheritedRole
|
|
const role = delta.maxInheritedRole;
|
|
const validRoles = new Set(this.defaultBasicGroupNames);
|
|
if (role && !validRoles.has(role)) {
|
|
throw new ApiError(`Invalid maxInheritedRole ${role}`, 400);
|
|
}
|
|
}
|
|
if (delta.users) {
|
|
// Verify roles
|
|
const deltaRoles = Object.keys(delta.users).map(_userId => delta.users![_userId]);
|
|
// Cannot set role "members" on workspace/doc.
|
|
const validRoles = new Set(isOrg ? this.defaultNonGuestGroupNames : this.defaultBasicGroupNames);
|
|
for (const role of deltaRoles) {
|
|
if (role && !validRoles.has(role)) {
|
|
throw new ApiError(`Invalid user role ${role}`, 400);
|
|
}
|
|
}
|
|
// Lookup emails
|
|
const emailMap = delta.users;
|
|
const emails = Object.keys(emailMap);
|
|
const emailUsers = await Promise.all(
|
|
emails.map(async email => await this.getUserByLogin(email, {manager: transaction}))
|
|
);
|
|
emails.forEach((email, i) => {
|
|
const userIdAffected = emailUsers[i]!.id;
|
|
// Org-level sharing with everyone would allow serious spamming - forbid it.
|
|
if (emailMap[email] !== null && // allow removing anything
|
|
userId !== this.getSupportUserId() && // allow support user latitude
|
|
userIdAffected === this.getEveryoneUserId() &&
|
|
isOrg) {
|
|
throw new ApiError('This user cannot share with everyone at top level', 403);
|
|
}
|
|
userIdMap[userIdAffected] = emailMap[email];
|
|
});
|
|
}
|
|
const userIdDelta = delta.users ? userIdMap : null;
|
|
const userIds = Object.keys(userIdDelta || {});
|
|
const removingSelf = userIds.length === 1 && userIds[0] === String(userId) &&
|
|
delta.maxInheritedRole === undefined && userIdDelta?.[userId] === null;
|
|
const permissionThreshold = removingSelf ? Permissions.VIEW : Permissions.ACL_EDIT;
|
|
return {
|
|
userIdDelta,
|
|
permissionThreshold,
|
|
affectsSelf: userId in userIdMap,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A helper to throw an error if a user with ACL_EDIT permission attempts
|
|
* to change their own access rights. The user permissions are expected to
|
|
* be in the supplied QueryResult, or if none is supplied are assumed to be
|
|
* ACL_EDIT.
|
|
*/
|
|
private _failIfPowerfulAndChangingSelf(analysis: PermissionDeltaAnalysis, result?: QueryResult<any>) {
|
|
const permissions: Permissions = result ? result.data.permissions : Permissions.ACL_EDIT;
|
|
if (permissions === undefined) {
|
|
throw new Error('Query malformed');
|
|
}
|
|
if ((permissions & Permissions.ACL_EDIT) && analysis.affectsSelf) {
|
|
// editors don't get to remove themselves.
|
|
// TODO: Consider when to allow updating own permissions - allowing updating own
|
|
// permissions indiscriminately could lead to orphaned resources.
|
|
throw new ApiError('Bad request: cannot update own permissions', 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper for adjusting acl rules. Given an array of top-level groups from the resource
|
|
* of interest, returns the updated groups. The returned groups should be saved to
|
|
* update the group inheritance in the database. Updates the passed in groups.
|
|
*
|
|
* NOTE that all group memberUsers must be populated.
|
|
*/
|
|
private async _updateUserPermissions(
|
|
groups: NonGuestGroup[],
|
|
userDelta: UserIdDelta,
|
|
manager: EntityManager
|
|
): Promise<void> {
|
|
// Get the user objects which map to non-null values in the userDelta.
|
|
const userIds = Object.keys(userDelta).filter(userId => userDelta[userId])
|
|
.map(userIdStr => parseInt(userIdStr, 10));
|
|
const users = await this._getUsers(userIds, manager);
|
|
|
|
// Add unaffected users to the delta so that we have a record of where they are.
|
|
groups.forEach(grp => {
|
|
grp.memberUsers.forEach(usr => {
|
|
if (!(usr.id in userDelta)) {
|
|
userDelta[usr.id] = grp.name;
|
|
users.push(usr);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Create mapping from group names to top-level groups (contain the inherited groups)
|
|
const topGroups: {[groupName: string]: NonGuestGroup} = {};
|
|
groups.forEach(grp => {
|
|
// Note that this has a side effect of resetting the memberUsers arrays.
|
|
grp.memberUsers = [];
|
|
topGroups[grp.name] = grp;
|
|
});
|
|
|
|
// Add users to groups (this has a side-effect of updating the group memberUsers)
|
|
users.forEach(user => {
|
|
const groupName = userDelta[user.id]!;
|
|
// NOTE that the special names constant is ordered from least to most permissive.
|
|
// The destination must be a reserved inheritance group or null.
|
|
if (groupName && !this.defaultNonGuestGroupNames.includes(groupName)) {
|
|
throw new Error(`_updateUserPermissions userDelta contains invalid group`);
|
|
}
|
|
topGroups[groupName].memberUsers.push(user);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run an operation in an existing transaction if available, otherwise create
|
|
* a new transaction for it.
|
|
*
|
|
* @param transaction: the manager of an existing transaction, or undefined.
|
|
* @param op: the operation to run in a transaction.
|
|
*/
|
|
private _runInTransaction(transaction: EntityManager|undefined,
|
|
op: (manager: EntityManager) => Promise<any>): Promise<any> {
|
|
if (transaction) { return op(transaction); }
|
|
return this._connection.transaction(op);
|
|
}
|
|
|
|
/**
|
|
* Returns a Promise for an array of User entites for the given userIds.
|
|
*/
|
|
private async _getUsers(userIds: number[], optManager?: EntityManager): Promise<User[]> {
|
|
if (userIds.length === 0) {
|
|
return [];
|
|
}
|
|
const manager = optManager || new EntityManager(this._connection);
|
|
const queryBuilder = manager.createQueryBuilder()
|
|
.select('users')
|
|
.from(User, 'users')
|
|
.where('users.id IN (:...userIds)', {userIds});
|
|
return await queryBuilder.getMany();
|
|
}
|
|
|
|
/**
|
|
* Aggregate the given columns as a json object. The keys should be simple
|
|
* alphanumeric strings, and the values should be the names of sql columns -
|
|
* this method is not set up to quote concrete values.
|
|
*/
|
|
private _aggJsonObject(content: {[key: string]: string}): string {
|
|
const args = [...Object.keys(content).map(key => [`'${key}'`, content[key]])];
|
|
if (this._dbType === 'postgres') {
|
|
return `json_agg(json_build_object(${args.join(',')}))`;
|
|
} else {
|
|
return `json_group_array(json_object(${args.join(',')}))`;
|
|
}
|
|
}
|
|
|
|
private _docs(manager?: EntityManager) {
|
|
return (manager || this._connection).createQueryBuilder()
|
|
.select('docs')
|
|
.from(Document, 'docs');
|
|
}
|
|
|
|
/**
|
|
* Construct a QueryBuilder for a select query on a specific doc given by urlId.
|
|
* Provides options for running in a transaction and adding permission info.
|
|
* See QueryOptions documentation above.
|
|
*
|
|
* In order to accept urlIds, the aliases, workspaces, and orgs tables are joined.
|
|
*/
|
|
private _doc(scope: DocScope, options: QueryOptions = {}): SelectQueryBuilder<Document> {
|
|
const {urlId, userId} = scope;
|
|
// Check if doc is being accessed with a merged org url. If so,
|
|
// we will only filter urlId matches, and will allow docId matches
|
|
// for team site documents. This is for backwards compatibility,
|
|
// to support https://docs.getgrist.com/api/docs/<docid> for team
|
|
// site documents.
|
|
const mergedOrg = this.isMergedOrg(scope.org || null);
|
|
let query = this._docs(options.manager)
|
|
.leftJoinAndSelect('docs.workspace', 'workspaces')
|
|
.leftJoinAndSelect('workspaces.org', 'orgs')
|
|
.leftJoinAndSelect('docs.aliases', 'aliases')
|
|
.where(new Brackets(cond => {
|
|
return cond
|
|
.where('docs.id = :urlId', {urlId})
|
|
.orWhere(new Brackets(urlIdCond => {
|
|
let urlIdQuery = urlIdCond
|
|
.where('aliases.url_id = :urlId', {urlId})
|
|
.andWhere('aliases.org_id = orgs.id');
|
|
if (mergedOrg) {
|
|
// Filter specifically for merged org documents.
|
|
urlIdQuery = urlIdQuery.andWhere('orgs.owner_id is not null');
|
|
}
|
|
return urlIdQuery;
|
|
}));
|
|
}));
|
|
// TODO includeSupport should really be false, and the support for it should be removed.
|
|
// (For this, example doc URLs should be under docs.getgrist.com rather than team domains.)
|
|
// Add access information and query limits
|
|
query = this._applyLimit(query, {...scope, includeSupport: true}, ['docs', 'workspaces', 'orgs'], 'open');
|
|
if (options.markPermissions) {
|
|
let effectiveUserId = userId;
|
|
let threshold = options.markPermissions;
|
|
if (options.allowSpecialPermit && scope.specialPermit && scope.specialPermit.docId) {
|
|
query = query.andWhere('docs.id = :docId', {docId: scope.specialPermit.docId});
|
|
effectiveUserId = this.getPreviewerUserId();
|
|
threshold = Permissions.VIEW;
|
|
}
|
|
// Compute whether we have access to the doc
|
|
query = query.addSelect(
|
|
this._markIsPermitted('docs', effectiveUserId, 'open', threshold),
|
|
'is_permitted'
|
|
);
|
|
}
|
|
return query;
|
|
}
|
|
|
|
private _workspaces(manager?: EntityManager) {
|
|
return (manager || this._connection).createQueryBuilder()
|
|
.select('workspaces')
|
|
.from(Workspace, 'workspaces');
|
|
}
|
|
|
|
/**
|
|
* Construct "ON" clause for joining docs. This clause takes care of filtering
|
|
* out any docs that are not to be listed due to soft deletion. This filtering
|
|
* is done in the "ON" clause rather than in a "WHERE" clause since we still
|
|
* want to list workspaces even if there are no docs within them. A "WHERE" clause
|
|
* would entirely remove information about a workspace with no docs. The "ON"
|
|
* clause, in combination with a "LEFT JOIN", preserves the workspace information
|
|
* and just sets doc information to NULL.
|
|
*/
|
|
private _onDoc(scope: Scope) {
|
|
const onDefault = 'docs.workspace_id = workspaces.id';
|
|
if (scope.showAll) {
|
|
return onDefault;
|
|
} else if (scope.showOnlyPinned) {
|
|
return `${onDefault} AND docs.is_pinned = TRUE AND (workspaces.removed_at IS NULL AND docs.removed_at IS NULL)`;
|
|
} else if (scope.showRemoved) {
|
|
return `${onDefault} AND (workspaces.removed_at IS NOT NULL OR docs.removed_at IS NOT NULL)`;
|
|
} else {
|
|
return `${onDefault} AND (workspaces.removed_at IS NULL AND docs.removed_at IS NULL)`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Construct a QueryBuilder for a select query on a specific workspace given by
|
|
* wsId. Provides options for running in a transaction and adding permission info.
|
|
* See QueryOptions documentation above.
|
|
*/
|
|
private _workspace(scope: Scope, wsId: number, options: QueryOptions = {}): SelectQueryBuilder<Workspace> {
|
|
let query = this._workspaces(options.manager)
|
|
.where('workspaces.id = :wsId', {wsId});
|
|
if (options.markPermissions) {
|
|
let effectiveUserId = scope.userId;
|
|
let threshold = options.markPermissions;
|
|
if (options.allowSpecialPermit && scope.specialPermit &&
|
|
scope.specialPermit.workspaceId === wsId) {
|
|
effectiveUserId = this.getPreviewerUserId();
|
|
threshold = Permissions.VIEW;
|
|
}
|
|
// Compute whether we have access to the doc
|
|
query = query.addSelect(
|
|
this._markIsPermitted('workspaces', effectiveUserId, 'open', threshold),
|
|
'is_permitted'
|
|
);
|
|
}
|
|
return query;
|
|
}
|
|
|
|
private _orgs(manager?: EntityManager) {
|
|
return (manager || this._connection).createQueryBuilder()
|
|
.select('orgs')
|
|
.from(Organization, 'orgs');
|
|
}
|
|
|
|
// Adds a where clause to filter orgs by domain or id.
|
|
// If org is null, filter for user's personal org.
|
|
// if includeSupport is true, include the org of the support@ user (for the Samples workspace)
|
|
private _whereOrg<T extends WhereExpression>(qb: T, org: string|number, includeSupport = false): T {
|
|
if (this.isMergedOrg(org)) {
|
|
// Select from universe of personal orgs.
|
|
// Don't panic though! While this means that SQL can't use an organization id
|
|
// to narrow down queries, it will still be filtering via joins against the user and
|
|
// groups the user belongs to.
|
|
qb = qb.andWhere('orgs.owner_id is not null');
|
|
return qb;
|
|
}
|
|
// Always include the org of the support@ user, which contains the Samples workspace,
|
|
// which we always show. (For isMergedOrg case, it's already included.)
|
|
if (includeSupport) {
|
|
const supportId = this._specialUserIds[SUPPORT_EMAIL];
|
|
return qb.andWhere(new Brackets((q) =>
|
|
this._wherePlainOrg(q, org).orWhere('orgs.owner_id = :supportId', {supportId})));
|
|
} else {
|
|
return this._wherePlainOrg(qb, org);
|
|
}
|
|
}
|
|
|
|
private _wherePlainOrg<T extends WhereExpression>(qb: T, org: string|number): T {
|
|
if (typeof org === 'number') {
|
|
return qb.andWhere('orgs.id = :org', {org});
|
|
}
|
|
if (org.startsWith(`docs-${this._idPrefix}`)) {
|
|
// this is someone's personal org
|
|
const ownerId = org.split(`docs-${this._idPrefix}`)[1];
|
|
qb = qb.andWhere('orgs.owner_id = :ownerId', {ownerId});
|
|
} else if (org.startsWith(`o-${this._idPrefix}`)) {
|
|
// this is an org identified by org id
|
|
const orgId = org.split(`o-${this._idPrefix}`)[1];
|
|
qb = qb.andWhere('orgs.id = :orgId', {orgId});
|
|
} else {
|
|
// this is a regular domain
|
|
qb = qb.andWhere('orgs.domain = :org', {org});
|
|
}
|
|
return qb;
|
|
}
|
|
|
|
private _withAccess(qb: SelectQueryBuilder<any>, users: AvailableUsers,
|
|
table: 'orgs'|'workspaces'|'docs',
|
|
accessStyle: AccessStyle = 'open') {
|
|
return qb
|
|
.addSelect(this._markIsPermitted(table, users, accessStyle, null), `${table}_permissions`);
|
|
}
|
|
|
|
/**
|
|
* Filter for orgs for which the user is a member of a group (or which are shared
|
|
* with "everyone@"). For access to workspaces and docs, we rely on the fact that
|
|
* the user will be added to a guest group at the organization level.
|
|
*
|
|
* If AvailableUsers is a profile list, we do NOT include orgs accessible
|
|
* via "everyone@" (this affects the "api/session/access/all" endpoint).
|
|
*
|
|
* Otherwise, orgs shared with "everyone@" are candidates for inclusion.
|
|
* If an orgKey is supplied, it is the only org which will be considered
|
|
* for inclusion on the basis of sharing with "everyone@". TODO: consider
|
|
* whether this wrinkle is needed anymore, or can be safely removed.
|
|
*/
|
|
private _filterByOrgGroups(qb: SelectQueryBuilder<Organization>, users: AvailableUsers,
|
|
orgKey: string|number|null,
|
|
options?: {ignoreEveryoneShares?: boolean}) {
|
|
qb = qb
|
|
.leftJoin('orgs.aclRules', 'acl_rules')
|
|
.leftJoin('acl_rules.group', 'groups')
|
|
.leftJoin('groups.memberUsers', 'members');
|
|
if (isSingleUser(users)) {
|
|
// Add an exception for the previewer user, if present.
|
|
const previewerId = this._specialUserIds[PREVIEWER_EMAIL];
|
|
if (users === previewerId) { return qb; }
|
|
const everyoneId = this._specialUserIds[EVERYONE_EMAIL];
|
|
if (options?.ignoreEveryoneShares) {
|
|
return qb.where('members.id = :userId', {userId: users});
|
|
}
|
|
return qb.andWhere(new Brackets(cond => {
|
|
// Accept direct membership, or via a share with "everyone@".
|
|
return cond
|
|
.where('members.id = :userId', {userId: users})
|
|
.orWhere(new Brackets(everyoneCond => {
|
|
const everyoneQuery = everyoneCond.where('members.id = :everyoneId', {everyoneId});
|
|
return (orgKey !== null) ? this._whereOrg(everyoneQuery, orgKey) : everyoneQuery;
|
|
}));
|
|
}));
|
|
}
|
|
|
|
// The user hasn't been narrowed down to one choice, so join against logins and
|
|
// check normalized email.
|
|
const emails = new Set(users.map(profile => normalizeEmail(profile.email)));
|
|
// Empty list needs to be special-cased since "in ()" isn't supported in postgres.
|
|
if (emails.size === 0) { return qb.andWhere('1 = 0'); }
|
|
return qb
|
|
.leftJoin('members.logins', 'memberLogins')
|
|
.andWhere('memberLogins.email in (:...emails)', {emails: [...emails]});
|
|
}
|
|
|
|
private _single(result: QueryResult<any>) {
|
|
if (result.status === 200) {
|
|
// TODO: assert result is really singular.
|
|
result.data = result.data[0];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Helper for adjusting acl inheritance rules. Given an array of top-level groups from the
|
|
* resource of interest, and an array of inherited groups belonging to the parent resource,
|
|
* moves the inherited groups to the group with the destination name or lower, if their
|
|
* permission level is lower. If the destination group name is omitted, the groups are
|
|
* moved to their original inheritance locations. If the destination group name is null,
|
|
* the groups are all removed and there is no access inheritance to this resource.
|
|
* Returns the updated array of top-level groups. These returned groups should be saved
|
|
* to update the group inheritance in the database.
|
|
*
|
|
* For all passed-in groups, their .memberGroups will be reset. For
|
|
* the basic roles (owner | editor | viewer), these will get updated
|
|
* to include inheritedGroups, with roles reduced to dest when dest
|
|
* is given. All of the basic roles must be present among
|
|
* groups. Any non-basic roles present among inheritedGroups will be
|
|
* ignored.
|
|
*
|
|
* Does not modify inheritedGroups.
|
|
*/
|
|
private _moveInheritedGroups(
|
|
groups: NonGuestGroup[], inheritedGroups: Group[], dest?: roles.BasicRole|null
|
|
): void {
|
|
// Limit scope to those inheritedGroups that have basic roles (viewers, editors, owners).
|
|
inheritedGroups = inheritedGroups.filter(group => roles.isBasicRole(group.name));
|
|
|
|
// NOTE that the special names constant is ordered from least to most permissive.
|
|
const reverseDefaultNames = this.defaultBasicGroupNames.reverse();
|
|
|
|
// The destination must be a reserved inheritance group or null.
|
|
if (dest && !reverseDefaultNames.includes(dest)) {
|
|
throw new Error('moveInheritedGroups called with invalid destination name');
|
|
}
|
|
|
|
// Mapping from group names to top-level groups
|
|
const topGroups: {[groupName: string]: NonGuestGroup} = {};
|
|
groups.forEach(grp => {
|
|
// Note that this has a side effect of initializing the memberGroups arrays.
|
|
grp.memberGroups = [];
|
|
topGroups[grp.name] = grp;
|
|
});
|
|
|
|
// The destFunc maps from an inherited group to its required top-level group name.
|
|
const destFunc = (inherited: Group) =>
|
|
dest === null ? null : reverseDefaultNames.find(sp => sp === inherited.name || sp === dest);
|
|
|
|
// Place inherited groups (this has the side-effect of updating member groups)
|
|
inheritedGroups.forEach(grp => {
|
|
if (!roles.isBasicRole(grp.name)) {
|
|
// We filtered out such groups at the start of this method, but just in case...
|
|
throw new Error(`${grp.name} is not an inheritable group`);
|
|
}
|
|
const moveTo = destFunc(grp);
|
|
if (moveTo) {
|
|
topGroups[moveTo].memberGroups.push(grp);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a name to group mapping for the standard groups. Useful when adding a new child
|
|
* entity. Finds and includes the correct parent groups as member groups.
|
|
*/
|
|
private _createGroups(inherit?: Organization|Workspace): {[name: string]: Group} {
|
|
const groupMap: {[name: string]: Group} = {};
|
|
this.defaultGroups.forEach(groupProps => {
|
|
if (!groupProps.orgOnly || !inherit) {
|
|
// Skip this group if it's an org only group and the resource inherits from a parent.
|
|
const group = new Group();
|
|
group.name = groupProps.name;
|
|
if (inherit) {
|
|
this._setInheritance(group, inherit);
|
|
}
|
|
groupMap[groupProps.name] = group;
|
|
}
|
|
});
|
|
return groupMap;
|
|
}
|
|
|
|
// Sets the given group to inherit the groups in the given parent resource.
|
|
private _setInheritance(group: Group, parent: Organization|Workspace) {
|
|
// Add the parent groups to the group
|
|
const groupProps = this.defaultGroups.find(special => special.name === group.name);
|
|
if (!groupProps) {
|
|
throw new Error(`Non-standard group passed to _addInheritance: ${group.name}`);
|
|
}
|
|
if (groupProps.nestParent) {
|
|
const parentGroups = (parent.aclRules as AclRule[]).map((_aclRule: AclRule) => _aclRule.group);
|
|
const inheritGroup = parentGroups.find((_parentGroup: Group) => _parentGroup.name === group.name);
|
|
if (!inheritGroup) {
|
|
throw new Error(`Special group ${group.name} not found in ${parent.name} for inheritance`);
|
|
}
|
|
group.memberGroups = [inheritGroup];
|
|
}
|
|
}
|
|
|
|
// Return a QueryResult reflecting the output of a query builder.
|
|
// If a rawQueryBuilder is supplied, it is used to make the query,
|
|
// but then the original queryBuilder is used to interpret the results
|
|
// as entities (make sure the two queries give results in the same format!)
|
|
// Checks on all "permissions" fields which select queries set on
|
|
// resources to indicate whether the user has access.
|
|
// If the output is empty, and `emptyAllowed` is not set, we signal that the desired
|
|
// resource does not exist (404).
|
|
// If the overall permissions do not allow viewing, we signal that the resource is forbidden.
|
|
// Access fields are added to all entities giving the group name corresponding
|
|
// with the access level of the user.
|
|
// Returns the resource fetched by the queryBuilder.
|
|
private async _verifyAclPermissions<T extends Resource>(
|
|
queryBuilder: SelectQueryBuilder<T>,
|
|
options: {
|
|
rawQueryBuilder?: SelectQueryBuilder<any>,
|
|
emptyAllowed?: boolean,
|
|
scope?: Scope,
|
|
} = {}
|
|
): Promise<QueryResult<any>> {
|
|
const results = await (options.rawQueryBuilder ?
|
|
getRawAndEntities(options.rawQueryBuilder, queryBuilder) :
|
|
queryBuilder.getRawAndEntities());
|
|
if (results.entities.length === 0 ||
|
|
(results.entities.length === 1 && results.entities[0].filteredOut)) {
|
|
if (options.emptyAllowed) { return {status: 200, data: []}; }
|
|
return {errMessage: `${getFrom(queryBuilder)} not found`, status: 404};
|
|
}
|
|
const resources = this._normalizeQueryResults(results.entities, {
|
|
scope: options.scope,
|
|
});
|
|
if (resources.length === 0 && !options.emptyAllowed) {
|
|
return {errMessage: "access denied", status: 403};
|
|
} else {
|
|
return {
|
|
status: 200,
|
|
data: resources
|
|
};
|
|
}
|
|
}
|
|
|
|
// Normalize query results in the following ways:
|
|
// * Convert `permissions` fields to summary `access` fields.
|
|
// * Set appropriate `domain` fields for personal organizations.
|
|
// * Include `billingAccount` field only for a billing account manager.
|
|
// * Replace `user.logins` objects with user.email and user.anonymous.
|
|
// * Collapse fields from nested `manager.user` objects into the surrounding
|
|
// `manager` objects.
|
|
//
|
|
// Find any nested entities with a "permissions" field, and add to them an
|
|
// "access" field (if the permission is a simple number) or an "accessOptions"
|
|
// field (if the permission is json). Entities in a list that the user doesn't
|
|
// have the right to access may be removed.
|
|
// * They are removed for workspaces in orgs.
|
|
// * They are not removed for docs in workspaces, if user has right to delete
|
|
// the workspace.
|
|
//
|
|
// When returning organizations, set the domain to docs-${userId} for personal orgs.
|
|
// We could also have simply stored that domain in the database, but have kept
|
|
// them out for now, for the flexibility to change how we want these kinds of orgs
|
|
// to be presented without having to do awkward migrations.
|
|
//
|
|
// The suppressDomain option ensures that any organization domains are given
|
|
// in ugly o-NNNN form.
|
|
private _normalizeQueryResults(value: any,
|
|
options: {
|
|
suppressDomain?: boolean,
|
|
scope?: Scope,
|
|
parentPermissions?: number,
|
|
} = {}): any {
|
|
// We only need to examine objects, excluding null.
|
|
if (typeof value !== 'object' || value === null) { return value; }
|
|
// For arrays, add access information and remove anything user should not see.
|
|
if (Array.isArray(value)) {
|
|
const items = value.map(v => this._normalizeQueryResults(v, options));
|
|
// If the items are not workspaces, and the user can delete their parent, then
|
|
// ignore the user's access level when deciding whether to filter them out or
|
|
// to keep them.
|
|
const ignoreAccess = options.parentPermissions &&
|
|
(options.parentPermissions & Permissions.REMOVE) && // tslint:disable-line:no-bitwise
|
|
items.length > 0 && !items[0].docs;
|
|
return items.filter(v => !this._isForbidden(v, Boolean(ignoreAccess), options.scope));
|
|
}
|
|
// For hashes, iterate through key/values, adding access info if 'permissions' field is found.
|
|
if (value.billingAccount) {
|
|
// This is an organization with billing account information available. Check limits.
|
|
const org = value as Organization;
|
|
const features = org.billingAccount.product.features;
|
|
if (!features.vanityDomain) {
|
|
// Vanity domain not allowed for this org.
|
|
options = {...options, suppressDomain: true};
|
|
}
|
|
}
|
|
const permissions = (typeof value.permissions === 'number') ? value.permissions : undefined;
|
|
const childOptions = { ...options, parentPermissions: permissions };
|
|
for (const key of Object.keys(value)) {
|
|
const subValue = value[key];
|
|
// When returning organizations, set the domain to docs-${userId} for personal orgs.
|
|
// We could also have simply stored that domain in the database. I'd prefer to keep
|
|
// them out for now, for the flexibility to change how we want these kinds of orgs
|
|
// to be presented without having to do awkward migrations.
|
|
if (key === 'domain') {
|
|
value[key] = this.normalizeOrgDomain(value.id, subValue, value.owner && value.owner.id,
|
|
false, options.suppressDomain);
|
|
continue;
|
|
}
|
|
if (key === 'billingAccount') {
|
|
if (value[key].managers) {
|
|
value[key].isManager = Boolean(value[key].managers.length);
|
|
delete value[key].managers;
|
|
}
|
|
continue;
|
|
}
|
|
if (key === 'logins') {
|
|
const logins = subValue;
|
|
delete value[key];
|
|
if (logins.length !== 1) {
|
|
throw new ApiError('Cannot find unique login for user', 500);
|
|
}
|
|
value.email = logins[0].displayEmail;
|
|
value.anonymous = (logins[0].userId === this.getAnonymousUserId());
|
|
continue;
|
|
}
|
|
if (key === 'managers') {
|
|
const managers = this._normalizeQueryResults(subValue, childOptions);
|
|
for (const manager of managers) {
|
|
if (manager.user) {
|
|
Object.assign(manager, manager.user);
|
|
delete manager.user;
|
|
}
|
|
}
|
|
value[key] = managers;
|
|
continue;
|
|
}
|
|
if (key === 'prefs' && Array.isArray(subValue)) {
|
|
delete value[key];
|
|
const prefs = this._normalizeQueryResults(subValue, childOptions);
|
|
for (const pref of prefs) {
|
|
if (pref.orgId && pref.userId) {
|
|
value.userOrgPrefs = pref.prefs;
|
|
} else if (pref.orgId) {
|
|
value.orgPrefs = pref.prefs;
|
|
} else if (pref.userId) {
|
|
value.userPrefs = pref.prefs;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (key !== 'permissions') {
|
|
value[key] = this._normalizeQueryResults(subValue, childOptions);
|
|
continue;
|
|
}
|
|
if (typeof subValue === 'number' || !subValue) {
|
|
// Find the first special group for which the user has all permissions.
|
|
value.access = this._getRoleFromPermissions(subValue || 0);
|
|
if (subValue & Permissions.PUBLIC) { // tslint:disable-line:no-bitwise
|
|
value.public = true;
|
|
}
|
|
} else {
|
|
// Resource may be accessed by multiple users, encoded in JSON.
|
|
const accessOptions: AccessOption[] = readJson(this._dbType, subValue);
|
|
value.accessOptions = accessOptions.map(option => ({
|
|
access: this._getRoleFromPermissions(option.perms), ...option
|
|
}));
|
|
}
|
|
delete value.permissions; // permissions is not specified in the api, so we drop it.
|
|
}
|
|
return value;
|
|
}
|
|
|
|
// entity is forbidden if it contains an access field set to null, or an accessOptions field
|
|
// that is the empty list.
|
|
private _isForbidden(entity: any, ignoreAccess: boolean, scope?: Scope): boolean {
|
|
if (!entity) { return false; }
|
|
if (entity.filteredOut) { return true; }
|
|
// Specifically for workspaces (as determined by having a "docs" field):
|
|
// if showing trash, and the workspace looks empty, and the workspace is itself
|
|
// not marked as trash, then filter it out. This situation can arise when there is
|
|
// a trash doc in a workspace that the user does not have access to, and also a
|
|
// doc that the user does have access to.
|
|
if (entity.docs && scope?.showRemoved && entity.docs.length === 0 &&
|
|
!entity.removedAt) { return true; }
|
|
if (ignoreAccess) { return false; }
|
|
if (entity.access === null) { return true; }
|
|
if (!entity.accessOptions) { return false; }
|
|
return entity.accessOptions.length === 0;
|
|
}
|
|
|
|
// Returns the most permissive default role that does not have more permissions than the passed
|
|
// in argument.
|
|
private _getRoleFromPermissions(permissions: number): roles.Role|null {
|
|
permissions &= ~Permissions.PUBLIC; // tslint:disable-line:no-bitwise
|
|
const group = this.defaultBasicGroups.find(grp =>
|
|
(permissions & grp.permissions) === grp.permissions); // tslint:disable-line:no-bitwise
|
|
return group ? group.name : null;
|
|
}
|
|
|
|
// Returns the maxInheritedRole group name set on a resource.
|
|
// The resource's aclRules, groups, and memberGroups must be populated.
|
|
private _getMaxInheritedRole(res: Workspace|Document): roles.BasicRole|null {
|
|
const groups = (res.aclRules as AclRule[]).map((_aclRule: AclRule) => _aclRule.group);
|
|
let maxInheritedRole: roles.NonGuestRole|null = null;
|
|
for (const name of this.defaultBasicGroupNames) {
|
|
const group = groups.find(_grp => _grp.name === name);
|
|
if (!group) {
|
|
throw new Error(`Error in _getMaxInheritedRole: group ${name} not found in ${res.name}`);
|
|
}
|
|
if (group.memberGroups.length > 0) {
|
|
maxInheritedRole = name;
|
|
break;
|
|
}
|
|
}
|
|
return roles.getEffectiveRole(maxInheritedRole);
|
|
}
|
|
|
|
/**
|
|
* Return a query builder to check if we have access to the given resource.
|
|
* Tests the given permission-level access, defaulting to view permission.
|
|
* @param resType: type of resource (table name)
|
|
* @param userId: id of user accessing the resource
|
|
* @param permissions: permission to test for - if null, we return the permissions
|
|
*/
|
|
private _markIsPermitted(
|
|
resType: 'orgs'|'workspaces'|'docs',
|
|
users: AvailableUsers,
|
|
accessStyle: AccessStyle,
|
|
permissions: Permissions|null = Permissions.VIEW
|
|
): (qb: SelectQueryBuilder<any>) => SelectQueryBuilder<any> {
|
|
const idColumn = resType.slice(0, -1) + "_id";
|
|
return qb => {
|
|
const getBasicPermissions = (q: SelectQueryBuilder<any>) => {
|
|
if (permissions !== null) {
|
|
q = q.select('acl_rules.permissions');
|
|
} else {
|
|
const everyoneId = this._specialUserIds[EVERYONE_EMAIL];
|
|
const anonId = this._specialUserIds[ANONYMOUS_USER_EMAIL];
|
|
// Overall permissions are the bitwise-or of all individual
|
|
// permissions from ACL rules. We also include
|
|
// Permissions.PUBLIC if any of the ACL rules are for the
|
|
// public (shared with everyone@ or anon@). This could be
|
|
// optimized if we eliminate one of those users. The guN
|
|
// aliases are joining in _getUsersAcls, and refer to the
|
|
// group_users table at different levels of nesting.
|
|
|
|
// When listing, everyone@ shares do not contribute to access permissions,
|
|
// 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.
|
|
|
|
// 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, `(${publicFlagSql} | ${contributionSql} | ${previewerSql})`, 8),
|
|
'permissions'
|
|
);
|
|
}
|
|
q = q.from('acl_rules', 'acl_rules');
|
|
q = this._getUsersAcls(q, users, accessStyle);
|
|
q = q.andWhere(`acl_rules.${idColumn} = ${resType}.id`);
|
|
if (permissions !== null) {
|
|
q = q.andWhere(`(acl_rules.permissions & ${permissions}) = ${permissions}`).limit(1);
|
|
} else if (!isSingleUser(users)) {
|
|
q = q.addSelect('profiles.id');
|
|
q = q.addSelect('profiles.display_email');
|
|
q = q.addSelect('profiles.name');
|
|
// anything we select without aggregating, we must also group by (postgres is fussy
|
|
// about this)
|
|
q = q.groupBy('profiles.id');
|
|
q = q.addGroupBy('profiles.display_email');
|
|
q = q.addGroupBy('profiles.name');
|
|
}
|
|
return q;
|
|
};
|
|
if (isSingleUser(users)) {
|
|
return getBasicPermissions(qb.subQuery());
|
|
} else {
|
|
return qb.subQuery()
|
|
.from(subQb => getBasicPermissions(subQb.subQuery()), 'options')
|
|
.select(this._aggJsonObject({id: 'options.id',
|
|
email: 'options.display_email',
|
|
perms: 'options.permissions',
|
|
name: 'options.name'}));
|
|
}
|
|
};
|
|
}
|
|
|
|
// Takes a query that includes acl_rules, and filters for just those acl_rules that apply
|
|
// to the user, either directly or via up to three layers of nested groups. Two layers are
|
|
// sufficient for our current ACL setup. A third is added as a low-cost preparation
|
|
// for implementing something like teams in the future. It has no measurable effect on
|
|
// speed.
|
|
private _getUsersAcls(qb: SelectQueryBuilder<any>, users: AvailableUsers,
|
|
accessStyle: AccessStyle) {
|
|
// Every acl_rule is associated with a single group. A user may
|
|
// be a direct member of that group, via the group_users table.
|
|
// Or they may be a member of a group that is a member of that
|
|
// group, via group_groups. Or they may be even more steps
|
|
// removed. We unroll to a fixed number of steps, and use joins
|
|
// rather than a recursive query, since we need this step to be as
|
|
// fast as possible.
|
|
qb = qb
|
|
// filter for the specified user being a direct or indirect member of the acl_rule's group
|
|
.where(new Brackets(cond => {
|
|
if (isSingleUser(users)) {
|
|
// Users is an integer, so ok to insert into sql. It we
|
|
// didn't, we'd need to use distinct parameter names, since
|
|
// we may include this code with different user ids in the
|
|
// same query
|
|
cond = cond.where(`gu0.user_id = ${users}`);
|
|
cond = cond.orWhere(`gu1.user_id = ${users}`);
|
|
cond = cond.orWhere(`gu2.user_id = ${users}`);
|
|
cond = cond.orWhere(`gu3.user_id = ${users}`);
|
|
// Support the special "everyone" user.
|
|
const everyoneId = this._specialUserIds[EVERYONE_EMAIL];
|
|
if (everyoneId === undefined) {
|
|
throw new Error("Special user id for EVERYONE_EMAIL not found");
|
|
}
|
|
cond = cond.orWhere(`gu0.user_id = ${everyoneId}`);
|
|
cond = cond.orWhere(`gu1.user_id = ${everyoneId}`);
|
|
cond = cond.orWhere(`gu2.user_id = ${everyoneId}`);
|
|
cond = cond.orWhere(`gu3.user_id = ${everyoneId}`);
|
|
if (accessStyle === 'list') {
|
|
// Support also the special anonymous user. Currently, by convention, sharing a
|
|
// resource with anonymous should make it listable.
|
|
const anonId = this._specialUserIds[ANONYMOUS_USER_EMAIL];
|
|
if (anonId === undefined) {
|
|
throw new Error("Special user id for ANONYMOUS_USER_EMAIL not found");
|
|
}
|
|
cond = cond.orWhere(`gu0.user_id = ${anonId}`);
|
|
cond = cond.orWhere(`gu1.user_id = ${anonId}`);
|
|
cond = cond.orWhere(`gu2.user_id = ${anonId}`);
|
|
cond = cond.orWhere(`gu3.user_id = ${anonId}`);
|
|
}
|
|
// Add an exception for the previewer user, if present.
|
|
const previewerId = this._specialUserIds[PREVIEWER_EMAIL];
|
|
if (users === previewerId) {
|
|
// All acl_rules granting view access are available to previewer user.
|
|
cond = cond.orWhere('acl_rules.permissions = :permission',
|
|
{permission: Permissions.VIEW});
|
|
}
|
|
} else {
|
|
cond = cond.where('gu0.user_id = profiles.id');
|
|
cond = cond.orWhere('gu1.user_id = profiles.id');
|
|
cond = cond.orWhere('gu2.user_id = profiles.id');
|
|
cond = cond.orWhere('gu3.user_id = profiles.id');
|
|
}
|
|
return cond;
|
|
}));
|
|
if (!isSingleUser(users)) {
|
|
// We need to join against a list of users.
|
|
const emails = new Set(users.map(profile => normalizeEmail(profile.email)));
|
|
if (emails.size > 0) {
|
|
// the 1 = 1 on clause seems the shortest portable way to do a cross join in postgres
|
|
// and sqlite via typeorm.
|
|
qb = qb.leftJoin('(select users.id, display_email, email, name from users inner join logins ' +
|
|
'on users.id = logins.user_id where logins.email in (:...emails))',
|
|
'profiles', '1 = 1');
|
|
qb = qb.setParameter('emails', [...emails]);
|
|
} else {
|
|
// Add a dummy user with id 0, for simplicity. This user will
|
|
// not match any group. The casts are needed for a postgres 9.5 issue
|
|
// where type inference fails (we use 9.5 on jenkins).
|
|
qb = qb.leftJoin(`(select 0 as id, cast('none' as text) as display_email, ` +
|
|
`cast('none' as text) as email, cast('none' as text) as name)`,
|
|
'profiles', '1 = 1');
|
|
}
|
|
}
|
|
// join the relevant groups and subgroups
|
|
return qb
|
|
.leftJoin('group_groups', 'gg1', 'gg1.group_id = acl_rules.group_id')
|
|
.leftJoin('group_groups', 'gg2', 'gg2.group_id = gg1.subgroup_id')
|
|
.leftJoin('group_groups', 'gg3', 'gg3.group_id = gg2.subgroup_id')
|
|
// join the users in the relevant groups and subgroups.
|
|
.leftJoin('group_users', 'gu3', 'gg3.subgroup_id = gu3.group_id')
|
|
.leftJoin('group_users', 'gu2', 'gg2.subgroup_id = gu2.group_id')
|
|
.leftJoin('group_users', 'gu1', 'gg1.subgroup_id = gu1.group_id')
|
|
.leftJoin('group_users', 'gu0', 'acl_rules.group_id = gu0.group_id');
|
|
}
|
|
|
|
// Apply limits to the query. Results should be limited to a specific org
|
|
// if request is from a branded webpage; results should be limited to a
|
|
// specific user or set of users.
|
|
private _applyLimit<T>(qb: SelectQueryBuilder<T>, limit: Scope,
|
|
resources: Array<'docs'|'workspaces'|'orgs'>,
|
|
accessStyle: AccessStyle): SelectQueryBuilder<T> {
|
|
if (limit.org) {
|
|
// Filtering on merged org is a special case, see urlIdQuery
|
|
const mergedOrg = this.isMergedOrg(limit.org || null);
|
|
if (!mergedOrg) {
|
|
qb = this._whereOrg(qb, limit.org, limit.includeSupport || false);
|
|
}
|
|
}
|
|
if (limit.users || limit.userId) {
|
|
for (const res of resources) {
|
|
qb = this._withAccess(qb, limit.users || limit.userId, res, accessStyle);
|
|
}
|
|
}
|
|
if (resources.includes('docs') && resources.includes('workspaces') && !limit.showAll) {
|
|
// Add Workspace.filteredOut column that is set for workspaces that should be filtered out.
|
|
// We don't use a WHERE clause directly since this would leave us unable to distinguish
|
|
// an empty result from insufficient access; and there's no straightforward way to do
|
|
// what we want in an ON clause.
|
|
// Filter out workspaces only if there are no docs in them (The "ON" clause from
|
|
// _onDocs will have taken care of including the right docs). If there are docs,
|
|
// then include the workspace regardless of whether it itself has been soft-deleted
|
|
// or not.
|
|
// TODO: if getOrgWorkspaces and getWorkspace were restructured to make two queries
|
|
// rather than a single query, this trickiness could be eliminated.
|
|
if (limit.showRemoved) {
|
|
qb = qb.addSelect('docs.id IS NULL AND workspaces.removed_at IS NULL',
|
|
'workspaces_filtered_out');
|
|
} else {
|
|
qb = qb.addSelect('docs.id IS NULL AND workspaces.removed_at IS NOT NULL',
|
|
'workspaces_filtered_out');
|
|
}
|
|
}
|
|
return qb;
|
|
}
|
|
|
|
// Filter out all personal orgs, and add back in a single merged org.
|
|
private _mergePersonalOrgs(userId: number, orgs: Organization[]): Organization[] {
|
|
const regularOrgs = orgs.filter(org => org.owner === null);
|
|
const personalOrg = orgs.find(org => org.owner && org.owner.id === userId);
|
|
if (!personalOrg) { return regularOrgs; }
|
|
personalOrg.id = 0;
|
|
personalOrg.domain = this.mergedOrgDomain();
|
|
return [personalOrg].concat(regularOrgs);
|
|
}
|
|
|
|
// Check if shares are about to exceed a limit, and emit a meaningful
|
|
// ApiError if so.
|
|
// If checkChange is set, issue an error only if a new share is being
|
|
// made.
|
|
private _restrictShares(role: roles.NonGuestRole|null, limit: number,
|
|
before: User[], after: User[], checkChange: boolean, kind: string,
|
|
features: Features) {
|
|
const existingUserIds = new Set(before.map(user => user.id));
|
|
// Do not emit error if users are not added, even if the number is past the limit.
|
|
if (after.length > limit &&
|
|
(!checkChange || after.some(user => !existingUserIds.has(user.id)))) {
|
|
const more = limit > 0 ? ' more' : '';
|
|
throw new ApiError(
|
|
checkChange ? `No${more} external ${kind} ${role || 'shares'} permitted` :
|
|
`Too many external ${kind} ${role || 'shares'}`,
|
|
403, {
|
|
limit: {
|
|
quantity: 'collaborators',
|
|
subquantity: role || undefined,
|
|
maximum: limit,
|
|
value: before.length,
|
|
projectedValue: after.length
|
|
},
|
|
tips: canAddOrgMembers(features) ? [{
|
|
action: 'add-members',
|
|
message: 'add users as team members to the site first'
|
|
}] : [{
|
|
action: 'upgrade',
|
|
message: 'pay for more team members'
|
|
}]
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check if document shares exceed any of the share limits, and emit a meaningful
|
|
// ApiError if so. If both membersBefore and membersAfter are specified, fail
|
|
// only if a new share is being added, but otherwise don't complain even if limits
|
|
// are exceeded. If only membersBefore is specified, fail strictly if limits are
|
|
// exceeded.
|
|
private _restrictAllDocShares(features: Features,
|
|
nonOrgMembersBefore: Map<roles.NonGuestRole, User[]>,
|
|
nonOrgMembersAfter: Map<roles.NonGuestRole, User[]>,
|
|
checkChange: boolean = true) {
|
|
// Apply a limit to document shares that is not specific to a particular role.
|
|
if (features.maxSharesPerDoc !== undefined) {
|
|
this._restrictShares(null, features.maxSharesPerDoc, removeRole(nonOrgMembersBefore),
|
|
removeRole(nonOrgMembersAfter), checkChange, 'document', features);
|
|
}
|
|
if (features.maxSharesPerDocPerRole) {
|
|
for (const role of this.defaultBasicGroupNames) {
|
|
const limit = features.maxSharesPerDocPerRole[role];
|
|
if (limit === undefined) { continue; }
|
|
// Apply a per-role limit to document shares.
|
|
this._restrictShares(role, limit, nonOrgMembersBefore.get(role) || [],
|
|
nonOrgMembersAfter.get(role) || [], checkChange, 'document', features);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Throw an error if there's no room for adding another document.
|
|
private async _checkRoomForAnotherDoc(workspace: Workspace, manager: EntityManager) {
|
|
const features = workspace.org.billingAccount.product.features;
|
|
if (features.maxDocsPerOrg !== undefined) {
|
|
// we need to count how many docs are in the current org, and if we
|
|
// are already at or above the limit, then fail.
|
|
const wss = this.unwrapQueryResult(await this.getOrgWorkspaces({userId: this.getPreviewerUserId()},
|
|
workspace.org.id,
|
|
{manager}));
|
|
const count = wss.map(ws => ws.docs.length).reduce((a, b) => a + b, 0);
|
|
if (count >= features.maxDocsPerOrg) {
|
|
throw new ApiError('No more documents permitted', 403, {
|
|
limit: {
|
|
quantity: 'docs',
|
|
maximum: features.maxDocsPerOrg,
|
|
value: count,
|
|
projectedValue: count + 1
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// For the moment only the support user can add both everyone@ and anon@ to a
|
|
// resource, since that allows spam. TODO: enhance or remove.
|
|
private _checkUserChangeAllowed(userId: number, groups: Group[]) {
|
|
if (userId === this.getSupportUserId()) { return; }
|
|
const ids = new Set(flatten(groups.map(g => g.memberUsers)).map(u => u.id));
|
|
if (ids.has(this.getEveryoneUserId()) && ids.has(this.getAnonymousUserId())) {
|
|
throw new Error('this user cannot share with everyone and anonymous');
|
|
}
|
|
}
|
|
|
|
// Fetch a Document with all access information loaded. Make sure the user has the
|
|
// specified permissions on the doc. The Document's organization will have product
|
|
// feature information loaded also.
|
|
private async _loadDocAccess(scope: DocScope, markPermissions: Permissions,
|
|
transaction?: EntityManager): Promise<Document> {
|
|
return await this._runInTransaction(transaction, async manager => {
|
|
|
|
const docQuery = this._doc(scope, {manager, markPermissions})
|
|
// Join the doc's ACL rules and groups/users so we can edit them.
|
|
.leftJoinAndSelect('docs.aclRules', 'acl_rules')
|
|
.leftJoinAndSelect('acl_rules.group', 'doc_groups')
|
|
.leftJoinAndSelect('doc_groups.memberUsers', 'doc_group_users')
|
|
.leftJoinAndSelect('doc_groups.memberGroups', 'doc_group_groups')
|
|
.leftJoinAndSelect('doc_group_users.logins', 'doc_user_logins')
|
|
// Join the workspace so we know what should be inherited. We will join
|
|
// the workspace member groups/users as a separate query, since
|
|
// SQL results are flattened, and multiplying the number of rows we have already
|
|
// by the number of workspace users could get excessive.
|
|
.leftJoinAndSelect('docs.workspace', 'workspace');
|
|
const queryResult = await verifyIsPermitted(docQuery);
|
|
const doc: Document = this.unwrapQueryResult(queryResult);
|
|
|
|
// Load the workspace's member groups/users.
|
|
const workspaceQuery = this._workspace(scope, doc.workspace.id, {manager})
|
|
.leftJoinAndSelect('workspaces.aclRules', 'workspace_acl_rules')
|
|
.leftJoinAndSelect('workspace_acl_rules.group', 'workspace_groups')
|
|
.leftJoinAndSelect('workspace_groups.memberUsers', 'workspace_group_users')
|
|
.leftJoinAndSelect('workspace_groups.memberGroups', 'workspace_group_groups')
|
|
.leftJoinAndSelect('workspace_group_users.logins', 'workspace_user_logins')
|
|
// We'll need the org as well. We will join its members as a separate query, since
|
|
// SQL results are flattened, and multiplying the number of rows we have already
|
|
// by the number of org users could get excessive.
|
|
.leftJoinAndSelect('workspaces.org', 'org');
|
|
doc.workspace = (await workspaceQuery.getOne())!;
|
|
|
|
// Load the org's member groups/users.
|
|
let orgQuery = this.org(scope, doc.workspace.org.id, {manager})
|
|
.leftJoinAndSelect('orgs.aclRules', 'org_acl_rules')
|
|
.leftJoinAndSelect('org_acl_rules.group', 'org_groups')
|
|
.leftJoinAndSelect('org_groups.memberUsers', 'org_group_users')
|
|
.leftJoinAndSelect('org_group_users.logins', 'org_user_logins');
|
|
orgQuery = this._addFeatures(orgQuery);
|
|
doc.workspace.org = (await orgQuery.getOne())!;
|
|
return doc;
|
|
});
|
|
}
|
|
|
|
// Emit an event indicating that the count of users with access to the org has changed, with
|
|
// the customerId and the updated number of users.
|
|
// The org argument must include the billingAccount.
|
|
private _userChangeNotification(
|
|
userId: number,
|
|
org: Organization, // Must include billingAccount
|
|
countBefore: number,
|
|
countAfter: number,
|
|
membersBefore: Map<roles.NonGuestRole, User[]>,
|
|
membersAfter: Map<roles.NonGuestRole, User[]>
|
|
) {
|
|
return () => {
|
|
const customerId = org.billingAccount.stripeCustomerId;
|
|
const change: UserChange = {userId, org, customerId,
|
|
countBefore, countAfter,
|
|
membersBefore, membersAfter};
|
|
this.emit('userChange', change);
|
|
};
|
|
}
|
|
|
|
// Create a notification function that emits an event when users may have been added to a resource.
|
|
private _inviteNotification(userId: number, resource: Organization|Workspace|Document,
|
|
userIdDelta: UserIdDelta, membersBefore: Map<roles.NonGuestRole, User[]>): () => void {
|
|
return () => this.emit('addUser', userId, resource, userIdDelta, membersBefore);
|
|
}
|
|
|
|
// Given two arrays of groups, returns a map of users present in the first array but
|
|
// not the second, where the map is broken down by user role.
|
|
// This method is used for checking limits on shares.
|
|
// Excluded users are removed from the results.
|
|
private _getUserDifference(groupsA: Group[], groupsB: Group[]): Map<roles.NonGuestRole, User[]> {
|
|
const subtractSet: Set<number> =
|
|
new Set(flatten(groupsB.map(grp => grp.memberUsers)).map(usr => usr.id));
|
|
const result = new Map<roles.NonGuestRole, User[]>();
|
|
for (const group of groupsA) {
|
|
const name = group.name;
|
|
if (!roles.isNonGuestRole(name)) { continue; }
|
|
result.set(name, group.memberUsers.filter(user => !subtractSet.has(user.id)));
|
|
}
|
|
return this._withoutExcludedUsers(result);
|
|
}
|
|
|
|
private _withoutExcludedUsers(members: Map<roles.NonGuestRole, User[]>): Map<roles.NonGuestRole, User[]> {
|
|
const excludedUsers = this.getExcludedUserIds();
|
|
for (const [role, users] of members.entries()) {
|
|
members.set(role, users.filter((user) => !excludedUsers.includes(user.id)));
|
|
}
|
|
return members;
|
|
}
|
|
|
|
private _billingManagerNotification(userId: number, addUserId: number, orgs: Organization[]) {
|
|
return () => {
|
|
this.emit('addBillingManager', userId, addUserId, orgs);
|
|
};
|
|
}
|
|
|
|
private _teamCreatorNotification(userId: number) {
|
|
return () => {
|
|
this.emit('teamCreator', userId);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check for anonymous user, either encoded directly as an id, or as a singular
|
|
* profile (this case arises during processing of the session/access/all endpoint
|
|
* whether we are checking for available orgs without committing yet to a particular
|
|
* choice of user).
|
|
*/
|
|
private _isAnonymousUser(users: AvailableUsers): boolean {
|
|
return isSingleUser(users) ? users === this.getAnonymousUserId() :
|
|
users.length === 1 && normalizeEmail(users[0].email) === ANONYMOUS_USER_EMAIL;
|
|
}
|
|
|
|
// Set Workspace.removedAt to null (undeletion) or to a datetime (soft deletion)
|
|
private _setWorkspaceRemovedAt(scope: Scope, wsId: number, removedAt: Date|null) {
|
|
return this._connection.transaction(async manager => {
|
|
const wsQuery = this._workspace({...scope, showAll: true}, wsId, {
|
|
manager,
|
|
markPermissions: Permissions.REMOVE
|
|
});
|
|
const workspace: Workspace = this.unwrapQueryResult(await verifyIsPermitted(wsQuery));
|
|
await manager.createQueryBuilder()
|
|
.update(Workspace).set({removedAt}).where({id: workspace.id})
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
// Set Document.removedAt to null (undeletion) or to a datetime (soft deletion)
|
|
private _setDocumentRemovedAt(scope: DocScope, removedAt: Date|null) {
|
|
return this._connection.transaction(async manager => {
|
|
let docQuery = this._doc({...scope, showAll: true}, {
|
|
manager,
|
|
markPermissions: Permissions.REMOVE,
|
|
allowSpecialPermit: true
|
|
});
|
|
if (!removedAt) {
|
|
docQuery = this._addFeatures(docQuery); // pull in billing information for doc count limits
|
|
}
|
|
const doc: Document = this.unwrapQueryResult(await verifyIsPermitted(docQuery));
|
|
if (!removedAt) {
|
|
await this._checkRoomForAnotherDoc(doc.workspace, manager);
|
|
}
|
|
await manager.createQueryBuilder()
|
|
.update(Document).set({removedAt}).where({id: doc.id})
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
private _filterAccessData(
|
|
scope: Scope,
|
|
users: UserAccessData[],
|
|
maxInheritedRole: roles.BasicRole|null,
|
|
docId?: string
|
|
): {personal: true, public: boolean}|undefined {
|
|
if (scope.userId === this.getPreviewerUserId()) { return; }
|
|
|
|
// If we have special access to the resource, don't filter user information.
|
|
if (scope.specialPermit?.docId === docId && docId) { return; }
|
|
|
|
const thisUser = this.getAnonymousUserId() === scope.userId
|
|
? null
|
|
: users.find(user => user.id === scope.userId);
|
|
const realAccess = thisUser ? getRealAccess(thisUser, { maxInheritedRole, users }) : null;
|
|
|
|
// If we are an owner, don't filter user information.
|
|
if (thisUser && realAccess === 'owners') { return; }
|
|
|
|
// Limit user information returned to being about the current user.
|
|
users.length = 0;
|
|
if (thisUser) { users.push(thisUser); }
|
|
return { personal: true, public: !realAccess };
|
|
}
|
|
}
|
|
|
|
// Return a QueryResult reflecting the output of a query builder.
|
|
// Checks on the "is_permitted" field which select queries set on resources to
|
|
// indicate whether the user has access.
|
|
// If the output is empty, we signal that the desired resource does not exist.
|
|
// If the "is_permitted" field is falsy, we signal that the resource is forbidden.
|
|
// Returns the resource fetched by the queryBuilder.
|
|
async function verifyIsPermitted(
|
|
queryBuilder: SelectQueryBuilder<any>
|
|
): Promise<QueryResult<any>> {
|
|
const results = await queryBuilder.getRawAndEntities();
|
|
if (results.entities.length === 0) {
|
|
return {
|
|
status: 404,
|
|
errMessage: `${getFrom(queryBuilder)} not found`
|
|
};
|
|
} else if (results.entities.length > 1) {
|
|
return {
|
|
status: 400,
|
|
errMessage: `ambiguous ${getFrom(queryBuilder)} request`
|
|
};
|
|
} else if (!results.raw[0].is_permitted) {
|
|
return {
|
|
status: 403,
|
|
errMessage: "access denied"
|
|
};
|
|
}
|
|
return {
|
|
status: 200,
|
|
data: results.entities[0]
|
|
};
|
|
}
|
|
|
|
// Returns all first-level memberUsers in the resources. Requires all resources' aclRules, groups
|
|
// and memberUsers to be populated.
|
|
// If optRoles is provided, only checks membership in resource groups with the given roles.
|
|
function getResourceUsers(res: Resource|Resource[], optRoles?: string[]): User[] {
|
|
res = Array.isArray(res) ? res : [res];
|
|
const users: {[uid: string]: User} = {};
|
|
let resAcls: AclRule[] = flatten(res.map(_res => _res.aclRules as AclRule[]));
|
|
if (optRoles) {
|
|
resAcls = resAcls.filter(_acl => optRoles.includes(_acl.group.name));
|
|
}
|
|
resAcls.forEach((aclRule: AclRule) => {
|
|
aclRule.group.memberUsers.forEach((u: User) => users[u.id] = u);
|
|
});
|
|
const userList = Object.keys(users).map(uid => users[uid]);
|
|
userList.sort((a, b) => a.id - b.id);
|
|
return userList;
|
|
}
|
|
|
|
// Returns a map of userIds to the user's strongest default role on the given resource.
|
|
// The resource's aclRules, groups, and memberUsers must be populated.
|
|
function getMemberUserRoles<T extends roles.Role>(res: Resource, allowRoles: T[]): {[userId: string]: T} {
|
|
// Add the users to a map to ensure uniqueness. (A user may be present in
|
|
// more than one group)
|
|
const userMap: {[userId: string]: T} = {};
|
|
(res.aclRules as AclRule[]).forEach((aclRule: AclRule) => {
|
|
const role = aclRule.group.name as T;
|
|
if (allowRoles.includes(role)) {
|
|
// Map the users to remove sensitive information from the result and
|
|
// to add the group names.
|
|
aclRule.group.memberUsers.forEach((u: User) => {
|
|
// If the user is already present in another group, use the more
|
|
// powerful role name.
|
|
userMap[u.id] = userMap[u.id] ? roles.getStrongestRole(userMap[u.id], role) : role;
|
|
});
|
|
}
|
|
});
|
|
return userMap;
|
|
}
|
|
|
|
// Extract a human-readable name for the type of entity being selected.
|
|
function getFrom(queryBuilder: SelectQueryBuilder<any>): string {
|
|
const alias = queryBuilder.expressionMap.mainAlias;
|
|
return (alias && alias.metadata && alias.metadata.name.toLowerCase()) || 'resource';
|
|
}
|
|
|
|
// Flatten a map of users per role into a simple list of users.
|
|
export function removeRole(usersWithRoles: Map<roles.NonGuestRole, User[]>) {
|
|
return flatten([...usersWithRoles.values()]);
|
|
}
|
|
|
|
function getNonGuestGroups(entity: Organization|Workspace|Document): NonGuestGroup[] {
|
|
return (entity.aclRules as AclRule[]).map(aclRule => aclRule.group).filter(isNonGuestGroup);
|
|
}
|
|
|
|
// Returns a map of users indexed by their roles. Optionally excludes users whose ids are in
|
|
// excludeUsers.
|
|
function getUsersWithRole(groups: NonGuestGroup[], excludeUsers?: number[]): Map<roles.NonGuestRole, User[]> {
|
|
const members = new Map<roles.NonGuestRole, User[]>();
|
|
for (const group of groups) {
|
|
let users = group.memberUsers;
|
|
if (excludeUsers) {
|
|
users = users.filter((user) => !excludeUsers.includes(user.id));
|
|
}
|
|
members.set(group.name, users);
|
|
}
|
|
return members;
|
|
}
|
|
|
|
export async function makeDocAuthResult(docPromise: Promise<Document>): Promise<DocAuthResult> {
|
|
try {
|
|
const doc = await docPromise;
|
|
const removed = Boolean(doc.removedAt || doc.workspace.removedAt);
|
|
return {docId: doc.id, access: doc.access, removed, cachedDoc: doc};
|
|
} catch (error) {
|
|
return {docId: null, access: null, removed: null, error};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts DocAuthKey information from scope. This includes everything needed to
|
|
* identify the document to access. Throws if information is not present.
|
|
*/
|
|
export function getDocAuthKeyFromScope(scope: Scope): DocAuthKey {
|
|
const {urlId, userId, org} = scope;
|
|
if (!urlId) { throw new Error('document required'); }
|
|
return {urlId, userId, org};
|
|
}
|