diff --git a/packages/controllers/jest.config.js b/packages/controllers/jest.config.js index 239f4d3d9c..f459441f41 100644 --- a/packages/controllers/jest.config.js +++ b/packages/controllers/jest.config.js @@ -10,9 +10,26 @@ module.exports = { // statements: 100, // }, // }, - preset: 'ts-jest', - runner: '@jest-runner/electron', - testEnvironment: '@jest-runner/electron/environment', - testRegex: ['\\.test\\.ts$'], + silent: true, testTimeout: 5000, + projects: [ + { + displayName: 'runner: electron', + testMatch: [ + '/src/plugins/**/*.test.ts', + '/src/services/**/*.test.ts', + ], + preset: 'ts-jest', + runner: '@jest-runner/electron', + testEnvironment: '@jest-runner/electron/environment', + }, + { + displayName: 'runner: default', + testPathIgnorePatterns: [ + '/src/plugins/*', + '/src/services/*', + ], + preset: 'ts-jest', + }, + ], }; diff --git a/packages/controllers/package.json b/packages/controllers/package.json index 8b060d459d..3e70645b69 100644 --- a/packages/controllers/package.json +++ b/packages/controllers/package.json @@ -31,7 +31,10 @@ "@metamask/post-message-stream": "4.0.0", "@metamask/safe-event-emitter": "^2.0.0", "@metamask/snap-workers": "^0.2.0", + "@types/deep-freeze-strict": "^1.1.0", + "deep-freeze-strict": "^1.1.1", "eth-rpc-errors": "^4.0.2", + "fast-deep-equal": "^3.1.3", "immer": "^9.0.6", "json-rpc-engine": "^6.1.0", "json-rpc-middleware-stream": "^3.0.0", diff --git a/packages/controllers/src/index.ts b/packages/controllers/src/index.ts index 90595681f8..38a58b9756 100644 --- a/packages/controllers/src/index.ts +++ b/packages/controllers/src/index.ts @@ -1,4 +1,4 @@ -export * from './services/WebWorkerExecutionEnvironmentService'; -export * from './plugins/PluginController'; -export * from './resource/ExternalResourceController'; -export * from './services/ExecutionEnvironmentService'; +export * from './permissions'; +export * from './plugins'; +export * from './resource'; +export * from './services'; diff --git a/packages/controllers/src/permissions/Caveat.test.ts b/packages/controllers/src/permissions/Caveat.test.ts new file mode 100644 index 0000000000..e3ff7f1431 --- /dev/null +++ b/packages/controllers/src/permissions/Caveat.test.ts @@ -0,0 +1,60 @@ +import * as errors from './errors'; +import { + CaveatSpecificationConstraint, + CaveatSpecificationMap, + decorateWithCaveats, + PermissionConstraint, +} from '.'; + +// This function is mostly tested through the PermissionController tests, +// we just add a couple here for completeness. +describe('decorateWithCaveats', () => { + it('decorates a method with caveat', async () => { + const methodImplementation = () => [1, 2, 3]; + + const caveatSpecifications = { + reverse: { + decorator: (method: any, _caveat: any) => async () => { + return (await method()).reverse(); + }, + }, + } as unknown as CaveatSpecificationMap; + + const permission = { + caveats: [{ type: 'reverse', value: null }], + } as unknown as PermissionConstraint; + + const decorated = decorateWithCaveats( + methodImplementation, + permission, + caveatSpecifications, + ); + + expect(methodImplementation()).toStrictEqual([1, 2, 3]); + expect(await decorated({} as any)).toStrictEqual([3, 2, 1]); + }); + + it('throws an error if the caveat type is unrecognized', () => { + const methodImplementation = () => [1, 2, 3]; + + const caveatSpecifications = { + reverse: { + decorator: (method: any, _caveat: any) => () => + method().reverse() as any, + }, + } as unknown as CaveatSpecificationMap; + + const permission = { + // This type doesn't exist + caveats: [{ type: 'kaplar', value: null }], + } as unknown as PermissionConstraint; + + expect(() => + decorateWithCaveats( + methodImplementation, + permission, + caveatSpecifications, + )({} as any), + ).toThrow(new errors.UnrecognizedCaveatTypeError('kaplar')); + }); +}); diff --git a/packages/controllers/src/permissions/Caveat.ts b/packages/controllers/src/permissions/Caveat.ts new file mode 100644 index 0000000000..988cb671ae --- /dev/null +++ b/packages/controllers/src/permissions/Caveat.ts @@ -0,0 +1,227 @@ +import { Json } from 'json-rpc-engine'; + +import { UnrecognizedCaveatTypeError } from './errors'; +import { + AsyncRestrictedMethod, + RestrictedMethod, + PermissionConstraint, + RestrictedMethodParameters, +} from './Permission'; + +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; + + /** + * Any additional data necessary to enforce the caveat. + * + * TODO:TS4.4 Make optional + */ + readonly value: Json; +}; + +/** + * TODO:docs + * + * @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; + + /** + * Any additional data necessary to enforce the caveat. + * + * TODO:TS4.4 Make optional + */ + 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 decorate restricted method implementation. + */ +export type CaveatDecorator = ( + decorated: AsyncRestrictedMethod, + caveat: ParentCaveat, +) => AsyncRestrictedMethod; + +/** + * TODO:docs + */ +type ExtractCaveatValueFromDecorator> = + Decorator extends ( + decorated: any, + caveat: infer ParentCaveat, + ) => AsyncRestrictedMethod + ? ParentCaveat extends CaveatConstraint + ? ParentCaveat['value'] + : never + : never; + +/** + * TODO:docs + */ +export type CaveatValidator = ( + caveat: { type: ParentCaveat['type']; value: unknown }, + origin?: string, + target?: string, +) => void; + +/** + * TODO:docs + */ +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; +}; + +/** + * TODO:docs + */ +export type CaveatSpecification = { + /** + * The string type of the caveat. + */ + type: SpecifiedCaveat['type']; + + /** + * 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; +}; + +/** + * TODO:docs + */ +export type CaveatSpecificationMap< + Specification extends CaveatSpecificationConstraint, +> = Record; + +// TODO: Figure out why we have to extend "any" in this type. +/** + * TODO:docs + */ +export type ExtractCaveats< + Specification extends CaveatSpecification, +> = Specification extends any + ? Caveat< + Specification['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 CaveatSpecification, + 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 CaveatSpecification, + 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. + */ +export function decorateWithCaveats< + CaveatSpecifications extends CaveatSpecification, +>( + 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/packages/controllers/src/permissions/Permission.test.ts b/packages/controllers/src/permissions/Permission.test.ts new file mode 100644 index 0000000000..62a368cebb --- /dev/null +++ b/packages/controllers/src/permissions/Permission.test.ts @@ -0,0 +1,45 @@ +import { constructPermission, CaveatConstraint } 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 = [{ type: 'foo', value: 'bar' }] as [CaveatConstraint]; + + expect( + constructPermission({ + invoker, + target, + caveats, + }), + ).toMatchObject( + expect.objectContaining({ + id: expect.any(String), + parentCapability: target, + invoker, + caveats: [...caveats], + date: expect.any(Number), + }), + ); + }); +}); diff --git a/packages/controllers/src/permissions/Permission.ts b/packages/controllers/src/permissions/Permission.ts new file mode 100644 index 0000000000..efe8e93275 --- /dev/null +++ b/packages/controllers/src/permissions/Permission.ts @@ -0,0 +1,443 @@ +import { Json } from 'json-rpc-engine'; +import { nanoid } from 'nanoid'; +import { NonEmptyArray } from '../utils'; +import { CaveatConstraint } from './Caveat'; +// This is used in a docstring, but ESLint doesn't notice it. +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { PermissionController } from './PermissionController'; +import type { Caveat } from './Caveat'; +/* eslint-enable @typescript-eslint/no-unused-vars */ + +/** + * 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 documentation 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. + * + * In the context of MetaMask, this is always the name of an RPC method. + */ + 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 documentation 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, +> = ExtractPermissionTargetNames extends never + ? never + : 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. + * + * In the context of MetaMask, this is always the name of an RPC method. + */ + readonly parentCapability: ExtractPermissionTargetNames; + }; + +/** + * A utility type for ensuring that the given permission target name conforms to + * our naming conventions. + * + * See the documentation 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 documentation 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 documentation 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 = + { + 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 GenericRestrictedMethod = RestrictedMethod< + RestrictedMethodParameters, + Json +>; + +export type ValidRestrictedMethod< + MethodImplementation extends RestrictedMethod, +> = MethodImplementation extends (args: infer Options) => Json | Promise + ? Options extends RestrictedMethodOptions + ? MethodImplementation + : never + : never; + +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 documentation 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; + +export type PermissionSpecificationConstraint = { + /** + * The target resource of the permission. In other words, at the time of + * writing, the RPC method name. + */ + targetKey: string; + + /** + * An array of the caveat types that may be added to instances of this + * permission. + */ + allowedCaveats: Readonly> | null; + + /** + * The implementation of the restricted method that the permission + * corresponds to. + */ + methodImplementation: RestrictedMethod; + + /** + * 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; +}; + +/** + * 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['methodImplementation'] extends ValidRestrictedMethod< + Specification['methodImplementation'] + > + ? Specification + : never + : never; + +/** + * The specifications for all permissions and restricted methods supported by + * a particular {@link PermissionController}. + * + * @template Specifications - A 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/packages/controllers/src/permissions/PermissionController.test.ts b/packages/controllers/src/permissions/PermissionController.test.ts new file mode 100644 index 0000000000..dfa31fe9dc --- /dev/null +++ b/packages/controllers/src/permissions/PermissionController.test.ts @@ -0,0 +1,4000 @@ +import assert from 'assert'; +import { + ControllerMessenger, + Json, + AddApprovalRequest, + AcceptRequest as AcceptApprovalRequest, + RejectRequest as RejectApprovalRequest, + HasApprovalRequest, +} from '@metamask/controllers'; +import { JsonRpcEngine, PendingJsonRpcResponse } from 'json-rpc-engine'; +import { hasProperty, isPlainObject } from '../utils'; +import * as errors from './errors'; +import { + AsyncRestrictedMethod, + Caveat, + constructPermission, + CaveatConstraint, + PermissionConstraint, + MethodNames, + ValidPermission, + PermissionController, + PermissionControllerActions, + PermissionControllerEvents, + PermissionOptions, + RestrictedMethodOptions, + RestrictedMethodParameters, + ExtractSpecifications, + CaveatMutatorOperation, + RestrictedMethod, +} 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 + +// wallet_getSecret_* +// We only define types for this permission because it's the only one with a +// factory. +// Other permission types are extracted from the permission specifications in +// the permission controller. + +type SecretNamespacedPermissionKey = 'wallet_getSecret_*'; + +type SecretNamespacedPermission = ValidPermission< + SecretNamespacedPermissionKey, + NoopCaveat +>; + +/** + * Permission key constants. + */ +const PermissionKeys = { + wallet_doubleNumber: 'wallet_doubleNumber', + wallet_getSecretArray: 'wallet_getSecretArray', + wallet_getSecretObject: 'wallet_getSecretObject', + wallet_noop: 'wallet_noop', + wallet_noop2: 'wallet_noop2', + 'wallet_getSecret_*': 'wallet_getSecret_*', +} as const; + +/** + * 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_noop2: PermissionKeys.wallet_noop2, + wallet_getSecret_: (str: string) => `wallet_getSecret_${str}` as const, +} as const; + +/** + * Gets permission specifications for: + * - {@link SecretArrayPermission} + * - Has neither validator nor factory. + * - {@link SecretObjectPermission} + * - Has validator, but no factory. + * - {@link SecretNamespacedPermission} + * - Has both validator and factory. + * + * + * Used as a default in {@link getPermissionControllerOptions}. + * + * @returns The permission specifications. + */ +function getDefaultPermissionSpecifications() { + return { + [PermissionKeys.wallet_getSecretArray]: { + targetKey: PermissionKeys.wallet_getSecretArray, + allowedCaveats: [ + CaveatTypes.filterArrayResponse, + CaveatTypes.reverseArrayResponse, + ], + methodImplementation: (_args: RestrictedMethodOptions) => { + return ['a', 'b', 'c']; + }, + }, + [PermissionKeys.wallet_getSecretObject]: { + 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_*']]: { + 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]: { + 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]: { + targetKey: PermissionKeys.wallet_noop, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + }, + // This one exists just to check permission validator logic + [PermissionKeys.wallet_noop2]: { + targetKey: PermissionKeys.wallet_noop2, + 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'); + } + }, + }, + } as const; +} + +type DefaultPermissionSpecifications = ExtractSpecifications< + ReturnType +>; + +// The permissions controller + +const controllerName = 'PermissionController' as const; + +type ApprovalActions = + | HasApprovalRequest + | AddApprovalRequest + | AcceptApprovalRequest + | RejectApprovalRequest; + +/** * + * Gets a restricted controller messenger. + * + * Used as a default in {@link getPermissionControllerOptions}. + * + * @returns The restricted messenger. + */ +function getPermissionControllerMessenger() { + return new ControllerMessenger< + PermissionControllerActions | ApprovalActions, + PermissionControllerEvents + >().getRestricted< + typeof controllerName, + PermissionControllerActions['type'] | ApprovalActions['type'], + PermissionControllerEvents['type'] + >({ + name: controllerName, + allowedActions: [ + 'PermissionController:clearPermissions', + 'PermissionController:getSubjectNames', + 'PermissionController:hasPermissions', + 'ApprovalController:hasRequest', + 'ApprovalController:addRequest', + 'ApprovalController:acceptRequest', + 'ApprovalController:rejectRequest', + ], + }); +} + +/** + * Gets the default unrestricted methods array. + * + * Used as a default in {@link getPermissionControllerOptions}. + */ +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. + */ +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. + * + * For the options used, see {@link getPermissionControllerOptions}. + * + * @returns The default permission controller for testing. + */ +function getDefaultPermissionController( + opts = getPermissionControllerOptions(), +) { + return new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >(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 parentCapability - The `parentCapability` of the permission. + * @param caveats - The caveat array of the permission, or `null`. + * @param 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: string, + caveats: CaveatConstraint[] | null | typeof expect.objectContaining = null, + invoker = 'metamask.io', +) { + 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 target key is invalid', () => { + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + '': { targetKey: '' }, + } as any, + }), + ), + ).toThrow(`Invalid permission target key: ""`); + + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + foo_: { targetKey: 'foo_' }, + } as any, + }), + ), + ).toThrow(`Invalid permission target key: "foo_"`); + + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + 'foo*': { targetKey: 'foo*' }, + } as any, + }), + ), + ).toThrow(`Invalid permission target key: "foo*"`); + }); + + it('throws if a permission specification map key does not match its "targetKey" value', () => { + expect( + () => + new PermissionController< + DefaultPermissionSpecifications, + DefaultCaveatSpecifications + >( + getPermissionControllerOptions({ + permissionSpecifications: { + ...getDefaultPermissionSpecifications(), + foo: { targetKey: 'bar' }, + } as any, + }), + ), + ).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, + ) as RestrictedMethod; + + expect(await method({} as any)).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'), + ) as RestrictedMethod; + + expect( + await method({ method: 'wallet_getSecret_foo' } as any), + ).toStrictEqual('Hello, secret friend "foo"!'); + }); + + it('throws an error if the method does not exist', () => { + const controller = getDefaultPermissionController(); + expect(() => controller.getRestrictedMethod('foo')).toThrow( + errors.methodNotFound({ method: '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( + PermissionNames.wallet_getSecretArray, + null, + 'metamask.io', + ), + ); + }); + + it('returns undefined for non-existing permissions', () => { + const controller = getDefaultPermissionController(); + expect( + controller.getPermission( + 'metamask.io', + PermissionNames.wallet_getSecretArray, + ), + ).toBeUndefined(); + }); + }); + + describe('getPermissions', () => { + it('gets existing permissions', () => { + const controller = getDefaultPermissionControllerWithState(); + + expect(controller.getPermissions('metamask.io')).toStrictEqual({ + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher( + PermissionNames.wallet_getSecretArray, + null, + '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( + PermissionNames.wallet_getSecretObject, + null, + 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( + PermissionNames.wallet_getSecretArray, + null, + 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( + PermissionNames.wallet_getSecretArray, + null, + origin1, + ), + }, + }, + [origin2]: { + origin: origin2, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher( + PermissionNames.wallet_getSecretObject, + null, + origin2, + ), + }, + }, + [origin3]: { + origin: origin3, + permissions: { + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('foo'), + [{ type: CaveatTypes.noopCaveat, value: null }], + 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( + PermissionNames.wallet_getSecretObject, + null, + origin0, + ), + }, + }, + [origin2]: { + origin: origin2, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher( + PermissionNames.wallet_getSecretObject, + null, + origin2, + ), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('foo'), + [{ type: CaveatTypes.noopCaveat, value: null }], + origin2, + ), + }, + }, + [origin3]: { + origin: origin3, + permissions: { + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('foo'), + [{ type: CaveatTypes.noopCaveat, value: null }], + origin3, + ), + }, + }, + [origin4]: { + origin: origin4, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher( + PermissionNames.wallet_getSecretObject, + null, + origin4, + ), + [PermissionNames.wallet_getSecret_('bar')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('bar'), + [{ type: CaveatTypes.noopCaveat, value: null }], + 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( + 'wallet_getSecretArray', + [{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }], + 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( + 'wallet_getSecretObject', + [{ type: CaveatTypes.filterObjectResponse, value: ['foo'] }], + origin, + ), + }, + }, + }, + }); + + controller.addCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.noopCaveat, + null, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretObject: getPermissionMatcher( + 'wallet_getSecretObject', + [ + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + { type: CaveatTypes.noopCaveat, value: null }, + ], + 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 as any, + ['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( + 'wallet_getSecretArray', + [{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }], + origin, + ), + }, + }, + }, + }); + + controller.updateCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ['bar'], + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher( + 'wallet_getSecretArray', + [{ type: CaveatTypes.filterArrayResponse, value: ['bar'] }], + 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( + 'wallet_getSecretArray', + [{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }], + origin, + ), + }, + }, + }, + }); + + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretArray, + CaveatTypes.filterArrayResponse, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretArray: getPermissionMatcher( + 'wallet_getSecretArray', + null, + 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( + 'wallet_getSecretObject', + [ + { type: CaveatTypes.noopCaveat, value: null }, + { type: CaveatTypes.filterObjectResponse, value: ['foo'] }, + ], + origin, + ), + }, + }, + }, + }); + + controller.removeCaveat( + origin, + PermissionNames.wallet_getSecretObject, + CaveatTypes.noopCaveat, + ); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + wallet_getSecretObject: getPermissionMatcher( + 'wallet_getSecretObject', + [{ type: CaveatTypes.filterObjectResponse, value: ['foo'] }], + 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', + } + + /** + * @returns A tuple of a permission controller and an object listing the + * origins of its subjects. Each subject has at least one permission + * with caveats. + */ + 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( + PermissionNames.wallet_getSecretArray, + [{ type: CaveatTypes.filterArrayResponse, value: ['a'] }], + MultiCaveatOrigins.a, + ), + ...overrides[MultiCaveatOrigins.a], + }, + }, + + [MultiCaveatOrigins.b]: { + origin: MultiCaveatOrigins.b, + permissions: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher( + PermissionNames.wallet_getSecretArray, + [ + { type: CaveatTypes.filterArrayResponse, value: ['b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + MultiCaveatOrigins.b, + ), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher( + PermissionNames.wallet_getSecretObject, + [ + { type: CaveatTypes.filterObjectResponse, value: ['b'] }, + { type: CaveatTypes.noopCaveat, value: null }, + ], + MultiCaveatOrigins.b, + ), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('foo'), + [{ type: CaveatTypes.noopCaveat, value: null }], + MultiCaveatOrigins.b, + ), + [PermissionNames.wallet_doubleNumber]: getPermissionMatcher( + PermissionNames.wallet_doubleNumber, + null, + MultiCaveatOrigins.b, + ), + ...overrides[MultiCaveatOrigins.b], + }, + }, + + [MultiCaveatOrigins.c]: { + origin: MultiCaveatOrigins.c, + permissions: { + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher( + PermissionNames.wallet_getSecretObject, + [{ type: CaveatTypes.filterObjectResponse, value: ['c'] }], + MultiCaveatOrigins.c, + ), + [PermissionNames.wallet_getSecret_('bar')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('bar'), + [{ type: CaveatTypes.noopCaveat, value: null }], + 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( + PermissionNames.wallet_getSecretArray, + [{ type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }], + MultiCaveatOrigins.a, + ), + }, + [MultiCaveatOrigins.b]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher( + PermissionNames.wallet_getSecretArray, + [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + 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( + PermissionNames.wallet_getSecretArray, + [ + { type: CaveatTypes.filterArrayResponse, value: ['a', 'b'] }, + { type: CaveatTypes.reverseArrayResponse, value: null }, + ], + 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( + PermissionNames.wallet_getSecretArray, + null, + MultiCaveatOrigins.a, + ), + }, + [MultiCaveatOrigins.b]: { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher( + PermissionNames.wallet_getSecretArray, + [{ type: CaveatTypes.reverseArrayResponse, value: null }], + 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( + '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( + 'wallet_getSecretArray', + ), + wallet_getSecretObject: getPermissionMatcher( + '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( + 'wallet_getSecret_kabob', + [{ 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( + 'wallet_getSecret_kabob', + [{ type: CaveatTypes.noopCaveat, value: null }], + origin1, + ), + }, + }, + [origin2]: { + origin: origin2, + permissions: { + wallet_getSecretArray: getPermissionMatcher( + 'wallet_getSecretArray', + null, + 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( + '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( + 'wallet_getSecretArray', + ), + wallet_getSecretObject: getPermissionMatcher( + '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( + 'wallet_getSecretArray', + ), + }, + }, + }, + }); + + controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretObject: {}, + }, + preserveExistingPermissions: false, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: expect.objectContaining({ + wallet_getSecretObject: getPermissionMatcher( + '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({ method: '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' }] as any, + }, + }, + }), + ).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, + }, + ] as any, + }, + }, + }), + ).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', + }, + ] as any, + }, + }, + }), + ).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'], + }, + ] as any, + }, + }, + }), + ).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'], + }, + ] as any, + }, + }, + }), + ).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_noop2]: { + 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 }, + }; + }) as any); + + const controller = getDefaultPermissionController(options); + expect( + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + ), + ).toMatchObject([ + { + [PermissionNames.wallet_getSecretArray]: getPermissionMatcher( + PermissionNames.wallet_getSecretArray, + null, + 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 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 }, + }; + }) as any); + + 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( + PermissionNames.wallet_getSecretArray, + null, + origin, + ), + [PermissionNames.wallet_getSecretObject]: getPermissionMatcher( + PermissionNames.wallet_getSecretObject, + [{ type: CaveatTypes.filterObjectResponse, value: ['baz'] }], + origin, + ), + [PermissionNames.wallet_getSecret_('foo')]: getPermissionMatcher( + PermissionNames.wallet_getSecret_('foo'), + [{ type: CaveatTypes.noopCaveat, value: null }], + 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({ + method: 'wallet_getSecretKabob', + data: { + 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) as any, + ); + + 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]: {}, + }, + }; + }) as any); + + 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]: {}, + }, + }; + }) as any); + + 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 + }; + }) as any); + + 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) as any, + ); + + await expect( + async () => + await controller.requestPermissions( + { origin }, + { + [PermissionNames.wallet_getSecretArray]: {}, + }, + id, + ), + ).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, + }, + }, + }; + }) as any); + + 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((async (..._args: any) => true) as any) + .mockImplementationOnce((async (..._args: any) => undefined) as any); + + 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) as any) + .mockImplementationOnce(((..._args: any) => undefined) as any); + + 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) as any); + + 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) as any) + .mockImplementationOnce(((..._args: any) => { + throw new Error('unexpected failure'); + }) as any) + .mockImplementationOnce(((..._args: any) => undefined) as any); + + 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) as any) + .mockImplementationOnce((async (..._args: any) => undefined) as any); + + 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) as any); + + 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('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({ + method: 'wallet_getMeTacos', + data: { 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 options = getPermissionControllerOptions(); + const { messenger } = options; + 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:getSubjectNames', () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + 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:hasPermissions', () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + 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'); + }); + }); + + 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('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({ + method: 'wallet_foo', + data: { 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/packages/controllers/src/permissions/PermissionController.ts b/packages/controllers/src/permissions/PermissionController.ts new file mode 100644 index 0000000000..0a9a5ef6c1 --- /dev/null +++ b/packages/controllers/src/permissions/PermissionController.ts @@ -0,0 +1,1974 @@ +import { + Json, + BaseControllerV2 as BaseController, + RestrictedControllerMessenger, + AddApprovalRequest, + AcceptRequest as AcceptApprovalRequest, + RejectRequest as RejectApprovalRequest, + HasApprovalRequest, + StateMetadata, +} from '@metamask/controllers'; +// These are used in docstrings, but ESLint is ignorant of docstrings. +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { + ApprovalController, + ControllerMessenger, +} from '@metamask/controllers'; +/* eslint-enable @typescript-eslint/no-unused-vars */ +import deepFreeze from 'deep-freeze-strict'; +import { castDraft, Draft, Patch } from 'immer'; +import { nanoid } from 'nanoid'; +import { + isPlainObject, + hasProperty, + NonEmptyArray, + Mutable, + isNonEmptyArray, + isValidJson, +} from '../utils'; +import { + decorateWithCaveats, + ExtractCaveat, + ExtractCaveats, + CaveatSpecificationMap, + ExtractCaveatValue, + CaveatConstraint, + CaveatSpecificationConstraint, +} from './Caveat'; +import { + constructPermission, + findCaveat, + PermissionConstraint, + OriginString, + RequestedPermissions, + RestrictedMethod, + RestrictedMethodParameters, + ValidPermissionSpecification, + PermissionSpecificationMap, + ValidPermission, + ExtractAllowedCaveatTypes, + ExtractPermissionSpecification, + PermissionSpecificationConstraint, +} from './Permission'; +import { + PermissionDoesNotExistError, + methodNotFound, + UnrecognizedSubjectError, + CaveatDoesNotExistError, + UnrecognizedCaveatTypeError, + CaveatMissingValueError, + InvalidCaveatFieldsError, + InvalidSubjectIdentifierError, + InvalidCaveatTypeError, + CaveatAlreadyExistsError, + InvalidCaveatError, + InvalidApprovedPermissionError, + unauthorized, + invalidParams, + internalError, + userRejectedRequest, + PermissionsRequestNotFoundError, + ForbiddenCaveatError, + CaveatInvalidJsonError, + DuplicateCaveatError, + InvalidCaveatsPropertyError, +} from './errors'; +import { MethodNames } from './utils'; +import { getPermissionMiddlewareFactory } from './permission-middleware'; + +/** + * 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 = + 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 extends PermissionConstraint + ? { + subjects: PermissionControllerSubjects; + } + : never; + +/** + * Get the state metadata of the {@link PermissionController}. + * + * @template Permission - The controller's permission type union. + */ +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. + */ +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)[]; +}; + +/** + * Checks whether the specified subject has any permissions. + */ +export type HasPermissions = { + type: `${typeof controllerName}:hasPermissions`; + handler: GenericPermissionController['hasPermissions']; +}; + +/** + * Clears all permissions from the {@link PermissionController}. + */ +export type ClearPermissions = { + type: `${typeof controllerName}:clearPermissions`; + handler: () => void; +}; + +// TODO: Implement all desired actions +/** + * The {@link ControllerMessenger} actions of the {@link PermissionController}. + */ +export type PermissionControllerActions = + | ClearPermissions + | GetPermissionControllerState + | GetSubjects + | HasPermissions; + +/** + * 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; + +/** + * 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 ValidPermissionSpecification, + ControllerCaveatSpecification extends CaveatSpecificationConstraint, +> = { + messenger: PermissionControllerMessenger; + caveatSpecifications: CaveatSpecificationMap; + permissionSpecifications: PermissionSpecificationMap; + unrestrictedMethods: string[]; + state?: Partial< + PermissionControllerState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + > + >; +}; + +/** + * The permission controller. See the documentation 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. + */ + 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 documentation for more details. + */ + public createPermissionMiddleware: ReturnType< + typeof getPermissionMiddlewareFactory + >; + + /** + * @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 documentation 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, { targetKey: innerTargetKey, allowedCaveats }]) => { + // 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`, + () => this.clearState(), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:getSubjectNames`, + () => this.getSubjectNames(), + ); + + this.messagingSystem.registerActionHandler( + `${controllerName}:hasPermissions`, + (origin: OriginString) => this.hasPermissions(origin), + ); + } + + /** + * Clears the state of the controller. + */ + clearState(): void { + this.update((_draftState) => { + return { + ...getDefaultState< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >(), + }; + }); + } + + /** + * 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 { + const targetKey = this.getTargetKey(method); + if (!targetKey) { + const error = methodNotFound({ method }); + if (origin) { + error.data = { origin }; + } + + throw error; + } + + return this.getPermissionSpecification(targetKey).methodImplementation; + } + + /** + * @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 target - 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, + target: SubjectPermission['parentCapability'], + ): SubjectPermission | undefined { + return this.state.subjects[origin]?.permissions[ + target + ] as SubjectPermission; + } + + /** + * 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, + ); + } + + /** + * Used for retrieving the key that manages the restricted method + * associated with the current RPC `method` key. + * + * Used to support our namespaced method feature, which allows blocks + * of methods to be hidden behind a restricted method with a trailing `_` character. + * + * @param method - The requested RPC method. + * @returns The internal key of the method. + */ + private getTargetKey( + method: string, + ): ControllerPermissionSpecification['targetKey'] | undefined { + if (hasProperty(this._permissionSpecifications, method)) { + return method; + } + + const wildCardMethodsWithoutWildCard: Record = {}; + for (const targetKey of Object.keys(this._permissionSpecifications)) { + const wildCardMatch = targetKey.match(/(.+)\*$/u); + if (wildCardMatch) { + wildCardMethodsWithoutWildCard[wildCardMatch[1]] = true; + } + } + + // Check for potentially nested namespaces: + // Ex: wildzone_ + // Ex: eth_plugin_ + const segments = method.split('_'); + let targetKey = ''; + + while ( + segments.length > 0 && + !hasProperty(this._permissionSpecifications, targetKey) && + !wildCardMethodsWithoutWildCard[targetKey] + ) { + targetKey += `${segments.shift()}_`; + } + + if (wildCardMethodsWithoutWildCard[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({ method: 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 origin - The origin of the grantee subject. + * @param requestedPermissions - The requested permissions. + * @param id - The id of the permissions request. + * @param preserveExistingPermissions - Whether to preserve the subject's + * existing permissions. + * @returns The granted permissions and request metadata. + */ + async requestPermissions( + subject: PermissionSubjectMetadata, + requestedPermissions: RequestedPermissions, + id = nanoid(), + preserveExistingPermissions = true, + ): Promise< + [ + SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >, + { id: string; origin: OriginString }, + ] + > { + const { origin } = subject; + 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 methodName of Object.keys(requestedPermissions)) { + const permission = requestedPermissions[methodName]; + const targetKey = this.getTargetKey(methodName); + + if (!targetKey) { + throw methodNotFound({ + method: methodName, + data: { origin, requestedPermissions }, + }); + } + + if ( + !isPlainObject(permission) || + (permission.parentCapability !== undefined && + methodName !== 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, + methodName, + { 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. + */ + private _rejectPermissionsRequest(id: string, error: Error): void { + return this.messagingSystem.call( + 'ApprovalController:rejectRequest', + id, + error, + ); + } + + /** + * 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 methodName - The name of the method to execute. + * @param params - The parameters to pass to the method implementation. + * @returns The result of the executed method. + */ + async executeRestrictedMethod( + origin: OriginString, + methodName: ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + >['parentCapability'], + params?: RestrictedMethodParameters, + ): Promise { + // Throws if the method does not exist + const methodImplementation = this.getRestrictedMethod(methodName, origin); + + const result = await this._executeRestrictedMethod( + methodImplementation, + { origin }, + methodName, + params, + ); + + if (result === undefined) { + throw new Error( + `Internal request for method "${methodName}" 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 req - The request object associated with the request. + * @returns + */ + 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/packages/controllers/src/permissions/README.md b/packages/controllers/src/permissions/README.md new file mode 100644 index 0000000000..40d76d9792 --- /dev/null +++ b/packages/controllers/src/permissions/README.md @@ -0,0 +1,191 @@ +# 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 + +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 | +| Invocations | JSON-RPC requests | +| Permissions | Permission objects | +| Caveats | Caveat objects | +| Permission system | The `PermissionController` and its `json-rpc-engine` middleware | + +For the time being, JSON-RPC methods are the only kind of permission target that exists. +Therefore, our implementation of the permission system revolves around JSON-RPC requests and MetaMask's `json-rpc-engine` stack. +To use the permission system, every JSON-RPC method must be enumerated and designated as either "restricted" or "unrestricted". +Unrestricted methods can always be called by anyone. +Restricted methods require the requisite permission in order to be called. + +At any given moment, the `PermissionController` state tree describes the complete state of the permissions of all subjects known to the current MetaMask instance. +The `PermissionController` also provides methods for adding, updating, and removing permissions, and a `json-rpc-engine` middleware function factory so that the permissioning logic can be applied to incoming JSON-RPC requests. + +Once the permission middleware is injected into our 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. + +### 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. + +> In `rpc-cap`, caveat types were specified by `rpc-cap` itself, and the consumer had to choose from the available types. +> This did not end up being easy to reason about or work with in practice. +> Now, caveat types and their implementations are provided by the consumer, just like the restricted methods and their implementations. + +## Examples + +### 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), + ); + }, + }, +}; + +const permissionSpecifications = { + wallet_getSecretArray: { + // i.e. the restricted method name + target: 'wallet_getSecretArray', + methodImplementation: ( + _args: RestrictedMethodOptions, + ) => { + return ['secret1', 'secret2', 'secret3']; + }, + }, +}; + +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', +}); +``` + +### 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', +) +``` + +### Caveat Decorators + +Here follows some more example caveat decorator implementations. + +```typescript +// Validation / passthrough +export function onlyArrayParams( + method: AsyncRestrictedMethod, + _caveat: Caveat, +) { + 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, +) { + return async (args: RestrictedMethodOptions) => { + const accounts: string[] | unknown = await method(args); + return ( + accounts?.filter((account: string) => caveat.value.includes(account)) ?? + [] + ); + }; +} +``` diff --git a/packages/controllers/src/permissions/errors.ts b/packages/controllers/src/permissions/errors.ts new file mode 100644 index 0000000000..2699cab065 --- /dev/null +++ b/packages/controllers/src/permissions/errors.ts @@ -0,0 +1,243 @@ +import { errorCodes, ethErrors, EthereumRpcError } from 'eth-rpc-errors'; + +type UnauthorizedArg = { + data?: Record; +}; + +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, + }); +} + +type MethodNotFoundArg = { + method: string; + data?: unknown; +}; + +export function methodNotFound(opts: MethodNotFoundArg) { + const message = `The method "${opts.method}" does not exist / is not available.`; + return ethErrors.rpc.methodNotFound({ data: opts.data, message }); +} + +type InvalidParamsArg = { + message?: string; + data?: unknown; +}; + +export function invalidParams(opts: InvalidParamsArg) { + return ethErrors.rpc.invalidParams({ + data: opts.data, + message: opts.message, + }); +} + +export function userRejectedRequest>( + data?: Data, +): EthereumRpcError { + return ethErrors.provider.userRejectedRequest({ data }); +} + +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 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/packages/controllers/src/permissions/index.ts b/packages/controllers/src/permissions/index.ts new file mode 100644 index 0000000000..4f471a9893 --- /dev/null +++ b/packages/controllers/src/permissions/index.ts @@ -0,0 +1,7 @@ +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'; diff --git a/packages/controllers/src/permissions/permission-middleware.ts b/packages/controllers/src/permissions/permission-middleware.ts new file mode 100644 index 0000000000..3cc9d79eea --- /dev/null +++ b/packages/controllers/src/permissions/permission-middleware.ts @@ -0,0 +1,94 @@ +import { + AsyncJsonRpcEngineNextCallback, + createAsyncMiddleware, + Json, + JsonRpcRequest, + JsonRpcMiddleware, + PendingJsonRpcResponse, +} from 'json-rpc-engine'; +import { internalError } from './errors'; +import { + GenericPermissionController, + PermissionSubjectMetadata, + RestrictedMethodParameters, +} from '.'; +// This is used in a docstring, but ESLint doesn't notice it. +/* eslint-disable @typescript-eslint/no-unused-vars, import/order */ +import type { JsonRpcEngine } from 'json-rpc-engine'; +import type { PermissionController } from './PermissionController'; +/* eslint-enable @typescript-eslint/no-unused-vars, import/order */ + +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 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, subject.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/packages/controllers/src/permissions/rpc-methods/getPermissions.ts b/packages/controllers/src/permissions/rpc-methods/getPermissions.ts new file mode 100644 index 0000000000..a5aad701da --- /dev/null +++ b/packages/controllers/src/permissions/rpc-methods/getPermissions.ts @@ -0,0 +1,38 @@ +import type { PermittedHandlerExport } from '@metamask/rpc-methods/types'; +import type { + JsonRpcEngineEndCallback, + PendingJsonRpcResponse, +} from 'json-rpc-engine'; +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, + methodDescription: 'Get permissions.', + hookNames: { + getPermissionsForOrigin: true, + }, +}; + +export type GetPermissionsHooks = { + // This must be bound to the requesting origin. + getPermissionsForOrigin: () => SubjectPermissions; +}; + +async function getPermissionsImplementation( + _req: unknown, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { getPermissionsForOrigin }: GetPermissionsHooks, +): Promise { + res.result = Object.values(getPermissionsForOrigin() || {}); + return end(); +} diff --git a/packages/controllers/src/permissions/rpc-methods/index.ts b/packages/controllers/src/permissions/rpc-methods/index.ts new file mode 100644 index 0000000000..6b32b8efd8 --- /dev/null +++ b/packages/controllers/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/packages/controllers/src/permissions/rpc-methods/requestPermissions.ts b/packages/controllers/src/permissions/rpc-methods/requestPermissions.ts new file mode 100644 index 0000000000..37582f5d1b --- /dev/null +++ b/packages/controllers/src/permissions/rpc-methods/requestPermissions.ts @@ -0,0 +1,75 @@ +import { ethErrors } from 'eth-rpc-errors'; +import type { PermittedHandlerExport } from '@metamask/rpc-methods/types'; +import type { + JsonRpcEngineEndCallback, + JsonRpcRequest, + PendingJsonRpcResponse, +} from 'json-rpc-engine'; +import { MethodNames } from '../utils'; + +import { invalidParams } from '../errors'; +import type { PermissionConstraint, RequestedPermissions } from '../Permission'; + +export const requestPermissionsHandler: PermittedHandlerExport< + RequestPermissionsHooks, + [RequestedPermissions], + PermissionConstraint[] +> = { + methodNames: [MethodNames.requestPermissions], + implementation: requestPermissionsImplementation, + methodDescription: 'Request permissions.', + hookNames: { + requestPermissions: true, + }, +}; + +type RequestPermissions = ( + requestedPermissions: RequestedPermissions, + id: string, +) => Promise< + [Record, { id: string; origin: string }] +>; + +export type RequestPermissionsHooks = { + requestPermissions: RequestPermissions; +}; + +async function requestPermissionsImplementation( + req: JsonRpcRequest<[RequestedPermissions]>, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { requestPermissions }: RequestPermissionsHooks, +): Promise { + if ( + !Array.isArray(req.params) || + !req.params[0] || + typeof req.params[0] !== 'object' || + Array.isArray(req.params[0]) + ) { + return end(invalidParams({ data: { request: req } })); + } + + if (typeof req.id !== 'number' && !req.id) { + return end( + ethErrors.rpc.invalidRequest({ + message: 'Invalid request: Must specify an id.', + data: { request: req }, + }), + ); + } + + try { + const [requestedPermissions] = req.params; + const [grantedPermissions] = await requestPermissions( + requestedPermissions, + req.id.toString(), + ); + + // `wallet_requestPermission` is specified to return an array. + res.result = Object.values(grantedPermissions); + return end(); + } catch (error) { + return end(error); + } +} diff --git a/packages/controllers/src/permissions/utils.ts b/packages/controllers/src/permissions/utils.ts new file mode 100644 index 0000000000..8441d2d260 --- /dev/null +++ b/packages/controllers/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/packages/controllers/src/plugins/PluginController.ts b/packages/controllers/src/plugins/PluginController.ts index 65933441cd..628a91230b 100644 --- a/packages/controllers/src/plugins/PluginController.ts +++ b/packages/controllers/src/plugins/PluginController.ts @@ -57,16 +57,16 @@ export type InstallPluginsResult = { // Types that probably should be defined elsewhere in prod type RemoveAllPermissionsFunction = (pluginIds: string[]) => void; -type CloseAllConnectionsFunction = (domain: string) => void; +type CloseAllConnectionsFunction = (origin: string) => void; type RequestPermissionsFunction = ( - domain: string, + origin: string, requestedPermissions: RequestedPluginPermissions, ) => Promise; type HasPermissionFunction = ( - domain: string, + origin: string, permissionName: string, ) => boolean; -type GetPermissionsFunction = (domain: string) => IOcapLdCapability[]; +type GetPermissionsFunction = (origin: string) => IOcapLdCapability[]; type PluginId = string; type StoredPlugins = Record; diff --git a/packages/controllers/src/plugins/index.ts b/packages/controllers/src/plugins/index.ts new file mode 100644 index 0000000000..bb09430e92 --- /dev/null +++ b/packages/controllers/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './PluginController'; diff --git a/packages/controllers/src/resource/index.ts b/packages/controllers/src/resource/index.ts new file mode 100644 index 0000000000..533c38bd0a --- /dev/null +++ b/packages/controllers/src/resource/index.ts @@ -0,0 +1 @@ +export * from './ExternalResourceController'; diff --git a/packages/controllers/src/services/index.ts b/packages/controllers/src/services/index.ts new file mode 100644 index 0000000000..3a2505dda6 --- /dev/null +++ b/packages/controllers/src/services/index.ts @@ -0,0 +1,2 @@ +export * from './ExecutionEnvironmentService'; +export * from './WebWorkerExecutionEnvironmentService'; diff --git a/packages/controllers/src/utils.ts b/packages/controllers/src/utils.ts new file mode 100644 index 0000000000..90a7ab17ca --- /dev/null +++ b/packages/controllers/src/utils.ts @@ -0,0 +1,61 @@ +import { Json } from '@metamask/controllers'; +import deepEqual from 'fast-deep-equal'; + +type PlainObject = Record; + +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[]]; + +/** + * {@link NonEmptyArray} type guard. + * + * @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; +} + +/** + * {@link Json} type guard. + * + * @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; + } +} + +/** + * Makes every specified property of the specified object type mutable. + * + * @template T - The object whose readonly properties to make mutable. + * @template TargetKey - The property key(s) to make mutable. + */ +export type Mutable< + T extends Record, + TargetKey extends string, +> = { + -readonly [Key in keyof Pick]: T[Key]; +} & + { + [Key in keyof Omit]: T[Key]; + }; diff --git a/packages/controllers/tsconfig.json b/packages/controllers/tsconfig.json index ddcb35b914..df6fea5545 100644 --- a/packages/controllers/tsconfig.json +++ b/packages/controllers/tsconfig.json @@ -5,11 +5,7 @@ "composite": true, "outDir": "dist", "rootDir": "src", - "typeRoots": [ - "../../node_modules/@types", - "./node_modules/@types", - "./types" - ] + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"] }, "include": ["./src"], "exclude": ["**/*.test.ts"] diff --git a/yarn.lock b/yarn.lock index 904bd70d69..6fc5d4fb37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2623,6 +2623,11 @@ "@types/insert-module-globals" "*" "@types/node" "*" +"@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@*", "@types/glob@^7.1.1": version "7.1.4" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672" @@ -4843,6 +4848,11 @@ deep-equal@^1.0.0: object-keys "^1.1.1" regexp.prototype.flags "^1.2.0" +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"