diff --git a/packages/permission-controller/src/Permission.ts b/packages/permission-controller/src/Permission.ts index c6e033d2fb3..b5a05e65936 100644 --- a/packages/permission-controller/src/Permission.ts +++ b/packages/permission-controller/src/Permission.ts @@ -423,6 +423,12 @@ type PermissionSpecificationBase = { * Leaving this as undefined uses default behaviour where the permission is available to request for all subject types. */ subjectTypes?: readonly SubjectType[]; + + /** + * An array of associated permission names that fall under this "parent" permission. Target names would be limited to the + * array of child permissions associated with this particular permission. + */ + children?: Readonly; }; /** diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index c20e3cb71c5..5aea723568e 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -12,6 +12,13 @@ import assert from 'assert'; import { JsonRpcEngine } from 'json-rpc-engine'; import type { PendingJsonRpcResponse } from 'json-rpc-engine'; +import { + CaveatMutatorOperation, + constructPermission, + MethodNames, + PermissionController, + PermissionType, +} from '.'; import type { AsyncRestrictedMethod, Caveat, @@ -26,13 +33,6 @@ import type { RestrictedMethodParameters, ValidPermission, } from '.'; -import { - CaveatMutatorOperation, - constructPermission, - MethodNames, - PermissionController, - PermissionType, -} from '.'; import * as errors from './errors'; import type { EndowmentGetterParams } from './Permission'; import { SubjectType } from './SubjectMetadataController'; @@ -200,6 +200,11 @@ const PermissionKeys = { wallet_noopWithRequiredCaveat: 'wallet_noopWithRequiredCaveat', wallet_noopWithFactory: 'wallet_noopWithFactory', snap_foo: 'snap_foo', + snap_bar: 'snap_bar', + snap_baz: 'snap_baz', + snap_xyz: 'snap_xyz', + snap_abc: 'snap_abc', + snap_def: 'snap_def', endowmentAnySubject: 'endowmentAnySubject', endowmentSnapsOnly: 'endowmentSnapsOnly', } as const; @@ -232,6 +237,11 @@ const PermissionNames = { wallet_noopWithRequiredCaveat: PermissionKeys.wallet_noopWithRequiredCaveat, wallet_noopWithFactory: PermissionKeys.wallet_noopWithFactory, snap_foo: PermissionKeys.snap_foo, + snap_bar: PermissionKeys.snap_bar, + snap_baz: PermissionKeys.snap_baz, + snap_xyz: PermissionKeys.snap_xyz, + snap_abc: PermissionKeys.snap_abc, + snap_def: PermissionKeys.snap_def, endowmentAnySubject: PermissionKeys.endowmentAnySubject, endowmentSnapsOnly: PermissionKeys.endowmentSnapsOnly, } as const; @@ -419,6 +429,55 @@ function getDefaultPermissionSpecifications() { return null; }, subjectTypes: [SubjectType.Snap], + children: [PermissionKeys.snap_bar], + }, + [PermissionKeys.snap_bar]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.snap_bar, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + subjectTypes: [SubjectType.Snap], + children: [PermissionKeys.snap_baz], + }, + [PermissionKeys.snap_baz]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.snap_baz, + allowedCaveats: [CaveatTypes.filterArrayResponse], + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + subjectTypes: [SubjectType.Snap], + }, + [PermissionKeys.snap_xyz]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.snap_xyz, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + subjectTypes: [SubjectType.Snap], + children: [PermissionKeys.snap_baz], + }, + [PermissionKeys.snap_abc]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.snap_abc, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + subjectTypes: [SubjectType.Snap], + children: [PermissionKeys.snap_def], + }, + [PermissionKeys.snap_def]: { + permissionType: PermissionType.RestrictedMethod, + targetName: PermissionKeys.snap_def, + allowedCaveats: null, + methodImplementation: (_args: RestrictedMethodOptions) => { + return null; + }, + subjectTypes: [SubjectType.Snap], }, [PermissionKeys.endowmentAnySubject]: { permissionType: PermissionType.Endowment, @@ -612,6 +671,7 @@ describe('PermissionController', () => { beforeEach(() => { jest.clearAllMocks(); }); + describe('constructor', () => { it('initializes a new PermissionController', () => { const controller = getDefaultPermissionController(); @@ -804,11 +864,11 @@ describe('PermissionController', () => { }); describe('getSubjectNames', () => { - it('gets all subject names', () => { + it('gets all subject names', async () => { const controller = getDefaultPermissionController(); expect(controller.getSubjectNames()).toStrictEqual([]); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -817,7 +877,7 @@ describe('PermissionController', () => { expect(controller.getSubjectNames()).toStrictEqual(['foo']); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'bar' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -916,14 +976,14 @@ describe('PermissionController', () => { }); describe('revokeAllPermissions', () => { - it('revokes all permissions for the specified subject', () => { + it('revokes all permissions for the specified subject', async () => { const controller = getDefaultPermissionControllerWithState(); expect(controller.state).toStrictEqual(getExistingPermissionState()); controller.revokeAllPermissions('metamask.io'); expect(controller.state).toStrictEqual({ subjects: {} }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -935,13 +995,13 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual({ subjects: {} }); }); - it('throws an error if the specified subject has no permissions', () => { + it('throws an error if the specified subject has no permissions', async () => { const controller = getDefaultPermissionController(); expect(() => controller.revokeAllPermissions('metamask.io')).toThrow( new errors.UnrecognizedSubjectError('metamask.io'), ); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'metamask.io' }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: {}, @@ -966,12 +1026,12 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual({ subjects: {} }); }); - it('revokes a permission from an origin with multiple permissions', () => { + it('revokes a permission from an origin with multiple permissions', async () => { const controller = getDefaultPermissionControllerWithState(); expect(controller.state).toStrictEqual(getExistingPermissionState()); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: {}, @@ -1026,7 +1086,7 @@ describe('PermissionController', () => { }); describe('revokePermissions', () => { - it('revokes different permissions for multiple subjects', () => { + it('revokes different permissions for multiple subjects', async () => { const controller = getDefaultPermissionController(); const origin0 = 'origin0'; const origin1 = 'origin1'; @@ -1034,21 +1094,21 @@ describe('PermissionController', () => { const origin3 = 'origin3'; const origin4 = 'origin4'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin0 }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin1 }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin2 }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1056,7 +1116,7 @@ describe('PermissionController', () => { }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin3 }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1064,7 +1124,7 @@ describe('PermissionController', () => { }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin4 }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1249,11 +1309,11 @@ describe('PermissionController', () => { }); describe('hasCaveat', () => { - it('indicates whether a permission has a particular caveat', () => { + it('indicates whether a permission has a particular caveat', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1300,11 +1360,11 @@ describe('PermissionController', () => { }); describe('getCaveat', () => { - it('gets existing caveats', () => { + it('gets existing caveats', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1354,11 +1414,11 @@ describe('PermissionController', () => { }); describe('addCaveat', () => { - it('adds a caveat to the specified permission', () => { + it('adds a caveat to the specified permission', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1390,11 +1450,11 @@ describe('PermissionController', () => { }); }); - it(`appends a caveat to the specified permission's existing caveats`, () => { + it(`appends a caveat to the specified permission's existing caveats`, async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -1448,11 +1508,11 @@ describe('PermissionController', () => { }); }); - it('throws an error if a corresponding caveat already exists', () => { + it('throws an error if a corresponding caveat already exists', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -1498,11 +1558,11 @@ describe('PermissionController', () => { ); }); - it('throws an error if the permission fails to validate with the added caveat', () => { + it('throws an error if the permission fails to validate with the added caveat', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -1531,11 +1591,11 @@ describe('PermissionController', () => { }); describe('updateCaveat', () => { - it('updates an existing caveat', () => { + it('updates an existing caveat', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -1607,11 +1667,11 @@ describe('PermissionController', () => { ); }); - it('throws an error if no corresponding caveat exists', () => { + it('throws an error if no corresponding caveat exists', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1634,11 +1694,11 @@ describe('PermissionController', () => { ); }); - it('throws an error if the updated caveat fails to validate', () => { + it('throws an error if the updated caveat fails to validate', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -1659,11 +1719,11 @@ describe('PermissionController', () => { }); describe('removeCaveat', () => { - it('removes an existing caveat', () => { + it('removes an existing caveat', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -1713,11 +1773,11 @@ describe('PermissionController', () => { }); }); - it('removes an existing caveat, without modifying other caveats of the same permission', () => { + it('removes an existing caveat, without modifying other caveats of the same permission', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -1789,11 +1849,11 @@ describe('PermissionController', () => { ); }); - it('throws an error if no corresponding caveat exists', () => { + it('throws an error if no corresponding caveat exists', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -1834,11 +1894,11 @@ describe('PermissionController', () => { ); }); - it('throws an error if the permission fails to validate after caveat removal', () => { + it('throws an error if the permission fails to validate after caveat removal', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_noopWithRequiredCaveat]: { @@ -1871,10 +1931,10 @@ describe('PermissionController', () => { * * @returns The permission controller instance */ - const getMultiCaveatController = () => { + const getMultiCaveatController = async () => { const controller = getDefaultPermissionController(); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: MultiCaveatOrigins.a }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -1883,7 +1943,7 @@ describe('PermissionController', () => { }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: MultiCaveatOrigins.b }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -1905,7 +1965,7 @@ describe('PermissionController', () => { }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: MultiCaveatOrigins.c }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -2001,8 +2061,8 @@ describe('PermissionController', () => { }; // This is effectively a test of the above test utilities. - it('multi-caveat controller has expected state', () => { - const controller = getMultiCaveatController(); + it('multi-caveat controller has expected state', async () => { + const controller = await getMultiCaveatController(); expect(controller.state).toStrictEqual(getMultiCaveatStateMatcher()); }); @@ -2023,8 +2083,8 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual({ subjects: {} }); }); - it('does nothing if the mutator returns the "noop" operation', () => { - const controller = getMultiCaveatController(); + it('does nothing if the mutator returns the "noop" operation', async () => { + const controller = await getMultiCaveatController(); // Although there are caveats, we always return the "noop" operation, and // therefore nothing happens. @@ -2037,8 +2097,8 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual(getMultiCaveatStateMatcher()); }); - it('updates the value of all caveats of a particular type', () => { - const controller = getMultiCaveatController(); + it('updates the value of all caveats of a particular type', async () => { + const controller = await getMultiCaveatController(); controller.updatePermissionsByCaveat( CaveatTypes.filterArrayResponse, @@ -2075,8 +2135,8 @@ describe('PermissionController', () => { ); }); - it('selectively updates the value of all caveats of a particular type', () => { - const controller = getMultiCaveatController(); + it('selectively updates the value of all caveats of a particular type', async () => { + const controller = await getMultiCaveatController(); let counter = 0; const mutator: any = () => { @@ -2110,8 +2170,8 @@ describe('PermissionController', () => { ); }); - it('deletes all caveats of a particular type', () => { - const controller = getMultiCaveatController(); + it('deletes all caveats of a particular type', async () => { + const controller = await getMultiCaveatController(); controller.updatePermissionsByCaveat( CaveatTypes.filterArrayResponse, @@ -2142,8 +2202,8 @@ describe('PermissionController', () => { ); }); - it('revokes permissions associated with a caveat', () => { - const controller = getMultiCaveatController(); + it('revokes permissions associated with a caveat', async () => { + const controller = await getMultiCaveatController(); controller.updatePermissionsByCaveat( CaveatTypes.filterObjectResponse, @@ -2164,8 +2224,8 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual(matcher); }); - it('deletes subject if all permissions are revoked', () => { - const controller = getMultiCaveatController(); + it('deletes subject if all permissions are revoked', async () => { + const controller = await getMultiCaveatController(); let counter = 0; const mutator: any = () => { @@ -2189,8 +2249,8 @@ describe('PermissionController', () => { expect(controller.state).toStrictEqual(matcher); }); - it('throws if caveat validation fails after a value is updated', () => { - const controller = getMultiCaveatController(); + it('throws if caveat validation fails after a value is updated', async () => { + const controller = await getMultiCaveatController(); expect(() => controller.updatePermissionsByCaveat( @@ -2205,8 +2265,8 @@ describe('PermissionController', () => { ).toThrow(`${CaveatTypes.filterArrayResponse} values must be arrays`); }); - it('throws if permission validation fails after a value is updated', () => { - const controller = getMultiCaveatController(); + it('throws if permission validation fails after a value is updated', async () => { + const controller = await getMultiCaveatController(); expect(() => controller.updatePermissionsByCaveat(CaveatTypes.noopCaveat, () => { @@ -2215,8 +2275,8 @@ describe('PermissionController', () => { ).toThrow('noopWithRequiredCaveat permission validation failed'); }); - it('throws if mutator returns unrecognized operation', () => { - const controller = getMultiCaveatController(); + it('throws if mutator returns unrecognized operation', async () => { + const controller = await getMultiCaveatController(); expect(() => controller.updatePermissionsByCaveat( @@ -2230,11 +2290,11 @@ describe('PermissionController', () => { }); describe('grantPermissions', () => { - it('grants new permission', () => { + it('grants new permission', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: {}, @@ -2255,11 +2315,11 @@ describe('PermissionController', () => { }); }); - it('grants new permissions (multiple at once)', () => { + it('grants new permissions (multiple at once)', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: {}, @@ -2286,19 +2346,19 @@ describe('PermissionController', () => { }); }); - it('grants new permissions (multiple origins)', () => { + it('grants new permissions (multiple origins)', async () => { const controller = getDefaultPermissionController(); const origin1 = 'metamask.io'; const origin2 = 'infura.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin1 }, approvedPermissions: { wallet_getSecretObject: {}, }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: origin2 }, approvedPermissions: { wallet_getSecretArray: {}, @@ -2331,7 +2391,7 @@ describe('PermissionController', () => { }); }); - it('grants new permission (endowment with caveats)', () => { + it('grants new permission (endowment with caveats)', async () => { const options = getPermissionControllerOptions(); const { messenger } = options; const origin = 'npm:@metamask/test-snap-bip44'; @@ -2350,7 +2410,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(options); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.endowmentSnapsOnly]: { @@ -2397,7 +2457,7 @@ describe('PermissionController', () => { ); }); - it('preserves existing permissions if preserveExistingPermissions is true', () => { + it('preserves existing permissions if preserveExistingPermissions is true', async () => { const controller = getDefaultPermissionControllerWithState(); const origin = 'metamask.io'; @@ -2414,7 +2474,7 @@ describe('PermissionController', () => { }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretObject: {}, @@ -2439,7 +2499,7 @@ describe('PermissionController', () => { }); }); - it('overwrites existing permissions if preserveExistingPermissions is false', () => { + it('overwrites existing permissions if preserveExistingPermissions is false', async () => { const controller = getDefaultPermissionControllerWithState(); const origin = 'metamask.io'; @@ -2456,7 +2516,7 @@ describe('PermissionController', () => { }, }); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretObject: {}, @@ -2478,47 +2538,59 @@ describe('PermissionController', () => { }); }); - it('throws if the origin is invalid', () => { + it('throws if the origin is invalid', async () => { const controller = getDefaultPermissionController(); - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin: '' }, approvedPermissions: { wallet_getSecretArray: {}, }, - }), - ).toThrow(new errors.InvalidSubjectIdentifierError('')); + }); + } catch (err) { + error = err; + } + expect(error).toStrictEqual(new errors.InvalidSubjectIdentifierError('')); - expect(() => - controller.grantPermissions({ + try { + await controller.grantPermissions({ subject: { origin: 2 as any }, approvedPermissions: { wallet_getSecretArray: {}, }, - }), - ).toThrow(new errors.InvalidSubjectIdentifierError(2)); + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual(new errors.InvalidSubjectIdentifierError(2)); }); - it('throws if the target does not exist', () => { + it('throws if the target does not exist', async () => { const controller = getDefaultPermissionController(); - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin: 'metamask.io' }, approvedPermissions: { wallet_getSecretFalafel: {}, }, - }), - ).toThrow(errors.methodNotFound('wallet_getSecretFalafel')); + }); + } catch (err) { + error = err; + } + expect(error).toStrictEqual( + errors.methodNotFound('wallet_getSecretFalafel'), + ); }); - it('throws if an approved permission is malformed', () => { + it('throws if an approved permission is malformed', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { @@ -2526,8 +2598,11 @@ describe('PermissionController', () => { parentCapability: 'wallet_getSecretObject', }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + expect(error).toStrictEqual( new errors.InvalidApprovedPermissionError( origin, 'wallet_getSecretArray', @@ -2535,8 +2610,8 @@ describe('PermissionController', () => { ), ); - expect(() => - controller.grantPermissions({ + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { @@ -2547,8 +2622,12 @@ describe('PermissionController', () => { parentCapability: 'wallet_getSecretArray', }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.InvalidApprovedPermissionError( origin, 'wallet_getSecretObject', @@ -2557,12 +2636,12 @@ describe('PermissionController', () => { ); }); - it('throws if an approved permission has duplicate caveats', () => { + it('throws if an approved permission has duplicate caveats', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { @@ -2573,8 +2652,12 @@ describe('PermissionController', () => { ], }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.DuplicateCaveatError( CaveatTypes.filterArrayResponse, origin, @@ -2583,20 +2666,24 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat is not a plain object', () => { + it('throws if a requested caveat is not a plain object', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { caveats: [[]] as any, }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.InvalidCaveatError( [], origin, @@ -2604,16 +2691,20 @@ describe('PermissionController', () => { ), ); - expect(() => - controller.grantPermissions({ + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { caveats: ['foo'] as any, }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.InvalidCaveatError( [], origin, @@ -2622,12 +2713,12 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat has more than two keys', () => { + it('throws if a requested caveat has more than two keys', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { @@ -2639,8 +2730,12 @@ describe('PermissionController', () => { ] as any, }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.InvalidCaveatFieldsError( { ...{ type: CaveatTypes.filterArrayResponse, value: ['foo'] }, @@ -2652,12 +2747,12 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat type is not a string', () => { + it('throws if a requested caveat type is not a string', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { @@ -2669,8 +2764,12 @@ describe('PermissionController', () => { ] as any, }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.InvalidCaveatTypeError( { type: 2, @@ -2682,20 +2781,24 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat type does not exist', () => { + it('throws if a requested caveat type does not exist', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { caveats: [{ type: 'fooType', value: 'bar' }], }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.UnrecognizedCaveatTypeError( 'fooType', origin, @@ -2704,12 +2807,12 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat has no value field', () => { + it('throws if a requested caveat has no value field', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretArray: { @@ -2721,8 +2824,12 @@ describe('PermissionController', () => { ] as any, }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.CaveatMissingValueError( { type: CaveatTypes.filterArrayResponse, @@ -2734,49 +2841,110 @@ describe('PermissionController', () => { ); }); - it('throws if a requested caveat has a value that is not valid JSON', () => { + it('throws if a requested caveat has a value that is not valid JSON', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - + let error; 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, - }, - ], + const invalidValue1 = { foo: () => undefined }; + const invalidValue2 = circular; + const invalidValue3 = { foo: BigInt(10) }; + try { + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue1 as unknown as Json, }, - }, - }), - ).toThrow( - new errors.CaveatInvalidJsonError( - { - type: CaveatTypes.filterArrayResponse, - value: invalidValue, - }, - origin, - PermissionNames.wallet_getSecretArray, - ), - ); - }, + ], + }, + }, + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( + new errors.CaveatInvalidJsonError( + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue1, + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + + try { + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue2, + }, + ], + }, + }, + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( + new errors.CaveatInvalidJsonError( + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue2, + }, + origin, + PermissionNames.wallet_getSecretArray, + ), + ); + + try { + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + wallet_getSecretArray: { + caveats: [ + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue3 as unknown as Json, + }, + ], + }, + }, + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( + new errors.CaveatInvalidJsonError( + { + type: CaveatTypes.filterArrayResponse, + value: invalidValue3, + }, + origin, + PermissionNames.wallet_getSecretArray, + ), ); }); - it('throws if caveat validation fails', () => { + it('throws if caveat validation fails', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretObject]: { @@ -2788,16 +2956,20 @@ describe('PermissionController', () => { ], }, }, - }), - ).toThrow(new Error('NoopCaveat value must be null')); + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual(new Error('NoopCaveat value must be null')); }); - it('throws if the requested permission specifies disallowed caveats', () => { + it('throws if the requested permission specifies disallowed caveats', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_getSecretObject: { @@ -2809,8 +2981,12 @@ describe('PermissionController', () => { ], }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.ForbiddenCaveatError( CaveatTypes.filterArrayResponse, origin, @@ -2819,12 +2995,12 @@ describe('PermissionController', () => { ); }); - it('throws if the requested permission specifies caveats, and no caveats are allowed', () => { + it('throws if the requested permission specifies caveats, and no caveats are allowed', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { wallet_doubleNumber: { @@ -2836,8 +3012,12 @@ describe('PermissionController', () => { ], }, }, - }), - ).toThrow( + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( new errors.ForbiddenCaveatError( CaveatTypes.filterArrayResponse, origin, @@ -2846,12 +3026,12 @@ describe('PermissionController', () => { ); }); - it('throws if the permission validator throws', () => { + it('throws if the permission validator throws', async () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - - expect(() => - controller.grantPermissions({ + let error; + try { + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_noopWithValidator]: { @@ -2860,8 +3040,14 @@ describe('PermissionController', () => { ], }, }, - }), - ).toThrow(new Error('noop permission validation failed')); + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( + new Error('noop permission validation failed'), + ); }); }); @@ -3844,12 +4030,13 @@ describe('PermissionController', () => { }, ]); - expect(callActionSpy).toHaveBeenCalledTimes(4); + expect(callActionSpy).toHaveBeenCalledTimes(6); expect(callActionSpy).toHaveBeenNthCalledWith( 1, 'SubjectMetadataController:getSubjectMetadata', origin, ); + expect(callActionSpy).toHaveBeenNthCalledWith( 2, 'ApprovalController:addRequest', @@ -3864,11 +4051,13 @@ describe('PermissionController', () => { }, true, ); + expect(callActionSpy).toHaveBeenNthCalledWith( 3, 'SubjectMetadataController:getSubjectMetadata', origin, ); + expect(callActionSpy).toHaveBeenNthCalledWith( 4, 'SubjectMetadataController:getSubjectMetadata', @@ -4502,7 +4691,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.endowmentAnySubject]: {}, @@ -4521,7 +4710,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -4560,7 +4749,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -4579,7 +4768,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_doubleNumber]: {}, @@ -4599,7 +4788,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -4620,7 +4809,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -4681,7 +4870,7 @@ describe('PermissionController', () => { ); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_doubleNumber]: {}, @@ -4702,7 +4891,7 @@ describe('PermissionController', () => { }); describe('controller actions', () => { - it('action: PermissionController:clearPermissions', () => { + it('action: PermissionController:clearPermissions', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4713,7 +4902,7 @@ describe('PermissionController', () => { >(options); const clearStateSpy = jest.spyOn(controller, 'clearState'); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -4753,7 +4942,7 @@ describe('PermissionController', () => { }), ); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { [PermissionNames.endowmentAnySubject]: {}, @@ -4800,7 +4989,7 @@ describe('PermissionController', () => { ); }); - it('action: PermissionController:getSubjectNames', () => { + it('action: PermissionController:getSubjectNames', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4815,7 +5004,7 @@ describe('PermissionController', () => { messenger.call('PermissionController:getSubjectNames'), ).toStrictEqual([]); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -4828,7 +5017,7 @@ describe('PermissionController', () => { expect(getSubjectNamesSpy).toHaveBeenCalledTimes(2); }); - it('action: PermissionController:hasPermission', () => { + it('action: PermissionController:hasPermission', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4847,7 +5036,7 @@ describe('PermissionController', () => { ), ).toBe(false); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -4890,7 +5079,7 @@ describe('PermissionController', () => { ); }); - it('action: PermissionController:hasPermissions', () => { + it('action: PermissionController:hasPermissions', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4905,7 +5094,7 @@ describe('PermissionController', () => { false, ); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -4920,7 +5109,7 @@ describe('PermissionController', () => { expect(hasPermissionsSpy).toHaveBeenNthCalledWith(2, 'foo'); }); - it('action: PermissionController:getPermissions', () => { + it('action: PermissionController:getPermissions', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4935,7 +5124,7 @@ describe('PermissionController', () => { messenger.call('PermissionController:getPermissions', 'foo'), ).toBeUndefined(); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -4954,7 +5143,7 @@ describe('PermissionController', () => { expect(getPermissionsSpy).toHaveBeenNthCalledWith(2, 'foo'); }); - it('action: PermissionController:revokeAllPermissions', () => { + it('action: PermissionController:revokeAllPermissions', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4964,7 +5153,7 @@ describe('PermissionController', () => { DefaultCaveatSpecifications >(options); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -4988,7 +5177,7 @@ describe('PermissionController', () => { expect(revokeAllPermissionsSpy).toHaveBeenNthCalledWith(1, 'foo'); }); - it('action: PermissionController:revokePermissionForAllSubjects', () => { + it('action: PermissionController:revokePermissionForAllSubjects', async () => { const messenger = getUnrestrictedMessenger(); const options = getPermissionControllerOptions({ messenger: getPermissionControllerMessenger(messenger), @@ -4998,7 +5187,7 @@ describe('PermissionController', () => { DefaultCaveatSpecifications >(options); - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin: 'foo' }, approvedPermissions: { wallet_getSecretArray: {}, @@ -5038,12 +5227,15 @@ describe('PermissionController', () => { DefaultCaveatSpecifications >(options); - const result = messenger.call('PermissionController:grantPermissions', { - subject: { origin: 'foo' }, - approvedPermissions: { wallet_getSecretArray: {} }, - }); + const result = await messenger.call( + 'PermissionController:grantPermissions', + { + subject: { origin: 'foo' }, + approvedPermissions: { wallet_getSecretArray: {} }, + }, + ); - expect(result).toHaveProperty('wallet_getSecretArray'); + expect(result.permissions).toHaveProperty('wallet_getSecretArray'); expect(controller.hasPermission('foo', 'wallet_getSecretArray')).toBe( true, ); @@ -5108,7 +5300,7 @@ describe('PermissionController', () => { const updateCaveatSpy = jest.spyOn(controller, 'updateCaveat'); - await messenger.call( + messenger.call( 'PermissionController:updateCaveat', 'metamask.io', 'wallet_getSecretArray', @@ -5143,7 +5335,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: {}, @@ -5166,7 +5358,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -5191,7 +5383,7 @@ describe('PermissionController', () => { const controller = getDefaultPermissionController(); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_getSecretArray]: { @@ -5312,7 +5504,7 @@ describe('PermissionController', () => { ); const origin = 'metamask.io'; - controller.grantPermissions({ + await controller.grantPermissions({ subject: { origin }, approvedPermissions: { [PermissionNames.wallet_doubleNumber]: {}, @@ -5337,4 +5529,215 @@ describe('PermissionController', () => { expect(error).toMatchObject(expect.objectContaining(expectedError)); }); }); + + describe('permission groups', () => { + it('grants the child permissions in a single permission group', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'npm:@metamask/test-snap-bip44'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementation(() => { + return { + origin, + name: origin, + subjectType: SubjectType.Snap, + iconUrl: null, + extensionId: null, + }; + }); + + const controller = getDefaultPermissionController(options); + + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + snap_foo: {}, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + snap_foo: getPermissionMatcher({ + parentCapability: 'snap_foo', + invoker: origin, + }), + snap_bar: getPermissionMatcher({ + parentCapability: 'snap_bar', + invoker: origin, + }), + snap_baz: getPermissionMatcher({ + parentCapability: 'snap_baz', + invoker: origin, + }), + }, + }, + }, + }); + + expect(callActionSpy).toHaveBeenCalledTimes(3); + expect(callActionSpy).toHaveBeenCalledWith( + 'SubjectMetadataController:getSubjectMetadata', + origin, + ); + }); + + it('grants the child permissions of multiple permission groups', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'npm:@metamask/test-snap-bip44'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementation(() => { + return { + origin, + name: origin, + subjectType: SubjectType.Snap, + iconUrl: null, + extensionId: null, + }; + }); + + const controller = getDefaultPermissionController(options); + + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + snap_foo: {}, + snap_abc: {}, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + snap_foo: getPermissionMatcher({ + parentCapability: 'snap_foo', + invoker: origin, + }), + snap_bar: getPermissionMatcher({ + parentCapability: 'snap_bar', + invoker: origin, + }), + snap_baz: getPermissionMatcher({ + parentCapability: 'snap_baz', + invoker: origin, + }), + snap_abc: getPermissionMatcher({ + parentCapability: 'snap_abc', + invoker: origin, + }), + snap_def: getPermissionMatcher({ + parentCapability: 'snap_def', + invoker: origin, + }), + }, + }, + }, + }); + + expect(callActionSpy).toHaveBeenCalledTimes(5); + expect(callActionSpy).toHaveBeenCalledWith( + 'SubjectMetadataController:getSubjectMetadata', + origin, + ); + }); + + it('can handle when overlapping permission groups are granted', async () => { + const options = getPermissionControllerOptions(); + const { messenger } = options; + const origin = 'npm:@metamask/test-snap-bip44'; + + const callActionSpy = jest + .spyOn(messenger, 'call') + .mockImplementation(() => { + return { + origin, + name: origin, + subjectType: SubjectType.Snap, + iconUrl: null, + extensionId: null, + }; + }); + + const controller = getDefaultPermissionController(options); + const filterArrayResponse = { + type: 'filterArrayResponse', + value: ['foo'], + }; + + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + snap_foo: {}, + snap_xyz: {}, + snap_baz: { + caveats: [filterArrayResponse], + }, + }, + }); + + expect(controller.state).toStrictEqual({ + subjects: { + [origin]: { + origin, + permissions: { + snap_foo: getPermissionMatcher({ + parentCapability: 'snap_foo', + invoker: origin, + }), + snap_bar: getPermissionMatcher({ + parentCapability: 'snap_bar', + invoker: origin, + }), + snap_baz: getPermissionMatcher({ + parentCapability: 'snap_baz', + caveats: [filterArrayResponse], + invoker: origin, + }), + snap_xyz: getPermissionMatcher({ + parentCapability: 'snap_xyz', + invoker: origin, + }), + }, + }, + }, + }); + + expect(callActionSpy).toHaveBeenCalledTimes(4); + expect(callActionSpy).toHaveBeenCalledWith( + 'SubjectMetadataController:getSubjectMetadata', + origin, + ); + }); + }); + + it('throws when child permissions are requested outside of their parent group', async () => { + const controller = getDefaultPermissionController(); + const origin = 'npm:@metamask/example-snap'; + let error; + try { + await controller.grantPermissions({ + subject: { origin }, + approvedPermissions: { + snap_baz: {}, + }, + }); + } catch (err) { + error = err; + } + + expect(error).toStrictEqual( + new Error( + 'Invalid permission request, child permissions must also have their parent permissions requested.', + ), + ); + }); }); diff --git a/packages/permission-controller/src/PermissionController.ts b/packages/permission-controller/src/PermissionController.ts index db282cda67c..9ade8d35454 100644 --- a/packages/permission-controller/src/PermissionController.ts +++ b/packages/permission-controller/src/PermissionController.ts @@ -86,6 +86,7 @@ import { PermissionType, } from './Permission'; import { getPermissionMiddlewareFactory } from './permission-middleware'; +import { PermissionTree } from './PermissionTree'; import type { GetSubjectMetadata } from './SubjectMetadataController'; import { MethodNames } from './utils'; @@ -530,6 +531,8 @@ export class PermissionController< private readonly _unrestrictedMethods: ReadonlySet; + private readonly permissionTree: PermissionTree; + /** * The names of all JSON-RPC methods that will be ignored by the controller. * @@ -615,6 +618,8 @@ export class PermissionController< ...permissionSpecifications, }); + this.permissionTree = new PermissionTree(this._permissionSpecifications); + this.registerMessageHandlers(); this.createPermissionMiddleware = getPermissionMiddlewareFactory({ executeRestrictedMethod: this._executeRestrictedMethod.bind(this), @@ -1538,7 +1543,7 @@ export class PermissionController< * @param options.subject - The subject to grant permissions to. * @returns The granted permissions. */ - grantPermissions({ + async grantPermissions({ approvedPermissions, requestData, preserveExistingPermissions = true, @@ -1548,12 +1553,15 @@ export class PermissionController< subject: PermissionSubjectMetadata; preserveExistingPermissions?: boolean; requestData?: Record; - }): SubjectPermissions< - ExtractPermission< - ControllerPermissionSpecification, - ControllerCaveatSpecification - > - > { + }): Promise<{ + permissions: SubjectPermissions< + ExtractPermission< + ControllerPermissionSpecification, + ControllerCaveatSpecification + > + >; + data?: Record; + }> { const { origin } = subject; if (!origin || typeof origin !== 'string') { @@ -1573,8 +1581,26 @@ export class PermissionController< > >; + const populatedRequest = + this.permissionTree.getPopulatedRequest(approvedPermissions); + + const sideEffects = this.getSideEffects(populatedRequest); + + let data; + + if (Object.values(sideEffects.permittedHandlers).length > 0) { + const sideEffectsData = await this.executeSideEffects(sideEffects, { + permissions: populatedRequest, + metadata: requestData as PermissionsRequestMetadata, + }); + data = Object.keys(sideEffects.permittedHandlers).reduce( + (acc, permission, i) => ({ [permission]: sideEffectsData[i], ...acc }), + {}, + ); + } + for (const [requestedTarget, approvedPermission] of Object.entries( - approvedPermissions, + populatedRequest, )) { if (!this.targetExists(requestedTarget)) { throw methodNotFound(requestedTarget); @@ -1638,7 +1664,7 @@ export class PermissionController< } this.setValidatedPermissions(origin, permissions); - return permissions; + return { permissions, data }; } /** @@ -1888,38 +1914,14 @@ export class PermissionController< const { permissions: approvedPermissions, ...requestData } = approvedRequest; - const sideEffects = this.getSideEffects(approvedPermissions); - - if (Object.values(sideEffects.permittedHandlers).length > 0) { - const sideEffectsData = await this.executeSideEffects( - sideEffects, - approvedRequest, - ); - const mappedData = Object.keys(sideEffects.permittedHandlers).reduce( - (acc, permission, i) => ({ [permission]: sideEffectsData[i], ...acc }), - {}, - ); - - return [ - this.grantPermissions({ - subject, - approvedPermissions, - preserveExistingPermissions, - requestData, - }), - { data: mappedData, ...metadata }, - ]; - } + const { permissions, data } = await this.grantPermissions({ + subject, + approvedPermissions, + preserveExistingPermissions, + requestData, + }); - return [ - this.grantPermissions({ - subject, - approvedPermissions, - preserveExistingPermissions, - requestData, - }), - metadata, - ]; + return [permissions, { data, ...metadata }]; } /** diff --git a/packages/permission-controller/src/PermissionTree.test.ts b/packages/permission-controller/src/PermissionTree.test.ts new file mode 100644 index 00000000000..ff6ed5444c9 --- /dev/null +++ b/packages/permission-controller/src/PermissionTree.test.ts @@ -0,0 +1,78 @@ +import type { + PermissionSpecificationConstraint, + PermissionSpecificationMap, +} from './Permission'; +import { PermissionTree } from './PermissionTree'; + +describe('PermissionTree', () => { + const permissionSpecificationMap = { + permissionA: { + targetName: 'permissionA', + children: ['permissionB', 'permissionC'], + }, + permissionB: { + targetName: 'permissionB', + children: null, + }, + permissionC: { + targetName: 'permissionC', + children: ['permissionD'], + }, + permissionD: { + targetName: 'permissionD', + children: null, + }, + permissionE: { + targetName: 'permissionE', + children: ['permissionF', 'permissionB'], + }, + permissionF: { + targetName: 'permissionF', + children: null, + }, + } as unknown as PermissionSpecificationMap; + const tree = new PermissionTree(permissionSpecificationMap); + + describe('constructor', () => { + it('initializes a new PermissionTree', () => { + expect(tree.getRootPermissions()).toStrictEqual([ + 'permissionA', + 'permissionE', + ]); + }); + + it('throws if there are no permission specifications passed to it', () => { + expect(() => new PermissionTree(null as any)).toThrow( + 'Can not create PermissionTree without permission specifications.', + ); + }); + }); + + describe('getPopulatedRequest', () => { + it('populates a permission request based on child permissions', () => { + const permissionsRequest = { + permissionA: {}, + permissionE: {}, + }; + expect(tree.getPopulatedRequest(permissionsRequest)).toStrictEqual({ + permissionA: {}, + permissionB: {}, + permissionC: {}, + permissionD: {}, + permissionE: {}, + permissionF: {}, + }); + }); + + it('throws when a permission is requested with missing parent permissions', () => { + const invalidPermissionsRequest = { + permissionB: {}, + permissionD: {}, + }; + + expect(() => tree.getPopulatedRequest(invalidPermissionsRequest)).toThrow( + 'Invalid permission request, child permissions must also have their parent permissions requested.', + ); + }); + }); +}); diff --git a/packages/permission-controller/src/PermissionTree.ts b/packages/permission-controller/src/PermissionTree.ts new file mode 100644 index 00000000000..1c21ec5f50f --- /dev/null +++ b/packages/permission-controller/src/PermissionTree.ts @@ -0,0 +1,234 @@ +import type { + PermissionSpecificationMap, + PermissionSpecificationConstraint, + RequestedPermissions, +} from './Permission'; + +/** + * The Permission Tree. This class assumes an instance of the Permission + * Controller passing in permissions specifications for the tree to analyze. + */ +export class PermissionTree { + private readonly _permissionSpecifications: Readonly< + PermissionSpecificationMap + >; + + private readonly childToParentPermissionMap: Readonly< + Map< + PermissionSpecificationConstraint['targetName'], + Set + > + >; + + private rootPermissions: PermissionSpecificationConstraint['targetName'][]; + + /** + * Constructs the PermissionTree. + * + * @param permissionSpecifications - Permission specification map passed in from the Permission Controller. + */ + constructor( + permissionSpecifications: PermissionSpecificationMap, + ) { + this._permissionSpecifications = permissionSpecifications; + this.childToParentPermissionMap = new Map(); + this.rootPermissions = []; + this.createTree(); + } + + /** + * Helper function to construct the PermissionTree. + * It identifies the root permissions and marks the child -> parent relationships + * that exist amongst the specifications passed in. + */ + private createTree() { + if (!this._permissionSpecifications) { + throw new Error( + 'Can not create PermissionTree without permission specifications.', + ); + } + + const rootNodes = this.calculateRootPermissions( + this._permissionSpecifications, + ); + + this.rootPermissions = rootNodes; + + for (const rootPermission of rootNodes) { + this.traverseRoot( + this._permissionSpecifications[rootPermission], + rootPermission, + ); + } + } + + /** + * Calculates the root permissions by walking through permissions and their children, + * it will then mark any permission that is called from a parent as NOT root. + * + * @param permissions - Map of the permission specifications. + * @returns The root permissions amongst the permissions passed in. + */ + + private calculateRootPermissions( + permissions: PermissionSpecificationMap, + ) { + const rootMap = new Map(); + const visitedNodes = new Set< + PermissionSpecificationConstraint['targetName'] + >(); + + for (const permissionName of Object.keys(permissions)) { + rootMap.set(permissionName, true); + } + + for (const permission of Object.values(permissions)) { + this.traverseNode(permission, rootMap, visitedNodes); + } + + return [...rootMap.entries()].reduce< + PermissionSpecificationConstraint['targetName'][] + >((rootPermissions, [permissionName, isRoot]) => { + if (isRoot) { + rootPermissions.push(permissionName); + } + return rootPermissions; + }, []); + } + + /** + * Traverses nodes and marks them as not root if the function call is coming from a parent. + * + * @param node - The permission specification. + * @param rootMap - A map of all nodes to whether or not they're a root node. + * @param visitedNodes - Nodes that have already been visited in the recursion tree. + * @param isChild - Boolean to determine if the node is a child. + */ + private traverseNode( + node: PermissionSpecificationConstraint, + rootMap: Map, + visitedNodes: Set, + isChild = false, + ) { + if (!visitedNodes.has(node.targetName)) { + if (isChild) { + rootMap.set(node.targetName, false); + } + + visitedNodes.add(node.targetName); + + if (node.children) { + for (const child of node.children) { + const permission = this._permissionSpecifications[child]; + this.traverseNode(permission, rootMap, visitedNodes, true); + } + } + } + } + + /** + * Traverses a permission chain from its root node. This function is used + * to mark the relationships between child -> parent. + * + * @param node - The permission specification. + * @param rootName - The name of the parent permission. + */ + private traverseRoot( + node: PermissionSpecificationConstraint, + rootName: PermissionSpecificationConstraint['targetName'], + ) { + if (rootName !== node.targetName) { + const parents = this.childToParentPermissionMap.get(node.targetName); + if (!parents) { + this.childToParentPermissionMap.set(node.targetName, new Set(rootName)); + } else { + parents.add(rootName); + } + } + + if (node.children) { + for (const child of node.children) { + this.traverseRoot( + this._permissionSpecifications[child], + node.targetName, + ); + } + } + } + + /** + * Helper function to help determine if a permission request is valid by + * determining if any of the parent permissions it belongs to were also + * included in the request. + * + * @param approvedPermissions - The permission request, this is at the point of `grantPermissions` in the Permission Controller. + * @returns A boolean indiciating if the request has valid permission groups. + */ + private hasValidPermissionGroups(approvedPermissions: RequestedPermissions) { + const permissions = Object.keys(approvedPermissions); + for (const permission of permissions) { + const parents = this.childToParentPermissionMap.get(permission); + if ( + parents && + !permissions.some((requestedPermission) => + parents.has(requestedPermission), + ) + ) { + return false; + } + } + return true; + } + + /** + * This function returns the root permissions that were determined at the point + * the PermissionTree was created. + * + * @returns The root permissions. + */ + getRootPermissions() { + return this.rootPermissions; + } + + /** + * Takes a permission request and populates it with all of the permissions' children. + * + * @param approvedPermissions - The permission request. + * @returns A populated permission request. + */ + getPopulatedRequest(approvedPermissions: RequestedPermissions) { + if (!this.hasValidPermissionGroups(approvedPermissions)) { + throw new Error( + 'Invalid permission request, child permissions must also have their parent permissions requested.', + ); + } + + const populatedRequest = { ...approvedPermissions }; + + const traverseRequestedPermissions = ( + node: PermissionSpecificationConstraint, + ) => { + if (!populatedRequest[node.targetName]) { + populatedRequest[node.targetName] = {}; + } + + if (node.children) { + for (const child of node.children) { + const childNode = this._permissionSpecifications[child]; + if (childNode) { + traverseRequestedPermissions(childNode); + } + } + } + }; + + for (const approvedPermission of Object.keys(approvedPermissions)) { + const permissionNode = this._permissionSpecifications[approvedPermission]; + if (permissionNode) { + traverseRequestedPermissions(permissionNode); + } + } + + return populatedRequest; + } +}