Vendorize cancan with performance improvements (#7448)

* Vendorize CanCan with performance improvements

* docs
This commit is contained in:
Tom Moor
2024-08-22 20:10:58 -04:00
committed by GitHub
parent 5549676185
commit d2b3e50a48
8 changed files with 198 additions and 150 deletions

View File

@@ -95,7 +95,6 @@
"babel-plugin-transform-class-properties": "^6.24.1",
"body-scroll-lock": "^4.0.0-beta.0",
"bull": "^4.12.2",
"cancan": "3.1.0",
"chalk": "^4.1.0",
"class-validator": "^0.14.1",
"command-score": "^0.1.2",

View File

@@ -1,13 +1,189 @@
import CanCan from "cancan";
import isObject from "lodash/isPlainObject";
import { Model } from "sequelize-typescript";
import { AuthorizationError } from "@server/errors";
type Constructor = new (...args: any) => any;
type Condition<T extends Constructor, P extends Constructor> = (
performer: InstanceType<P>,
target: InstanceType<T> | null,
options?: any
) => boolean;
type Ability = {
model: Constructor;
action: string;
target: Constructor | Model | string;
condition?: Condition<Constructor, Constructor>;
};
/**
* Class that provides a simple way to define and check authorization abilities.
* This is originally adapted from https://www.npmjs.com/package/cancan
*/
export class CanCan {
public abilities: Ability[] = [];
/**
* Define an authorized ability for a model, action, and target.
*
* @param model The model that the ability is for.
* @param actions The action or actions that are allowed.
* @param targets The target or targets that the ability applies to.
* @param condition The condition that must be met for the ability to apply
*/
public allow = <T extends Constructor, P extends Constructor>(
model: P,
actions: string | ReadonlyArray<string>,
targets: T | ReadonlyArray<T> | string | ReadonlyArray<string>,
condition?: Condition<T, P> | object
) => {
if (
typeof condition !== "undefined" &&
typeof condition !== "function" &&
!isObject(condition)
) {
throw new TypeError(
`Expected condition to be object or function, got ${typeof condition}`
);
}
if (condition && isObject(condition)) {
condition = this.getConditionFn(condition);
}
(this.toArray(actions) as string[]).forEach((action) => {
(this.toArray(targets) as T[]).forEach((target) => {
this.abilities.push({ model, action, target, condition } as Ability);
});
});
};
/**
* Check if a performer can perform an action on a target.
*
* @param performer The performer that is trying to perform the action.
* @param action The action that the performer is trying to perform.
* @param target The target that the action is upon.
* @param options Additional options to pass to the condition function.
* @returns Whether the performer can perform the action on the target.
*/
public can = (
performer: Model,
action: string,
target: Model | null | undefined,
options = {}
) => {
const matchingAbilities = this.abilities.filter(
(ability) =>
performer instanceof ability.model &&
(ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)) &&
(ability.action === "manage" || action === ability.action)
);
// Check conditions only for matching abilities
return matchingAbilities.some(
(ability) =>
!ability.condition ||
ability.condition(performer, target, options || {})
);
};
/**
* Check if a performer cannot perform an action on a target, which is the opposite of `can`.
*
* @param performer The performer that is trying to perform the action.
* @param action The action that the performer is trying to perform.
* @param target The target that the action is upon.
* @param options Additional options to pass to the condition function.
* @returns Whether the performer cannot perform the action on the target.
*/
public cannot = (
performer: Model,
action: string,
target: Model | null | undefined,
options = {}
) => !this.can(performer, action, target, options);
/**
* Guard if a performer can perform an action on a target, throwing an error if they cannot.
*
* @param performer The performer that is trying to perform the action.
* @param action The action that the performer is trying to perform.
* @param target The target that the action is upon.
* @param options Additional options to pass to the condition function.
* @throws AuthorizationError If the performer cannot perform the action on the target.
*/
public authorize = (
performer: Model,
action: string,
target: Model | null | undefined,
options = {}
): asserts target => {
if (this.cannot(performer, action, target, options)) {
throw AuthorizationError("Authorization error");
}
};
// Private methods
private get = (obj: object, key: string) =>
"get" in obj && typeof obj.get === "function" ? obj.get(key) : obj[key];
private isPartiallyEqual = (target: object, obj: object) =>
Object.keys(obj).every((key) => this.get(target, key) === obj[key]);
private getConditionFn =
(condition: object) => (performer: Model, target: Model) =>
this.isPartiallyEqual(target, condition);
private toArray = (value: unknown): unknown[] => {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value;
}
if (typeof value === "string") {
return [value];
}
if (typeof value[Symbol.iterator] === "function") {
// @ts-expect-error - TS doesn't know that value is iterable
return [...value];
}
return [value];
};
}
const cancan = new CanCan();
export const _can = cancan.can;
export const { allow, can, cannot, abilities } = cancan;
export const _authorize = cancan.authorize;
// This is exported separately as a workaround for the following issue:
// https://github.com/microsoft/TypeScript/issues/36931
export const authorize: typeof cancan.authorize = cancan.authorize;
export const _cannot = cancan.cannot;
// The MIT License (MIT)
export const _abilities = cancan.abilities;
// Copyright (c) Vadim Demedes <vdemedes@gmail.com> (github.com/vadimdemedes)
export const allow = cancan.allow;
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

View File

@@ -2,7 +2,7 @@ import invariant from "invariant";
import some from "lodash/some";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow, _can as can } from "./cancan";
import { allow, can } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>

View File

@@ -6,7 +6,7 @@ import {
TeamPreference,
} from "@shared/types";
import { Document, Revision, User, Team } from "@server/models";
import { allow, _cannot as cannot, _can as can } from "./cancan";
import { allow, cannot, can } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createDocument", Team, (actor, document) =>

