mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
0139b91b5d
* chore: Replace lodash with es-toolkit Migrate all direct lodash imports to es-toolkit/compat for a smaller, faster, lodash-compatible utility library. Transitive lodash usage from other packages remains unchanged. * fix: Restore isPlainObject semantics in CanCan policy The lodash migration aliased `isObject` to `lodash/isPlainObject` and the codemod incorrectly mapped the local name to es-toolkit's `isObject`, which also returns true for arrays and functions. This caused condition objects in policy definitions to be skipped, breaking authorization checks across the codebase. * fix: Restore unicode-aware length counting in validators es-toolkit/compat's size() returns string.length, while lodash's _.size() counts unicode code points. Switch to [...value].length to preserve the previous behavior so multi-byte characters like emoji count as one.
305 lines
9.9 KiB
TypeScript
305 lines
9.9 KiB
TypeScript
import { isPlainObject } from "es-toolkit/compat";
|
||
import type { Model } from "sequelize-typescript";
|
||
import { AuthorizationError } from "@server/errors";
|
||
|
||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any -- variance requires `any` to accept arbitrary constructors
|
||
type Constructor = new (...args: any[]) => unknown;
|
||
|
||
type Policy = Record<string, boolean | string[]>;
|
||
|
||
type Condition<T extends Constructor, P extends Constructor> = (
|
||
performer: InstanceType<P>,
|
||
target: InstanceType<T> | null,
|
||
options?: unknown
|
||
) => boolean | string;
|
||
|
||
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 {
|
||
/**
|
||
* 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" &&
|
||
!isPlainObject(condition)
|
||
) {
|
||
throw new TypeError(
|
||
`Expected condition to be object or function, got ${typeof condition}`
|
||
);
|
||
}
|
||
|
||
if (condition && isPlainObject(condition)) {
|
||
condition = this.getConditionFn(condition);
|
||
}
|
||
|
||
(this.toArray(actions) as string[]).forEach((action) => {
|
||
(this.toArray(targets) as T[]).forEach((target) => {
|
||
const ability = { model, action, target, condition } as Ability;
|
||
|
||
// Add to index
|
||
if (!this.abilities.has(model)) {
|
||
this.abilities.set(model, new Map());
|
||
}
|
||
const actionMap = this.abilities.get(model)!;
|
||
if (!actionMap.has(action)) {
|
||
actionMap.set(action, []);
|
||
}
|
||
actionMap.get(action)!.push(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.getMatchingAbilities(
|
||
performer,
|
||
action,
|
||
target
|
||
);
|
||
|
||
// Check conditions only for matching abilities
|
||
const seenConditions = new Set<boolean | string>();
|
||
const membershipIds: string[] = [];
|
||
let hasNonMembershipMatch = false;
|
||
|
||
for (const ability of matchingAbilities) {
|
||
if (!ability.condition) {
|
||
continue;
|
||
}
|
||
|
||
const result = ability.condition(performer, target, options);
|
||
|
||
if (!result || seenConditions.has(result)) {
|
||
continue;
|
||
}
|
||
|
||
seenConditions.add(result);
|
||
|
||
if (typeof result === "string") {
|
||
membershipIds.push(result);
|
||
} else {
|
||
hasNonMembershipMatch = true;
|
||
}
|
||
}
|
||
|
||
return membershipIds.length > 0 ? membershipIds : hasNonMembershipMatch;
|
||
};
|
||
|
||
/*
|
||
* 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.
|
||
*/
|
||
public serialize = (performer: Model, target: Model | null): Policy => {
|
||
const output: Record<string, boolean | string[]> = {};
|
||
|
||
// Get all unique actions to check from the index
|
||
const actionsToCheck = new Set<string>();
|
||
for (const [model, actionMap] of this.abilities.entries()) {
|
||
if (performer instanceof model) {
|
||
for (const [action, abilities] of actionMap.entries()) {
|
||
for (const ability of abilities) {
|
||
if (target instanceof (ability.target as Constructor)) {
|
||
actionsToCheck.add(action);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check each unique action once
|
||
actionsToCheck.forEach((action) => {
|
||
try {
|
||
output[action] = this.can(performer, action, target);
|
||
} catch (_err) {
|
||
output[action] = false;
|
||
}
|
||
});
|
||
|
||
return output;
|
||
};
|
||
|
||
/**
|
||
* 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 getMatchingAbilities = (
|
||
performer: Model,
|
||
action: string,
|
||
target: Model | null | undefined
|
||
) => {
|
||
const matchingAbilities: Ability[] = [];
|
||
|
||
// Use index to find abilities by model and action
|
||
for (const [model, actionMap] of this.abilities.entries()) {
|
||
if (!(performer instanceof model)) {
|
||
continue;
|
||
}
|
||
|
||
// Check for specific action
|
||
const specificAbilities = actionMap.get(action);
|
||
if (specificAbilities) {
|
||
for (const ability of specificAbilities) {
|
||
if (
|
||
ability.target === "all" ||
|
||
target === ability.target ||
|
||
target instanceof (ability.target as Constructor)
|
||
) {
|
||
matchingAbilities.push(ability);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for "manage" action (applies to all actions)
|
||
const manageAbilities = actionMap.get("manage");
|
||
if (manageAbilities) {
|
||
for (const ability of manageAbilities) {
|
||
if (
|
||
ability.target === "all" ||
|
||
target === ability.target ||
|
||
target instanceof (ability.target as Constructor)
|
||
) {
|
||
matchingAbilities.push(ability);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return matchingAbilities;
|
||
};
|
||
|
||
// Index for fast lookups: Map<model, Map<action, Ability[]>>
|
||
private abilities: Map<Constructor, Map<string, Ability[]>> = new Map();
|
||
|
||
private get = <T extends object>(obj: T, key: keyof T) =>
|
||
"get" in obj && typeof obj.get === "function" ? obj.get(key) : obj[key];
|
||
|
||
private isPartiallyEqual = <T extends object>(target: T, obj: T) =>
|
||
Object.keys(obj).every(
|
||
// @ts-expect-error TODO
|
||
(key: keyof T) => 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];
|
||
}
|
||
// @ts-expect-error - TS doesn't know that value is iterable
|
||
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 { allow, can, cannot, serialize } = cancan;
|
||
|
||
// 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;
|
||
|
||
// The MIT License (MIT)
|
||
|
||
// Copyright (c) Vadim Demedes <vdemedes@gmail.com> (github.com/vadimdemedes)
|
||
|
||
// 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.
|