Skip to content

Commit

Permalink
feat: mask method for custom methods (#101)
Browse files Browse the repository at this point in the history
* feat: mask method for custom methods

* docs: options.method for custom methods
  • Loading branch information
fratzinger authored Jun 27, 2023
1 parent 924fea6 commit 8da9b3e
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 26 deletions.
2 changes: 2 additions & 0 deletions docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Does a complex permission check and transforms data if necessary.
| `modelName` | `feathers-casl` checks permissions per item. Because most items are plain javascript objects, it does not know which type the item is. Per default it looks for `context.path` (for example: `tasks`). You can change this behavior with the option `modelName` to use Class-like names (for example: `Task`)<br><br>**Type:** `string \| ((context: HookContext) => string)`<br>**optional** - _Default:_ `(context) => context.path` |
| `useUpdateData` | For the method `update`, `authorize` checks are made against the data in the database (`can('update', 'posts', { isPublished: true })`). You can also make a check against your data from the request (as in `.update(id, data, params)`). To support these checks, you have to set `useUpdateData: true` and then you need to add a general `can('update-data, 'posts')` and then you can add rules like `cannot('update-data', 'posts', { isPublished: false })`.<br><br>**Type:** `boolean`<br>**optional** - _Default:_ `false` |
| `usePatchData` | For the method `patch`, `authorize` checks are made against the data in the database (`can('patch', 'posts', { isPublished: true })`). You can also make a check against your data from the request (as in `.patch(id, data, params)`). To support these checks, you have to set `usePatchData: true` and then you need to add a general `can('patch-data, 'posts')` and then you can add rules like `cannot('patch-data', 'posts', { isPublished: false })`.<br><br>**Type:** `boolean`<br>**optional** - _Default:_ `false` |
| `method` | You can override the `context.method`. This is useful for custom methods that behave like a regular method. For example, if you have a custom method `'sum'` that sums up all items in your database, you can set `method: 'find'` to make `feathers-casl` behave like a regular `find` method.<br><br>**Type:** `string \| ((context: HookContext) => string)`<br>**optional** - _Default:_ `context.method` |

### Notable Principles

Expand Down Expand Up @@ -74,6 +75,7 @@ You don't want to use `checkBasicPermission` exclusively! Always use it with [`a
| `checkMultiActions` | If your database adapters allow `multi: true` you probably want to authorize `single:create/patch/remove` but not `multi:create/patch/remove`. Especially for `remove` this could be crucial. `feathers-casl` has built in support for `multi` anyway. But by default it acts exactly the same for `single` as for `multi` requests.<br><br>**Type:** `boolean`<br>**optional** - _Default:_ `false` |
| `modelName` | `feathers-casl` checks permissions per item. Because most items are plain javascript objects, it does not know which type the item is. Per default it looks for `context.path` (for example: `tasks`). You can change this behavior with the option `modelName` to use Class-like names (for example: `Task`)<br><br>**Type:** `string \| ((context: HookContext) => string)`<br>**optional** - _Default:_ `(context) => context.path` |
| `storeAbilityForAuthorize` | [`authorize`-hook](#authorize) uses `checkBasicPermission` under the hood. But `checkBasicPermission` can be used before `authorize` in your hook chain. In that case, to reduce computation time, you can set `storeAbilityForAuthorize: true` so the basic check in `authorize` will be skipped because it knows, that `checkBasicPermission` has run.<br><br>**Type:** `boolean`<br>**optional** - _Default:_ `false` |
| `method` | You can override the `context.method`. This is useful for custom methods that behave like a regular method. For example, if you have a custom method `'sum'` that sums up all items in your database, you can set `method: 'find'` to make `feathers-casl` behave like a regular `find` method.<br><br>**Type:** `string \| ((context: HookContext) => string)`<br>**optional** - _Default:_ `context.method` |

### Notable Principles

Expand Down
11 changes: 6 additions & 5 deletions src/hooks/authorize/authorize.hook.after.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
AuthorizeHookOptions,
HasRestrictingFieldsOptions,
} from "../../types";
import { getMethodName } from "../../utils/getMethodName";

export const authorizeAfter = async <H extends HookContext = HookContext>(
context: H,
Expand Down Expand Up @@ -82,7 +83,9 @@ export const authorizeAfter = async <H extends HookContext = HookContext>(

const $select: string[] | undefined = params.query?.$select;

if (context.method !== "remove") {
const method = getMethodName(context, options);

if (method !== "remove") {
const $newSelect = getConditionalSelect(
$select,
ability,
Expand Down Expand Up @@ -139,11 +142,9 @@ export const authorizeAfter = async <H extends HookContext = HookContext>(
}
} else {
result = pickFieldsForItem(items[0]);
if (context.method === "get" && _isEmpty(result)) {
if (method === "get" && _isEmpty(result)) {
if (options.actionOnForbidden) options.actionOnForbidden();
throw new Forbidden(
`You're not allowed to ${context.method} ${modelName}`
);
throw new Forbidden(`You're not allowed to ${method} ${modelName}`);
}
}

Expand Down
39 changes: 29 additions & 10 deletions src/hooks/authorize/authorize.hook.before.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { mergeQueryFromAbility } from "../../utils/mergeQueryFromAbility";
import type { HookContext } from "@feathersjs/feathers";

import type { AuthorizeHookOptions } from "../../types";
import { getMethodName } from "../../utils/getMethodName";

export const authorizeBefore = async <H extends HookContext = HookContext>(
context: H,
Expand All @@ -38,6 +39,8 @@ export const authorizeBefore = async <H extends HookContext = HookContext>(
return context;
}

const method = getMethodName(context, options);

if (!getPersistedConfig(context, "madeBasicCheck")) {
const basicCheck = checkBasicPermission({
notSkippable: true,
Expand All @@ -48,6 +51,7 @@ export const authorizeBefore = async <H extends HookContext = HookContext>(
checkMultiActions: options.checkMultiActions,
modelName: options.modelName,
storeAbilityForAuthorize: true,
method,
});
await basicCheck(context);
}
Expand All @@ -70,7 +74,7 @@ export const authorizeBefore = async <H extends HookContext = HookContext>(
return context;
}

const multi = isMulti(context);
const multi = method === "find" || isMulti(context);

// if context is with multiple items, there's a change that we need to handle each item separately
if (multi) {
Expand All @@ -80,14 +84,19 @@ export const authorizeBefore = async <H extends HookContext = HookContext>(
}
}

options = {
...options,
method,
};

if (
["find", "get"].includes(context.method) ||
(isMulti && !hasRestrictingConditions(ability, "find", modelName))
["find", "get"].includes(method) ||
(multi && !hasRestrictingConditions(ability, "find", modelName))
) {
setPersistedConfig(context, "skipRestrictingRead.conditions", true);
}

const { method, id } = context;
const { id } = context;
const availableFields = getAvailableFields(context, options);

if (["get", "patch", "update", "remove"].includes(method) && id != null) {
Expand Down Expand Up @@ -123,7 +132,14 @@ const handleSingle = async <H extends HookContext = HookContext>(
// -> initial 'get' and 'remove' have no data at all
// -> initial 'patch' maybe has just partial data
// -> initial 'update' maybe has completely changed data, for what the check could pass but not for initial data
const { params, method, service, id } = context;
const method = getMethodName(context, options);

options = {
...options,
method,
};

const { params, service, id } = context;

const query = mergeQueryFromAbility(
context.app,
Expand Down Expand Up @@ -206,18 +222,20 @@ const checkData = <H extends HookContext = HookContext>(
data: Record<string, unknown>,
options: Pick<
AuthorizeHookOptions,
"actionOnForbidden" | "usePatchData" | "useUpdateData"
"actionOnForbidden" | "usePatchData" | "useUpdateData" | "method"
>
): void => {
const method = getMethodName(context, options);

if (
(context.method === "patch" && !options.usePatchData) ||
(context.method === "update" && !options.useUpdateData)
(method === "patch" && !options.usePatchData) ||
(method === "update" && !options.useUpdateData)
) {
return;
}
throwUnlessCan(
ability,
`${context.method}-data`,
`${method}-data`,
subject(modelName, data),
modelName,
options
Expand All @@ -231,7 +249,8 @@ const handleMulti = async <H extends HookContext = HookContext>(
availableFields: string[] | undefined,
options: AuthorizeHookOptions
): Promise<H> => {
const { method } = context;
const method = getMethodName(context, options);

// multi: find | patch | remove

if (method === "patch") {
Expand Down
18 changes: 11 additions & 7 deletions src/hooks/authorize/authorize.hook.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ThrowUnlessCanOptions,
} from "../../types";
import type { Promisable } from "type-fest";
import { getMethodName } from "../../utils/getMethodName";

declare module "@feathersjs/feathers" {
interface Params {
Expand Down Expand Up @@ -85,8 +86,13 @@ export const getAdapter = (

export const getAbility = (
context: HookContext,
options?: Pick<HookBaseOptions, "ability" | "checkAbilityForInternal">
options?: Pick<
HookBaseOptions,
"ability" | "checkAbilityForInternal" | "method"
>
): Promise<AnyAbility | undefined> => {
const method = getMethodName(context, options);

// if params.ability is set, return it over options.ability
if (context?.params?.ability) {
if (typeof context.params.ability === "function") {
Expand All @@ -108,7 +114,7 @@ export const getAbility = (
}
}

if (!options.checkAbilityForInternal && !context.params?.provider) {
if (!options?.checkAbilityForInternal && !context.params?.provider) {
return Promise.resolve(undefined);
}

Expand All @@ -121,9 +127,7 @@ export const getAbility = (
}
}

throw new Forbidden(
`You're not allowed to ${context.method} on '${context.path}'`
);
throw new Forbidden(`You're not allowed to ${method} on '${context.path}'`);
};

export const throwUnlessCan = <T extends ForcedSubject<string>>(
Expand Down Expand Up @@ -195,9 +199,9 @@ export const checkMulti = (
context: HookContext,
ability: AnyAbility,
modelName: string,
options?: Pick<AuthorizeHookOptions, "actionOnForbidden">
options?: Pick<AuthorizeHookOptions, "actionOnForbidden" | "method">
): boolean => {
const { method } = context;
const method = getMethodName(context, options);
const currentIsMulti = isMulti(context);
if (!currentIsMulti) {
return true;
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
HookBaseOptions,
ThrowUnlessCanOptions,
} from "../types";
import { getMethodName } from "../utils/getMethodName";

const defaultOptions: HookBaseOptions = {
ability: undefined,
Expand All @@ -33,9 +34,11 @@ export const checkCreatePerItem = (
options: Partial<
Pick<ThrowUnlessCanOptions, "actionOnForbidden" | "skipThrow">
> &
Partial<Pick<CheckBasicPermissionHookOptions, "checkCreateForData">>
Partial<
Pick<CheckBasicPermissionHookOptions, "checkCreateForData" | "method">
>
): HookContext => {
const { method } = context;
const method = getMethodName(context, options);
if (method !== "create" || !options.checkCreateForData) {
return context;
}
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface HookBaseOptions<H extends HookContext = HookContext> {
checkMultiActions: boolean;
modelName: GetModelName;
notSkippable: boolean;
method?: string | ((context: H) => string);
}

export interface CheckBasicPermissionHookOptions<
Expand Down
10 changes: 8 additions & 2 deletions src/utils/checkBasicPermission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
CheckBasicPermissionUtilsOptions,
CheckBasicPermissionHookOptionsExclusive,
} from "../types";
import { getMethodName } from "./getMethodName";

const defaultOptions: CheckBasicPermissionHookOptionsExclusive = {
checkCreateForData: false,
Expand All @@ -30,9 +31,14 @@ export const checkBasicPermissionUtil = async <H extends HookContext>(
context: H,
_options?: Partial<CheckBasicPermissionUtilsOptions>
): Promise<H> => {
const options = makeOptions(_options);
let options = makeOptions(_options);

const { method } = context;
const method = getMethodName(context, options);

options = {
...options,
method,
};

if (!options.modelName) {
return context;
Expand Down
16 changes: 16 additions & 0 deletions src/utils/getMethodName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { HookContext } from "@feathersjs/feathers";

export const getMethodName = (
context: HookContext,
options?: { method?: string | ((context: HookContext) => string) }
): string => {
if (options?.method) {
if (typeof options.method === "function") {
return options.method(context);
} else {
return options.method;
}
}

return context.method;
};
97 changes: 97 additions & 0 deletions test/hooks/authorize/authorize.options.method.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { MemoryService } from "@feathersjs/memory";
import { filterObject } from "feathers-utils";
import { authorize, type Adapter, type ServiceCaslOptions } from "../../../src";
import { feathers } from "@feathersjs/feathers";
import { defineAbility } from "@casl/ability";
import { resolveAction } from "../../test-utils";

declare module "@feathersjs/memory" {
interface MemoryServiceOptions {
casl?: ServiceCaslOptions;
}
}

class CustomService extends MemoryService {
sum(data: any, params: any) {
return this.find(params);
}
}

describe("authorize.options.method", () => {
it("should work", async () => {
const app = feathers<{
tests: CustomService;
}>();

const id = "id";

app.use(
"tests",
new CustomService({
id,
multi: true,
startId: 1,
filters: {
...filterObject("$nor"),
},
operators: ["$nor"],
casl: {
availableFields: [
"id",
"userId",
"hi",
"test",
"published",
"supersecret",
"hidden",
],
},
paginate: {
default: 10,
max: 50,
},
}),
{
methods: ["find", "get", "create", "update", "patch", "remove", "sum"],
}
);

const hook = authorize({
method: "find",
adapter: "@feathersjs/memory",
});

const service = app.service("tests");

service.hooks({
before: {
sum: [hook],
},
after: {
sum: [hook],
},
});

const item1 = await service.create({ test: true, userId: 1 });
await service.create({ test: true, userId: 2 });
await service.create({ test: true, userId: 3 });
const items = await service.find({ paginate: false });
assert.strictEqual(items.length, 3, "has three items");

const returnedItems = (await service.sum(null, {
ability: defineAbility(
(can) => {
can("read", "tests", { userId: 1 });
},
{ resolveAction }
),
paginate: false,
})) as any as any[];

assert.deepStrictEqual(
returnedItems,
[{ [id]: item1[id], test: true, userId: 1 }],
"just returned one item"
);
});
});

0 comments on commit 8da9b3e

Please sign in to comment.