View File

@@ -1,25 +1,11 @@
import type {
ApiKey,
Attachment,
AuthenticationProvider,
Collection,
Comment,
Document,
FileOperation,
Integration,
Pin,
SearchQuery,
Share,
Star,
Subscription,
User,
Team,
Group,
WebhookSubscription,
Notification,
UserMembership,
} from "@server/models";
import { _abilities, _can, _cannot, _authorize } from "./cancan";
import type { Model } from "sequelize-typescript";
import type { User } from "@server/models";
import { abilities, can } from "./cancan";
// export everything from cancan
export * from "./cancan";
// Import all policies
import "./apiKey";
import "./attachment";
import "./authenticationProvider";
@@ -42,48 +28,18 @@ import "./userMembership";
type Policy = Record<string, boolean>;
// this should not be needed but is a workaround for this TypeScript issue:
// https://github.com/microsoft/TypeScript/issues/36931
export const authorize: typeof _authorize = _authorize;
export const can = _can;
export const cannot = _cannot;
export const abilities = _abilities;
/*
* Given a user and a model output an object which describes the actions the
* user may take against the model. This serialized policy is used for testing
* and sent in API responses to allow clients to adjust which UI is displayed.
*/
export function serialize(
model: User,
target:
| ApiKey
| Attachment
| AuthenticationProvider
| Collection
| Comment
| Document
| FileOperation
| Integration
| Pin
| SearchQuery
| Share
| Star
| Subscription
| User
| Team
| Group
| WebhookSubscription
| Notification
| UserMembership
| null
): Policy {
export function serialize(model: User, target: Model | null): Policy {
const output = {};
abilities.forEach((ability) => {
if (model instanceof ability.model && target instanceof ability.target) {
if (
model instanceof ability.model &&
target instanceof (ability.target as any)
) {
let response = true;
try {

View File

@@ -1,5 +1,5 @@
import { Share, Team, User } from "@server/models";
import { allow, _can as can } from "./cancan";
import { allow, can } from "./cancan";
import { and, isOwner, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createShare", Team, (actor, team) =>

View File

@@ -1,59 +0,0 @@
declare module "cancan" {
import { Model } from "sequelize-typescript";
namespace CanCan {
interface Option {
instanceOf?: ((instance: any, model: any) => boolean) | undefined;
createError?: (() => any) | undefined;
}
}
class CanCan {
constructor(options?: CanCan.Option);
allow<
T extends new (...args: any) => any,
U extends new (...args: any) => any
>(
model: U,
actions: string | ReadonlyArray<string>,
targets: T | ReadonlyArray<T> | string | ReadonlyArray<string>,
condition?:
| object
| ((
performer: InstanceType<U>,
target: InstanceType<T> | null,
options?: any
) => boolean)
): void;
can(
performer: Model,
action: string,
target: Model | null | undefined,
options?: any
): boolean;
cannot(
performer: Model,
action: string,
target: Model | null | undefined,
options?: any
): boolean;
authorize(
performer: Model,
action: string,
target: Model | null | undefined,
options?: any
): asserts target;
abilities: {
model: any;
action: string;
target: any;
}[];
}
export = CanCan;
}

View File

@@ -5602,11 +5602,6 @@ arraybuffer.prototype.slice@^1.0.3:
is-array-buffer "^3.0.4"
is-shared-array-buffer "^1.0.2"
arrify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
integrity "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA=="
asap@^2.0.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
@@ -5642,11 +5637,6 @@ attr-accept@^2.2.2:
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
integrity "sha1-ZGYTgJZgEQdJ6S8sEIM7cJaNkps= sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
auto-bind@^1.1.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-1.2.1.tgz#807f7910b0210db9eefe133f3492c28e89698b96"
integrity "sha1-gH95ELAhDbnu/hM/NJLCjolpi5Y= sha512-/W9yj1yKmBLwpexwAujeD9YHwYmRuWFGV8HWE7smQab797VeHa4/cnE2NFeDhA+E+5e/OGBI8763EhLjfZ/MXA=="
autotrack@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/autotrack/-/autotrack-2.4.1.tgz#ccbf010e3d95ef23c8dd6db4e8df025135c82ee6"
@@ -6233,15 +6223,6 @@ camelize@^1.0.0:
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
integrity "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs= sha512-W2lPwkBkMZwFlPCXhIlYgxu+7gC/NUlCtdK652DAJ1JdgV0sTrvuPFshNPrFa1TY2JOkLhgdeEBplB4ezEa+xg=="
cancan@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/cancan/-/cancan-3.1.0.tgz#4d148e73795324f689a9b1002e61839c17ea821e"
integrity "sha1-TRSOc3lTJPaJqbEALmGDnBfqgh4= sha512-Glz6HEEOfQ5Cv5yWx2Zu4zPtDBJzNcIAE/pSzE3XTncA2ZvfwA5w8wLvJ455Ud4qKEGpHay4Z0KduGNWCoKPXA=="
dependencies:
arrify "^1.0.1"
auto-bind "^1.1.0"
is-plain-obj "^1.1.0"
caniuse-lite@^1.0.30001629:
version "1.0.30001639"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001639.tgz#972b3a6adeacdd8f46af5fc7f771e9639f6c1521"
@@ -9721,11 +9702,6 @@ is-path-inside@^3.0.3:
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
integrity "sha1-0jE2LlOgf/Kw4Op/7QSRYf/RYoM= sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="
is-plain-obj@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
integrity "sha1-caUMhCnfync8kqOQpKA7OfzVHT4= sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="
is-plain-obj@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.0.0.tgz#06c0999fd7574edf5a906ba5644ad0feb3a84d22"