From 89c00500b9650d5748eae3f6d667f726b9d3cc6a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 24 Feb 2022 11:05:18 +0100 Subject: [PATCH] Migrate PermissionController and SubjectMetadataController (#692) * Initial copy-paste * Fix a bunch of imports etc * Fix some typing issues * Fix another type issue * Fix linting issues + add missing doc strings * Run Prettier on README * Fix some test typing issues + ignore others * Revert a recent change * Remove unused function * Remove remains of bold formatting * Fix usage of messengers to improve test typing * Fix some issues with JSDocs * Add util function tests * Use @metamask/types for a bunch of shared types * Fix a few import issues * Remove unnecessary ts-expect-error comments --- package.json | 5 + src/index.ts | 2 + src/permissions/Caveat.test.ts | 136 + src/permissions/Caveat.ts | 264 + src/permissions/Permission.test.ts | 87 + src/permissions/Permission.ts | 613 +++ src/permissions/PermissionController.test.ts | 4680 +++++++++++++++++ src/permissions/PermissionController.ts | 2168 ++++++++ src/permissions/README.md | 277 + src/permissions/endowments/index.ts | 5 + .../endowments/network-access.test.ts | 18 + src/permissions/endowments/network-access.ts | 43 + src/permissions/errors.test.ts | 19 + src/permissions/errors.ts | 285 + src/permissions/index.test.ts | 16 + src/permissions/index.ts | 8 + src/permissions/permission-middleware.ts | 98 + .../rpc-methods/getPermissions.test.ts | 48 + src/permissions/rpc-methods/getPermissions.ts | 48 + src/permissions/rpc-methods/index.ts | 10 + .../rpc-methods/requestPermissions.test.ts | 138 + .../rpc-methods/requestPermissions.ts | 82 + src/permissions/utils.ts | 27 + .../SubjectMetadataController.test.ts | 261 + .../SubjectMetadataController.ts | 222 + src/subject-metadata/index.ts | 1 + src/util.test.ts | 60 + src/util.ts | 51 + yarn.lock | 17 +- 29 files changed, 9688 insertions(+), 1 deletion(-) create mode 100644 src/permissions/Caveat.test.ts create mode 100644 src/permissions/Caveat.ts create mode 100644 src/permissions/Permission.test.ts create mode 100644 src/permissions/Permission.ts create mode 100644 src/permissions/PermissionController.test.ts create mode 100644 src/permissions/PermissionController.ts create mode 100644 src/permissions/README.md create mode 100644 src/permissions/endowments/index.ts create mode 100644 src/permissions/endowments/network-access.test.ts create mode 100644 src/permissions/endowments/network-access.ts create mode 100644 src/permissions/errors.test.ts create mode 100644 src/permissions/errors.ts create mode 100644 src/permissions/index.test.ts create mode 100644 src/permissions/index.ts create mode 100644 src/permissions/permission-middleware.ts create mode 100644 src/permissions/rpc-methods/getPermissions.test.ts create mode 100644 src/permissions/rpc-methods/getPermissions.ts create mode 100644 src/permissions/rpc-methods/index.ts create mode 100644 src/permissions/rpc-methods/requestPermissions.test.ts create mode 100644 src/permissions/rpc-methods/requestPermissions.ts create mode 100644 src/permissions/utils.ts create mode 100644 src/subject-metadata/SubjectMetadataController.test.ts create mode 100644 src/subject-metadata/SubjectMetadataController.ts create mode 100644 src/subject-metadata/index.ts diff --git a/package.json b/package.json index 24b6fd1477..f88b69a048 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "@ethereumjs/tx": "^3.2.1", "@metamask/contract-metadata": "^1.31.0", "@metamask/metamask-eth-abis": "3.0.0", + "@metamask/types": "^1.1.0", "@types/uuid": "^8.3.0", "abort-controller": "^3.0.0", "async-mutex": "^0.2.6", "babel-runtime": "^6.26.0", + "deep-freeze-strict": "^1.1.1", "eth-ens-namehash": "^2.0.8", "eth-json-rpc-infura": "^5.1.0", "eth-keyring-controller": "^6.2.1", @@ -55,8 +57,10 @@ "ethereumjs-wallet": "^1.0.1", "ethers": "^5.4.1", "ethjs-unit": "^0.1.6", + "fast-deep-equal": "^3.1.3", "immer": "^9.0.6", "isomorphic-fetch": "^3.0.0", + "json-rpc-engine": "^6.1.0", "jsonschema": "^1.2.4", "multiformats": "^9.5.2", "nanoid": "^3.1.31", @@ -73,6 +77,7 @@ "@metamask/eslint-config-jest": "^9.0.0", "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", + "@types/deep-freeze-strict": "^1.1.0", "@types/jest": "^26.0.22", "@types/jest-when": "^2.7.3", "@types/node": "^14.14.31", diff --git a/src/index.ts b/src/index.ts index acc75ce285..b549f845b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,4 +35,6 @@ export * from './assets/TokensController'; export * from './assets/CollectiblesController'; export * from './assets/TokenDetectionController'; export * from './assets/CollectibleDetectionController'; +export * from './permissions'; +export * from './subject-metadata'; export { util }; diff --git a/src/permissions/Caveat.test.ts b/src/permissions/Caveat.test.ts new file mode 100644 index 0000000000..8a573711b4 --- /dev/null +++ b/src/permissions/Caveat.test.ts @@ -0,0 +1,136 @@ +import * as errors from './errors'; +import { decorateWithCaveats, PermissionConstraint } from '.'; + +describe('decorateWithCaveats', () => { + it('decorates a method with caveat', async () => { + const methodImplementation = () => [1, 2, 3]; + + const caveatSpecifications = { + reverse: { + type: 'reverse', + decorator: (method: any, _caveat: any) => async () => { + return (await method()).reverse(); + }, + }, + }; + + const permission: PermissionConstraint = { + id: 'foo', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + caveats: [{ type: 'reverse', value: null }], + }; + + const decorated = decorateWithCaveats( + methodImplementation, + permission, + caveatSpecifications, + ); + + expect(methodImplementation()).toStrictEqual([1, 2, 3]); + expect( + await decorated({ + method: 'arbitraryMethod', + context: { origin: 'metamask.io' }, + }), + ).toStrictEqual([3, 2, 1]); + }); + + it('decorates a method with multiple caveats', async () => { + const methodImplementation = () => [1, 2, 3]; + + const caveatSpecifications = { + reverse: { + type: 'reverse', + decorator: (method: any, _caveat: any) => async () => { + return (await method()).reverse(); + }, + }, + slice: { + type: 'slice', + decorator: (method: any, caveat: any) => async () => { + return (await method()).slice(0, caveat.value); + }, + }, + }; + + const permission: PermissionConstraint = { + id: 'foo', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + caveats: [ + { type: 'reverse', value: null }, + { type: 'slice', value: 1 }, + ], + }; + + const decorated = decorateWithCaveats( + methodImplementation, + permission, + caveatSpecifications, + ); + + expect(methodImplementation()).toStrictEqual([1, 2, 3]); + expect( + await decorated({ + method: 'arbitraryMethod', + context: { origin: 'metamask.io' }, + }), + ).toStrictEqual([3]); + }); + + it('returns the unmodified method implementation if there are no caveats', () => { + const methodImplementation = () => [1, 2, 3]; + + const permission: PermissionConstraint = { + id: 'foo', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + caveats: null, + }; + + const decorated = decorateWithCaveats(methodImplementation, permission, {}); + expect(methodImplementation()).toStrictEqual( + decorated({ + method: 'arbitraryMethod', + context: { origin: 'metamask.io' }, + }), + ); + }); + + it('throws an error if the caveat type is unrecognized', () => { + const methodImplementation = () => [1, 2, 3]; + + const caveatSpecifications = { + reverse: { + type: 'reverse', + decorator: (method: any, _caveat: any) => async () => { + return (await method()).reverse(); + }, + }, + }; + + const permission: PermissionConstraint = { + id: 'foo', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + // This type doesn't exist + caveats: [{ type: 'kaplar', value: null }], + }; + + expect(() => + decorateWithCaveats( + methodImplementation, + permission, + caveatSpecifications, + )({ + method: 'arbitraryMethod', + context: { origin: 'metamask.io' }, + }), + ).toThrow(new errors.UnrecognizedCaveatTypeError('kaplar')); + }); +}); diff --git a/src/permissions/Caveat.ts b/src/permissions/Caveat.ts new file mode 100644 index 0000000000..5bf4aa522f --- /dev/null +++ b/src/permissions/Caveat.ts @@ -0,0 +1,264 @@ +import { Json } from '@metamask/types'; +import { UnrecognizedCaveatTypeError } from './errors'; +import { + AsyncRestrictedMethod, + RestrictedMethod, + PermissionConstraint, + RestrictedMethodParameters, +} from './Permission'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { PermissionController } from './PermissionController'; + +export type CaveatConstraint = { + /** + * The type of the caveat. The type is presumed to be meaningful in the + * context of the capability it is associated with. + * + * In MetaMask, every permission can only have one caveat of each type. + */ + readonly type: string; + + // TODO:TS4.4 Make optional + /** + * Any additional data necessary to enforce the caveat. + */ + readonly value: Json; +}; + +/** + * A `ZCAP-LD`-like caveat object. A caveat is associated with a particular + * permission, and stored in its `caveats` array. Conceptually, a caveat is + * an arbitrary attenuation of the authority granted by its associated + * permission. It is the responsibility of the host to interpret and apply + * the restriction represented by a caveat. + * + * @template Type - The type of the caveat. + * @template Value - The value associated with the caveat. + */ +export type Caveat = { + /** + * The type of the caveat. The type is presumed to be meaningful in the + * context of the capability it is associated with. + * + * In MetaMask, every permission can only have one caveat of each type. + */ + readonly type: Type; + + // TODO:TS4.4 Make optional + /** + * Any additional data necessary to enforce the caveat. + */ + readonly value: Value; +}; + +// Next, we define types used for specifying caveats at the consumer layer, +// and a function for applying caveats to a restricted method request. This is +// Accomplished by decorating the restricted method implementation with the +// the corresponding caveat functions. + +/** + * A function for applying caveats to a restricted method request. + * + * @template ParentCaveat - The caveat type associated with this decorator. + * @param decorated - The restricted method implementation to be decorated. + * The method may have already been decorated with other caveats. + * @param caveat - The caveat object. + * @returns The decorated restricted method implementation. + */ +export type CaveatDecorator = ( + decorated: AsyncRestrictedMethod, + caveat: ParentCaveat, +) => AsyncRestrictedMethod; + +/** + * Extracts a caveat value type from a caveat decorator. + * + * @template Decorator - The {@link CaveatDecorator} to extract a caveat value + * type from. + */ +type ExtractCaveatValueFromDecorator< + Decorator extends CaveatDecorator +> = Decorator extends ( + decorated: any, + caveat: infer ParentCaveat, +) => AsyncRestrictedMethod + ? ParentCaveat extends CaveatConstraint + ? ParentCaveat['value'] + : never + : never; + +/** + * A function for validating caveats of a particular type. + * + * @template ParentCaveat - The caveat type associated with this validator. + * @param caveat - The caveat object to validate. + * @param origin - The origin associated with the parent permission. + * @param target - The target of the parent permission. + */ +export type CaveatValidator = ( + caveat: { type: ParentCaveat['type']; value: unknown }, + origin?: string, + target?: string, +) => void; + +/** + * The constraint for caveat specification objects. Every {@link Caveat} + * supported by a {@link PermissionController} must have an associated + * specification, which is the source of truth for all caveat-related types. + * In addition, a caveat specification includes the decorator function used + * to apply the caveat's attenuation to a restricted method, and any validator + * function specified by the consumer. + * + * See the README for more details. + */ +export type CaveatSpecificationConstraint = { + /** + * The string type of the caveat. + */ + type: string; + + /** + * The decorator function used to apply the caveat to restricted method + * requests. + */ + decorator: CaveatDecorator; + + /** + * The validator function used to validate caveats of the associated type + * whenever they are instantiated. Caveat are instantiated whenever they are + * created or mutated. + * + * The validator should throw an appropriate JSON-RPC error if validation fails. + * + * If no validator is specified, no validation of caveat values will be + * performed. Although caveats can also be validated by permission validators, + * validating caveat values separately is strongly recommended. + */ + validator?: CaveatValidator; +}; + +/** + * Options for {@link CaveatSpecificationBuilder} functions. + */ +type CaveatSpecificationBuilderOptions< + DecoratorHooks extends Record, + ValidatorHooks extends Record +> = { + type?: string; + decoratorHooks?: DecoratorHooks; + validatorHooks?: ValidatorHooks; +}; + +/** + * A function that builds caveat specifications. Modules that specify caveats + * for external consumption should make this their primary / default export so + * that host applications can use them to generate concrete specifications + * tailored to their requirements. + */ +export type CaveatSpecificationBuilder< + Options extends CaveatSpecificationBuilderOptions, + Specification extends CaveatSpecificationConstraint +> = (options: Options) => Specification; + +/** + * A caveat specification export object, containing the + * {@link CaveatSpecificationBuilder} function and "hook name" objects. + */ +export type CaveatSpecificationBuilderExportConstraint = { + specificationBuilder: CaveatSpecificationBuilder< + CaveatSpecificationBuilderOptions, + CaveatSpecificationConstraint + >; + decoratorHookNames?: Record; + validatorHookNames?: Record; +}; + +/** + * The specifications for all caveats supported by a particular + * {@link PermissionController}. + * + * @template Specifications - The union of all {@link CaveatSpecificationConstraint} types. + */ +export type CaveatSpecificationMap< + CaveatSpecification extends CaveatSpecificationConstraint +> = Record; + +/** + * Extracts the union of all caveat types specified by the given + * {@link CaveatSpecificationConstraint} type. + * + * @template CaveatSpecification - The {@link CaveatSpecificationConstraint} to extract a + * caveat type union from. + */ +export type ExtractCaveats< + CaveatSpecification extends CaveatSpecificationConstraint +> = CaveatSpecification extends any + ? Caveat< + CaveatSpecification['type'], + ExtractCaveatValueFromDecorator + > + : never; + +/** + * Extracts the type of a specific {@link Caveat} from a union of caveat + * specifications. + * + * @template CaveatSpecifications - The union of all caveat specifications. + * @template CaveatType - The type of the caveat to extract. + */ +export type ExtractCaveat< + CaveatSpecifications extends CaveatSpecificationConstraint, + CaveatType extends string +> = Extract, { type: CaveatType }>; + +/** + * Extracts the value type of a specific {@link Caveat} from a union of caveat + * specifications. + * + * @template CaveatSpecifications - The union of all caveat specifications. + * @template CaveatType - The type of the caveat whose value to extract. + */ +export type ExtractCaveatValue< + CaveatSpecifications extends CaveatSpecificationConstraint, + CaveatType extends string +> = ExtractCaveat['value']; + +/** + * Decorate a restricted method implementation with its caveats. + * + * Note that all caveat functions (i.e. the argument and return value of the + * decorator) must be awaited. + * + * @param methodImplementation - The restricted method implementation + * @param permission - The origin's potential permission + * @param caveatSpecifications - All caveat implementations + * @returns The decorated method implementation + */ +export function decorateWithCaveats< + CaveatSpecifications extends CaveatSpecificationConstraint +>( + methodImplementation: RestrictedMethod, + permission: Readonly, // bound to the requesting origin + caveatSpecifications: CaveatSpecificationMap, // all caveat implementations +): RestrictedMethod { + const { caveats } = permission; + if (!caveats) { + return methodImplementation; + } + + let decorated = async ( + args: Parameters>[0], + ) => methodImplementation(args); + + for (const caveat of caveats) { + const specification = + caveatSpecifications[caveat.type as CaveatSpecifications['type']]; + if (!specification) { + throw new UnrecognizedCaveatTypeError(caveat.type); + } + + decorated = specification.decorator(decorated, caveat); + } + + return decorated; +} diff --git a/src/permissions/Permission.test.ts b/src/permissions/Permission.test.ts new file mode 100644 index 0000000000..7e13ee893e --- /dev/null +++ b/src/permissions/Permission.test.ts @@ -0,0 +1,87 @@ +import { findCaveat } from './Permission'; +import { constructPermission, CaveatConstraint, PermissionConstraint } from '.'; + +describe('constructPermission', () => { + it('constructs a permission', () => { + const invoker = 'foo.io'; + const target = 'wallet_bar'; + + expect( + constructPermission({ + invoker, + target, + }), + ).toMatchObject( + expect.objectContaining({ + id: expect.any(String), + parentCapability: target, + invoker, + caveats: null, + date: expect.any(Number), + }), + ); + }); + + it('constructs a permission with caveats', () => { + const invoker = 'foo.io'; + const target = 'wallet_bar'; + const caveats: [CaveatConstraint] = [{ type: 'foo', value: 'bar' }]; + + expect( + constructPermission({ + invoker, + target, + caveats, + }), + ).toMatchObject( + expect.objectContaining({ + id: expect.any(String), + parentCapability: target, + invoker, + caveats: [...caveats], + date: expect.any(Number), + }), + ); + }); +}); + +describe('findCaveat', () => { + it('finds a caveat', () => { + const permission: PermissionConstraint = { + id: 'arbitraryId', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + caveats: [{ type: 'foo', value: 'bar' }], + }; + + expect(findCaveat(permission, 'foo')).toStrictEqual({ + type: 'foo', + value: 'bar', + }); + }); + + it('returns undefined if the specified caveat does not exist', () => { + const permission: PermissionConstraint = { + id: 'arbitraryId', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + caveats: [{ type: 'foo', value: 'bar' }], + }; + + expect(findCaveat(permission, 'doesNotExist')).toBeUndefined(); + }); + + it('returns undefined if the permission has no caveats', () => { + const permission: PermissionConstraint = { + id: 'arbitraryId', + parentCapability: 'arbitraryMethod', + invoker: 'arbitraryInvoker', + date: Date.now(), + caveats: null, + }; + + expect(findCaveat(permission, 'doesNotExist')).toBeUndefined(); + }); +}); diff --git a/src/permissions/Permission.ts b/src/permissions/Permission.ts new file mode 100644 index 0000000000..df6b87db03 --- /dev/null +++ b/src/permissions/Permission.ts @@ -0,0 +1,613 @@ +import { Json } from '@metamask/types'; +import { nanoid } from 'nanoid'; +import { NonEmptyArray } from '../util'; +import { CaveatConstraint } from './Caveat'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { PermissionController } from './PermissionController'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { Caveat } from './Caveat'; + +/** + * The origin of a subject. + * Effectively the GUID of an entity that can have permissions. + */ +export type OriginString = string; + +/** + * The name of a permission target. + */ +type TargetName = string; + +/** + * A `ZCAP-LD`-like permission object. A permission is associated with a + * particular `invoker`, which is the holder of the permission. Possessing the + * permission grants access to a particular restricted resource, identified by + * the `parentCapability`. The use of the restricted resource may be further + * restricted by any `caveats` associated with the permission. + * + * See the README for details. + */ +export type PermissionConstraint = { + /** + * The context(s) in which this capability is meaningful. + * + * It is required by the standard, but we make it optional since there is only + * one context in our usage (i.e. the user's MetaMask instance). + */ + readonly '@context'?: NonEmptyArray; + + // TODO:TS4.4 Make optional + /** + * The caveats of the permission. + * + * @see {@link Caveat} For more information. + */ + readonly caveats: null | NonEmptyArray; + + /** + * The creation date of the permission, in UNIX epoch time. + */ + readonly date: number; + + /** + * The GUID of the permission object. + */ + readonly id: string; + + /** + * The origin string of the subject that has the permission. + */ + readonly invoker: OriginString; + + /** + * A pointer to the resource that possession of the capability grants + * access to, for example a JSON-RPC method or endowment. + */ + readonly parentCapability: string; +}; + +/** + * A `ZCAP-LD`-like permission object. A permission is associated with a + * particular `invoker`, which is the holder of the permission. Possessing the + * permission grants access to a particular restricted resource, identified by + * the `parentCapability`. The use of the restricted resource may be further + * restricted by any `caveats` associated with the permission. + * + * See the README for details. + * + * @template TargetKey - They key of the permission target that the permission + * corresponds to. + * @template AllowedCaveat - A union of the allowed {@link Caveat} types + * for the permission. + */ +export type ValidPermission< + TargetKey extends TargetName, + AllowedCaveat extends CaveatConstraint +> = PermissionConstraint & { + // TODO:TS4.4 Make optional + /** + * The caveats of the permission. + * + * @see {@link Caveat} For more information. + */ + readonly caveats: AllowedCaveat extends never + ? null + : NonEmptyArray | null; + + /** + * A pointer to the resource that possession of the capability grants + * access to, for example a JSON-RPC method or endowment. + */ + readonly parentCapability: ExtractPermissionTargetNames; +}; + +/** + * A utility type for ensuring that the given permission target name conforms to + * our naming conventions. + * + * See the README for the distinction between target names and keys. + */ +type ValidTargetName = Name extends `${string}*` + ? never + : Name extends `${string}_` + ? never + : Name; + +/** + * A utility type for extracting permission target names from a union of target + * keys. + * + * See the README for the distinction between target names and keys. + * + * @template Key - The target key type to extract target names from. + */ +export type ExtractPermissionTargetNames = ValidTargetName< + Key extends `${infer Base}_*` ? `${Base}_${string}` : Key +>; + +/** + * Extracts the permission key of a particular name from a union of keys. + * An internal utility type used in {@link ExtractPermissionTargetKey}. + * + * @template Key - The target key type to extract from. + * @template Name - The name whose key to extract. + */ +type KeyOfTargetName< + Key extends string, + Name extends string +> = Name extends ExtractPermissionTargetNames ? Key : never; + +/** + * A utility type for finding the permission target key corresponding to a + * target name. In a way, the inverse of {@link ExtractPermissionTargetNames}. + * + * See the README for the distinction between target names and keys. + * + * @template Key - The target key type to extract from. + * @template Name - The name whose key to extract. + */ +export type ExtractPermissionTargetKey< + Key extends string, + Name extends string +> = Key extends Name ? Key : Extract>; + +/** + * Internal utility for extracting the members types of an array. The type + * evalutes to `never` if the specified type is the empty tuple or neither + * an array nor a tuple. + * + * @template ArrayType - The array type whose members to extract. + */ +type ExtractArrayMembers = ArrayType extends [] + ? never + : ArrayType extends any[] | readonly any[] + ? ArrayType[number] + : never; + +/** + * A utility type for extracting the allowed caveat types for a particular + * permission from a permission specification type. + * + * @template PermissionSpecification - The permission specification type to + * extract valid caveat types from. + */ +export type ExtractAllowedCaveatTypes< + PermissionSpecification extends PermissionSpecificationConstraint +> = ExtractArrayMembers; + +/** + * The options object of {@link constructPermission}. + * + * @template TargetPermission - The {@link Permission} that will be constructed. + */ +export type PermissionOptions = { + target: TargetPermission['parentCapability']; + /** + * The origin string of the subject that has the permission. + */ + invoker: OriginString; + + /** + * The caveats of the permission. + * See {@link Caveat}. + */ + caveats?: NonEmptyArray; +}; + +/** + * The default permission factory function. Naively constructs a permission from + * the inputs. Sets a default, random `id` if none is provided. + * + * @see {@link Permission} For more details. + * @template TargetPermission- - The {@link Permission} that will be constructed. + * @param options - The options for the permission. + * @returns The new permission object. + */ +export function constructPermission< + TargetPermission extends PermissionConstraint +>(options: PermissionOptions): TargetPermission { + const { caveats = null, invoker, target } = options; + + return { + id: nanoid(), + parentCapability: target, + invoker, + caveats, + date: new Date().getTime(), + } as TargetPermission; +} + +/** + * Gets the caveat of the specified type belonging to the specified permission. + * + * @param permission - The permission whose caveat to retrieve. + * @param caveatType - The type of the caveat to retrieve. + * @returns The caveat, or undefined if no such caveat exists. + */ +export function findCaveat( + permission: PermissionConstraint, + caveatType: string, +): CaveatConstraint | undefined { + return permission.caveats?.find((caveat) => caveat.type === caveatType); +} + +/** + * A requested permission object. Just an object with any of the properties + * of a {@link PermissionConstraint} object. + */ +type RequestedPermission = Partial; + +/** + * A record of target names and their {@link RequestedPermission} objects. + */ +export type RequestedPermissions = Record; + +/** + * The restricted method context object. Essentially a way to pass internal + * arguments to restricted methods and caveat functions, most importantly the + * requesting origin. + */ +type RestrictedMethodContext = Readonly<{ + origin: OriginString; + [key: string]: any; +}>; + +export type RestrictedMethodParameters = Json[] | Record | void; + +/** + * The arguments passed to a restricted method implementation. + * + * @template Params - The JSON-RPC parameters of the restricted method. + */ +export type RestrictedMethodOptions< + Params extends RestrictedMethodParameters +> = { + method: TargetName; + params?: Params; + context: RestrictedMethodContext; +}; + +/** + * A synchronous restricted method implementation. + * + * @template Params - The JSON-RPC parameters of the restricted method. + * @template Result - The JSON-RPC result of the restricted method. + */ +export type SyncRestrictedMethod< + Params extends RestrictedMethodParameters, + Result extends Json +> = (args: RestrictedMethodOptions) => Result; + +/** + * An asynchronous restricted method implementation. + * + * @template Params - The JSON-RPC parameters of the restricted method. + * @template Result - The JSON-RPC result of the restricted method. + */ +export type AsyncRestrictedMethod< + Params extends RestrictedMethodParameters, + Result extends Json +> = (args: RestrictedMethodOptions) => Promise; + +/** + * A synchronous or asynchronous restricted method implementation. + * + * @template Params - The JSON-RPC parameters of the restricted method. + * @template Result - The JSON-RPC result of the restricted method. + */ +export type RestrictedMethod< + Params extends RestrictedMethodParameters, + Result extends Json +> = + | SyncRestrictedMethod + | AsyncRestrictedMethod; + +export type ValidRestrictedMethod< + MethodImplementation extends RestrictedMethod +> = MethodImplementation extends (args: infer Options) => Json | Promise + ? Options extends RestrictedMethodOptions + ? MethodImplementation + : never + : never; + +/** + * {@link EndowmentGetter} parameter object. + */ +export type EndowmentGetterParams = { + /** + * The origin of the requesting subject. + */ + origin: string; + + /** + * Any additional data associated with the request. + */ + requestData?: unknown; + + [key: string]: unknown; +}; + +/** + * A synchronous or asynchronous function that gets the endowments for a + * particular endowment permission. The getter receives the origin of the + * requesting subject and, optionally, additional request metadata. + */ +export type EndowmentGetter = ( + options: EndowmentGetterParams, +) => Endowments | Promise; + +export type PermissionFactory< + TargetPermission extends PermissionConstraint, + RequestData extends Record +> = ( + options: PermissionOptions, + requestData?: RequestData, +) => TargetPermission; + +export type PermissionValidatorConstraint = ( + permission: PermissionConstraint, + origin?: OriginString, + target?: string, +) => void; + +/** + * A utility type for ensuring that the given permission target key conforms to + * our naming conventions. + * + * See the README for the distinction between target names and keys. + * + * @template Key - The target key string to apply the constraint to. + */ +type ValidTargetKey = Key extends `${string}_*` + ? Key + : Key extends `${string}_` + ? never + : Key extends `${string}*` + ? never + : Key; + +/** + * The different possible types of permissions. + */ +export enum PermissionType { + /** + * A restricted JSON-RPC method. A subject must have the requisite permission + * to call a restricted JSON-RPC method. + */ + RestrictedMethod = 'RestrictedMethod', + + /** + * An "endowment" granted to subjects that possess the requisite permission, + * such as a global environment variable exposing a restricted API, etc. + */ + Endowment = 'Endowment', +} + +/** + * The base constraint for permission specification objects. Every + * {@link Permission} supported by a {@link PermissionController} must have an + * associated specification, which is the source of truth for all permission- + * related types. A permission specification includes the list of permitted + * caveats, and any factory and validation functions specified by the consumer. + * A concrete permission specification may specify further fields as necessary. + * + * See the README for more details. + */ +type PermissionSpecificationBase = { + /** + * The type of the specified permission. + */ + permissionType: Type; + + /** + * The target resource of the permission. The shape of this string depends on + * the permission type. For example, a restricted method target key will + * consist of either a complete method name or the prefix of a namespaced + * method, e.g. `wallet_snap_*`. + */ + targetKey: string; + + /** + * An array of the caveat types that may be added to instances of this + * permission. + */ + allowedCaveats: Readonly> | null; + + /** + * The factory function used to get permission objects. Permissions returned + * by this function are presumed to valid, and they will not be passed to the + * validator function associated with this specification (if any). In other + * words, the factory function should validate the permissions it creates. + * + * If no factory is specified, the {@link Permission} constructor will be + * used, and the validator function (if specified) will be called on newly + * constructed permissions. + */ + factory?: PermissionFactory>; + + /** + * The validator function used to validate permissions of the associated type + * whenever they are mutated. The only way a permission can be legally mutated + * is when its caveats are modified by the permission controller. + * + * The validator should throw an appropriate JSON-RPC error if validation fails. + */ + validator?: PermissionValidatorConstraint; +}; + +/** + * The constraint for restricted method permission specification objects. + * Permissions that correspond to JSON-RPC methods are specified using objects + * that conform to this type. + * + * See the README for more details. + */ +export type RestrictedMethodSpecificationConstraint = PermissionSpecificationBase & { + /** + * The implementation of the restricted method that the permission + * corresponds to. + */ + methodImplementation: RestrictedMethod; +}; + +/** + * The constraint for endowment permission specification objects. Permissions + * that endow callers with some restricted resource are specified using objects + * that conform to this type. + * + * See the README for more details. + */ +export type EndowmentSpecificationConstraint = PermissionSpecificationBase & { + /** + * Endowment permissions do not support caveats. + */ + allowedCaveats: null; + + /** + * The {@link EndowmentGetter} function for the permission. This function + * will be called by the {@link PermissionController} whenever the + * permission is invoked, after which the host can apply the endowments to + * the requesting subject in the intended manner. + */ + endowmentGetter: EndowmentGetter; +}; + +/** + * The constraint for permission specification objects. Every {@link Permission} + * supported by a {@link PermissionController} must have an associated + * specification, which is the source of truth for all permission-related types. + * All specifications must adhere to the {@link PermissionSpecificationBase} + * interface, but specifications may have different fields depending on the + * {@link PermissionType}. + * + * See the README for more details. + */ +export type PermissionSpecificationConstraint = + | EndowmentSpecificationConstraint + | RestrictedMethodSpecificationConstraint; + +/** + * Options for {@link PermissionSpecificationBuilder} functions. + */ +type PermissionSpecificationBuilderOptions< + FactoryHooks extends Record, + MethodHooks extends Record, + ValidatorHooks extends Record +> = { + targetKey?: string; + allowedCaveats?: Readonly> | null; + factoryHooks?: FactoryHooks; + methodHooks?: MethodHooks; + validatorHooks?: ValidatorHooks; +}; + +/** + * A function that builds a permission specification. Modules that specify + * permissions for external consumption should make this their primary / + * default export so that host applications can use them to generate concrete + * specifications tailored to their requirements. + */ +export type PermissionSpecificationBuilder< + Type extends PermissionType, + Options extends PermissionSpecificationBuilderOptions, + Specification extends PermissionSpecificationConstraint & { + permissionType: Type; + } +> = (options: Options) => Specification; + +/** + * A restricted method permission export object, containing the + * {@link PermissionSpecificationBuilder} function and "hook name" objects. + */ +export type PermissionSpecificationBuilderExportConstraint = { + targetKey: string; + specificationBuilder: PermissionSpecificationBuilder< + PermissionType, + PermissionSpecificationBuilderOptions, + PermissionSpecificationConstraint + >; + factoryHookNames?: Record; + methodHookNames?: Record; + validatorHookNames?: Record; +}; + +type ValidRestrictedMethodSpecification< + Specification extends RestrictedMethodSpecificationConstraint +> = Specification['methodImplementation'] extends ValidRestrictedMethod< + Specification['methodImplementation'] +> + ? Specification + : never; + +/** + * Constraint for {@link PermissionSpecificationConstraint} objects that + * evaluates to `never` if the specification contains any invalid fields. + * + * @template Specification - The permission specification to validate. + */ +export type ValidPermissionSpecification< + Specification extends PermissionSpecificationConstraint +> = Specification['targetKey'] extends ValidTargetKey< + Specification['targetKey'] +> + ? Specification['permissionType'] extends PermissionType.Endowment + ? Specification + : Specification['permissionType'] extends PermissionType.RestrictedMethod + ? ValidRestrictedMethodSpecification< + Extract + > + : never + : never; + +/** + * Checks that the specification has the expected permission type. + * + * @param specification - The specification to check. + * @param expectedType - The expected permission type. + * @template Specification - The specification to check. + * @template Type - The expected permission type. + * @returns Whether or not the specification is of the expected type. + */ +export function hasSpecificationType< + Specification extends PermissionSpecificationConstraint, + Type extends PermissionType +>( + specification: Specification, + expectedType: Type, +): specification is Specification & { + permissionType: Type; +} { + return specification.permissionType === expectedType; +} + +/** + * The specifications for all permissions supported by a particular + * {@link PermissionController}. + * + * @template Specifications - The union of all {@link PermissionSpecificationConstraint} types. + */ +export type PermissionSpecificationMap< + Specification extends PermissionSpecificationConstraint +> = { + [TargetKey in Specification['targetKey']]: Specification extends { + targetKey: TargetKey; + } + ? Specification + : never; +}; + +/** + * Extracts a specific {@link PermissionSpecificationConstraint} from a union of + * permission specifications. + * + * @template Specification - The specification union type to extract from. + * @template TargetKey - The `targetKey` of the specification to extract. + */ +export type ExtractPermissionSpecification< + Specification extends PermissionSpecificationConstraint, + TargetKey extends Specification['targetKey'] +> = Specification extends { + targetKey: TargetKey; +} + ? Specification + : never; diff --git a/src/permissions/PermissionController.test.ts b/src/permissions/PermissionController.test.ts new file mode 100644 index 0000000000..fc9b44f802 --- /dev/null +++ b/src/permissions/PermissionController.test.ts @@ -0,0 +1,4680 @@ +import assert from 'assert'; +import { JsonRpcEngine, PendingJsonRpcResponse } from 'json-rpc-engine'; +import { + AcceptRequest as AcceptApprovalRequest, + AddApprovalRequest, + HasApprovalRequest, + RejectRequest as RejectApprovalRequest, +} from '../approval/ApprovalController'; +import { hasProperty, isPlainObject } from '../util'; +import { Json } from '../BaseControllerV2'; +import { ControllerMessenger } from '../ControllerMessenger'; +import * as errors from './errors'; +import { EndowmentGetterParams } from './Permission'; +import { + AsyncRestrictedMethod, + Caveat, + CaveatConstraint, + CaveatMutatorOperation, + constructPermission, + ExtractSpecifications, + MethodNames, + PermissionConstraint, + PermissionController, + PermissionControllerActions, + PermissionControllerEvents, + PermissionControllerMessenger, + PermissionOptions, + PermissionType, + RestrictedMethodOptions, + RestrictedMethodParameters, + ValidPermission, +} from '.'; + +// Caveat types and specifications + +const CaveatTypes = { + filterArrayResponse: 'filterArrayResponse', + reverseArrayResponse: 'reverseArrayResponse', + filterObjectResponse: 'filterObjectResponse', + noopCaveat: 'noopCaveat', +} as const; + +type FilterArrayCaveat = Caveat< + typeof CaveatTypes.filterArrayResponse, + string[] +>; + +type ReverseArrayCaveat = Caveat; + +type FilterObjectCaveat = Caveat< + typeof CaveatTypes.filterObjectResponse, + string[] +>; + +type NoopCaveat = Caveat; + +/** + * Gets caveat specifications for: + * - {@link FilterArrayCaveat} + * - {@link FilterObjectCaveat} + * - {@link NoopCaveat} + * + * Used as a default in {@link getPermissionControllerOptions}. + * + * @returns The caveat specifications. + */ +function getDefaultCaveatSpecifications() { + return { + [CaveatTypes.filterArrayResponse]: { + type: CaveatTypes.filterArrayResponse, + decorator: ( + method: AsyncRestrictedMethod, + caveat: FilterArrayCaveat, + ) => async ( + args: RestrictedMethodOptions, + ) => { + const result: string[] | unknown = await method(args); + if (!Array.isArray(result)) { + throw Error('not an array'); + } + + return result.filter((resultValue) => + caveat.value.includes(resultValue), + ); + }, + validator: (caveat: { + type: typeof CaveatTypes.filterArrayResponse; + value: unknown; + }) => { + if (!Array.isArray(caveat.value)) { + throw new Error( + `${CaveatTypes.filterArrayResponse} values must be arrays`, + ); + } + }, + }, + [CaveatTypes.reverseArrayResponse]: { + type: CaveatTypes.reverseArrayResponse, + decorator: ( + method: AsyncRestrictedMethod, + _caveat: ReverseArrayCaveat, + ) => async ( + args: RestrictedMethodOptions, + ) => { + const result: unknown[] | unknown = await method(args); + if (!Array.isArray(result)) { + throw Error('not an array'); + } + + return result.reverse(); + }, + }, + [CaveatTypes.filterObjectResponse]: { + type: CaveatTypes.filterObjectResponse, + decorator: ( + method: AsyncRestrictedMethod, + caveat: FilterObjectCaveat, + ) => async ( + args: RestrictedMethodOptions, + ) => { + const result = await method(args); + if (!isPlainObject(result)) { + throw Error('not a plain object'); + } + + Object.keys(result).forEach((key) => { + if (!caveat.value.includes(key)) { + delete result[key]; + } + }); + return result; + }, + }, + [CaveatTypes.noopCaveat]: { + type: CaveatTypes.noopCaveat, + decorator: ( + method: AsyncRestrictedMethod, + _caveat: NoopCaveat, + ) => async ( + args: RestrictedMethodOptions, + ) => { + return method(args); + }, + validator: (caveat: { + type: typeof CaveatTypes.noopCaveat; + value: unknown; + }) => { + if (caveat.value !== null) { + throw new Error('NoopCaveat value must be null'); + } + }, + }, + } as const; +} + +type DefaultCaveatSpecifications = ExtractSpecifications< + ReturnType +>; + +// Permission types and specifications + +/** + * Permission key constants. + */ +const PermissionKeys = { + wallet_doubleNumber: 'wallet_doubleNumber', + wallet_getSecretArray: 'wallet_getSecretArray', + wallet_getSecretObject: 'wallet_getSecretObject', + wallet_noop: 'wallet_noop', + wallet_noopWithValidator: 'wallet_noopWithValidator', + wallet_noopWithFactory: 'wallet_noopWithFactory', + 'wallet_getSecret_*': 'wallet_getSecret_*', + endowmentPermission1: 'endowmentPermission1', +} as const; + +// wallet_getSecret_* +// We only define types for permissions with factories. +// Other permission types are extracted from the permission specifications in +// the permission controller. + +type SecretNamespacedPermission = ValidPermission< + typeof PermissionKeys['wallet_getSecret_*'], + NoopCaveat +>; + +type NoopWithFactoryPermission = ValidPermission< + typeof PermissionKeys['wallet_noopWithFactory'], + FilterArrayCaveat +>; + +/** + * Permission name (as opposed to keys) constants and getters. Since one of the + * permissions are namespaced, it's a getter function. + */ +const PermissionNames = { + wallet_doubleNumber: PermissionKeys.wallet_doubleNumber, + wallet_getSecretArray: PermissionKeys.wallet_getSecretArray, + wallet_getSecretObject: PermissionKeys.wallet_getSecretObject, + wallet_noop: PermissionKeys.wallet_noop, + wallet_noopWithValidator: PermissionKeys.wallet_noopWithValidator, + wallet_noopWithFactory: PermissionKeys.wallet_noopWithFactory, + endowmentPermission1: PermissionKeys.endowmentPermission1, + wallet_getSecret_: (str: string) => `wallet_getSecret_${str}` as const, +} as const; + +/** + * Gets permission specifications for our test permissions. + * Used as a default in {@link getPermissionControllerOptions}. + * + * @returns The permission specifications. + */ +function getDefaultPermissionSpecifications() { + return { + [PermissionKeys.wallet_getSecretArray]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys.wallet_getSecretArray, + allowedCaveats: [ + CaveatTypes.filterArrayResponse, + CaveatTypes.reverseArrayResponse, + ], + methodImplementation: (_args: RestrictedMethodOptions) => { + return ['a', 'b', 'c']; + }, + }, + [PermissionKeys.wallet_getSecretObject]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys.wallet_getSecretObject, + allowedCaveats: [ + CaveatTypes.filterObjectResponse, + CaveatTypes.noopCaveat, + ], + methodImplementation: (_args: RestrictedMethodOptions) => { + return { a: 'x', b: 'y', c: 'z' }; + }, + validator: (permission: PermissionConstraint) => { + // A dummy validator for a caveat type that should be impossible to add + assert.ok( + !permission.caveats?.some( + (caveat) => caveat.type === CaveatTypes.filterArrayResponse, + ), + 'getSecretObject permission validation failed', + ); + }, + }, + [PermissionKeys['wallet_getSecret_*']]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys['wallet_getSecret_*'], + allowedCaveats: [CaveatTypes.noopCaveat], + methodImplementation: (args: RestrictedMethodOptions) => { + return `Hello, secret friend "${args.method.replace( + 'wallet_getSecret_', + '', + )}"!`; + }, + factory: (options: PermissionOptions) => + constructPermission({ + ...options, + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + }), + validator: (permission: PermissionConstraint) => { + assert.deepStrictEqual( + permission.caveats, + [{ type: CaveatTypes.noopCaveat, value: null }], + 'getSecret_* permission validation failed', + ); + }, + }, + [PermissionKeys.wallet_doubleNumber]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys.wallet_doubleNumber, + allowedCaveats: null, + methodImplementation: ({ params }: RestrictedMethodOptions<[number]>) => { + if (!Array.isArray(params)) { + throw new Error( + `Invalid ${PermissionKeys.wallet_doubleNumber} request`, + ); + } + return params[0] * 2; + }, + }, + [PermissionKeys.wallet_noop]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys.wallet_noop, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + }, + // This one exists to check some permission validator logic + [PermissionKeys.wallet_noopWithValidator]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys.wallet_noopWithValidator, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + allowedCaveats: [CaveatTypes.noopCaveat, CaveatTypes.filterArrayResponse], + validator: (permission: PermissionConstraint) => { + if ( + permission.caveats?.some( + ({ type }) => type !== CaveatTypes.noopCaveat, + ) + ) { + throw new Error('noop permission validation failed'); + } + }, + }, + // This one exists just to check that permission factories can use the + // requestData of approved permission requests + [PermissionKeys.wallet_noopWithFactory]: { + permissionType: PermissionType.RestrictedMethod, + targetKey: PermissionKeys.wallet_noopWithFactory, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + allowedCaveats: [CaveatTypes.filterArrayResponse], + factory: ( + options: PermissionOptions, + requestData?: Record, + ) => { + if (!requestData) { + throw new Error('requestData is required'); + } + + return constructPermission({ + ...options, + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: requestData.caveatValue as string[], + }, + ], + }); + }, + }, + [PermissionKeys.endowmentPermission1]: { + permissionType: PermissionType.Endowment, + targetKey: PermissionKeys.endowmentPermission1, + endowmentGetter: (_options: EndowmentGetterParams) => ['endowment1'], + allowedCaveats: null, + }, + } as const; +} + +type DefaultPermissionSpecifications = ExtractSpecifications< + ReturnType +>; + +// The permissions controller + +const controllerName = 'PermissionController' as const; + +type ApprovalActions = + | HasApprovalRequest + | AddApprovalRequest + | AcceptApprovalRequest + | RejectApprovalRequest; + +/** + * Gets a unrestricted controller messenger. Used for tests. + * + * @returns The unrestricted messenger. + */ +function getUnrestrictedMessenger() { + return new ControllerMessenger< + PermissionControllerActions | ApprovalActions, + PermissionControllerEvents + >(); +} + +/** + * Gets a restricted controller messenger. + * Used as a default in {@link getPermissionControllerOptions}. + * + * @param messenger - Optional parameter to pass in a messenger + * @returns The restricted messenger. + */ +function getPermissionControllerMessenger( + messenger = getUnrestrictedMessenger(), +) { + return messenger.getRestricted< + typeof controllerName, + PermissionControllerActions['type'] | ApprovalActions['type'], + PermissionControllerEvents['type'] + >({ + name: controllerName, + allowedActions: [ + 'ApprovalController:hasRequest', + 'ApprovalController:addRequest', + 'ApprovalController:acceptRequest', + 'ApprovalController:rejectRequest', + ], + }) as PermissionControllerMessenger; +} + +/** + * Gets the default unrestricted methods array. + * Used as a default in {@link getPermissionControllerOptions}. + * + * @returns The unrestricted methods array + */ +function getDefaultUnrestrictedMethods() { + return ['wallet_unrestrictedMethod']; +} + +/** + * Gets some existing state to populate the permission controller with. + * There is one subject, "metamask.io", with one permission, "wallet_getSecretArray", with no caveats. + * + * @returns The existing mock state + */ +function getExistingPermissionState() { + return { + subjects: { + 'metamask.io': { + origin: 'metamask.io', + permissions: { + wallet_getSecretArray: { + id: 'escwEx9JrOxGZKZk3RkL4', + parentCapability: 'wallet_getSecretArray', + invoker: 'metamask.io', + caveats: null, + date: 1632618373085, + }, + }, + }, + }, + }; +} + +/** + * Gets constructor options for the permission controller. Returns defaults + * that can be overwritten by passing in replacement options. + * + * The following defaults are used: + * - `caveatSpecifications`: {@link getDefaultCaveatSpecifications} + * - `messenger`: {@link getPermissionControllerMessenger} + * - `permissionSpecifications`: {@link getDefaultPermissionSpecifications} + * - `unrestrictedMethods`: {@link getDefaultUnrestrictedMethods} + * - `state`: `undefined` + * + * @param opts - Permission controller options. + * @returns The permission controller constructor options. + */ +function getPermissionControllerOptions(opts?: Record) { + return { + caveatSpecifications: getDefaultCaveatSpecifications(), + messenger: getPermissionControllerMessenger(), + permissionSpecifications: getDefaultPermissionSpecifications(), + unrestrictedMethods: getDefaultUnrestrictedMethods(), + state: undefined, + ...opts, + }; +} + +/** + * Gets a "default" permission controller. This simply means a controller using + * the default caveat and permissions created in this test file. + * + * @param opts - For the options used, see {@link getPermissionControllerOptions} + * @returns The default permission controller for testing. + */ +function getDefaultPermissionController( + opts = getPermissionControllerOptions(), +) { + return new PermissionController< + typeof opts.permissionSpecifications[keyof typeof opts.permissionSpecifications], + typeof opts.caveatSpecifications[keyof typeof opts.caveatSpecifications] + >(opts); +} + +/** + * Gets an equivalent controller to the one returned by + * {@link getDefaultPermissionController}, except it's initialized with the + * state returned by {@link getExistingPermissionState}. + * + * @returns The default permission controller for testing, with some initial + * state. + */ +function getDefaultPermissionControllerWithState() { + return new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(getPermissionControllerOptions({ state: getExistingPermissionState() })); +} + +/** + * Gets a Jest matcher for a permission as they are stored in controller state. + * + * @param options - Options bag. + * @param options.parentCapability - The `parentCapability` of the permission. + * @param options.caveats - The caveat array of the permission, or `null`. + * @param options.invoker - The subject identifier (i.e. origin) of the subject. + * @returns A Jest matcher that matches permissions whose corresponding fields + * correspond to the parameters of this function. + */ +function getPermissionMatcher({ + parentCapability, + caveats = null, + invoker = 'metamask.io', +}: { + parentCapability: string; + caveats?: CaveatConstraint[] | null | typeof expect.objectContaining; + invoker?: string; +}) { + return expect.objectContaining({ + id: expect.any(String), + parentCapability, + invoker, + caveats, + date: expect.any(Number), + }); +} + +describe('PermissionController', () => { + describe('constructor', () => { + it('initializes a new PermissionController', () => { + const controller = getDefaultPermissionController(); + expect(controller.state).toStrictEqual({ subjects: {} }); + + expect(controller.unrestrictedMethods).toStrictEqual( + new Set(getDefaultUnrestrictedMethods()), + ); + }); + + it('rehydrates state', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(controller.state).toStrictEqual(getExistingPermissionState()); + }); + + it('throws if a permission specification permissionType is invalid', () => { + [null, '', 'kaplar'].forEach((invalidPermissionType) => { + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + foo: { + permissionType: invalidPermissionType, + }, + }, + }), + ), + ).toThrow(`Invalid permission type: "${invalidPermissionType}"`); + }); + }); + + it('throws if a permission specification targetKey is invalid', () => { + ['', 'foo_', 'foo*'].forEach((invalidTargetKey) => { + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + [invalidTargetKey]: { + permissionType: PermissionType.Endowment, + targetKey: invalidTargetKey, + }, + }, + }), + ), + ).toThrow(`Invalid permission target key: "${invalidTargetKey}"`); + }); + }); + + it('throws if a permission specification map key does not match its "targetKey" value', () => { + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + foo: { + permissionType: PermissionType.Endowment, + targetKey: 'bar', + }, + }, + }), + ), + ).toThrow( + `Invalid permission specification: key "foo" must match specification.target value "bar".`, + ); + }); + + it('throws if a permission specification lists unrecognized caveats', () => { + const permissionSpecifications = getDefaultPermissionSpecifications(); + (permissionSpecifications as any).wallet_getSecretArray.allowedCaveats.push( + 'foo', + ); + + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications, + }), + ), + ).toThrow(new errors.UnrecognizedCaveatTypeError('foo')); + }); + }); + + describe('clearState', () => { + it("clears the controller's state", () => { + const controller = getDefaultPermissionControllerWithState(); + expect(controller.state).toStrictEqual(getExistingPermissionState()); + + controller.clearState(); + expect(controller.state).toStrictEqual({ subjects: {} }); + }); + }); + + describe('getRestrictedMethod', () => { + it('gets the implementation of a restricted method', async () => { + const controller = getDefaultPermissionController(); + const method = controller.getRestrictedMethod( + PermissionNames.wallet_getSecretArray, + ); + + expect( + await method({ + method: 'wallet_getSecretArray', + context: { origin: 'github.com' }, + }), + ).toStrictEqual(['a', 'b', 'c']); + }); + + it('gets the implementation of a namespaced restricted method', async () => { + const controller = getDefaultPermissionController(); + const method = controller.getRestrictedMethod( + PermissionNames.wallet_getSecret_('foo'), + ); + + expect( + await method({ + method: 'wallet_getSecret_foo', + context: { origin: 'github.com' }, + }), + ).toStrictEqual('Hello, secret friend "foo"!'); + }); + + it('throws an error if the requested permission target is not a restricted method', () => { + const controller = getDefaultPermissionController(); + expect(() => + controller.getRestrictedMethod(PermissionNames.endowmentPermission1), + ).toThrow(errors.methodNotFound(PermissionNames.endowmentPermission1)); + }); + + it('throws an error if the method does not exist', () => { + const controller = getDefaultPermissionController(); + expect(() => controller.getRestrictedMethod('foo')).toThrow( + errors.methodNotFound('foo'), + ); + }); + }); + + describe('getSubjectNames', () => { + it('gets all subject names', () => { + const controller = getDefaultPermissionController(); + expect(controller.getSubjectNames()).toStrictEqual([]); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect(controller.getSubjectNames()).toStrictEqual(['foo']); + + controller.grantPermissions({ + subject: { origin: 'bar' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect(controller.getSubjectNames()).toStrictEqual(['foo', 'bar']); + }); + }); + + describe('getPermission', () => { + it('gets existing permissions', () => { + const controller = getDefaultPermissionControllerWithState(); + + expect( + controller.getPermission( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual( + getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: 'metamask.io', + }), + ); + }); + + it('returns undefined if the subject does not exist', () => { + const controller = getDefaultPermissionController(); + expect( + controller.getPermission( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ).toBeUndefined(); + }); + + it('returns undefined if the subject does not have the specified permission', () => { + const controller = getDefaultPermissionControllerWithState(); + expect( + controller.getPermission( + 'metamask.io', + PermissionNames.wallet_getSecretObject, + ), + ).toBeUndefined(); + }); + }); + + describe('getPermissions', () => { + it('gets existing permissions', () => { + const controller = getDefaultPermissionControllerWithState(); + + expect(controller.getPermissions('metamask.io')).toStrictEqual({ + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: 'metamask.io', + }), + }); + }); + + it('returns undefined for subjects without permissions', () => { + const controller = getDefaultPermissionController(); + expect(controller.getPermissions('metamask.io')).toBeUndefined(); + }); + }); + + describe('hasPermission', () => { + it('correctly indicates whether an origin has a permission', () => { + const controller = getDefaultPermissionControllerWithState(); + + expect( + controller.hasPermission( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual(true); + + expect( + controller.hasPermission( + 'metamask.io', + PermissionNames.wallet_getSecretObject, + ), + ).toStrictEqual(false); + }); + + it('correctly indicates whether an origin has a namespaced permission', () => { + const controller = getDefaultPermissionController(); + + controller.grantPermissions({ + subject: { origin: 'metamask.io' }, + approvedPermissions: { + wallet_getSecret_kabob: {}, + }, + }); + + expect( + controller.hasPermission( + 'metamask.io', + PermissionNames.wallet_getSecret_('kabob'), + ), + ).toStrictEqual(true); + + expect( + controller.hasPermission( + 'metamask.io', + PermissionNames.wallet_getSecret_('falafel'), + ), + ).toStrictEqual(false); + }); + }); + + describe('hasPermissions', () => { + it('correctly indicates whether an origin has any permissions', () => { + const controller = getDefaultPermissionControllerWithState(); + + expect(controller.hasPermissions('metamask.io')).toStrictEqual(true); + expect(controller.hasPermissions('foo.bar')).toStrictEqual(false); + }); + }); + + describe('revokeAllPermissions', () => { + it('revokes all permissions for the specified subject', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(controller.state).toStrictEqual(getExistingPermissionState()); + + controller.revokeAllPermissions('metamask.io'); + expect(controller.state).toStrictEqual({ subjects: {} }); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecret_('bar')]: {}, + }, + }); + + controller.revokeAllPermissions('foo'); + expect(controller.state).toStrictEqual({ subjects: {} }); + }); + + it('throws an error if the specified subject has no permissions', () => { + const controller = getDefaultPermissionController(); + expect(() => controller.revokeAllPermissions('metamask.io')).toThrow( + new errors.UnrecognizedSubjectError('metamask.io'), + ); + + controller.grantPermissions({ + subject: { origin: 'metamask.io' }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: {}, + }, + }); + + expect(() => controller.revokeAllPermissions('foo')).toThrow( + new errors.UnrecognizedSubjectError('foo'), + ); + }); + }); + + describe('revokePermission', () => { + it('revokes a permission from an origin with a single permission', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(controller.state).toStrictEqual(getExistingPermissionState()); + + controller.revokePermission( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ); + expect(controller.state).toStrictEqual({ subjects: {} }); + }); + + it('revokes a permission from an origin with multiple permissions', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(controller.state).toStrictEqual(getExistingPermissionState()); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: {}, + }, + }); + + controller.revokePermission( + origin, + PermissionNames.wallet_getSecretArray, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: null, + invoker: origin, + }), + }, + }, + }, + }); + }); + + it('revokes a namespaced permission', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(controller.state).toStrictEqual(getExistingPermissionState()); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + controller.revokePermission( + origin, + PermissionNames.wallet_getSecret_('foo'), + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + }, + }, + }, + }); + }); + + it('throws an error if the specified subject has no permissions', () => { + const controller = getDefaultPermissionController(); + expect(() => + controller.revokePermission( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ).toThrow(new errors.UnrecognizedSubjectError('metamask.io')); + }); + + it('throws an error if the requested subject does not have the specified permission', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(() => + controller.revokePermission( + 'metamask.io', + PermissionNames.wallet_getSecretObject, + ), + ).toThrow( + new errors.PermissionDoesNotExistError( + 'metamask.io', + PermissionNames.wallet_getSecretObject, + ), + ); + }); + }); + + describe('revokePermissions', () => { + it('revokes different permissions for multiple subjects', () => { + const controller = getDefaultPermissionController(); + const origin0 = 'origin0'; + const origin1 = 'origin1'; + const origin2 = 'origin2'; + const origin3 = 'origin3'; + const origin4 = 'origin4'; + + controller.grantPermissions({ + subject: { origin: origin0 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin1 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin2 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: {}, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin3 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin4 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: {}, + [PermissionNames.wallet_getSecret_('bar')]: {}, + }, + }); + + controller.revokePermissions({ + [origin0]: [PermissionNames.wallet_getSecretArray], + [origin2]: [ + PermissionNames.wallet_getSecretArray, + PermissionNames.wallet_getSecret_('foo'), + ], + [origin3]: [PermissionNames.wallet_getSecretArray], + [origin4]: [ + PermissionNames.wallet_getSecretArray, + PermissionNames.wallet_getSecretObject, + PermissionNames.wallet_getSecret_('bar'), + ], + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin1]: { + origin: origin1, + permissions: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin1, + }), + }, + }, + [origin2]: { + origin: origin2, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: null, + invoker: origin2, + }), + }, + }, + [origin3]: { + origin: origin3, + permissions: { + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin3, + }), + }, + }, + }, + }); + }); + + it('throws an error if a specified subject has no permissions', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(() => + controller.revokePermissions({ + foo: [PermissionNames.wallet_getSecretArray], + }), + ).toThrow(new errors.UnrecognizedSubjectError('foo')); + }); + + it('throws an error if the requested subject does not have the specified permission', () => { + const controller = getDefaultPermissionControllerWithState(); + expect(() => + controller.revokePermissions({ + 'metamask.io': [PermissionNames.wallet_getSecretObject], + }), + ).toThrow( + new errors.PermissionDoesNotExistError( + 'metamask.io', + PermissionNames.wallet_getSecretObject, + ), + ); + }); + }); + + describe('revokePermissionForAllSubjects', () => { + it('does nothing if there are no subjects', () => { + const controller = getDefaultPermissionController(); + controller.revokePermissionForAllSubjects( + PermissionNames.wallet_getSecretArray, + ); + expect(controller.state).toStrictEqual({ subjects: {} }); + }); + + it('revokes single permission from all subjects', () => { + const controller = getDefaultPermissionController(); + const origin0 = 'origin0'; + const origin1 = 'origin1'; + const origin2 = 'origin2'; + const origin3 = 'origin3'; + const origin4 = 'origin4'; + + controller.grantPermissions({ + subject: { origin: origin0 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin1 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin2 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: {}, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin3 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin4 }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: {}, + [PermissionNames.wallet_getSecret_('bar')]: {}, + }, + }); + + controller.revokePermissionForAllSubjects( + PermissionNames.wallet_getSecretArray, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin0]: { + origin: origin0, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: null, + invoker: origin0, + }), + }, + }, + [origin2]: { + origin: origin2, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: null, + invoker: origin2, + }), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin2, + }), + }, + }, + [origin3]: { + origin: origin3, + permissions: { + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin3, + }), + }, + }, + [origin4]: { + origin: origin4, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: null, + invoker: origin4, + }), + [PermissionNames.wallet_getSecret_('bar')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('bar'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin4, + }), + }, + }, + }, + }); + }); + }); + + describe('hasCaveat', () => { + it('indicates whether a permission has a particular caveat', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + expect( + controller.hasCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ).toStrictEqual(false); + + expect( + controller.hasCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.filterObjectResponse, + ), + ).toStrictEqual(true); + + expect( + controller.hasCaveat( + origin, + PermissionNames.wallet_getSecret_('foo'), + CaveatTypes.noopCaveat, + ), + ).toStrictEqual(true); + }); + + it('throws an error if no corresponding permission exists', () => { + const controller = getDefaultPermissionController(); + expect(() => + controller.hasCaveat( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ).toThrow( + new errors.PermissionDoesNotExistError( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ); + }); + }); + + describe('getCaveat', () => { + it('gets existing caveats', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + expect( + controller.getCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ).toBeUndefined(); + + expect( + controller.getCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.filterObjectResponse, + ), + ).toStrictEqual({ + type: CaveatTypes.filterObjectResponse, + value: ['kaplar'], + }); + + expect( + controller.getCaveat( + origin, + PermissionNames.wallet_getSecret_('foo'), + CaveatTypes.noopCaveat, + ), + ).toStrictEqual({ type: CaveatTypes.noopCaveat, value: null }); + }); + + it('throws an error if no corresponding permission exists', () => { + const controller = getDefaultPermissionController(); + expect(() => + controller.getCaveat( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ).toThrow( + new errors.PermissionDoesNotExistError( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ); + }); + }); + + describe('addCaveat', () => { + it('adds a caveat to the specified permission', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + controller.addCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ['foo'], + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + }); + + it(`appends a caveat to the specified permission's existing caveats`, () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + + controller.addCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.noopCaveat, + null, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + { type: CaveatTypes.noopCaveat, value: null }, + ], + invoker: origin, + }), + }, + }, + }, + }); + }); + + it('throws an error if a corresponding caveat already exists', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + }, + }, + }); + + expect(() => + controller.addCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.filterObjectResponse, + ['foo'], + ), + ).toThrow( + new errors.CaveatAlreadyExistsError( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.filterObjectResponse, + ), + ); + }); + + it('throws an error if no corresponding permission exists', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.addCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ['foo'], + ), + ).toThrow( + new errors.PermissionDoesNotExistError( + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws an error if the permission fails to validate with the added caveat', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + }, + }, + }); + + expect(() => + controller.addCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.filterArrayResponse, + ['foo'], + ), + ).toThrow( + new errors.ForbiddenCaveatError( + CaveatTypes.filterArrayResponse, + origin, + PermissionNames.wallet_getSecretObject, + ), + ); + }); + }); + + describe('updateCaveat', () => { + it('updates an existing caveat', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + + controller.updateCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ['bar'], + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['bar'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + }); + + it('throws an error if no corresponding permission exists', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.updateCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ['foo'], + ), + ).toThrow( + new errors.PermissionDoesNotExistError( + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws an error if no corresponding caveat exists', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + expect(() => + controller.updateCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ['foo'], + ), + ).toThrow( + new errors.CaveatDoesNotExistError( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ); + }); + + it('throws an error if the updated caveat fails to validate', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecret_('foo')]: { + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + }, + }, + }); + + expect(() => + controller.updateCaveat( + origin, + PermissionNames.wallet_getSecret_('foo'), + CaveatTypes.noopCaveat, + 'bar' as any, + ), + ).toThrow(new Error('NoopCaveat value must be null')); + }); + }); + + describe('removeCaveat', () => { + it('removes an existing caveat', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + caveats: null, + invoker: origin, + }), + }, + }, + }, + }); + }); + + it('removes an existing caveat, without modifying other caveats of the same permission', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.noopCaveat, value: null }, + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + caveats: [ + { type: CaveatTypes.noopCaveat, value: null }, + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.noopCaveat, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + }, + }, + }); + }); + + it('throws an error if no corresponding permission exists', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ).toThrow( + new errors.PermissionDoesNotExistError( + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws an error if no corresponding caveat exists', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + }, + }, + }); + + expect(() => + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ).toThrow( + new errors.CaveatDoesNotExistError( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ), + ); + + expect(() => + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.noopCaveat, + ), + ).toThrow( + new errors.CaveatDoesNotExistError( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.noopCaveat, + ), + ); + }); + + it('throws an error if the permission fails to validate after caveat removal', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecret_('foo')]: { + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + }, + }, + }); + + expect(() => + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecret_('foo'), + CaveatTypes.noopCaveat, + ), + ).toThrow(new Error('getSecret_* permission validation failed')); + }); + }); + + describe('updatePermissionsByCaveat', () => { + enum MultiCaveatOrigins { + a = 'a.com', + b = 'b.io', + c = 'c.biz', + } + + /** + * Generates a permission controller instance with some granted permissions for testing. + * + * @returns The permission controller instance + */ + const getMultiCaveatController = () => { + const controller = getDefaultPermissionController(); + + controller.grantPermissions({ + subject: { origin: MultiCaveatOrigins.a }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [{ type: CaveatTypes.filterArrayResponse, value: ['a'] }], + }, + }, + }); + + controller.grantPermissions({ + subject: { origin: MultiCaveatOrigins.b }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + }, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['b'] }, + { type: CaveatTypes.noopCaveat, value: null }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: { + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + }, + [PermissionNames.wallet_doubleNumber]: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: MultiCaveatOrigins.c }, + approvedPermissions: { + [PermissionNames.wallet_getSecretObject]: { + caveats: [{ type: CaveatTypes.filterObjectResponse, value: ['c'] }], + }, + [PermissionNames.wallet_getSecret_('bar')]: { + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + }, + }, + }); + + return controller; + }; + + const getMultiCaveatStateMatcher = ( + overrides: Partial< + Record> + > = {}, + ) => { + return { + subjects: { + [MultiCaveatOrigins.a]: { + origin: MultiCaveatOrigins.a, + permissions: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a'] }, + ], + invoker: MultiCaveatOrigins.a, + }), + ...overrides[MultiCaveatOrigins.a], + }, + }, + + [MultiCaveatOrigins.b]: { + origin: MultiCaveatOrigins.b, + permissions: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + invoker: MultiCaveatOrigins.b, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['b'] }, + { type: CaveatTypes.noopCaveat, value: null }, + ], + invoker: MultiCaveatOrigins.b, + }), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: MultiCaveatOrigins.b, + }), + [PermissionNames.wallet_doubleNumber]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_doubleNumber, + caveats: null, + invoker: MultiCaveatOrigins.b, + }), + ...overrides[MultiCaveatOrigins.b], + }, + }, + + [MultiCaveatOrigins.c]: { + origin: MultiCaveatOrigins.c, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['c'] }, + ], + invoker: MultiCaveatOrigins.c, + }), + [PermissionNames.wallet_getSecret_('bar')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('bar'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: MultiCaveatOrigins.c, + }), + ...overrides[MultiCaveatOrigins.c], + }, + }, + }, + }; + }; + + // This is effectively a test of the above test utilities. + it('multi-caveat controller has expected state', () => { + const controller = getMultiCaveatController(); + expect(controller.state).toStrictEqual(getMultiCaveatStateMatcher()); + }); + + it('does nothing if there are no subjects', () => { + const controller = getDefaultPermissionController(); + expect(controller.state).toStrictEqual({ subjects: {} }); + + // There are no caveats, so this does nothing. + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + () => { + return { + operation: CaveatMutatorOperation.updateValue, + value: ['a', 'b'], + }; + }, + ); + expect(controller.state).toStrictEqual({ subjects: {} }); + }); + + it('does nothing if the mutator returns the "noop" operation', () => { + const controller = getMultiCaveatController(); + + // Although there are caveats, we always return the "noop" operation, and + // therefore nothing happens. + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + () => { + return { operation: CaveatMutatorOperation.noop }; + }, + ); + expect(controller.state).toStrictEqual(getMultiCaveatStateMatcher()); + }); + + it('updates the value of all caveats of a particular type', () => { + const controller = getMultiCaveatController(); + + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + () => { + return { + operation: CaveatMutatorOperation.updateValue, + value: ['a', 'b'], + }; + }, + ); + + expect(controller.state).toStrictEqual( + getMultiCaveatStateMatcher({ + [MultiCaveatOrigins.a]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, + ], + invoker: MultiCaveatOrigins.a, + }), + }, + [MultiCaveatOrigins.b]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + invoker: MultiCaveatOrigins.b, + }), + }, + }), + ); + }); + + it('selectively updates the value of all caveats of a particular type', () => { + const controller = getMultiCaveatController(); + + let counter = 0; + const mutator: any = () => { + counter += 1; + return counter === 1 + ? { operation: CaveatMutatorOperation.noop } + : { + operation: CaveatMutatorOperation.updateValue, + value: ['a', 'b'], + }; + }; + + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + mutator, + ); + + expect(controller.state).toStrictEqual( + getMultiCaveatStateMatcher({ + [MultiCaveatOrigins.b]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + invoker: MultiCaveatOrigins.b, + }), + }, + }), + ); + }); + + it('deletes all caveats of a particular type', () => { + const controller = getMultiCaveatController(); + + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + () => { + return { operation: CaveatMutatorOperation.deleteCaveat }; + }, + ); + + expect(controller.state).toStrictEqual( + getMultiCaveatStateMatcher({ + [MultiCaveatOrigins.a]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: MultiCaveatOrigins.a, + }), + }, + [MultiCaveatOrigins.b]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: [ + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + invoker: MultiCaveatOrigins.b, + }), + }, + }), + ); + }); + + it('revokes permissions associated with a caveat', () => { + const controller = getMultiCaveatController(); + + controller.updatePermissionsByCaveat( + CaveatTypes.filterObjectResponse, + () => { + return { operation: CaveatMutatorOperation.revokePermission }; + }, + ); + + const matcher = getMultiCaveatStateMatcher(); + delete matcher.subjects[MultiCaveatOrigins.b].permissions[ + PermissionNames.wallet_getSecretObject + ]; + + delete matcher.subjects[MultiCaveatOrigins.c].permissions[ + PermissionNames.wallet_getSecretObject + ]; + + expect(controller.state).toStrictEqual(matcher); + }); + + it('deletes subject if all permissions are revoked', () => { + const controller = getMultiCaveatController(); + + let counter = 0; + const mutator: any = () => { + counter += 1; + return { + operation: + counter === 1 + ? CaveatMutatorOperation.revokePermission + : CaveatMutatorOperation.noop, + }; + }; + + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + mutator, + ); + + const matcher = getMultiCaveatStateMatcher(); + delete (matcher.subjects as any)[MultiCaveatOrigins.a]; + + expect(controller.state).toStrictEqual(matcher); + }); + + it('throws if caveat validation fails after a value is updated', () => { + const controller = getMultiCaveatController(); + + expect(() => + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + () => { + return { + operation: CaveatMutatorOperation.updateValue, + value: 'foo', + }; + }, + ), + ).toThrow(`${CaveatTypes.filterArrayResponse} values must be arrays`); + }); + + it('throws if permission validation fails after a value is updated', () => { + const controller = getMultiCaveatController(); + + expect(() => + controller.updatePermissionsByCaveat(CaveatTypes.noopCaveat, () => { + return { operation: CaveatMutatorOperation.deleteCaveat }; + }), + ).toThrow('getSecret_* permission validation failed'); + }); + + it('throws if mutator returns unrecognized operation', () => { + const controller = getMultiCaveatController(); + + expect(() => + controller.updatePermissionsByCaveat( + CaveatTypes.filterArrayResponse, + () => { + return { operation: 'foobar' } as any; + }, + ), + ).toThrow(`Unrecognized mutation result: "foobar"`); + }); + }); + + describe('grantPermissions', () => { + it('grants new permission', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + }, + }, + }, + }); + }); + + it('grants new permissions (multiple at once)', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: {}, + wallet_getSecretObject: { + parentCapability: 'wallet_getSecretObject', + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + }), + }, + }, + }, + }); + }); + + it('grants new permissions (namespaced, with factory and validator)', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecret_kabob: {}, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecret_kabob: getPermissionMatcher({ + parentCapability: 'wallet_getSecret_kabob', + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + }), + }, + }, + }, + }); + }); + + it('grants new permissions (multiple origins)', () => { + const controller = getDefaultPermissionController(); + const origin1 = 'metamask.io'; + const origin2 = 'infura.io'; + + controller.grantPermissions({ + subject: { origin: origin1 }, + approvedPermissions: { + wallet_getSecret_kabob: {}, + }, + }); + + controller.grantPermissions({ + subject: { origin: origin2 }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin1]: { + origin: origin1, + permissions: { + wallet_getSecret_kabob: getPermissionMatcher({ + parentCapability: 'wallet_getSecret_kabob', + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin1, + }), + }, + }, + [origin2]: { + origin: origin2, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + caveats: null, + invoker: origin2, + }), + }, + }, + }, + }); + }); + + it('preserves existing permissions if preserveExistingPermissions is true', () => { + const controller = getDefaultPermissionControllerWithState(); + const origin = 'metamask.io'; + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + }), + }, + }, + }); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretObject: {}, + }, + // preserveExistingPermissions is true by default + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + }), + }), + }, + }, + }); + }); + + it('overwrites existing permissions if preserveExistingPermissions is false', () => { + const controller = getDefaultPermissionControllerWithState(); + const origin = 'metamask.io'; + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher({ + parentCapability: 'wallet_getSecretArray', + }), + }, + }, + }, + }); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretObject: {}, + }, + preserveExistingPermissions: false, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_getSecretObject: getPermissionMatcher({ + parentCapability: 'wallet_getSecretObject', + }), + }), + }, + }, + }); + }); + + it('throws if the origin is invalid', () => { + const controller = getDefaultPermissionController(); + + expect(() => + controller.grantPermissions({ + subject: { origin: '' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }), + ).toThrow(new errors.InvalidSubjectIdentifierError('')); + + expect(() => + controller.grantPermissions({ + subject: { origin: 2 as any }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }), + ).toThrow(new errors.InvalidSubjectIdentifierError(2)); + }); + + it('throws if the target does not exist', () => { + const controller = getDefaultPermissionController(); + + expect(() => + controller.grantPermissions({ + subject: { origin: 'metamask.io' }, + approvedPermissions: { + wallet_getSecretFalafel: {}, + }, + }), + ).toThrow(errors.methodNotFound('wallet_getSecretFalafel')); + }); + + it('throws if an approved permission is malformed', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + // This must match the key + parentCapability: 'wallet_getSecretObject', + }, + }, + }), + ).toThrow( + new errors.InvalidApprovedPermissionError( + origin, + 'wallet_getSecretArray', + { parentCapability: 'wallet_getSecretObject' }, + ), + ); + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + parentCapability: 'wallet_getSecretArray', + }, + wallet_getSecretObject: { + // This must match the key + parentCapability: 'wallet_getSecretArray', + }, + }, + }), + ).toThrow( + new errors.InvalidApprovedPermissionError( + origin, + 'wallet_getSecretObject', + { parentCapability: 'wallet_getSecretArray' }, + ), + ); + }); + + it('throws if an approved permission has duplicate caveats', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + parentCapability: 'wallet_getSecretArray', + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + }, + }, + }), + ).toThrow( + new errors.DuplicateCaveatError( + CaveatTypes.filterArrayResponse, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws if a requested caveat is not a plain object', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [[]] as any, + }, + }, + }), + ).toThrow( + new errors.InvalidCaveatError( + [], + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: ['foo'] as any, + }, + }, + }), + ).toThrow( + new errors.InvalidCaveatError( + [], + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws if a requested caveat has more than two keys', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + ...{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + bar: 'bar', + }, + ] as any, + }, + }, + }), + ).toThrow( + new errors.InvalidCaveatFieldsError( + { + ...{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + bar: 'bar', + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws if a requested caveat type is not a string', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: 2, + value: ['foo'], + }, + ] as any, + }, + }, + }), + ).toThrow( + new errors.InvalidCaveatTypeError( + { + type: 2, + value: ['foo'], + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws if a requested caveat type does not exist', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [{ type: 'fooType', value: 'bar' }], + }, + }, + }), + ).toThrow( + new errors.UnrecognizedCaveatTypeError( + 'fooType', + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws if a requested caveat has no value field', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + foo: 'bar', + }, + ] as any, + }, + }, + }), + ).toThrow( + new errors.CaveatMissingValueError( + { + type: CaveatTypes.filterArrayResponse, + foo: 'bar', + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }); + + it('throws if a requested caveat has a value that is not valid JSON', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + const circular: any = { foo: 'bar' }; + circular.circular = circular; + + [{ foo: () => undefined }, circular, { foo: BigInt(10) }].forEach( + (invalidValue) => { + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue, + }, + ], + }, + }, + }), + ).toThrow( + new errors.CaveatInvalidJsonError( + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue, + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + }, + ); + }); + + it('throws if caveat validation fails', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecret_('foo')]: { + caveats: [ + { + type: CaveatTypes.noopCaveat, + value: 'bar', + }, + ], + }, + }, + }), + ).toThrow(new Error('NoopCaveat value must be null')); + }); + + it('throws if the requested permission specifies disallowed caveats', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretObject: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: ['bar'], + }, + ], + }, + }, + }), + ).toThrow( + new errors.ForbiddenCaveatError( + CaveatTypes.filterArrayResponse, + origin, + PermissionNames.wallet_getSecretObject, + ), + ); + }); + + it('throws if the requested permission specifies caveats, and no caveats are allowed', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_doubleNumber: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: ['bar'], + }, + ], + }, + }, + }), + ).toThrow( + new errors.ForbiddenCaveatError( + CaveatTypes.filterArrayResponse, + origin, + PermissionNames.wallet_doubleNumber, + ), + ); + }); + + it('throws if the permission validator throws', () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + expect(() => + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_noopWithValidator]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + }, + }, + }), + ).toThrow(new Error('noop permission validation failed')); + }); + }); + + describe('requestPermissions', () => { + it('requests a permission', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('requests a permission that requires requestData in its factory', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + caveatValue: ['foo'], // this will be added to the permission + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_noopWithFactory]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_noopWithFactory]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_noopWithFactory, + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_noopWithFactory]: {}, + }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('requests multiple permissions', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + metadata: { ...requestData.metadata }, + permissions: { ...requestData.permissions }, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + invoker: origin, + }), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('requests multiple permissions (approved permissions are a strict superset)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + metadata: { ...requestData.metadata }, + // wallet_getSecret_foo is added to the request + permissions: { + ...requestData.permissions, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + invoker: origin, + }), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('requests multiple permissions (approved permissions are a strict subset)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + const approvedPermissions = { ...requestData.permissions }; + delete approvedPermissions[PermissionNames.wallet_getSecretArray]; + + return { + metadata: { ...requestData.metadata }, + permissions: approvedPermissions, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + invoker: origin, + }), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('requests multiple permissions (an approved permission is modified)', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + const approvedPermissions = { ...requestData.permissions }; + approvedPermissions[PermissionNames.wallet_getSecretObject] = { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + }; + + return { + metadata: { ...requestData.metadata }, + permissions: approvedPermissions, + }; + }); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretArray, + caveats: null, + invoker: origin, + }), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecretObject, + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['kaplar'] }, + ], + invoker: origin, + }), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher({ + parentCapability: PermissionNames.wallet_getSecret_('foo'), + caveats: [{ type: CaveatTypes.noopCaveat, value: null }], + invoker: origin, + }), + }, + { id: expect.any(String), origin }, + ]); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + [PermissionNames.wallet_getSecretObject]: { + caveats: [ + { type: CaveatTypes.filterObjectResponse, value: ['baz'] }, + ], + }, + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('throws if requested permissions object is not a plain object', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); + + const callActionSpy = jest.spyOn(messenger, 'call'); + + for (const invalidInput of [ + // not plain objects + null, + 'foo', + [{ [PermissionNames.wallet_getSecretArray]: {} }], + ]) { + await expect( + async () => + await controller.requestPermissions( + { origin }, + invalidInput as any, + ), + ).rejects.toThrow( + errors.invalidParams({ + message: `Requested permissions for origin "${origin}" is not a plain object.`, + data: { origin, requestedPermissions: invalidInput }, + }), + ); + } + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if requested permissions object has no permissions', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest.spyOn(messenger, 'call'); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + // No permissions in object + await controller.requestPermissions({ origin }, {}), + ).rejects.toThrow( + errors.invalidParams({ + message: `Permissions request for origin "${origin}" contains no permissions.`, + data: { origin, requestedPermissions: {} }, + }), + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if requested permissions contain a (key : value.parentCapability) mismatch', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest.spyOn(messenger, 'call'); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + // parentCapability value does not match key + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + ), + ).rejects.toThrow( + errors.invalidParams({ + message: `Permissions request for origin "${origin}" contains invalid requested permission(s).`, + data: { + origin, + requestedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }, + }, + }), + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if requesting a permission for an unknown target', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest.spyOn(messenger, 'call'); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + wallet_getSecretKabob: {}, + }, + ), + ).rejects.toThrow( + errors.methodNotFound('wallet_getSecretKabob', { + origin, + requestedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + [PermissionNames.wallet_getSecretArray]: {}, + wallet_getSecretKabob: {}, + }, + }, + }), + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if the "caveat" property of a requested permission is invalid', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest.spyOn(messenger, 'call'); + + const controller = getDefaultPermissionController(options); + for (const invalidCaveatsValue of [ + [], // empty array + undefined, + 'foo', + 2, + Symbol('bar'), + ]) { + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + caveats: invalidCaveatsValue as any, + }, + }, + ), + ).rejects.toThrow( + new errors.InvalidCaveatsPropertyError( + origin, + PermissionNames.wallet_getSecretArray, + invalidCaveatsValue, + ), + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + } + }); + + it('throws if a requested permission has duplicate caveats', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest.spyOn(messenger, 'call'); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + { type: CaveatTypes.filterArrayResponse, value: ['foo'] }, + ], + }, + }, + ), + ).rejects.toThrow( + new errors.DuplicateCaveatError( + CaveatTypes.filterArrayResponse, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + + expect(callActionSpy).not.toHaveBeenCalled(); + }); + + it('throws if the approved request object is invalid', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); + const callActionSpy = jest.spyOn(messenger, 'call'); + + for (const invalidRequestObject of ['foo', null, { metadata: 'foo' }]) { + callActionSpy.mockClear(); + callActionSpy.mockImplementationOnce(async () => invalidRequestObject); + + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).rejects.toThrow( + errors.internalError( + `Approved permissions request for subject "${origin}" is invalid.`, + { data: { approvedRequest: invalidRequestObject } }, + ), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + } + }); + + it('throws if the approved request ID changed', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + // different id + metadata: { ...requestData.metadata, id: 'foo' }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).rejects.toThrow( + errors.internalError( + `Approved permissions request for subject "${origin}" mutated its id.`, + { originalId: expect.any(String), mutatedId: 'foo' }, + ), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('throws if the approved request origin changed', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + // different origin + metadata: { ...requestData.metadata, origin: 'foo.com' }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }; + }); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).rejects.toThrow( + errors.internalError( + `Approved permissions request for subject "${origin}" mutated its origin.`, + { originalOrigin: origin, mutatedOrigin: 'foo' }, + ), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('throws if no permissions were approved', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + metadata: { ...requestData.metadata }, + permissions: {}, // no permissions + }; + }); + + const controller = getDefaultPermissionController(options); + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).rejects.toThrow( + errors.internalError( + `Invalid approved permissions request: Permissions request for origin "${origin}" contains no permissions.`, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + + it('throws if approved permissions object is not a plain object', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const id = 'arbitraryId'; + const controller = getDefaultPermissionController(options); + + const callActionSpy = jest.spyOn(messenger, 'call'); + + // The metadata is valid, but the permissions are invalid + const getInvalidRequestObject = (invalidPermissions: any) => { + return { + metadata: { origin, id }, + permissions: invalidPermissions, + }; + }; + + for (const invalidRequestObject of [ + null, + 'foo', + [{ [PermissionNames.wallet_getSecretArray]: {} }], + ].map((invalidPermissions) => + getInvalidRequestObject(invalidPermissions), + )) { + callActionSpy.mockClear(); + callActionSpy.mockImplementationOnce(async () => invalidRequestObject); + + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + { id, preserveExistingPermissions: true }, + ), + ).rejects.toThrow( + errors.internalError( + `Invalid approved permissions request: Requested permissions for origin "${origin}" is not a plain object.`, + { data: { approvedRequest: invalidRequestObject } }, + ), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { [PermissionNames.wallet_getSecretArray]: {} }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + } + }); + + it('throws if approved permissions contain a (key : value.parentCapability) mismatch', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const controller = getDefaultPermissionController(options); + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (...args: any) => { + const [, { requestData }] = args; + return { + metadata: { ...requestData.metadata }, + permissions: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + // parentCapability value does not match key + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }; + }); + + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + ), + ).rejects.toThrow( + errors.invalidParams({ + message: `Invalid approved permissions request: Permissions request for origin "${origin}" contains invalid requested permission(s).`, + data: { + origin, + requestedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + [PermissionNames.wallet_getSecretObject]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }, + }, + }), + ); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + { + id: expect.any(String), + origin, + requestData: { + metadata: { id: expect.any(String), origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: { + parentCapability: PermissionNames.wallet_getSecretArray, + }, + }, + }, + type: MethodNames.requestPermissions, + }, + true, + ); + }); + }); + + describe('acceptPermissionsRequest', () => { + it('accepts a permissions request', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const id = 'foobar'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce((..._args: any) => true) + .mockImplementationOnce((..._args: any) => undefined); + + const controller = getDefaultPermissionController(options); + + await controller.acceptPermissionsRequest({ + metadata: { id, origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + expect(callActionSpy).toHaveBeenCalledTimes(2); + expect(callActionSpy).toHaveBeenNthCalledWith( + 1, + 'ApprovalController:hasRequest', + { + id, + }, + ); + + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, + 'ApprovalController:acceptRequest', + id, + { + metadata: { id, origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }, + ); + }); + + it('rejects the request if it contains no permissions', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const id = 'foobar'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce((..._args: any) => true) + .mockImplementationOnce((..._args: any) => undefined); + + const controller = getDefaultPermissionController(options); + + await controller.acceptPermissionsRequest({ + metadata: { id, origin }, + permissions: {}, + }); + + expect(callActionSpy).toHaveBeenCalledTimes(2); + expect(callActionSpy).toHaveBeenNthCalledWith( + 1, + 'ApprovalController:hasRequest', + { + id, + }, + ); + + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, + 'ApprovalController:rejectRequest', + id, + errors.invalidParams({ + message: 'Must request at least one permission.', + }), + ); + }); + + it('throws if the request does not exist', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const id = 'foobar'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce((..._args: any) => false); + + const controller = getDefaultPermissionController(options); + + await expect( + async () => + await controller.acceptPermissionsRequest({ + metadata: { id, origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }), + ).rejects.toThrow(new errors.PermissionsRequestNotFoundError(id)); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenNthCalledWith( + 1, + 'ApprovalController:hasRequest', + { + id, + }, + ); + }); + + it('rejects the request and throws if accepting the request throws', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'metamask.io'; + const id = 'foobar'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce((..._args: any) => true) + .mockImplementationOnce((..._args: any) => { + throw new Error('unexpected failure'); + }) + .mockImplementationOnce((..._args: any) => undefined); + + const controller = getDefaultPermissionController(options); + + await expect( + async () => + await controller.acceptPermissionsRequest({ + metadata: { id, origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }), + ).rejects.toThrow(new Error('unexpected failure')); + + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenNthCalledWith( + 1, + 'ApprovalController:hasRequest', + { + id, + }, + ); + + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, + 'ApprovalController:acceptRequest', + id, + { + metadata: { id, origin }, + permissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }, + ); + + expect(callActionSpy).toHaveBeenNthCalledWith( + 3, + 'ApprovalController:rejectRequest', + id, + new Error('unexpected failure'), + ); + }); + }); + + describe('rejectPermissionsRequest', () => { + it('rejects a permissions request', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const id = 'foobar'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce(async (..._args: any) => true) + .mockImplementationOnce(async (..._args: any) => undefined); + + const controller = getDefaultPermissionController(options); + + await controller.rejectPermissionsRequest(id); + + expect(callActionSpy).toHaveBeenCalledTimes(2); + expect(callActionSpy).toHaveBeenNthCalledWith( + 1, + 'ApprovalController:hasRequest', + { + id, + }, + ); + + expect(callActionSpy).toHaveBeenNthCalledWith( + 2, + 'ApprovalController:rejectRequest', + id, + errors.userRejectedRequest(), + ); + }); + + it('throws if the request does not exist', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const id = 'foobar'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementationOnce((..._args: any) => false); + + const controller = getDefaultPermissionController(options); + + await expect( + async () => await controller.rejectPermissionsRequest(id), + ).rejects.toThrow(new errors.PermissionsRequestNotFoundError(id)); + + expect(callActionSpy).toHaveBeenCalledTimes(1); + expect(callActionSpy).toHaveBeenNthCalledWith( + 1, + 'ApprovalController:hasRequest', + { + id, + }, + ); + }); + }); + + describe('getEndowments', () => { + it('gets the endowments', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.endowmentPermission1]: {}, + }, + }); + + expect( + await controller.getEndowments( + origin, + PermissionNames.endowmentPermission1, + ), + ).toStrictEqual(['endowment1']); + }); + + it('throws if the requested permission target is not an endowment', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + await expect( + controller.getEndowments( + origin, + PermissionNames.wallet_getSecretArray as any, + ), + ).rejects.toThrow( + new errors.EndowmentPermissionDoesNotExistError( + PermissionNames.wallet_getSecretArray, + origin, + ), + ); + }); + + it('throws if the subject does not have the requisite permission', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + await expect( + controller.getEndowments(origin, PermissionNames.endowmentPermission1), + ).rejects.toThrow( + errors.unauthorized({ + data: { origin, targetName: PermissionNames.endowmentPermission1 }, + }), + ); + }); + }); + + describe('executeRestrictedMethod', () => { + it('executes a restricted method', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + expect( + await controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual(['a', 'b', 'c']); + }); + + it('executes a restricted method with parameters', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_doubleNumber]: {}, + }, + }); + + expect( + await controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_doubleNumber, + [10], + ), + ).toStrictEqual(20); + }); + + it('executes a namespaced restricted method', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecret_('foo')]: {}, + }, + }); + + expect( + await controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_getSecret_('foo'), + ), + ).toStrictEqual('Hello, secret friend "foo"!'); + }); + + it('executes a restricted method with a caveat', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [{ type: CaveatTypes.filterArrayResponse, value: ['b'] }], + }, + }, + }); + + expect( + await controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual(['b']); + }); + + it('executes a restricted method with multiple caveats', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'c'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + }, + }, + }); + + expect( + await controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual(['c', 'a']); + }); + + it('throws if the subject does not have the requisite permission', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + await expect( + controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_doubleNumber, + ), + ).rejects.toThrow( + errors.unauthorized({ + data: { origin, method: PermissionNames.wallet_doubleNumber }, + }), + ); + }); + + it('throws if the requested method (i.e. target) does not exist', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + await expect( + controller.executeRestrictedMethod(origin, 'wallet_getMeTacos' as any), + ).rejects.toThrow(errors.methodNotFound('wallet_getMeTacos', { origin })); + }); + + it('throws if the restricted method returns undefined', async () => { + const permissionSpecifications = getDefaultPermissionSpecifications(); + (permissionSpecifications as any).wallet_doubleNumber.methodImplementation = () => + undefined; + + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications, + }), + ); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_doubleNumber]: {}, + }, + }); + + await expect( + controller.executeRestrictedMethod( + origin, + PermissionNames.wallet_doubleNumber, + ), + ).rejects.toThrow( + new Error( + `Internal request for method "${PermissionNames.wallet_doubleNumber}" as origin "${origin}" returned no result.`, + ), + ); + }); + }); + + describe('controller actions', () => { + it('action: PermissionController:clearPermissions', () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + const clearStateSpy = jest.spyOn(controller, 'clearState'); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect(hasProperty(controller.state.subjects, 'foo')).toStrictEqual(true); + + messenger.call('PermissionController:clearPermissions'); + expect(clearStateSpy).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual({ subjects: {} }); + }); + + it('action: PermissionController:getEndowments', async () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + const getEndowmentsSpy = jest.spyOn(controller, 'getEndowments'); + + await expect( + messenger.call( + 'PermissionController:getEndowments', + 'foo', + PermissionNames.endowmentPermission1, + ), + ).rejects.toThrow( + errors.unauthorized({ + data: { + origin: 'foo', + targetName: PermissionNames.endowmentPermission1, + }, + }), + ); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + [PermissionNames.endowmentPermission1]: {}, + }, + }); + + expect( + await messenger.call( + 'PermissionController:getEndowments', + 'foo', + PermissionNames.endowmentPermission1, + ), + ).toStrictEqual(['endowment1']); + + expect( + await messenger.call( + 'PermissionController:getEndowments', + 'foo', + PermissionNames.endowmentPermission1, + { arbitrary: 'requestData' }, + ), + ).toStrictEqual(['endowment1']); + + expect(getEndowmentsSpy).toHaveBeenCalledTimes(3); + expect(getEndowmentsSpy).toHaveBeenNthCalledWith( + 1, + 'foo', + PermissionNames.endowmentPermission1, + undefined, + ); + + expect(getEndowmentsSpy).toHaveBeenNthCalledWith( + 2, + 'foo', + PermissionNames.endowmentPermission1, + undefined, + ); + + expect(getEndowmentsSpy).toHaveBeenNthCalledWith( + 3, + 'foo', + PermissionNames.endowmentPermission1, + { arbitrary: 'requestData' }, + ); + }); + + it('action: PermissionController:getSubjectNames', () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + const getSubjectNamesSpy = jest.spyOn(controller, 'getSubjectNames'); + + expect( + messenger.call('PermissionController:getSubjectNames'), + ).toStrictEqual([]); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect( + messenger.call('PermissionController:getSubjectNames'), + ).toStrictEqual(['foo']); + expect(getSubjectNamesSpy).toHaveBeenCalledTimes(2); + }); + + it('action: PermissionController:hasPermission', () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + const hasPermissionSpy = jest.spyOn(controller, 'hasPermission'); + + expect( + messenger.call( + 'PermissionController:hasPermission', + 'foo', + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual(false); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + expect( + messenger.call( + 'PermissionController:hasPermission', + 'foo', + PermissionNames.wallet_getSecretArray, + ), + ).toStrictEqual(true); + + expect( + messenger.call( + 'PermissionController:hasPermission', + 'foo', + PermissionNames.wallet_getSecretObject, + ), + ).toStrictEqual(false); + + expect(hasPermissionSpy).toHaveBeenCalledTimes(3); + expect(hasPermissionSpy).toHaveBeenNthCalledWith( + 1, + 'foo', + PermissionNames.wallet_getSecretArray, + ); + + expect(hasPermissionSpy).toHaveBeenNthCalledWith( + 2, + 'foo', + PermissionNames.wallet_getSecretArray, + ); + + expect(hasPermissionSpy).toHaveBeenNthCalledWith( + 3, + 'foo', + PermissionNames.wallet_getSecretObject, + ); + }); + + it('action: PermissionController:hasPermissions', () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + const hasPermissionsSpy = jest.spyOn(controller, 'hasPermissions'); + + expect( + messenger.call('PermissionController:hasPermissions', 'foo'), + ).toStrictEqual(false); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect( + messenger.call('PermissionController:hasPermissions', 'foo'), + ).toStrictEqual(true); + expect(hasPermissionsSpy).toHaveBeenCalledTimes(2); + expect(hasPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); + expect(hasPermissionsSpy).toHaveBeenNthCalledWith(2, 'foo'); + }); + + it('action: PermissionController:getPermissions', () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + const getPermissionsSpy = jest.spyOn(controller, 'getPermissions'); + + expect( + messenger.call('PermissionController:getPermissions', 'foo'), + ).toBeUndefined(); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + + expect( + Object.keys( + messenger.call('PermissionController:getPermissions', 'foo'), + ), + ).toStrictEqual(['wallet_getSecretArray']); + + expect(getPermissionsSpy).toHaveBeenCalledTimes(3); + expect(getPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); + expect(getPermissionsSpy).toHaveBeenNthCalledWith(2, 'foo'); + }); + + it('action: PermissionController:revokeAllPermissions', () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + controller.grantPermissions({ + subject: { origin: 'foo' }, + approvedPermissions: { + wallet_getSecretArray: {}, + }, + }); + const revokeAllPermissionsSpy = jest.spyOn( + controller, + 'revokeAllPermissions', + ); + + expect( + controller.hasPermission('foo', 'wallet_getSecretArray'), + ).toStrictEqual(true); + + messenger.call('PermissionController:revokeAllPermissions', 'foo'); + + expect( + controller.hasPermission('foo', 'wallet_getSecretArray'), + ).toStrictEqual(false); + expect(revokeAllPermissionsSpy).toHaveBeenCalledTimes(1); + expect(revokeAllPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); + }); + + it('action: PermissionsController:requestPermissions', async () => { + const messenger = getUnrestrictedMessenger(); + const options = getPermissionControllerOptions({ + messenger: getPermissionControllerMessenger(messenger), + }); + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(options); + + // TODO(ritave): requestPermissions calls unregistered action ApprovalController:addRequest that + // can't be easily mocked, thus we mock the whole implementation + const requestPermissionsSpy = jest + .spyOn(controller, 'requestPermissions') + .mockImplementation(); + + await messenger.call( + 'PermissionController:requestPermissions', + { origin: 'foo' }, + { + wallet_getSecretArray: {}, + }, + ); + + expect(requestPermissionsSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('permission middleware', () => { + it('executes a restricted method', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: {}, + }, + }); + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }); + + expect(response.result).toStrictEqual(['a', 'b', 'c']); + }); + + it('executes a restricted method with a caveat', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [{ type: CaveatTypes.filterArrayResponse, value: ['b'] }], + }, + }, + }); + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }); + + expect(response.result).toStrictEqual(['b']); + }); + + it('executes a restricted method with multiple caveats', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_getSecretArray]: { + caveats: [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'c'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + }, + }, + }); + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }); + + expect(response.result).toStrictEqual(['c', 'a']); + }); + + it('passes through unrestricted methods', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + engine.push( + ( + _req: any, + res: PendingJsonRpcResponse<'success'>, + _next: any, + end: () => any, + ) => { + res.result = 'success'; + end(); + }, + ); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'wallet_unrestrictedMethod', + }); + + expect(response.result).toStrictEqual('success'); + }); + + it('throws an error if the subject has an invalid "origin" property', async () => { + const controller = getDefaultPermissionController(); + + ['', null, undefined, 2].forEach((invalidOrigin) => { + expect(() => + controller.createPermissionMiddleware({ + origin: invalidOrigin as any, + }), + ).toThrow( + new Error('The subject "origin" must be a non-empty string.'), + ); + }); + }); + + it('returns an error if the subject does not have the requisite permission', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + + const request: any = { + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_getSecretArray, + }; + + const expectedError = errors.unauthorized({ + data: { origin, method: PermissionNames.wallet_getSecretArray }, + }); + + const { error }: any = await engine.handle(request); + expect(error).toMatchObject(expect.objectContaining(expectedError)); + }); + + it('returns an error if the method does not exist', async () => { + const controller = getDefaultPermissionController(); + const origin = 'metamask.io'; + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + + const request: any = { + jsonrpc: '2.0', + id: 1, + method: 'wallet_foo', + }; + + const expectedError = errors.methodNotFound('wallet_foo', { origin }); + + const { error }: any = await engine.handle(request); + expect(error).toMatchObject(expect.objectContaining(expectedError)); + }); + + it('returns an error if the restricted method returns undefined', async () => { + const permissionSpecifications = getDefaultPermissionSpecifications(); + (permissionSpecifications as any).wallet_doubleNumber.methodImplementation = () => + undefined; + + const controller = new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications, + }), + ); + const origin = 'metamask.io'; + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + [PermissionNames.wallet_doubleNumber]: {}, + }, + }); + + const engine = new JsonRpcEngine(); + engine.push(controller.createPermissionMiddleware({ origin })); + + const request: any = { + jsonrpc: '2.0', + id: 1, + method: PermissionNames.wallet_doubleNumber, + }; + + const expectedError = errors.internalError( + `Request for method "${PermissionNames.wallet_doubleNumber}" returned undefined result.`, + { request: { ...request } }, + ); + + const { error }: any = await engine.handle(request); + expect(error).toMatchObject(expect.objectContaining(expectedError)); + }); + }); +}); diff --git a/src/permissions/PermissionController.ts b/src/permissions/PermissionController.ts new file mode 100644 index 0000000000..c4220c016a --- /dev/null +++ b/src/permissions/PermissionController.ts @@ -0,0 +1,2168 @@ +/* eslint-enable @typescript-eslint/no-unused-vars */ +import deepFreeze from 'deep-freeze-strict'; +import { Mutable } from '@metamask/types'; +import { castDraft, Draft, Patch } from 'immer'; +import { nanoid } from 'nanoid'; +import { + AcceptRequest as AcceptApprovalRequest, + AddApprovalRequest, + HasApprovalRequest, + RejectRequest as RejectApprovalRequest, +} from '../approval/ApprovalController'; +import { Json, BaseController, StateMetadata } from '../BaseControllerV2'; +import { RestrictedControllerMessenger } from '../ControllerMessenger'; +import { + hasProperty, + isNonEmptyArray, + isPlainObject, + isValidJson, + NonEmptyArray, +} from '../util'; +import { + CaveatConstraint, + CaveatSpecificationConstraint, + CaveatSpecificationMap, + decorateWithCaveats, + ExtractCaveat, + ExtractCaveats, + ExtractCaveatValue, +} from './Caveat'; +import { + CaveatAlreadyExistsError, + CaveatDoesNotExistError, + CaveatInvalidJsonError, + CaveatMissingValueError, + DuplicateCaveatError, + EndowmentPermissionDoesNotExistError, + ForbiddenCaveatError, + internalError, + InvalidApprovedPermissionError, + InvalidCaveatError, + InvalidCaveatFieldsError, + InvalidCaveatsPropertyError, + InvalidCaveatTypeError, + invalidParams, + InvalidSubjectIdentifierError, + methodNotFound, + PermissionDoesNotExistError, + PermissionsRequestNotFoundError, + unauthorized, + UnrecognizedCaveatTypeError, + UnrecognizedSubjectError, + userRejectedRequest, +} from './errors'; +import { + constructPermission, + EndowmentSpecificationConstraint, + ExtractAllowedCaveatTypes, + ExtractPermissionSpecification, + findCaveat, + hasSpecificationType, + OriginString, + PermissionConstraint, + PermissionSpecificationConstraint, + PermissionSpecificationMap, + PermissionType, + RequestedPermissions, + RestrictedMethod, + RestrictedMethodParameters, + RestrictedMethodSpecificationConstraint, + ValidPermission, + ValidPermissionSpecification, +} from './Permission'; +import { getPermissionMiddlewareFactory } from './permission-middleware'; +import { MethodNames } from './utils'; + +/** + * Metadata associated with {@link PermissionController} subjects. + */ +export type PermissionSubjectMetadata = { + origin: OriginString; +}; + +/** + * Metadata associated with permission requests. + */ +export type PermissionsRequestMetadata = PermissionSubjectMetadata & { + id: string; +}; + +/** + * Used for prompting the user about a proposed new permission. + * Includes information about the grantee subject, requested permissions, and + * any additional information added by the consumer. + * + * All properties except `permissions` are passed to any factories found for + * the requested permissions. + */ +export type PermissionsRequest = { + metadata: PermissionsRequestMetadata; + permissions: RequestedPermissions; + [key: string]: Json; +}; + +/** + * The name of the {@link PermissionController}. + */ +const controllerName = 'PermissionController'; + +/** + * Permissions associated with a {@link PermissionController} subject. + */ +export type SubjectPermissions< + Permission extends PermissionConstraint +> = Record; + +/** + * Permissions and metadata associated with a {@link PermissionController} + * subject. + */ +export type PermissionSubjectEntry< + SubjectPermission extends PermissionConstraint +> = { + origin: SubjectPermission['invoker']; + permissions: SubjectPermissions; +}; + +/** + * All subjects of a {@link PermissionController}. + * + * @template SubjectPermission - The permissions of the subject. + */ +export type PermissionControllerSubjects< + SubjectPermission extends PermissionConstraint +> = Record< + SubjectPermission['invoker'], + PermissionSubjectEntry +>; + +// TODO:TS4.4 Enable compiler flags to forbid unchecked member access +/** + * The state of a {@link PermissionController}. + * + * @template Permission - The controller's permission type union. + */ +export type PermissionControllerState< + Permission +> = Permission extends PermissionConstraint + ? { + subjects: PermissionControllerSubjects; + } + : never; + +/** + * Get the state metadata of the {@link PermissionController}. + * + * @template Permission - The controller's permission type union. + * @returns The state metadata + */ +function getStateMetadata() { + return { subjects: { anonymous: true, persist: true } } as StateMetadata< + PermissionControllerState + >; +} + +/** + * Get the default state of the {@link PermissionController}. + * + * @template Permission - The controller's permission type union. + * @returns The default state of the controller + */ +function getDefaultState() { + return { subjects: {} } as PermissionControllerState; +} + +/** + * Gets the state of the {@link PermissionController}. + */ +export type GetPermissionControllerState = { + type: `${typeof controllerName}:getState`; + handler: () => PermissionControllerState; +}; + +/** + * Gets the names of all subjects from the {@link PermissionController}. + */ +export type GetSubjects = { + type: `${typeof controllerName}:getSubjectNames`; + handler: () => (keyof PermissionControllerSubjects)[]; +}; + +/** + * Gets the permissions for specified subject + */ +export type GetPermissions = { + type: `${typeof controllerName}:getPermissions`; + handler: GenericPermissionController['getPermissions']; +}; + +/** + * Checks whether the specified subject has any permissions. + */ +export type HasPermissions = { + type: `${typeof controllerName}:hasPermissions`; + handler: GenericPermissionController['hasPermissions']; +}; + +/** + * Checks whether the specified subject has a specific permission. + */ +export type HasPermission = { + type: `${typeof controllerName}:hasPermission`; + handler: GenericPermissionController['hasPermission']; +}; + +/** + * Requests given permissions for a specified origin + */ +export type RequestPermissions = { + type: `${typeof controllerName}:requestPermissions`; + handler: GenericPermissionController['requestPermissions']; +}; + +/** + * Removes all permissions for a given origin + */ +export type RevokeAllPermissions = { + type: `${typeof controllerName}:revokeAllPermissions`; + handler: GenericPermissionController['revokeAllPermissions']; +}; + +/** + * Clears all permissions from the {@link PermissionController}. + */ +export type ClearPermissions = { + type: `${typeof controllerName}:clearPermissions`; + handler: () => void; +}; + +/** + * Gets the endowments for the given subject and permission. + */ +export type GetEndowments = { + type: `${typeof controllerName}:getEndowments`; + handler: GenericPermissionController['getEndowments']; +}; + +/** + * The {@link ControllerMessenger} actions of the {@link PermissionController}. + */ +export type PermissionControllerActions = + | ClearPermissions + | GetEndowments + | GetPermissionControllerState + | GetSubjects + | GetPermissions + | HasPermission + | HasPermissions + | RevokeAllPermissions + | RequestPermissions; + +/** + * The generic state change event of the {@link PermissionController}. + */ +export type PermissionControllerStateChange = { + type: `${typeof controllerName}:stateChange`; + payload: [PermissionControllerState, Patch[]]; +}; + +/** + * The {@link ControllerMessenger} events of the {@link PermissionController}. + * + * The permission controller only emits its generic state change events. + * Consumers should use selector subscriptions to subscribe to relevant + * substate. + */ +export type PermissionControllerEvents = PermissionControllerStateChange; + +/** + * The external {@link ControllerMessenger} actions available to the + * {@link PermissionController}. + */ +type AllowedActions = + | AddApprovalRequest + | HasApprovalRequest + | AcceptApprovalRequest + | RejectApprovalRequest; + +/** + * The messenger of the {@link PermissionController}. + */ +export type PermissionControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + PermissionControllerActions | AllowedActions, + PermissionControllerEvents, + AllowedActions['type'], + never +>; + +/** + * A generic {@link PermissionController}. + */ +export type GenericPermissionController = PermissionController< + PermissionSpecificationConstraint, + CaveatSpecificationConstraint +>; + +/** + * Describes the possible results of a {@link CaveatMutator} function. + */ +export enum CaveatMutatorOperation { + noop, + updateValue, + deleteCaveat, + revokePermission, +} + +/** + * Given a caveat value, returns a {@link CaveatMutatorOperation} and, optionally, + * a new caveat value. + * + * @see {@link PermissionController.updatePermissionsByCaveat} for more details. + * @template Caveat - The caveat type for which this mutator is intended. + * @param caveatValue - The existing value of the caveat being mutated. + * @returns A tuple of the mutation result and, optionally, the new caveat + * value. + */ +export type CaveatMutator = ( + caveatValue: TargetCaveat['value'], +) => CaveatMutatorResult; + +type CaveatMutatorResult = + | Readonly<{ + operation: CaveatMutatorOperation.updateValue; + value: CaveatConstraint['value']; + }> + | Readonly<{ + operation: Exclude< + CaveatMutatorOperation, + CaveatMutatorOperation.updateValue + >; + }>; + +/** + * Extracts the permission(s) specified by the given permission and caveat + * specifications. + * + * @template ControllerPermissionSpecification - The permission specification(s) + * to extract from. + * @template ControllerCaveatSpecification - The caveat specification(s) to + * extract from. Necessary because {@link Permission} has a generic parameter + * that describes the allowed caveats for the permission. + */ +export type ExtractPermission< + ControllerPermissionSpecification extends PermissionSpecificationConstraint, + ControllerCaveatSpecification extends CaveatSpecificationConstraint +> = ControllerPermissionSpecification extends ValidPermissionSpecification + ? ValidPermission< + ControllerPermissionSpecification['targetKey'], + ExtractCaveats + > + : never; + +/** + * Extracts the restricted method permission(s) specified by the given + * permission and caveat specifications. + * + * @template ControllerPermissionSpecification - The permission specification(s) + * to extract from. + * @template ControllerCaveatSpecification - The caveat specification(s) to + * extract from. Necessary because {@link Permission} has a generic parameter + * that describes the allowed caveats for the permission. + */ +export type ExtractRestrictedMethodPermission< + ControllerPermissionSpecification extends PermissionSpecificationConstraint, + ControllerCaveatSpecification extends CaveatSpecificationConstraint +> = ExtractPermission< + Extract< + ControllerPermissionSpecification, + RestrictedMethodSpecificationConstraint + >, + ControllerCaveatSpecification +>; + +/** + * Extracts the endowment permission(s) specified by the given permission and + * caveat specifications. + * + * @template ControllerPermissionSpecification - The permission specification(s) + * to extract from. + * @template ControllerCaveatSpecification - The caveat specification(s) to + * extract from. Necessary because {@link Permission} has a generic parameter + * that describes the allowed caveats for the permission. + */ +export type ExtractEndowmentPermission< + ControllerPermissionSpecification extends PermissionSpecificationConstraint, + ControllerCaveatSpecification extends CaveatSpecificationConstraint +> = ExtractPermission< + Extract, + ControllerCaveatSpecification +>; + +/** + * Options for the {@link PermissionController} constructor. + * + * @template ControllerPermissionSpecification - A union of the types of all + * permission specifications available to the controller. Any referenced caveats + * must be included in the controller's caveat specifications. + * @template ControllerCaveatSpecification - A union of the types of all + * caveat specifications available to the controller. + */ +export type PermissionControllerOptions< + ControllerPermissionSpecification extends PermissionSpecificationConstraint, + ControllerCaveatSpecification extends CaveatSpecificationConstraint +> = { + messenger: PermissionControllerMessenger; + caveatSpecifications: CaveatSpecificationMap; + permissionSpecifications: PermissionSpecificationMap; + unrestrictedMethods: string[]; + state?: Partial< + PermissionControllerState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + > + >; +}; + +/** + * The permission controller. See the README for details. + * + * Assumes the existence of an {@link ApprovalController} reachable via the + * {@link ControllerMessenger}. + * + * @template ControllerPermissionSpecification - A union of the types of all + * permission specifications available to the controller. Any referenced caveats + * must be included in the controller's caveat specifications. + * @template ControllerCaveatSpecification - A union of the types of all + * caveat specifications available to the controller. + */ +export class PermissionController< + ControllerPermissionSpecification extends PermissionSpecificationConstraint, + ControllerCaveatSpecification extends CaveatSpecificationConstraint +> extends BaseController< + typeof controllerName, + PermissionControllerState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >, + PermissionControllerMessenger +> { + private readonly _caveatSpecifications: Readonly< + CaveatSpecificationMap + >; + + private readonly _permissionSpecifications: Readonly< + PermissionSpecificationMap + >; + + private readonly _unrestrictedMethods: ReadonlySet; + + /** + * The names of all JSON-RPC methods that will be ignored by the controller. + * + * @returns The names of all unrestricted JSON-RPC methods + */ + public get unrestrictedMethods(): ReadonlySet { + return this._unrestrictedMethods; + } + + /** + * Returns a `json-rpc-engine` middleware function factory, so that the rules + * described by the state of this controller can be applied to incoming + * JSON-RPC requests. + * + * The middleware **must** be added in the correct place in the middleware + * stack in order for it to work. See the README for an example. + */ + public createPermissionMiddleware: ReturnType< + typeof getPermissionMiddlewareFactory + >; + + /** + * Constructs the PermissionController. + * + * @param options - Permission controller options. + * @param options.caveatSpecifications - The specifications of all caveats + * available to the controller. See {@link CaveatSpecificationMap} and the + * documentation for more details. + * @param options.permissionSpecifications - The specifications of all + * permissions available to the controller. See + * {@link PermissionSpecificationMap} and the README for more details. + * @param options.unrestrictedMethods - The callable names of all JSON-RPC + * methods ignored by the new controller. + * @param options.messenger - The controller messenger. See + * {@link BaseController} for more information. + * @param options.state - Existing state to hydrate the controller with at + * initialization. + */ + constructor( + options: PermissionControllerOptions< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >, + ) { + const { + caveatSpecifications, + permissionSpecifications, + unrestrictedMethods, + messenger, + state = {}, + } = options; + + super({ + name: controllerName, + metadata: getStateMetadata< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >(), + messenger, + state: { + ...getDefaultState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >(), + ...state, + }, + }); + + this._unrestrictedMethods = new Set(unrestrictedMethods); + this._caveatSpecifications = deepFreeze({ ...caveatSpecifications }); + + this.validatePermissionSpecifications( + permissionSpecifications, + this._caveatSpecifications, + ); + + this._permissionSpecifications = deepFreeze({ + ...permissionSpecifications, + }); + + this.registerMessageHandlers(); + this.createPermissionMiddleware = getPermissionMiddlewareFactory({ + executeRestrictedMethod: this._executeRestrictedMethod.bind(this), + getRestrictedMethod: this.getRestrictedMethod.bind(this), + isUnrestrictedMethod: this.unrestrictedMethods.has.bind( + this.unrestrictedMethods, + ), + }); + } + + /** + * Gets a permission specification. + * + * @param targetKey - The target key of the permission specification to get. + * @returns The permission specification with the specified target key. + */ + private getPermissionSpecification< + TargetKey extends ControllerPermissionSpecification['targetKey'] + >( + targetKey: TargetKey, + ): ExtractPermissionSpecification< + ControllerPermissionSpecification, + TargetKey + > { + return this._permissionSpecifications[targetKey]; + } + + /** + * Gets a caveat specification. + * + * @param caveatType - The type of the caveat specification to get. + * @returns The caveat specification with the specified type. + */ + private getCaveatSpecification< + CaveatType extends ControllerCaveatSpecification['type'] + >(caveatType: CaveatType) { + return this._caveatSpecifications[caveatType]; + } + + /** + * Constructor helper for validating permission specifications. This is + * intended to prevent the use of invalid target keys which, while impossible + * to add in TypeScript, could rather easily occur in plain JavaScript. + * + * Throws an error if validation fails. + * + * @param permissionSpecifications - The permission specifications passed to + * this controller's constructor. + * @param caveatSpecifications - The caveat specifications passed to this + * controller. + */ + private validatePermissionSpecifications( + permissionSpecifications: PermissionSpecificationMap, + caveatSpecifications: CaveatSpecificationMap, + ) { + Object.entries( + permissionSpecifications, + ).forEach( + ([ + targetKey, + { permissionType, targetKey: innerTargetKey, allowedCaveats }, + ]) => { + if (!permissionType || !hasProperty(PermissionType, permissionType)) { + throw new Error(`Invalid permission type: "${permissionType}"`); + } + + // Check if the target key is the empty string, ends with "_", or ends + // with "*" but not "_*" + if (!targetKey || /_$/u.test(targetKey) || /[^_]\*$/u.test(targetKey)) { + throw new Error(`Invalid permission target key: "${targetKey}"`); + } + + if (targetKey !== innerTargetKey) { + throw new Error( + `Invalid permission specification: key "${targetKey}" must match specification.target value "${innerTargetKey}".`, + ); + } + + if (allowedCaveats) { + allowedCaveats.forEach((caveatType) => { + if (!hasProperty(caveatSpecifications, caveatType)) { + throw new UnrecognizedCaveatTypeError(caveatType); + } + }); + } + }, + ); + } + + /** + * Constructor helper for registering the controller's messaging system + * actions. + */ + private registerMessageHandlers(): void { + this.messagingSystem.registerActionHandler( + `${controllerName}:clearPermissions` as const, + () => this.clearState(), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:getEndowments` as const, + (origin: string, targetName: string, requestData?: unknown) => + this.getEndowments(origin, targetName, requestData), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:getSubjectNames` as const, + () => this.getSubjectNames(), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:getPermissions` as const, + (origin: OriginString) => this.getPermissions(origin), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:hasPermission` as const, + (origin: OriginString, targetName: string) => + this.hasPermission(origin, targetName), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:hasPermissions` as const, + (origin: OriginString) => this.hasPermissions(origin), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:revokeAllPermissions` as const, + (origin: OriginString) => this.revokeAllPermissions(origin), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:requestPermissions` as const, + (subject: PermissionSubjectMetadata, permissions: RequestedPermissions) => + this.requestPermissions(subject, permissions), + ); + } + + /** + * Clears the state of the controller. + */ + clearState(): void { + this.update((_draftState) => { + return { + ...getDefaultState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >(), + }; + }); + } + + /** + * Gets the permission specification corresponding to the given permission + * type and target name. Throws an error if the target name does not + * correspond to a permission, or if the specification is not of the + * given permission type. + * + * @template Type - The type of the permission specification to get. + * @param permissionType - The type of the permission specification to get. + * @param targetName - The name of the permission whose specification to get. + * @param requestingOrigin - The origin of the requesting subject, if any. + * Will be added to any thrown errors. + * @returns The specification object corresponding to the given type and + * target name. + */ + private getTypedPermissionSpecification( + permissionType: Type, + targetName: string, + requestingOrigin?: string, + ): ControllerPermissionSpecification & { permissionType: Type } { + const failureError = + permissionType === PermissionType.RestrictedMethod + ? methodNotFound( + targetName, + requestingOrigin ? { origin: requestingOrigin } : undefined, + ) + : new EndowmentPermissionDoesNotExistError( + targetName, + requestingOrigin, + ); + + const targetKey = this.getTargetKey(targetName); + if (!targetKey) { + throw failureError; + } + + const specification = this.getPermissionSpecification(targetKey); + if (!hasSpecificationType(specification, permissionType)) { + throw failureError; + } + + return specification; + } + + /** + * Gets the implementation of the specified restricted method. + * + * A JSON-RPC error is thrown if the method does not exist. + * + * @see {@link PermissionController.executeRestrictedMethod} and + * {@link PermissionController.createPermissionMiddleware} for internal usage. + * @param method - The name of the restricted method. + * @param origin - The origin associated with the request for the restricted + * method, if any. + * @returns The restricted method implementation. + */ + getRestrictedMethod( + method: string, + origin?: string, + ): RestrictedMethod { + return this.getTypedPermissionSpecification( + PermissionType.RestrictedMethod, + method, + origin, + ).methodImplementation; + } + + /** + * Gets a list of all origins of subjects. + * + * @returns The origins (i.e. IDs) of all subjects. + */ + getSubjectNames(): OriginString[] { + return Object.keys(this.state.subjects); + } + + /** + * Gets the permission for the specified target of the subject corresponding + * to the specified origin. + * + * @param origin - The origin of the subject. + * @param targetName - The method name as invoked by a third party (i.e., not + * a method key). + * @returns The permission if it exists, or undefined otherwise. + */ + getPermission< + SubjectPermission extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >( + origin: OriginString, + targetName: SubjectPermission['parentCapability'], + ): SubjectPermission | undefined { + return this.state.subjects[origin]?.permissions[targetName] as + | SubjectPermission + | undefined; + } + + /** + * Gets all permissions for the specified subject, if any. + * + * @param origin - The origin of the subject. + * @returns The permissions of the subject, if any. + */ + getPermissions(origin: OriginString) { + return this.state.subjects[origin]?.permissions; + } + + /** + * Checks whether the subject with the specified origin has the specified + * permission. + * + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @returns Whether the subject has the permission. + */ + hasPermission( + origin: OriginString, + target: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + ): boolean { + return Boolean(this.getPermission(origin, target)); + } + + /** + * Checks whether the subject with the specified origin has any permissions. + * Use this if you want to know if a subject "exists". + * + * @param origin - The origin of the subject to check. + * @returns Whether the subject has any permissions. + */ + hasPermissions(origin: OriginString): boolean { + return Boolean(this.state.subjects[origin]); + } + + /** + * Revokes all permissions from the specified origin. + * + * Throws an error of the origin has no permissions. + * + * @param origin - The origin whose permissions to revoke. + */ + revokeAllPermissions(origin: OriginString): void { + this.update((draftState) => { + if (!draftState.subjects[origin]) { + throw new UnrecognizedSubjectError(origin); + } + delete draftState.subjects[origin]; + }); + } + + /** + * Revokes the specified permission from the subject with the specified + * origin. + * + * Throws an error if the subject or the permission does not exist. + * + * @param origin - The origin of the subject whose permission to revoke. + * @param target - The target name of the permission to revoke. + */ + revokePermission( + origin: OriginString, + target: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + ): void { + this.revokePermissions({ [origin]: [target] }); + } + + /** + * Revokes the specified permissions from the specified subjects. + * + * Throws an error if any of the subjects or permissions do not exist. + * + * @param subjectsAndPermissions - An object mapping subject origins + * to arrays of permission target names to revoke. + */ + revokePermissions( + subjectsAndPermissions: Record< + OriginString, + NonEmptyArray< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'] + > + >, + ): void { + this.update((draftState) => { + Object.keys(subjectsAndPermissions).forEach((origin) => { + if (!hasProperty(draftState.subjects, origin)) { + throw new UnrecognizedSubjectError(origin); + } + + subjectsAndPermissions[origin].forEach((target) => { + const { permissions } = draftState.subjects[origin]; + if (!hasProperty(permissions as Record, target)) { + throw new PermissionDoesNotExistError(origin, target); + } + + this.deletePermission(draftState.subjects, origin, target); + }); + }); + }); + } + + /** + * Revokes all permissions corresponding to the specified target for all subjects. + * Does nothing if no subjects or no such permission exists. + * + * @param target - The name of the target to revoke all permissions for. + */ + revokePermissionForAllSubjects( + target: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + ): void { + if (this.getSubjectNames().length === 0) { + return; + } + + this.update((draftState) => { + Object.entries(draftState.subjects).forEach(([origin, subject]) => { + const { permissions } = subject; + + if (hasProperty(permissions as Record, target)) { + this.deletePermission(draftState.subjects, origin, target); + } + }); + }); + } + + /** + * Deletes the permission identified by the given origin and target. If the + * permission is the single remaining permission of its subject, the subject + * is also deleted. + * + * @param subjects - The draft permission controller subjects. + * @param origin - The origin of the subject associated with the permission + * to delete. + * @param target - The target name of the permission to delete. + */ + private deletePermission( + subjects: Draft>, + origin: OriginString, + target: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + ): void { + const { permissions } = subjects[origin]; + if (Object.keys(permissions).length > 1) { + delete permissions[target]; + } else { + delete subjects[origin]; + } + } + + /** + * Checks whether the permission of the subject corresponding to the given + * origin has a caveat of the specified type. + * + * Throws an error if the subject does not have a permission with the + * specified target name. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to check for. + * @returns Whether the permission has the specified caveat. + */ + hasCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractAllowedCaveatTypes + >(origin: OriginString, target: TargetName, caveatType: CaveatType): boolean { + return Boolean(this.getCaveat(origin, target, caveatType)); + } + + /** + * Gets the caveat of the specified type, if any, for the permission of + * the subject corresponding to the given origin. + * + * Throws an error if the subject does not have a permission with the + * specified target name. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to get. + * @returns The caveat, or `undefined` if no such caveat exists. + */ + getCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractAllowedCaveatTypes + >( + origin: OriginString, + target: TargetName, + caveatType: CaveatType, + ): ExtractCaveat | undefined { + const permission = this.getPermission(origin, target); + if (!permission) { + throw new PermissionDoesNotExistError(origin, target); + } + + return findCaveat(permission, caveatType) as + | ExtractCaveat + | undefined; + } + + /** + * Adds a caveat of the specified type, with the specified caveat value, to + * the permission corresponding to the given subject origin and permission + * target. + * + * For modifying existing caveats, use + * {@link PermissionController.updateCaveat}. + * + * Throws an error if no such permission exists, or if the caveat already + * exists. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to add. + * @param caveatValue - The value of the caveat to add. + */ + addCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractAllowedCaveatTypes + >( + origin: OriginString, + target: TargetName, + caveatType: CaveatType, + caveatValue: ExtractCaveatValue, + ): void { + if (this.hasCaveat(origin, target, caveatType)) { + throw new CaveatAlreadyExistsError(origin, target, caveatType); + } + + this.setCaveat(origin, target, caveatType, caveatValue); + } + + /** + * Updates the value of the caveat of the specified type belonging to the + * permission corresponding to the given subject origin and permission + * target. + * + * For adding new caveats, use + * {@link PermissionController.addCaveat}. + * + * Throws an error if no such permission or caveat exists. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to update. + * @param caveatValue - The new value of the caveat. + */ + updateCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractAllowedCaveatTypes, + CaveatValue extends ExtractCaveatValue< + ControllerCaveatSpecification, + CaveatType + > + >( + origin: OriginString, + target: TargetName, + caveatType: CaveatType, + caveatValue: CaveatValue, + ): void { + if (!this.hasCaveat(origin, target, caveatType)) { + throw new CaveatDoesNotExistError(origin, target, caveatType); + } + + this.setCaveat(origin, target, caveatType, caveatValue); + } + + /** + * Sets the specified caveat on the specified permission. Overwrites existing + * caveats of the same type in-place (preserving array order), and adds the + * caveat to the end of the array otherwise. + * + * Throws an error if the permission does not exist or fails to validate after + * its caveats have been modified. + * + * @see {@link PermissionController.addCaveat} + * @see {@link PermissionController.updateCaveat} + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to set. + * @param caveatValue - The value of the caveat to set. + */ + private setCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractAllowedCaveatTypes + >( + origin: OriginString, + target: TargetName, + caveatType: CaveatType, + caveatValue: ExtractCaveatValue, + ): void { + this.update((draftState) => { + const subject = draftState.subjects[origin]; + + // Unreachable because `hasCaveat` is always called before this, and it + // throws if permissions are missing. TypeScript needs this, however. + /* istanbul ignore if */ + if (!subject) { + throw new UnrecognizedSubjectError(origin); + } + + const permission = subject.permissions[target]; + + /* istanbul ignore if: practically impossible, but TypeScript wants it */ + if (!permission) { + throw new PermissionDoesNotExistError(origin, target); + } + + const caveat = { + type: caveatType, + value: caveatValue, + }; + this.validateCaveat(caveat, origin, target); + + if (permission.caveats) { + const caveatIndex = permission.caveats.findIndex( + (existingCaveat) => existingCaveat.type === caveat.type, + ); + + if (caveatIndex === -1) { + permission.caveats.push(caveat); + } else { + permission.caveats.splice(caveatIndex, 1, caveat); + } + } else { + // Typecast: At this point, we don't know if the specific permission + // is allowed to have caveats, but it should be impossible to call + // this method for a permission that may not have any caveats. + // If all else fails, the permission validator is also called. + permission.caveats = [caveat] as any; + } + + this.validateModifiedPermission(permission, origin, target); + }); + } + + /** + * Updates all caveats with the specified type for all subjects and + * permissions by applying the specified mutator function to them. + * + * ATTN: Permissions can be revoked entirely by the action of this method, + * read on for details. + * + * Caveat mutators are functions that receive a caveat value and return a + * tuple consisting of a {@link CaveatMutatorOperation} and, optionally, a new + * value to update the existing caveat with. + * + * For each caveat, depending on the mutator result, this method will: + * - Do nothing ({@link CaveatMutatorOperation.noop}) + * - Update the value of the caveat ({@link CaveatMutatorOperation.updateValue}). The caveat specification validator, if any, will be called after updating the value. + * - Delete the caveat ({@link CaveatMutatorOperation.deleteCaveat}). The permission specification validator, if any, will be called after deleting the caveat. + * - Revoke the parent permission ({@link CaveatMutatorOperation.revokePermission}) + * + * This method throws if the validation of any caveat or permission fails. + * + * @param targetCaveatType - The type of the caveats to update. + * @param mutator - The mutator function which will be applied to all caveat + * values. + */ + updatePermissionsByCaveat< + CaveatType extends ExtractCaveats['type'], + TargetCaveat extends ExtractCaveat< + ControllerCaveatSpecification, + CaveatType + > + >(targetCaveatType: CaveatType, mutator: CaveatMutator): void { + if (Object.keys(this.state.subjects).length === 0) { + return; + } + + this.update((draftState) => { + Object.values(draftState.subjects).forEach((subject) => { + Object.values(subject.permissions).forEach((permission) => { + const { caveats } = permission; + const targetCaveat = caveats?.find( + ({ type }) => type === targetCaveatType, + ); + if (!targetCaveat) { + return; + } + + // The mutator may modify the caveat value in place, and must always + // return a valid mutation result. + const mutatorResult = mutator(targetCaveat.value); + switch (mutatorResult.operation) { + case CaveatMutatorOperation.noop: + break; + + case CaveatMutatorOperation.updateValue: + // Typecast: `Mutable` is used here to assign to a readonly + // property. `targetConstraint` should already be mutable because + // it's part of a draft, but for some reason it's not. We can't + // use the more-correct `Draft` type here either because it + // results in an error. + (targetCaveat as Mutable).value = + mutatorResult.value; + + this.validateCaveat( + targetCaveat, + subject.origin, + permission.parentCapability, + ); + break; + + case CaveatMutatorOperation.deleteCaveat: + this.deleteCaveat( + permission, + targetCaveatType, + subject.origin, + permission.parentCapability, + ); + break; + + case CaveatMutatorOperation.revokePermission: + this.deletePermission( + draftState.subjects, + subject.origin, + permission.parentCapability, + ); + break; + + default: { + // This type check ensures that the switch statement is + // exhaustive. + const _exhaustiveCheck: never = mutatorResult; + throw new Error( + `Unrecognized mutation result: "${ + (_exhaustiveCheck as any).operation + }"`, + ); + } + } + }); + }); + }); + } + + /** + * Removes the caveat of the specified type from the permission corresponding + * to the given subject origin and target name. + * + * Throws an error if no such permission or caveat exists. + * + * @template TargetName - The permission target name. Should be inferred. + * @template CaveatType - The valid caveat types for the permission. Should + * be inferred. + * @param origin - The origin of the subject. + * @param target - The target name of the permission. + * @param caveatType - The type of the caveat to remove. + */ + removeCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractAllowedCaveatTypes + >(origin: OriginString, target: TargetName, caveatType: CaveatType): void { + this.update((draftState) => { + const permission = draftState.subjects[origin]?.permissions[target]; + if (!permission) { + throw new PermissionDoesNotExistError(origin, target); + } + + if (!permission.caveats) { + throw new CaveatDoesNotExistError(origin, target, caveatType); + } + + this.deleteCaveat(permission, caveatType, origin, target); + }); + } + + /** + * Deletes the specified caveat from the specified permission. If no caveats + * remain after deletion, the permission's caveat property is set to `null`. + * The permission is validated after being modified. + * + * Throws an error if the permission does not have a caveat with the specified + * type. + * + * @param permission - The permission whose caveat to delete. + * @param caveatType - The type of the caveat to delete. + * @param origin - The origin the permission subject. + * @param target - The name of the permission target. + */ + private deleteCaveat< + TargetName extends ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + CaveatType extends ExtractCaveats['type'] + >( + permission: Draft, + caveatType: CaveatType, + origin: OriginString, + target: TargetName, + ): void { + /* istanbul ignore if: not possible in our usage */ + if (!permission.caveats) { + throw new CaveatDoesNotExistError(origin, target, caveatType); + } + + const caveatIndex = permission.caveats.findIndex( + (existingCaveat) => existingCaveat.type === caveatType, + ); + + if (caveatIndex === -1) { + throw new CaveatDoesNotExistError(origin, target, caveatType); + } + + if (permission.caveats.length === 1) { + permission.caveats = null; + } else { + permission.caveats.splice(caveatIndex, 1); + } + + this.validateModifiedPermission(permission, origin, target); + } + + /** + * Validates the specified modified permission. Should **always** be invoked + * on a permission after its caveats have been modified. + * + * Just like {@link PermissionController.validatePermission}, except that the + * corresponding target key and specification are retrieved first, and an + * error is thrown if the target key does not exist. + * + * @param permission - The modified permission to validate. + * @param origin - The origin associated with the permission. + * @param targetName - The target name name of the permission. + */ + private validateModifiedPermission( + permission: Draft, + origin: OriginString, + targetName: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + ): void { + const targetKey = this.getTargetKey(permission.parentCapability); + /* istanbul ignore if: this should be impossible */ + if (!targetKey) { + throw new Error( + `Fatal: Existing permission target key "${targetKey}" has no specification.`, + ); + } + + this.validatePermission( + this.getPermissionSpecification(targetKey), + permission as PermissionConstraint, + origin, + targetName, + ); + } + + /** + * Gets the key for the specified permission target. + * + * Used to support our namespaced permission target feature, which is used + * to implement namespaced restricted JSON-RPC methods. + * + * @param target - The requested permission target. + * @returns The internal key of the permission target. + */ + private getTargetKey( + target: string, + ): ControllerPermissionSpecification['targetKey'] | undefined { + if (hasProperty(this._permissionSpecifications, target)) { + return target; + } + + const namespacedTargetsWithoutWildcard: Record = {}; + for (const targetKey of Object.keys(this._permissionSpecifications)) { + const wildCardMatch = targetKey.match(/(.+)\*$/u); + if (wildCardMatch) { + namespacedTargetsWithoutWildcard[wildCardMatch[1]] = true; + } + } + + // Check for potentially nested namespaces: + // Ex: wildzone_ + // Ex: eth_plugin_ + const segments = target.split('_'); + let targetKey = ''; + + while ( + segments.length > 0 && + !hasProperty(this._permissionSpecifications, targetKey) && + !namespacedTargetsWithoutWildcard[targetKey] + ) { + targetKey += `${segments.shift()}_`; + } + + if (namespacedTargetsWithoutWildcard[targetKey]) { + return `${targetKey}*`; + } + + return undefined; + } + + /** + * Grants _approved_ permissions to the specified subject. Every permission and + * caveat is stringently validated – including by calling every specification + * validator – and an error is thrown if any validation fails. + * + * ATTN: This method does **not** prompt the user for approval. + * + * @see {@link PermissionController.requestPermissions} For initiating a + * permissions request requiring user approval. + * @param options - Options bag. + * @param options.approvedPermissions - The requested permissions approved by + * the user. + * @param options.requestData - Permission request data. Passed to permission + * factory functions. + * @param options.preserveExistingPermissions - Whether to preserve the + * subject's existing permissions. + * @param options.subject - The subject to grant permissions to. + * @returns The granted permissions. + */ + grantPermissions({ + approvedPermissions, + requestData, + preserveExistingPermissions = true, + subject, + }: { + approvedPermissions: RequestedPermissions; + subject: PermissionSubjectMetadata; + preserveExistingPermissions?: boolean; + requestData?: Record; + }): SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + > { + const { origin } = subject; + + if (!origin || typeof origin !== 'string') { + throw new InvalidSubjectIdentifierError(origin); + } + + const permissions = (preserveExistingPermissions + ? { + ...this.getPermissions(origin), + } + : {}) as SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >; + + for (const [requestedTarget, approvedPermission] of Object.entries( + approvedPermissions, + )) { + const targetKey = this.getTargetKey(requestedTarget); + if (!targetKey) { + throw methodNotFound(requestedTarget); + } + + if ( + approvedPermission.parentCapability !== undefined && + requestedTarget !== approvedPermission.parentCapability + ) { + throw new InvalidApprovedPermissionError( + origin, + requestedTarget, + approvedPermission, + ); + } + + // The requested target must be a valid target name if we found its key. + // We reassign it to change its type. + const targetName = requestedTarget as ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability']; + const specification = this.getPermissionSpecification(targetKey); + + // The requested caveats are validated here. + const caveats = this.constructCaveats( + origin, + targetName, + approvedPermission.caveats, + ); + + const permissionOptions = { + caveats, + invoker: origin, + target: targetName, + }; + + let permission: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >; + if (specification.factory) { + permission = specification.factory(permissionOptions, requestData); + + // Full caveat and permission validation is performed here since the + // factory function can arbitrarily modify the entire permission object, + // including its caveats. + this.validatePermission(specification, permission, origin, targetName); + } else { + permission = constructPermission(permissionOptions); + + // We do not need to validate caveats in this case, because the plain + // permission constructor function does not modify the caveats, which + // were already validated by `constructCaveats` above. + this.validatePermission(specification, permission, origin, targetName, { + invokePermissionValidator: true, + performCaveatValidation: false, + }); + } + permissions[targetName] = permission; + } + + this.setValidatedPermissions(origin, permissions); + return permissions; + } + + /** + * Validates the specified permission by: + * - Ensuring that its `caveats` property is either `null` or a non-empty array. + * - Ensuring that it only includes caveats allowed by its specification. + * - Ensuring that it includes no duplicate caveats (by caveat type). + * - Validating each caveat object, if `performCaveatValidation` is `true`. + * - Calling the validator of its specification, if one exists and `invokePermissionValidator` is `true`. + * + * An error is thrown if validation fails. + * + * @param specification - The specification of the permission. + * @param permission - The permission to validate. + * @param origin - The origin associated with the permission. + * @param targetName - The target name of the permission. + * @param validationOptions - Validation options. + * @param validationOptions.invokePermissionValidator - Whether to invoke the + * permission's consumer-specified validator function, if any. + * @param validationOptions.performCaveatValidation - Whether to invoke + * {@link PermissionController.validateCaveat} on each of the permission's + * caveats. + */ + private validatePermission( + specification: PermissionSpecificationConstraint, + permission: PermissionConstraint, + origin: OriginString, + targetName: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + { invokePermissionValidator, performCaveatValidation } = { + invokePermissionValidator: true, + performCaveatValidation: true, + }, + ): void { + const { allowedCaveats, validator } = specification; + if (hasProperty(permission, 'caveats')) { + const { caveats } = permission; + + if (caveats !== null && !(Array.isArray(caveats) && caveats.length > 0)) { + throw new InvalidCaveatsPropertyError(origin, targetName, caveats); + } + + const seenCaveatTypes = new Set(); + caveats?.forEach((caveat) => { + if (performCaveatValidation) { + this.validateCaveat(caveat, origin, targetName); + } + + if (!allowedCaveats?.includes(caveat.type)) { + throw new ForbiddenCaveatError(caveat.type, origin, targetName); + } + + if (seenCaveatTypes.has(caveat.type)) { + throw new DuplicateCaveatError(caveat.type, origin, targetName); + } + seenCaveatTypes.add(caveat.type); + }); + } + + if (invokePermissionValidator && validator) { + validator(permission, origin, targetName); + } + } + + /** + * Assigns the specified permissions to the subject with the given origin. + * Overwrites all existing permissions, and creates a subject entry if it + * doesn't already exist. + * + * ATTN: Assumes that the new permissions have been validated. + * + * @param origin - The origin of the grantee subject. + * @param permissions - The new permissions for the grantee subject. + */ + private setValidatedPermissions( + origin: OriginString, + permissions: Record< + string, + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >, + ): void { + this.update((draftState) => { + if (!draftState.subjects[origin]) { + draftState.subjects[origin] = { origin, permissions: {} }; + } + + draftState.subjects[origin].permissions = castDraft(permissions); + }); + } + + /** + * Validates the requested caveats for the permission of the specified + * subject origin and target name and returns the validated caveat array. + * + * Throws an error if validation fails. + * + * @param origin - The origin of the permission subject. + * @param target - The permission target name. + * @param requestedCaveats - The requested caveats to construct. + * @returns The constructed caveats. + */ + private constructCaveats( + origin: OriginString, + target: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + requestedCaveats?: unknown[] | null, + ): NonEmptyArray> | undefined { + const caveatArray = requestedCaveats?.map((requestedCaveat) => { + this.validateCaveat(requestedCaveat, origin, target); + + // Reassign so that we have a fresh object. + const { type, value } = requestedCaveat as CaveatConstraint; + return { type, value } as ExtractCaveats; + }); + + return caveatArray && isNonEmptyArray(caveatArray) + ? caveatArray + : undefined; + } + + /** + * This methods validates that the specified caveat is an object with the + * expected properties and types. It also ensures that a caveat specification + * exists for the requested caveat type, and calls the specification + * validator, if it exists, on the caveat object. + * + * Throws an error if validation fails. + * + * @param caveat - The caveat object to validate. + * @param origin - The origin associated with the subject of the parent + * permission. + * @param target - The target name associated with the parent permission. + */ + private validateCaveat( + caveat: unknown, + origin: OriginString, + target: string, + ): void { + if (!isPlainObject(caveat)) { + throw new InvalidCaveatError(caveat, origin, target); + } + + if (Object.keys(caveat).length !== 2) { + throw new InvalidCaveatFieldsError(caveat, origin, target); + } + + if (typeof caveat.type !== 'string') { + throw new InvalidCaveatTypeError(caveat, origin, target); + } + + const specification = this.getCaveatSpecification(caveat.type); + if (!specification) { + throw new UnrecognizedCaveatTypeError(caveat.type, origin, target); + } + + if (!hasProperty(caveat, 'value') || caveat.value === undefined) { + throw new CaveatMissingValueError(caveat, origin, target); + } + + if (!isValidJson(caveat.value)) { + throw new CaveatInvalidJsonError(caveat, origin, target); + } + + // Typecast: TypeScript still believes that the caveat is a PlainObject. + specification.validator?.(caveat as CaveatConstraint, origin, target); + } + + /** + * Initiates a permission request that requires user approval. This should + * always be used to grant additional permissions to a subject, unless user + * approval has been obtained through some other means. + * + * Permissions are validated at every step of the approval process, and this + * method will reject if validation fails. + * + * @see {@link ApprovalController} For the user approval logic. + * @see {@link PermissionController.acceptPermissionsRequest} For the method + * that _accepts_ the request and resolves the user approval promise. + * @see {@link PermissionController.rejectPermissionsRequest} For the method + * that _rejects_ the request and the user approval promise. + * @param subject - The grantee subject. + * @param requestedPermissions - The requested permissions. + * @param options - Additional options. + * @param options.id - The id of the permissions request. Defaults to a unique + * id. + * @param options.preserveExistingPermissions - Whether to preserve the + * subject's existing permissions. Defaults to `true`. + * @returns The granted permissions and request metadata. + */ + async requestPermissions( + subject: PermissionSubjectMetadata, + requestedPermissions: RequestedPermissions, + options: { + id?: string; + preserveExistingPermissions?: boolean; + } = {}, + ): Promise< + [ + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >, + { id: string; origin: OriginString }, + ] + > { + const { origin } = subject; + const { id = nanoid(), preserveExistingPermissions = true } = options; + this.validateRequestedPermissions(origin, requestedPermissions); + + const metadata = { + id, + origin, + }; + + const permissionsRequest = { + metadata, + permissions: requestedPermissions, + }; + + const { + permissions: approvedPermissions, + ...requestData + } = await this.requestUserApproval(permissionsRequest); + + return [ + this.grantPermissions({ + subject, + approvedPermissions, + preserveExistingPermissions, + requestData, + }), + metadata, + ]; + } + + /** + * Validates requested permissions. Throws if validation fails. + * + * This method ensures that the requested permissions are a properly + * formatted {@link RequestedPermissions} object, and performs the same + * validation as {@link PermissionController.grantPermissions}, except that + * consumer-specified permission validator functions are not called, since + * they are only called on fully constructed, approved permissions that are + * otherwise completely valid. + * + * Unrecognzied properties on requested permissions are ignored. + * + * @param origin - The origin of the grantee subject. + * @param requestedPermissions - The requested permissions. + */ + private validateRequestedPermissions( + origin: OriginString, + requestedPermissions: unknown, + ): void { + if (!isPlainObject(requestedPermissions)) { + throw invalidParams({ + message: `Requested permissions for origin "${origin}" is not a plain object.`, + data: { origin, requestedPermissions }, + }); + } + + if (Object.keys(requestedPermissions).length === 0) { + throw invalidParams({ + message: `Permissions request for origin "${origin}" contains no permissions.`, + data: { requestedPermissions }, + }); + } + + for (const targetName of Object.keys(requestedPermissions)) { + const permission = requestedPermissions[targetName]; + const targetKey = this.getTargetKey(targetName); + + if (!targetKey) { + throw methodNotFound(targetName, { origin, requestedPermissions }); + } + + if ( + !isPlainObject(permission) || + (permission.parentCapability !== undefined && + targetName !== permission.parentCapability) + ) { + throw invalidParams({ + message: `Permissions request for origin "${origin}" contains invalid requested permission(s).`, + data: { origin, requestedPermissions }, + }); + } + + // Here we validate the permission without invoking its validator, if any. + // The validator will be invoked after the permission has been approved. + this.validatePermission( + this.getPermissionSpecification(targetKey), + // Typecast: The permission is still a "PlainObject" here. + permission as PermissionConstraint, + origin, + targetName, + { invokePermissionValidator: false, performCaveatValidation: true }, + ); + } + } + + /** + * Adds a request to the {@link ApprovalController} using the + * {@link AddApprovalRequest} action. Also validates the resulting approved + * permissions request, and throws an error if validation fails. + * + * @param permissionsRequest - The permissions request object. + * @returns The approved permissions request object. + */ + private async requestUserApproval(permissionsRequest: PermissionsRequest) { + const { origin, id } = permissionsRequest.metadata; + const approvedRequest = await this.messagingSystem.call( + 'ApprovalController:addRequest', + { + id, + origin, + requestData: permissionsRequest, + type: MethodNames.requestPermissions, + }, + true, + ); + + this.validateApprovedPermissions(approvedRequest, { id, origin }); + return approvedRequest as PermissionsRequest; + } + + /** + * Validates an approved {@link PermissionsRequest} object. The approved + * request must have the required `metadata` and `permissions` properties, + * the `id` and `origin` of the `metadata` must match the original request + * metadata, and the requested permissions must be valid per + * {@link PermissionController.validateRequestedPermissions}. Any extra + * metadata properties are ignored. + * + * An error is thrown if validation fails. + * + * @param approvedRequest - The approved permissions request object. + * @param originalMetadata - The original request metadata. + */ + private validateApprovedPermissions( + approvedRequest: unknown, + originalMetadata: PermissionsRequestMetadata, + ) { + const { id, origin } = originalMetadata; + + if ( + !isPlainObject(approvedRequest) || + !isPlainObject(approvedRequest.metadata) + ) { + throw internalError( + `Approved permissions request for subject "${origin}" is invalid.`, + { data: { approvedRequest } }, + ); + } + + const { + metadata: { id: newId, origin: newOrigin }, + permissions, + } = approvedRequest; + + if (newId !== id) { + throw internalError( + `Approved permissions request for subject "${origin}" mutated its id.`, + { originalId: id, mutatedId: newId }, + ); + } + + if (newOrigin !== origin) { + throw internalError( + `Approved permissions request for subject "${origin}" mutated its origin.`, + { originalOrigin: origin, mutatedOrigin: newOrigin }, + ); + } + + try { + this.validateRequestedPermissions(origin, permissions); + } catch (error) { + // Re-throw as an internal error; we should never receive invalid approved + // permissions. + throw internalError( + `Invalid approved permissions request: ${error.message}`, + error.data, + ); + } + } + + /** + * Accepts a permissions request created by + * {@link PermissionController.requestPermissions}. + * + * @param request - The permissions request. + */ + async acceptPermissionsRequest(request: PermissionsRequest): Promise { + const { id } = request.metadata; + + if (!this.hasApprovalRequest({ id })) { + throw new PermissionsRequestNotFoundError(id); + } + + if (Object.keys(request.permissions).length === 0) { + this._rejectPermissionsRequest( + id, + invalidParams({ + message: 'Must request at least one permission.', + }), + ); + return; + } + + try { + this.messagingSystem.call( + 'ApprovalController:acceptRequest', + id, + request, + ); + } catch (error) { + // If accepting unexpectedly fails, reject the request and re-throw the + // error + this._rejectPermissionsRequest(id, error); + throw error; + } + } + + /** + * Rejects a permissions request created by + * {@link PermissionController.requestPermissions}. + * + * @param id - The id of the request to be rejected. + */ + async rejectPermissionsRequest(id: string): Promise { + if (!this.hasApprovalRequest({ id })) { + throw new PermissionsRequestNotFoundError(id); + } + + this._rejectPermissionsRequest(id, userRejectedRequest()); + } + + /** + * Checks whether the {@link ApprovalController} has a particular permissions + * request. + * + * @see {@link PermissionController.acceptPermissionsRequest} and + * {@link PermissionController.rejectPermissionsRequest} for usage. + * @param options - The {@link HasApprovalRequest} options. + * @param options.id - The id of the approval request to check for. + * @returns Whether the specified request exists. + */ + private hasApprovalRequest(options: { id: string }): boolean { + return this.messagingSystem.call( + 'ApprovalController:hasRequest', + // Typecast: For some reason, the type here expects all of the possible + // HasApprovalRequest options to be specified, when they're actually all + // optional. Passing just the id is definitely valid, so we just cast it. + options as any, + ); + } + + /** + * Rejects the permissions request with the specified id, with the specified + * error as the reason. This method is effectively a wrapper around a + * messenger call for the `ApprovalController:rejectRequest` action. + * + * @see {@link PermissionController.acceptPermissionsRequest} and + * {@link PermissionController.rejectPermissionsRequest} for usage. + * @param id - The id of the request to reject. + * @param error - The error associated with the rejection. + * @returns Nothing + */ + private _rejectPermissionsRequest(id: string, error: Error): void { + return this.messagingSystem.call( + 'ApprovalController:rejectRequest', + id, + error, + ); + } + + /** + * Gets the subject's endowments per the specified endowment permission. + * Throws if the subject does not have the required permission or if the + * permission is not an endowment permission. + * + * @param origin - The origin of the subject whose endowments to retrieve. + * @param targetName - The name of the endowment permission. This must be a + * valid permission target name. + * @param requestData - Additional data associated with the request, if any. + * Forwarded to the endowment getter function for the permission. + * @returns The endowments, if any. + */ + async getEndowments( + origin: string, + targetName: ExtractEndowmentPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + requestData?: unknown, + ): Promise { + if (!this.hasPermission(origin, targetName)) { + throw unauthorized({ data: { origin, targetName } }); + } + + return this.getTypedPermissionSpecification( + PermissionType.Endowment, + targetName, + origin, + ).endowmentGetter({ origin, requestData }); + } + + /** + * Executes a restricted method as the subject with the given origin. + * The specified params, if any, will be passed to the method implementation. + * + * ATTN: Great caution should be exercised in the use of this method. + * Methods that cause side effects or affect application state should + * be avoided. + * + * This method will first attempt to retrieve the requested restricted method + * implementation, throwing if it does not exist. The method will then be + * invoked as though the subject with the specified origin had invoked it with + * the specified parameters. This means that any existing caveats will be + * applied to the restricted method, and this method will throw if the + * restricted method or its caveat decorators throw. + * + * In addition, this method will throw if the subject does not have a + * permission for the specified restricted method. + * + * @param origin - The origin of the subject to execute the method on behalf + * of. + * @param targetName - The name of the method to execute. This must be a valid + * permission target name. + * @param params - The parameters to pass to the method implementation. + * @returns The result of the executed method. + */ + async executeRestrictedMethod( + origin: OriginString, + targetName: ExtractRestrictedMethodPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + params?: RestrictedMethodParameters, + ): Promise { + // Throws if the method does not exist + const methodImplementation = this.getRestrictedMethod(targetName, origin); + + const result = await this._executeRestrictedMethod( + methodImplementation, + { origin }, + targetName, + params, + ); + + if (result === undefined) { + throw new Error( + `Internal request for method "${targetName}" as origin "${origin}" returned no result.`, + ); + } + + return result; + } + + /** + * An internal method used in the controller's `json-rpc-engine` middleware + * and {@link PermissionController.executeRestrictedMethod}. Calls the + * specified restricted method implementation after decorating it with the + * caveats of its permission. Throws if the subject does not have the + * requisite permission. + * + * ATTN: Parameter validation is the responsibility of the caller, or + * the restricted method implementation in the case of `params`. + * + * @see {@link PermissionController.executeRestrictedMethod} and + * {@link PermissionController.createPermissionMiddleware} for usage. + * @param methodImplementation - The implementation of the method to call. + * @param subject - Metadata about the subject that made the request. + * @param method - The method name + * @param params - Params needed for executing the restricted method + * @returns The result of the restricted method implementation + */ + private _executeRestrictedMethod( + methodImplementation: RestrictedMethod, + subject: PermissionSubjectMetadata, + method: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + params: RestrictedMethodParameters = [], + ): ReturnType> { + const { origin } = subject; + + const permission = this.getPermission(origin, method); + if (!permission) { + throw unauthorized({ data: { origin, method } }); + } + + return decorateWithCaveats( + methodImplementation, + permission, + this._caveatSpecifications, + )({ method, params, context: { origin } }); + } +} diff --git a/src/permissions/README.md b/src/permissions/README.md new file mode 100644 index 0000000000..bb17c517d4 --- /dev/null +++ b/src/permissions/README.md @@ -0,0 +1,277 @@ +# PermissionController + +The `PermissionController` is the heart of an object capability-inspired permission system. +It is the successor of the original MetaMask permission system, [`rpc-cap`](https://github.com/MetaMask/rpc-cap). + +## Conceptual Overview + +The permission system itself belongs to a **host**, and it mediates the access to resources – called **targets** – of distinct **subjects**. +A target can belong to the host itself, or another subject. + +When a subject attempts to access a target, we say that they **invoke** it. +The system ensures that subjects can only invoke a target if they have the **permission** to do so. +Permissions are associated with a subject and target, and they are part of the state of the permission system. + +Permissions can have **caveats**, which are host-defined attenuations of the authority a permission grants over a particular target. + +## Implementation Overview + +At any given moment, the `PermissionController` state tree describes the complete state of the permissions of all subjects known to the host (i.e., the MetaMask instance). +The `PermissionController` also provides methods for adding, updating, and removing permissions, and enforcing the rules described by its state tree. +Permission system concepts correspond to components of the MetaMask stack as follows: + +| Concept | Implementation | +| :---------------- | :-------------------------------------------------------------- | +| Host | The MetaMask application | +| Subjects | Websites, Snaps, or other extensions | +| Targets | JSON-RPC methods, endowments | +| Invocations | JSON-RPC requests, endowment retrieval | +| Permissions | Permission objects | +| Caveats | Caveat objects | +| Permission system | The `PermissionController` and its `json-rpc-engine` middleware | + +### Target Keys and Target Names + +When consuming or reading the `PermissionController`, you will encounter the concepts of "target keys" and "target names". +As described in the previous section, a permission grants a subject access to a restricted resource, called a _target_, which is some string. +Targets are referred to by consumers by their _names_, and internally (in the `PermissionController`) by their _keys_. +This distinction exists to enable namespaced targets, specifically namespaced JSON-RPC methods. + +All targets have a single key. +If a target _is not_ namespaced, the key is identical to its name. +If a target _is_ namespaced, it may have any number of names, all of which are distinct from its key. +Permissions are always requested and invoked by their target name(s). + +For example, for the non-namespaced restricted method `eth_accounts`, both the key and the name is `eth_accounts`. +On the other hand, the namespaced restricted method `wallet_getSecret_*` has the key `wallet_getSecret_*`, and any number of names where the `*` wildcard character is substituted for some valid string per the method implementation. + +See [below](#construction) for a concrete example. + +### Permission / Target Types + +In practice, targets can be different things, necessitating distinct implementations in order to enforce the logic of the permission system. +This being the case, the `PermissionController` defines different **permission / target types**, intended for different kinds of permission targets. +At present, there are two permission / target types. + +#### JSON-RPC Methods + +Restricting access to JSON-RPC methods was the motivating and only supported use case for the original permission system, and remains the predominant kind of permission to this day. +The `PermissionController` provides patterns for creating restricted JSON-RPC method implementations and caveats, and a `json-rpc-engine` middleware function factory. +To permission a JSON-RPC server, every JSON-RPC method must be enumerated and designated as either "restricted" or "unrestricted", and a permission middleware function must be added to the `json-rpc-engine` middleware stack. +Unrestricted methods can always be called by anyone. +Restricted methods require the requisite permission in order to be called. + +Once the permission middleware is injected into the middleware stack, every JSON-RPC request will be handled in one of the following ways: + +- If the requested method is neither restricted nor unrestricted, the request will be rejected with a `methodNotFound` error. +- If the requested method is unrestricted, it will pass through the middleware unmodified. +- If the requested method is restricted, the middleware will attempt to get the permission corresponding to the subject and target, and: + - If the request is authorized, call the corresponding method with the request parameters. + - If the request is not authorized, reject the request with an `unauthorized` error. + +#### Endowments + +The name "endowment" comes from the endowments that you may provide to a [Secure EcmaScript (SES) `Compartment`](https://github.com/endojs/endo/tree/26d991afb01cf824827db0c958c50970e038112f/packages/ses#compartment) when it is constructed. +SES endowments are simply names that appear in the compartment's global scope. +In the context of the `PermissionController`, endowments are simply "things" that subjects should not be able to access by default. +They _could_ be the names of endowments that are to be made available to a particular SES `Compartment`, but they could also be any JavaScript value, and it is the host's responsibility to make sense of them. + +At present, endowment permissions may not have any caveats, but caveat support may be added in the future. + +### Caveats + +Caveats are arbitrary restrictions on restricted method requests. +Every permission has a `caveats` field, which is either an array of caveats or `null`. +Every caveat has a string `type`, and every type has an associated function that is used to apply the caveat to a restricted method request. +When the `PermissionController` is constructed, the consumer specifies the available caveat types and their implementations. + +## Examples + +In addition to the below examples, the [`PermissionController` unit tests](./PermissionController.test.ts) show how to set up the controller. + +### Construction + +```typescript +// To construct a permission controller, we first need to define the caveat +// types and restricted methods. + +const caveatSpecifications = { + filterArrayResponse: { + type: 'filterArrayResponse', + // If a permission has any caveats, its corresponding restricted method + // implementation is decorated / wrapped with the implementations of its + // caveats, using the caveat's decorator function. + decorator: ( + // Restricted methods and other caveats can be async, so we have to + // assume that the method is async. + method: AsyncRestrictedMethod, + caveat: FilterArrayCaveat, + ) => async (args: RestrictedMethodOptions) => { + const result = await method(args); + if (!Array.isArray(result)) { + throw Error('not an array'); + } + + return result.filter((resultValue) => caveat.value.includes(resultValue)); + }, + }, +}; + +// The property names of this object must be target keys. +const permissionSpecifications = { + // This is a plain restricted method. + wallet_getSecretArray: { + // Every permission must have this field. + permissionType: PermissionType.RestrictedMethod, + // i.e. the restricted method name + targetKey: 'wallet_getSecretArray', + allowedCaveats: ['filterArrayResponse'], + // Every restricted method must specify its implementation in its + // specification. + methodImplementation: ( + _args: RestrictedMethodOptions, + ) => { + return ['secret1', 'secret2', 'secret3']; + }, + }, + + // This is a namespaced restricted method. + 'wallet_getSecret_*': { + permissionType: PermissionType.RestrictedMethod, + targetKey: 'wallet_getSecret_*', + methodImplementation: ( + args: RestrictedMethodOptions, + ) => { + // The "method" is the string method name that was externally requested, + // and the "*" in the target key for this method will be replaced with + // some string whose value should affect the behavior of this method. + // + // "context" contains the origin of the requester and anything attached + // by the host during permission request processing. + const { method, context } = args; + + const secretName = method.replace('wallet_getSecret_', ''); + return context.getSecret(secretName); + }, + }, + + // This is an endowment. + secretEndowment: { + permissionType: PermissionType.Endowment, + // Naming conventions for endowments are yet to be established. + targetKey: 'endowment:globals', + // This function will be called to retrieve the subject's endowment(s). + // Here we imagine that these are the names of globals that will be made + // available to a SES Compartment. + endowmentGetter: (_options: EndowmentGetterParams) => [ + 'fetch', + 'Math', + 'setTimeout', + ], + }, +}; + +const permissionController = new PermissionController({ + caveatSpecifications, + messenger: controllerMessenger, // assume this was given + permissionSpecifications, + unrestrictedMethods: ['wallet_unrestrictedMethod'], +}); +``` + +### Adding the Permission Middleware + +```typescript +// This should take place where a middleware stack is created for a particular +// subject. + +// The subject could be a port, stream, socket, etc. +const origin = getOrigin(subject); + +const engine = new JsonRpcEngine(); +engine.push(/* your various middleware*/); +engine.push(permissionController.createPermissionMiddleware({ origin })); +// Your middleware stack is now permissioned +engine.push(/* your other various middleware*/); +``` + +### Calling a Restricted Method Internally + +```typescript +// Sometimes, we need to call a restricted method internally, as a particular subject. +permissionController.executeRestrictedMethod(origin, 'wallet_getSecretArray'); + +// If the restricted method has any parameters, they are given as the third +// argument to executeRestrictedMethod(). +permissionController.executeRestrictedMethod(origin, 'wallet_getSecret', { + secretType: 'array', +}); +``` + +### Getting Endowments + +```typescript +// Getting endowments internally is the only option, since the host has to apply +// them in some way external to the permission system. +const endowments = await permissionController.getEndowments( + origin, + 'endowment:globals', +); + +// Now the endowments can be applied, whatever that means. +applyEndowments(origin, endowments); +``` + +### Requesting and Getting Permissions + +```typescript +// From the perspective of subjects, requesting and getting permissions +// works the same as it does with `rpc-cap`. +const approvedPermissions = await ethereum.request({ + method: 'wallet_requestPermissions', + params: [{ + wallet_getSecretArray: {}, + }] +}) + +const existingPermissions = await ethereum.request({ + method: 'wallet_getPermissions', +) +``` + +### Restricted Method Caveat Decorators + +Here follows some more example caveat decorator implementations. + +```typescript +// Validation / passthrough +export function onlyArrayParams( + method: AsyncRestrictedMethod, + _caveat: Caveat<'PassthroughCaveat', never>, +) { + return async (args: RestrictedMethodOptions) => { + if (!Array.isArray(args.params)) { + throw new EthereumJsonRpcError(); + } + + return method(args); + }; +} + +// "Return handler" example +export function eth_accounts( + method: AsyncRestrictedMethod, + caveat: Caveat<'RestrictAccountCaveat', string[]>, +) { + return async (args: RestrictedMethodOptions) => { + const accounts: string[] | Json = await method(args); + if (!Array.isArray(args.params)) { + throw new EthereumJsonRpcError(); + } + + return ( + accounts.filter((account: string) => caveat.value.includes(account)) ?? [] + ); + }; +} +``` diff --git a/src/permissions/endowments/index.ts b/src/permissions/endowments/index.ts new file mode 100644 index 0000000000..92d1387372 --- /dev/null +++ b/src/permissions/endowments/index.ts @@ -0,0 +1,5 @@ +import { networkAccessEndowmentBuilder } from './network-access'; + +export const endowmentPermissionBuilders = { + [networkAccessEndowmentBuilder.targetKey]: networkAccessEndowmentBuilder, +} as const; diff --git a/src/permissions/endowments/network-access.test.ts b/src/permissions/endowments/network-access.test.ts new file mode 100644 index 0000000000..3b72319ea0 --- /dev/null +++ b/src/permissions/endowments/network-access.test.ts @@ -0,0 +1,18 @@ +import { PermissionType } from '../Permission'; +import { networkAccessEndowmentBuilder } from './network-access'; + +describe('endowment:network-access', () => { + it('builds the expected permission specification', () => { + const specification = networkAccessEndowmentBuilder.specificationBuilder( + {}, + ); + expect(specification).toStrictEqual({ + permissionType: PermissionType.Endowment, + targetKey: 'endowment:network-access', + endowmentGetter: expect.any(Function), + allowedCaveats: null, + }); + + expect(specification.endowmentGetter()).toStrictEqual(['fetch']); + }); +}); diff --git a/src/permissions/endowments/network-access.ts b/src/permissions/endowments/network-access.ts new file mode 100644 index 0000000000..4768a26756 --- /dev/null +++ b/src/permissions/endowments/network-access.ts @@ -0,0 +1,43 @@ +import { + PermissionSpecificationBuilder, + PermissionType, + EndowmentGetterParams, + ValidPermissionSpecification, +} from '../Permission'; + +const permissionName = 'endowment:network-access'; + +type NetworkAccessEndowmentSpecification = ValidPermissionSpecification<{ + permissionType: PermissionType.Endowment; + targetKey: typeof permissionName; + endowmentGetter: (_options?: any) => ['fetch']; + allowedCaveats: null; +}>; + +/** + * `endowment:network-access` returns the name of global browser API(s) that + * enable network access. This is intended to populate the endowments of the + * SES Compartment in which a Snap executes. + * + * @param _builderOptions - optional specification builder options + * @returns The specification for the network endowment + */ +const specificationBuilder: PermissionSpecificationBuilder< + PermissionType.Endowment, + any, + NetworkAccessEndowmentSpecification +> = (_builderOptions?: any) => { + return { + permissionType: PermissionType.Endowment, + targetKey: permissionName, + allowedCaveats: null, + endowmentGetter: (_getterOptions?: EndowmentGetterParams) => { + return ['fetch']; + }, + }; +}; + +export const networkAccessEndowmentBuilder = Object.freeze({ + targetKey: permissionName, + specificationBuilder, +} as const); diff --git a/src/permissions/errors.test.ts b/src/permissions/errors.test.ts new file mode 100644 index 0000000000..8958859418 --- /dev/null +++ b/src/permissions/errors.test.ts @@ -0,0 +1,19 @@ +import { EndowmentPermissionDoesNotExistError } from './errors'; + +describe('error', () => { + describe('EndowmentPermissionDoesNotExistError', () => { + it('adds origin argument to data property', () => { + expect( + new EndowmentPermissionDoesNotExistError('bar', 'foo.com').data, + ).toStrictEqual({ + origin: 'foo.com', + }); + }); + + it('does not add an origin property if no data is provided', () => { + expect( + new EndowmentPermissionDoesNotExistError('bar').data, + ).toBeUndefined(); + }); + }); +}); diff --git a/src/permissions/errors.ts b/src/permissions/errors.ts new file mode 100644 index 0000000000..538b3486d2 --- /dev/null +++ b/src/permissions/errors.ts @@ -0,0 +1,285 @@ +import { errorCodes, ethErrors, EthereumRpcError } from 'eth-rpc-errors'; + +type UnauthorizedArg = { + data?: Record; +}; + +/** + * Utility function for building an "unauthorized" error. + * + * @param opts - Optional arguments that add extra context + * @returns The built error + */ +export function unauthorized(opts: UnauthorizedArg) { + return ethErrors.provider.unauthorized({ + message: + 'Unauthorized to perform action. Try requesting the required permission(s) first. For more information, see: https://docs.metamask.io/guide/rpc-api.html#permissions', + data: opts.data, + }); +} + +/** + * Utility function for building a "method not found" error. + * + * @param method - The method in question. + * @param data - Optional data for context. + * @returns The built error + */ +export function methodNotFound(method: string, data?: unknown) { + const message = `The method "${method}" does not exist / is not available.`; + + const opts: Parameters[0] = { message }; + if (data !== undefined) { + opts.data = data; + } + return ethErrors.rpc.methodNotFound(opts); +} + +type InvalidParamsArg = { + message?: string; + data?: unknown; +}; + +/** + * Utility function for building an "invalid params" error. + * + * @param opts - Optional arguments that add extra context + * @returns The built error + */ +export function invalidParams(opts: InvalidParamsArg) { + return ethErrors.rpc.invalidParams({ + data: opts.data, + message: opts.message, + }); +} + +/** + * Utility function for building an "user rejected request" error. + * + * @param data - Optional data to add extra context + * @returns The built error + */ +export function userRejectedRequest>( + data?: Data, +): EthereumRpcError { + return ethErrors.provider.userRejectedRequest({ data }); +} + +/** + * Utility function for building an internal error. + * + * @param message - The error message + * @param data - Optional data to add extra context + * @returns The built error + */ +export function internalError>( + message: string, + data?: Data, +): EthereumRpcError { + return ethErrors.rpc.internal({ message, data }); +} + +export class InvalidSubjectIdentifierError extends Error { + constructor(origin: unknown) { + super( + `Invalid subject identifier: "${ + typeof origin === 'string' ? origin : typeof origin + }"`, + ); + } +} + +export class UnrecognizedSubjectError extends Error { + constructor(origin: string) { + super(`Unrecognized subject: "${origin}" has no permissions.`); + } +} + +export class InvalidApprovedPermissionError extends Error { + public data: { + origin: string; + target: string; + approvedPermission: Record; + }; + + constructor( + origin: string, + target: string, + approvedPermission: Record, + ) { + super( + `Invalid approved permission for origin "${origin}" and target "${target}".`, + ); + this.data = { origin, target, approvedPermission }; + } +} +export class PermissionDoesNotExistError extends Error { + constructor(origin: string, target: string) { + super(`Subject "${origin}" has no permission for "${target}".`); + } +} + +export class EndowmentPermissionDoesNotExistError extends Error { + public data?: { origin: string }; + + constructor(target: string, origin?: string) { + super(`Subject "${origin}" has no permission for "${target}".`); + if (origin) { + this.data = { origin }; + } + } +} + +export class UnrecognizedCaveatTypeError extends Error { + public data: { + caveatType: string; + origin?: string; + target?: string; + }; + + constructor(caveatType: string); + + constructor(caveatType: string, origin: string, target: string); + + constructor(caveatType: string, origin?: string, target?: string) { + super(`Unrecognized caveat type: "${caveatType}"`); + this.data = { caveatType }; + if (origin !== undefined) { + this.data.origin = origin; + } + + if (target !== undefined) { + this.data.target = target; + } + } +} + +export class InvalidCaveatsPropertyError extends Error { + public data: { origin: string; target: string; caveatsProperty: unknown }; + + constructor(origin: string, target: string, caveatsProperty: unknown) { + super( + `The "caveats" property of permission for "${target}" of subject "${origin}" is invalid. It must be a non-empty array if specified.`, + ); + this.data = { origin, target, caveatsProperty }; + } +} + +export class CaveatDoesNotExistError extends Error { + constructor(origin: string, target: string, caveatType: string) { + super( + `Permission for "${target}" of subject "${origin}" has no caveat of type "${caveatType}".`, + ); + } +} + +export class CaveatAlreadyExistsError extends Error { + constructor(origin: string, target: string, caveatType: string) { + super( + `Permission for "${target}" of subject "${origin}" already has a caveat of type "${caveatType}".`, + ); + } +} + +export class InvalidCaveatError extends EthereumRpcError { + public data: { origin: string; target: string }; + + constructor(receivedCaveat: unknown, origin: string, target: string) { + super( + errorCodes.rpc.invalidParams, + `Invalid caveat. Caveats must be plain objects.`, + { receivedCaveat }, + ); + this.data = { origin, target }; + } +} + +export class InvalidCaveatTypeError extends Error { + public data: { + caveat: Record; + origin: string; + target: string; + }; + + constructor(caveat: Record, origin: string, target: string) { + super(`Caveat types must be strings. Received: "${typeof caveat.type}"`); + this.data = { caveat, origin, target }; + } +} + +export class CaveatMissingValueError extends Error { + public data: { + caveat: Record; + origin: string; + target: string; + }; + + constructor(caveat: Record, origin: string, target: string) { + super(`Caveat is missing "value" field.`); + this.data = { caveat, origin, target }; + } +} + +export class CaveatInvalidJsonError extends Error { + public data: { + caveat: Record; + origin: string; + target: string; + }; + + constructor(caveat: Record, origin: string, target: string) { + super(`Caveat "value" is invalid JSON.`); + this.data = { caveat, origin, target }; + } +} + +export class InvalidCaveatFieldsError extends Error { + public data: { + caveat: Record; + origin: string; + target: string; + }; + + constructor(caveat: Record, origin: string, target: string) { + super( + `Caveat has unexpected number of fields: "${Object.keys(caveat).length}"`, + ); + this.data = { caveat, origin, target }; + } +} + +export class ForbiddenCaveatError extends Error { + public data: { + caveatType: string; + origin: string; + target: string; + }; + + constructor(caveatType: string, origin: string, targetName: string) { + super( + `Permissions for target "${targetName}" may not have caveats of type "${caveatType}".`, + ); + this.data = { caveatType, origin, target: targetName }; + } +} + +export class DuplicateCaveatError extends Error { + public data: { + caveatType: string; + origin: string; + target: string; + }; + + constructor(caveatType: string, origin: string, targetName: string) { + super( + `Permissions for target "${targetName}" contains multiple caveats of type "${caveatType}".`, + ); + this.data = { caveatType, origin, target: targetName }; + } +} + +export class PermissionsRequestNotFoundError extends Error { + constructor(id: string) { + super(`Permissions request with id "${id}" not found.`); + } +} diff --git a/src/permissions/index.test.ts b/src/permissions/index.test.ts new file mode 100644 index 0000000000..7f14245ad1 --- /dev/null +++ b/src/permissions/index.test.ts @@ -0,0 +1,16 @@ +import { endowmentPermissionBuilders } from '.'; + +describe('index file', () => { + describe('endowmentPermissionBuilders', () => { + // For coverage purposes + it('returns the expected permission specifications', () => { + expect( + endowmentPermissionBuilders[ + 'endowment:network-access' + ].specificationBuilder({}), + ).toMatchObject({ + targetKey: 'endowment:network-access', + }); + }); + }); +}); diff --git a/src/permissions/index.ts b/src/permissions/index.ts new file mode 100644 index 0000000000..7dd762eca1 --- /dev/null +++ b/src/permissions/index.ts @@ -0,0 +1,8 @@ +export * from './Caveat'; +export * from './Permission'; +export * from './PermissionController'; +export * from './utils'; + +// TODO: Move these to the appropriate package +export * as permissionRpcMethods from './rpc-methods'; +export { endowmentPermissionBuilders } from './endowments'; diff --git a/src/permissions/permission-middleware.ts b/src/permissions/permission-middleware.ts new file mode 100644 index 0000000000..9f55e4f996 --- /dev/null +++ b/src/permissions/permission-middleware.ts @@ -0,0 +1,98 @@ +import type { Json } from '@metamask/types'; +import { + JsonRpcMiddleware, + AsyncJsonRpcEngineNextCallback, + createAsyncMiddleware, + PendingJsonRpcResponse, + JsonRpcRequest, +} from 'json-rpc-engine'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { JsonRpcEngine } from 'json-rpc-engine'; +import { internalError } from './errors'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { PermissionController } from './PermissionController'; +import { + GenericPermissionController, + PermissionSubjectMetadata, + RestrictedMethodParameters, +} from '.'; + +type PermissionMiddlewareFactoryOptions = { + executeRestrictedMethod: GenericPermissionController['_executeRestrictedMethod']; + getRestrictedMethod: GenericPermissionController['getRestrictedMethod']; + isUnrestrictedMethod: (method: string) => boolean; +}; + +/** + * Creates a permission middleware function factory. Intended for internal use + * in the {@link PermissionController}. Like any {@link JsonRpcEngine} + * middleware, each middleware will only receive requests from a particular + * subject / origin. However, each middleware also requires access to some + * `PermissionController` internals, which is why this "factory factory" exists. + * + * The middlewares returned by the factory will pass through requests for + * unrestricted methods, and attempt to execute restricted methods. If a method + * is neither restricted nor unrestricted, a "method not found" error will be + * returned. + * If a method is restricted, the middleware will first attempt to retrieve the + * subject's permission for that method. If the permission is found, the method + * will be executed. Otherwise, an "unauthorized" error will be returned. + * + * @param options - Options bag. + * @param options.executeRestrictedMethod - {@link PermissionController._executeRestrictedMethod}. + * @param options.getRestrictedMethod - {@link PermissionController.getRestrictedMethod}. + * @param options.isUnrestrictedMethod - A function that checks whether a + * particular method is unrestricted. + * @returns A permission middleware factory function. + */ +export function getPermissionMiddlewareFactory({ + executeRestrictedMethod, + getRestrictedMethod, + isUnrestrictedMethod, +}: PermissionMiddlewareFactoryOptions) { + return function createPermissionMiddleware( + subject: PermissionSubjectMetadata, + ): JsonRpcMiddleware { + const { origin } = subject; + if (typeof origin !== 'string' || !origin) { + throw new Error('The subject "origin" must be a non-empty string.'); + } + + const permissionsMiddleware = async ( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + next: AsyncJsonRpcEngineNextCallback, + ): Promise => { + const { method, params } = req; + + // Skip registered unrestricted methods. + if (isUnrestrictedMethod(method)) { + return next(); + } + + // This will throw if no restricted method implementation is found. + const methodImplementation = getRestrictedMethod(method, origin); + + // This will throw if the permission does not exist. + const result = await executeRestrictedMethod( + methodImplementation, + subject, + method, + params, + ); + + if (result === undefined) { + res.error = internalError( + `Request for method "${req.method}" returned undefined result.`, + { request: req }, + ); + return undefined; + } + + res.result = result; + return undefined; + }; + + return createAsyncMiddleware(permissionsMiddleware); + }; +} diff --git a/src/permissions/rpc-methods/getPermissions.test.ts b/src/permissions/rpc-methods/getPermissions.test.ts new file mode 100644 index 0000000000..5bfac06baa --- /dev/null +++ b/src/permissions/rpc-methods/getPermissions.test.ts @@ -0,0 +1,48 @@ +import { JsonRpcEngine } from 'json-rpc-engine'; +import { getPermissionsHandler } from './getPermissions'; + +describe('getPermissions RPC method', () => { + it('returns the values of the object returned by getPermissionsForOrigin', async () => { + const { implementation } = getPermissionsHandler; + const mockGetPermissionsForOrigin = jest.fn().mockImplementationOnce(() => { + return { a: 'a', b: 'b', c: 'c' }; + }); + + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => + implementation(req as any, res as any, next, end, { + getPermissionsForOrigin: mockGetPermissionsForOrigin, + }), + ); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'arbitraryName', + }); + expect(response.result).toStrictEqual(['a', 'b', 'c']); + expect(mockGetPermissionsForOrigin).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array if getPermissionsForOrigin returns a falsy value', async () => { + const { implementation } = getPermissionsHandler; + const mockGetPermissionsForOrigin = jest + .fn() + .mockImplementationOnce(() => null); + + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => + implementation(req as any, res as any, next, end, { + getPermissionsForOrigin: mockGetPermissionsForOrigin, + }), + ); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'arbitraryName', + }); + expect(response.result).toStrictEqual([]); + expect(mockGetPermissionsForOrigin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/permissions/rpc-methods/getPermissions.ts b/src/permissions/rpc-methods/getPermissions.ts new file mode 100644 index 0000000000..d234c67bc6 --- /dev/null +++ b/src/permissions/rpc-methods/getPermissions.ts @@ -0,0 +1,48 @@ +import type { + JsonRpcEngineEndCallback, + PendingJsonRpcResponse, + PermittedHandlerExport, +} from '@metamask/types'; +import { MethodNames } from '../utils'; + +import type { PermissionConstraint } from '../Permission'; +import type { SubjectPermissions } from '../PermissionController'; + +export const getPermissionsHandler: PermittedHandlerExport< + GetPermissionsHooks, + void, + PermissionConstraint[] +> = { + methodNames: [MethodNames.getPermissions], + implementation: getPermissionsImplementation, + hookNames: { + getPermissionsForOrigin: true, + }, +}; + +export type GetPermissionsHooks = { + // This must be bound to the requesting origin. + getPermissionsForOrigin: () => SubjectPermissions; +}; + +/** + * Get Permissions implementation to be used in JsonRpcEngine middleware. + * + * @param _req - The JsonRpcEngine request - unused + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.getPermissionsForOrigin - The specific method hook needed for this method implementation + * @returns A promise that resolves to nothing + */ +async function getPermissionsImplementation( + _req: unknown, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { getPermissionsForOrigin }: GetPermissionsHooks, +): Promise { + res.result = Object.values(getPermissionsForOrigin() || {}); + return end(); +} diff --git a/src/permissions/rpc-methods/index.ts b/src/permissions/rpc-methods/index.ts new file mode 100644 index 0000000000..6b32b8efd8 --- /dev/null +++ b/src/permissions/rpc-methods/index.ts @@ -0,0 +1,10 @@ +import { + requestPermissionsHandler, + RequestPermissionsHooks, +} from './requestPermissions'; +import { getPermissionsHandler, GetPermissionsHooks } from './getPermissions'; + +export type PermittedRpcMethodHooks = RequestPermissionsHooks & + GetPermissionsHooks; + +export const handlers = [requestPermissionsHandler, getPermissionsHandler]; diff --git a/src/permissions/rpc-methods/requestPermissions.test.ts b/src/permissions/rpc-methods/requestPermissions.test.ts new file mode 100644 index 0000000000..d1144eb195 --- /dev/null +++ b/src/permissions/rpc-methods/requestPermissions.test.ts @@ -0,0 +1,138 @@ +import { JsonRpcEngine, createAsyncMiddleware } from 'json-rpc-engine'; +import { ethErrors, serializeError } from 'eth-rpc-errors'; +import { requestPermissionsHandler } from './requestPermissions'; + +describe('requestPermissions RPC method', () => { + it('returns the values of the object returned by requestPermissionsForOrigin', async () => { + const { implementation } = requestPermissionsHandler; + const mockRequestPermissionsForOrigin = jest + .fn() + .mockImplementationOnce(() => { + // Resolve this promise after a timeout to ensure that the function + // is awaited properly. + return new Promise((resolve) => { + setTimeout(() => { + resolve([{ a: 'a', b: 'b', c: 'c' }]); + }, 10); + }); + }); + + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => + implementation(req as any, res as any, next, end, { + requestPermissionsForOrigin: mockRequestPermissionsForOrigin, + }), + ); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'arbitraryName', + params: [{}], + }); + + expect(response.result).toStrictEqual(['a', 'b', 'c']); + expect(mockRequestPermissionsForOrigin).toHaveBeenCalledTimes(1); + expect(mockRequestPermissionsForOrigin).toHaveBeenCalledWith({}, '1'); + }); + + it('returns an error if requestPermissionsForOrigin rejects', async () => { + const { implementation } = requestPermissionsHandler; + const mockRequestPermissionsForOrigin = jest + .fn() + .mockImplementationOnce(async () => { + throw new Error('foo'); + }); + + const engine = new JsonRpcEngine(); + const end: any = () => undefined; // this won't be called + + // Pass the middleware function to createAsyncMiddleware so the error + // is catched. + engine.push( + createAsyncMiddleware( + (req, res, next) => + implementation(req as any, res as any, next, end, { + requestPermissionsForOrigin: mockRequestPermissionsForOrigin, + }) as any, + ), + ); + + const response: any = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'arbitraryName', + params: [{}], + }); + + expect(response.result).toBeUndefined(); + expect(response.error).toStrictEqual(serializeError(new Error('foo'))); + expect(mockRequestPermissionsForOrigin).toHaveBeenCalledTimes(1); + expect(mockRequestPermissionsForOrigin).toHaveBeenCalledWith({}, '1'); + }); + + it('returns an error if the request has an invalid id', async () => { + const { implementation } = requestPermissionsHandler; + const mockRequestPermissionsForOrigin = jest.fn(); + + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => + implementation(req as any, res as any, next, end, { + requestPermissionsForOrigin: mockRequestPermissionsForOrigin, + }), + ); + + for (const invalidId of ['', null, {}]) { + const req = { + jsonrpc: '2.0', + id: invalidId, + method: 'arbitraryName', + params: [], // doesn't matter + }; + + const expectedError = ethErrors.rpc + .invalidRequest({ + message: 'Invalid request: Must specify a valid id.', + data: { request: { ...req } }, + }) + .serialize(); + delete expectedError.stack; + + const response: any = await engine.handle(req as any); + expect(response.error).toStrictEqual(expectedError); + expect(mockRequestPermissionsForOrigin).not.toHaveBeenCalled(); + } + }); + + it('returns an error if the request params are invalid', async () => { + const { implementation } = requestPermissionsHandler; + const mockRequestPermissionsForOrigin = jest.fn(); + + const engine = new JsonRpcEngine(); + engine.push((req, res, next, end) => + implementation(req as any, res as any, next, end, { + requestPermissionsForOrigin: mockRequestPermissionsForOrigin, + }), + ); + + for (const invalidParams of ['foo', ['bar']]) { + const req = { + jsonrpc: '2.0', + id: 1, + method: 'arbitraryName', + params: invalidParams, + }; + + const expectedError = ethErrors.rpc + .invalidParams({ + data: { request: { ...req } }, + }) + .serialize(); + delete expectedError.stack; + + const response: any = await engine.handle(req as any); + expect(response.error).toStrictEqual(expectedError); + expect(mockRequestPermissionsForOrigin).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/permissions/rpc-methods/requestPermissions.ts b/src/permissions/rpc-methods/requestPermissions.ts new file mode 100644 index 0000000000..6f9b88a752 --- /dev/null +++ b/src/permissions/rpc-methods/requestPermissions.ts @@ -0,0 +1,82 @@ +import { ethErrors } from 'eth-rpc-errors'; +import type { + JsonRpcEngineEndCallback, + JsonRpcRequest, + PendingJsonRpcResponse, + PermittedHandlerExport, +} from '@metamask/types'; +import { MethodNames } from '../utils'; + +import { invalidParams } from '../errors'; +import type { PermissionConstraint, RequestedPermissions } from '../Permission'; +import { isPlainObject } from '../../util'; + +export const requestPermissionsHandler: PermittedHandlerExport< + RequestPermissionsHooks, + [RequestedPermissions], + PermissionConstraint[] +> = { + methodNames: [MethodNames.requestPermissions], + implementation: requestPermissionsImplementation, + hookNames: { + requestPermissionsForOrigin: true, + }, +}; + +type RequestPermissions = ( + requestedPermissions: RequestedPermissions, + id: string, +) => Promise< + [Record, { id: string; origin: string }] +>; + +export type RequestPermissionsHooks = { + requestPermissionsForOrigin: RequestPermissions; +}; + +/** + * Request Permissions implementation to be used in JsonRpcEngine middleware. + * + * @param req - The JsonRpcEngine request + * @param res - The JsonRpcEngine result object + * @param _next - JsonRpcEngine next() callback - unused + * @param end - JsonRpcEngine end() callback + * @param options - Method hooks passed to the method implementation + * @param options.requestPermissionsForOrigin - The specific method hook needed for this method implementation + * @returns A promise that resolves to nothing + */ +async function requestPermissionsImplementation( + req: JsonRpcRequest<[RequestedPermissions]>, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { requestPermissionsForOrigin }: RequestPermissionsHooks, +): Promise { + const { id, params } = req; + + if ( + (typeof id !== 'number' && typeof id !== 'string') || + (typeof id === 'string' && !id) + ) { + return end( + ethErrors.rpc.invalidRequest({ + message: 'Invalid request: Must specify a valid id.', + data: { request: req }, + }), + ); + } + + if (!Array.isArray(params) || !isPlainObject(params[0])) { + return end(invalidParams({ data: { request: req } })); + } + + const [requestedPermissions] = params; + const [grantedPermissions] = await requestPermissionsForOrigin( + requestedPermissions, + String(id), + ); + + // `wallet_requestPermission` is specified to return an array. + res.result = Object.values(grantedPermissions); + return end(); +} diff --git a/src/permissions/utils.ts b/src/permissions/utils.ts new file mode 100644 index 0000000000..a613e5d134 --- /dev/null +++ b/src/permissions/utils.ts @@ -0,0 +1,27 @@ +import { + CaveatSpecificationConstraint, + CaveatSpecificationMap, +} from './Caveat'; +import { + PermissionSpecificationConstraint, + PermissionSpecificationMap, +} from './Permission'; + +export enum MethodNames { + requestPermissions = 'wallet_requestPermissions', + getPermissions = 'wallet_getPermissions', +} + +/** + * Utility type for extracting a union of all individual caveat or permission + * specification types from a {@link CaveatSpecificationMap} or + * {@link PermissionSpecificationMap}. + * + * @template SpecificationsMap - The caveat or permission specifications map + * whose specification type union to extract. + */ +export type ExtractSpecifications< + SpecificationsMap extends + | CaveatSpecificationMap + | PermissionSpecificationMap +> = SpecificationsMap[keyof SpecificationsMap]; diff --git a/src/subject-metadata/SubjectMetadataController.test.ts b/src/subject-metadata/SubjectMetadataController.test.ts new file mode 100644 index 0000000000..72fca87950 --- /dev/null +++ b/src/subject-metadata/SubjectMetadataController.test.ts @@ -0,0 +1,261 @@ +import { Json } from '../BaseControllerV2'; +import { ControllerMessenger } from '../ControllerMessenger'; +import { HasPermissions } from '../permissions'; +import { + SubjectMetadataController, + SubjectMetadataControllerActions, + SubjectMetadataControllerEvents, + SubjectMetadataControllerMessenger, +} from './SubjectMetadataController'; + +const controllerName = 'SubjectMetadataController'; + +/** + * Utility function for creating a controller messenger. + * + * @returns A tuple containing the messenger and a spy for the "hasPermission" action handler + */ +function getSubjectMetadataControllerMessenger() { + const controllerMessenger = new ControllerMessenger< + SubjectMetadataControllerActions | HasPermissions, + SubjectMetadataControllerEvents + >(); + + const hasPermissionsSpy = jest.fn(); + controllerMessenger.registerActionHandler( + 'PermissionController:hasPermissions', + hasPermissionsSpy, + ); + + return [ + controllerMessenger.getRestricted< + typeof controllerName, + SubjectMetadataControllerActions['type'] | HasPermissions['type'], + SubjectMetadataControllerEvents['type'] + >({ + name: controllerName, + allowedActions: [ + 'PermissionController:hasPermissions', + 'SubjectMetadataController:getState', + ], + }) as SubjectMetadataControllerMessenger, + hasPermissionsSpy, + ] as const; +} + +/** + * Utility function for building subject metadata. + * + * @param origin - The subject's origin + * @param name - Optional subject name + * @param opts - Optional extra options for the metadata + * @returns The created metadata object + */ +function getSubjectMetadata( + origin: string, + name?: string, + opts?: Record, +) { + return { + origin, + name: name ?? null, + iconUrl: null, + extensionId: null, + ...opts, + }; +} + +describe('SubjectMetadataController', () => { + describe('constructor', () => { + it('initializes a subject metadata controller', () => { + const controller = new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit: 10, + }); + expect(controller.state).toStrictEqual({ subjectMetadata: {} }); + }); + + it('trims subject metadata state on startup', () => { + const [ + messenger, + hasPermissionsSpy, + ] = getSubjectMetadataControllerMessenger(); + hasPermissionsSpy.mockImplementationOnce(() => false); + hasPermissionsSpy.mockImplementationOnce(() => true); + + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 10, + state: { + subjectMetadata: { + 'foo.com': getSubjectMetadata('foo.com', 'foo'), + 'bar.io': getSubjectMetadata('bar.io', 'bar'), + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'bar.io': getSubjectMetadata('bar.io', 'bar') }, + }); + }); + + it('throws if the subject cache limit is invalid', () => { + [0, -1, 1.1].forEach((subjectCacheLimit) => { + expect( + () => + new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit, + }), + ).toThrow( + `subjectCacheLimit must be a positive integer. Received: "${subjectCacheLimit}"`, + ); + }); + }); + }); + + describe('clearState', () => { + it('clears the controller state, and continues to function normally afterwards', () => { + const [ + messenger, + hasPermissionsSpy, + ] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 3, + }); + + // No subject will have permissions. + hasPermissionsSpy.mockImplementation(() => false); + + // Add subjects up to the cache limit + controller.addSubjectMetadata(getSubjectMetadata('foo.com', 'foo')); + controller.addSubjectMetadata(getSubjectMetadata('bar.com', 'bar')); + controller.addSubjectMetadata(getSubjectMetadata('baz.com', 'baz')); + + expect(Object.keys(controller.state.subjectMetadata)).toHaveLength(3); + + // Clear the state + controller.clearState(); + expect(Object.keys(controller.state.subjectMetadata)).toHaveLength(0); + + // Add another subject, which also does not have any permissions + controller.addSubjectMetadata(getSubjectMetadata('fizz.com', 'fizz')); + + // Observe that the subject was added normally + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'fizz.com': getSubjectMetadata('fizz.com', 'fizz') }, + }); + }); + }); + + describe('addSubjectMetadata', () => { + it('adds subject metadata', () => { + const controller = new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit: 10, + }); + + controller.addSubjectMetadata(getSubjectMetadata('foo.com', 'foo')); + controller.addSubjectMetadata(getSubjectMetadata('bar.com')); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + 'foo.com': getSubjectMetadata('foo.com', 'foo'), + 'bar.com': getSubjectMetadata('bar.com'), + }, + }); + }); + + it('fills in missing fields of added subject metadata', () => { + const controller = new SubjectMetadataController({ + messenger: getSubjectMetadataControllerMessenger()[0], + subjectCacheLimit: 10, + }); + + controller.addSubjectMetadata({ origin: 'foo.com', name: 'foo' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { 'foo.com': getSubjectMetadata('foo.com', 'foo') }, + }); + }); + + it('does not delete metadata for subjects with permissions if cache size is exceeded', () => { + const [ + messenger, + hasPermissionsSpy, + ] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 1, + }); + hasPermissionsSpy.mockImplementationOnce(() => true); + + controller.addSubjectMetadata({ origin: 'foo.com', name: 'foo' }); + controller.addSubjectMetadata({ origin: 'bar.io', name: 'bar' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + 'foo.com': getSubjectMetadata('foo.com', 'foo'), + 'bar.io': getSubjectMetadata('bar.io', 'bar'), + }, + }); + }); + + it('deletes metadata for subjects without permissions if cache size is exceeded', () => { + const [ + messenger, + hasPermissionsSpy, + ] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 1, + }); + hasPermissionsSpy.mockImplementationOnce(() => false); + + controller.addSubjectMetadata({ origin: 'foo.com', name: 'foo' }); + controller.addSubjectMetadata({ origin: 'bar.io', name: 'bar' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + 'bar.io': getSubjectMetadata('bar.io', 'bar'), + }, + }); + }); + }); + + describe('trimMetadataState', () => { + it('deletes all subjects without permissions from state', () => { + const [ + messenger, + hasPermissionsSpy, + ] = getSubjectMetadataControllerMessenger(); + const controller = new SubjectMetadataController({ + messenger, + subjectCacheLimit: 4, + }); + + controller.addSubjectMetadata({ origin: 'A', name: 'a' }); + controller.addSubjectMetadata({ origin: 'B', name: 'b' }); + controller.addSubjectMetadata({ origin: 'C', name: 'c' }); + controller.addSubjectMetadata({ origin: 'D', name: 'd' }); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + A: getSubjectMetadata('A', 'a'), + B: getSubjectMetadata('B', 'b'), + C: getSubjectMetadata('C', 'c'), + D: getSubjectMetadata('D', 'd'), + }, + }); + + hasPermissionsSpy.mockImplementationOnce(() => true); + hasPermissionsSpy.mockImplementationOnce(() => false); + hasPermissionsSpy.mockImplementationOnce(() => false); + hasPermissionsSpy.mockImplementationOnce(() => true); + + controller.trimMetadataState(); + expect(controller.state).toStrictEqual({ + subjectMetadata: { + A: getSubjectMetadata('A', 'a'), + D: getSubjectMetadata('D', 'd'), + }, + }); + }); + }); +}); diff --git a/src/subject-metadata/SubjectMetadataController.ts b/src/subject-metadata/SubjectMetadataController.ts new file mode 100644 index 0000000000..2c0ad67a81 --- /dev/null +++ b/src/subject-metadata/SubjectMetadataController.ts @@ -0,0 +1,222 @@ +import type { Patch } from 'immer'; +import { Json } from '@metamask/types'; +import { BaseController } from '../BaseControllerV2'; +import { RestrictedControllerMessenger } from '../ControllerMessenger'; + +import type { + GenericPermissionController, + PermissionSubjectMetadata, + HasPermissions, +} from '../permissions'; + +const controllerName = 'SubjectMetadataController'; + +type SubjectOrigin = string; + +export type SubjectMetadata = PermissionSubjectMetadata & { + [key: string]: Json; + // TODO:TS4.4 make optional + name: string | null; + extensionId: string | null; + iconUrl: string | null; +}; + +type SubjectMetadataToAdd = PermissionSubjectMetadata & { + name?: string | null; + extensionId?: string | null; + iconUrl?: string | null; +} & Record; + +export type SubjectMetadataControllerState = { + subjectMetadata: Record; +}; + +const stateMetadata = { + subjectMetadata: { persist: true, anonymous: false }, +}; + +const defaultState: SubjectMetadataControllerState = { + subjectMetadata: {}, +}; + +export type GetSubjectMetadataState = { + type: `${typeof controllerName}:getState`; + handler: () => SubjectMetadataControllerState; +}; + +export type SubjectMetadataControllerActions = GetSubjectMetadataState; + +export type SubjectMetadataStateChange = { + type: `${typeof controllerName}:stateChange`; + payload: [SubjectMetadataControllerState, Patch[]]; +}; + +export type SubjectMetadataControllerEvents = SubjectMetadataStateChange; + +type AllowedActions = HasPermissions; + +export type SubjectMetadataControllerMessenger = RestrictedControllerMessenger< + typeof controllerName, + SubjectMetadataControllerActions | AllowedActions, + SubjectMetadataControllerEvents, + AllowedActions['type'], + never +>; + +type SubjectMetadataControllerOptions = { + messenger: SubjectMetadataControllerMessenger; + subjectCacheLimit: number; + state?: Partial; +}; + +/** + * A controller for storing metadata associated with permission subjects. More + * or less, a cache. + */ +export class SubjectMetadataController extends BaseController< + typeof controllerName, + SubjectMetadataControllerState, + SubjectMetadataControllerMessenger +> { + private subjectCacheLimit: number; + + private subjectsWithoutPermissionsEcounteredSinceStartup: Set; + + private subjectHasPermissions: GenericPermissionController['hasPermissions']; + + constructor({ + messenger, + subjectCacheLimit, + state = {}, + }: SubjectMetadataControllerOptions) { + if (!Number.isInteger(subjectCacheLimit) || subjectCacheLimit < 1) { + throw new Error( + `subjectCacheLimit must be a positive integer. Received: "${subjectCacheLimit}"`, + ); + } + + const hasPermissions = (origin: string) => { + return messenger.call('PermissionController:hasPermissions', origin); + }; + + super({ + name: controllerName, + metadata: stateMetadata, + messenger, + state: { + ...SubjectMetadataController.getTrimmedState(state, hasPermissions), + }, + }); + + this.subjectHasPermissions = hasPermissions; + this.subjectCacheLimit = subjectCacheLimit; + this.subjectsWithoutPermissionsEcounteredSinceStartup = new Set(); + } + + /** + * Clears the state of this controller. Also resets the cache of subjects + * encountered since startup, so as to not prematurely reach the cache limit. + */ + clearState(): void { + this.subjectsWithoutPermissionsEcounteredSinceStartup.clear(); + this.update((_draftState) => { + return { ...defaultState }; + }); + } + + /** + * Stores domain metadata for the given origin (subject). Deletes metadata for + * subjects without permissions in a FIFO manner once more than + * {@link SubjectMetadataController.subjectCacheLimit} distinct origins have + * been added since boot. + * + * In order to prevent a degraded user experience, + * metadata is never deleted for subjects with permissions, since metadata + * cannot yet be requested on demand. + * + * @param metadata - The subject metadata to store. + */ + addSubjectMetadata(metadata: SubjectMetadataToAdd): void { + const { origin } = metadata; + const newMetadata: SubjectMetadata = { + ...metadata, + extensionId: metadata.extensionId || null, + iconUrl: metadata.iconUrl || null, + name: metadata.name || null, + }; + + let originToForget: string | null = null; + // We only delete the oldest encountered subject from the cache, again to + // ensure that the user's experience isn't degraded by missing icons etc. + if ( + this.subjectsWithoutPermissionsEcounteredSinceStartup.size >= + this.subjectCacheLimit + ) { + const cachedOrigin = this.subjectsWithoutPermissionsEcounteredSinceStartup + .values() + .next().value; + + this.subjectsWithoutPermissionsEcounteredSinceStartup.delete( + cachedOrigin, + ); + + if (!this.subjectHasPermissions(cachedOrigin)) { + originToForget = cachedOrigin; + } + } + + this.subjectsWithoutPermissionsEcounteredSinceStartup.add(origin); + + this.update((draftState) => { + // Typecast: ts(2589) + draftState.subjectMetadata[origin] = newMetadata as any; + if (typeof originToForget === 'string') { + delete draftState.subjectMetadata[originToForget]; + } + }); + } + + /** + * Deletes all subjects without permissions from the controller's state. + */ + trimMetadataState(): void { + this.update((draftState) => { + return SubjectMetadataController.getTrimmedState( + // Typecast: ts(2589) + draftState as any, + this.subjectHasPermissions, + ); + }); + } + + /** + * Returns a new state object that only includes subjects with permissions. + * This method is static because we want to call it in the constructor, before + * the controller's state is initialized. + * + * @param state - The state object to trim. + * @param hasPermissions - A function that returns a boolean indicating + * whether a particular subject (identified by its origin) has any + * permissions. + * @returns The new state object. If the specified `state` object has no + * subject metadata, the returned object will be equivalent to the default + * state of this controller. + */ + private static getTrimmedState( + state: Partial, + hasPermissions: SubjectMetadataController['subjectHasPermissions'], + ): SubjectMetadataControllerState { + const { subjectMetadata = {} } = state; + + return { + subjectMetadata: Object.keys(subjectMetadata).reduce< + Record + >((newSubjectMetadata, origin) => { + if (hasPermissions(origin)) { + newSubjectMetadata[origin] = subjectMetadata[origin]; + } + return newSubjectMetadata; + }, {}), + }; + } +} diff --git a/src/subject-metadata/index.ts b/src/subject-metadata/index.ts new file mode 100644 index 0000000000..3b0bcaa14b --- /dev/null +++ b/src/subject-metadata/index.ts @@ -0,0 +1 @@ +export * from './SubjectMetadataController'; diff --git a/src/util.test.ts b/src/util.test.ts index d02aeb9447..c257ca6140 100644 --- a/src/util.test.ts +++ b/src/util.test.ts @@ -1232,3 +1232,63 @@ describe('util', () => { }); }); }); + +describe('isPlainObject', () => { + it('returns false for null values', () => { + expect(util.isPlainObject(null)).toBe(false); + expect(util.isPlainObject(undefined)).toBe(false); + }); + + it('returns false for non objects', () => { + expect(util.isPlainObject(5)).toBe(false); + expect(util.isPlainObject('foo')).toBe(false); + }); + + it('returns false for arrays', () => { + expect(util.isPlainObject(['foo'])).toBe(false); + expect(util.isPlainObject([{}])).toBe(false); + }); + + it('returns true for objects', () => { + expect(util.isPlainObject({ foo: 'bar' })).toBe(true); + expect(util.isPlainObject({ foo: 'bar', test: { num: 5 } })).toBe(true); + }); +}); + +describe('hasProperty', () => { + it('returns false for non existing properties', () => { + expect(util.hasProperty({ foo: 'bar' }, 'property')).toBe(false); + }); + + it('returns true for existing properties', () => { + expect(util.hasProperty({ foo: 'bar' }, 'foo')).toBe(true); + }); +}); + +describe('isNonEmptyArray', () => { + it('returns false non arrays', () => { + // @ts-expect-error Invalid type for testing purposes + expect(util.isNonEmptyArray(null)).toBe(false); + // @ts-expect-error Invalid type for testing purposes + expect(util.isNonEmptyArray(undefined)).toBe(false); + }); + + it('returns false for empty array', () => { + expect(util.isNonEmptyArray([])).toBe(false); + }); + + it('returns true arrays with at least one item', () => { + expect(util.isNonEmptyArray([1])).toBe(true); + expect(util.isNonEmptyArray([1, 2, 3, 4])).toBe(true); + }); +}); + +describe('isValidJson', () => { + it('returns false for class instances', () => { + expect(util.isValidJson(new Map())).toBe(false); + }); + + it('returns true for valid JSON', () => { + expect(util.isValidJson({ foo: 'bar', test: { num: 5 } })).toBe(true); + }); +}); diff --git a/src/util.ts b/src/util.ts index 699d6aaf8b..437ed2cc0c 100644 --- a/src/util.ts +++ b/src/util.ts @@ -13,6 +13,7 @@ import ensNamehash from 'eth-ens-namehash'; import { TYPED_MESSAGE_SCHEMA, typedSignatureHash } from 'eth-sig-util'; import { validate } from 'jsonschema'; import { CID } from 'multiformats/cid'; +import deepEqual from 'fast-deep-equal'; import { Transaction, FetchAllOptions, @@ -24,6 +25,7 @@ import { PersonalMessageParams } from './message-manager/PersonalMessageManager' import { TypedMessageParams } from './message-manager/TypedMessageManager'; import { Token } from './assets/TokenRatesController'; import { MAINNET } from './constants'; +import { Json } from './BaseControllerV2'; const hexRe = /^[0-9A-Fa-f]+$/gu; @@ -882,3 +884,52 @@ export function getFormattedIpfsUrl( const cidAndPath = removeIpfsProtocolPrefix(ipfsUrl); return `${origin}/ipfs/${cidAndPath}`; } + +type PlainObject = Record; + +/** + * Determines whether a value is a "plain" object. + * + * @param value - A value to check + * @returns True if the passed value is a plain object + */ +export function isPlainObject(value: unknown): value is PlainObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +export const hasProperty = ( + object: PlainObject, + key: string | number | symbol, +) => Reflect.hasOwnProperty.call(object, key); + +/** + * Like {@link Array}, but always non-empty. + * + * @template T - The non-empty array member type. + */ +export type NonEmptyArray = [T, ...T[]]; + +/** + * Type guard for {@link NonEmptyArray}. + * + * @template T - The non-empty array member type. + * @param value - The value to check. + * @returns Whether the value is a non-empty array. + */ +export function isNonEmptyArray(value: T[]): value is NonEmptyArray { + return Array.isArray(value) && value.length > 0; +} + +/** + * Type guard for {@link Json}. + * + * @param value - The value to check. + * @returns Whether the value is valid JSON. + */ +export function isValidJson(value: unknown): value is Json { + try { + return deepEqual(value, JSON.parse(JSON.stringify(value))); + } catch (_) { + return false; + } +} diff --git a/yarn.lock b/yarn.lock index e4c272e446..f3ff9bcaa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1112,6 +1112,11 @@ resolved "https://registry.yarnpkg.com/@metamask/safe-event-emitter/-/safe-event-emitter-2.0.0.tgz#af577b477c683fad17c619a78208cede06f9605c" integrity sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q== +"@metamask/types@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@metamask/types/-/types-1.1.0.tgz#9bd14b33427932833c50c9187298804a18c2e025" + integrity sha512-EEV/GjlYkOSfSPnYXfOosxa3TqYtIW3fhg6jdw+cok/OhMgNn4wCfbENFqjytrHMU2f7ZKtBAvtiP5V8H44sSw== + "@nodelib/fs.scandir@2.1.3": version "2.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b" @@ -1250,6 +1255,11 @@ resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ== +"@types/deep-freeze-strict@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/deep-freeze-strict/-/deep-freeze-strict-1.1.0.tgz#447a6a2576191344aa42310131dd3df5c41492c4" + integrity sha1-RHpqJXYZE0SqQjEBMd099cQUksQ= + "@types/glob@^7.1.1": version "7.1.4" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672" @@ -2569,6 +2579,11 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +deep-freeze-strict@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" + integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA= + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -3735,7 +3750,7 @@ fake-merkle-patricia-tree@^1.0.1: dependencies: checkpoint-store "^1.1.0" -fast-deep-equal@^3.1.1: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==