From 460ffb61ee214f8667ffe2e661b3320143a163c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Libert?= Date: Tue, 2 Apr 2024 10:46:27 +0200 Subject: [PATCH] add access control (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BenRey Co-authored-by: Thomas Sénéchal <44696378+thomas-senechal@users.noreply.github.com> --- assembly/__tests__/access_control.spec.ts | 255 ++++++++++++++++++++++ assembly/index.ts | 3 + assembly/security/accessControl.ts | 251 +++++++++++++++++++++ assembly/security/index.ts | 6 + package-lock.json | 22 +- 5 files changed, 520 insertions(+), 17 deletions(-) create mode 100644 assembly/__tests__/access_control.spec.ts create mode 100644 assembly/security/accessControl.ts create mode 100644 assembly/security/index.ts diff --git a/assembly/__tests__/access_control.spec.ts b/assembly/__tests__/access_control.spec.ts new file mode 100644 index 00000000..62f8a404 --- /dev/null +++ b/assembly/__tests__/access_control.spec.ts @@ -0,0 +1,255 @@ +import { Address, resetStorage } from '..'; +import { AccessControl } from '../security/accessControl'; + +describe('AccessControl - use case tests', () => { + test('should control access to functions 1', () => { + resetStorage(); + + expect(() => { + const adminAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKr', + ); + + const controller = new AccessControl(1); + + const ADMIN = controller.newPermission('admin'); + controller.grantPermission(ADMIN, adminAddress); + + const USER = controller.newPermission('user'); + controller.grantPermission(USER, userAddress); + + controller.mustHaveAnyPermission(ADMIN | USER, adminAddress); + controller.mustHaveAnyPermission(ADMIN | USER, userAddress); + }).not.toThrow('or on multiple permissions should work'); + }); + + test('should control access to functions 2', () => { + resetStorage(); + + expect(() => { + const adminAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKr', + ); + const guestAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKs', + ); + + const controller = new AccessControl(1); + + const ADMIN = controller.newPermission('admin'); + controller.grantPermission(ADMIN, adminAddress); + + const USER = controller.newPermission('user'); + controller.grantPermission(USER, userAddress); + + controller.mustHaveAnyPermission(ADMIN | USER, guestAddress); + }).toThrow('or on multiple permissions should work'); + }); +}); + +describe('AccessControl - unit tests', () => { + test('should create new permissions', () => { + resetStorage(); + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + expect(ADMIN).toStrictEqual(1, 'Admin permission should be 2⁰ = 1'); + const USER = accessControl.newPermission('user'); + expect(USER).toStrictEqual(2, 'User permission should be 2¹ = 2'); + const GUEST = accessControl.newPermission('guest'); + expect(GUEST).toStrictEqual(4, 'Guest permission should be 2² = 4'); + }); + + test('should panic on unknown permission', () => { + resetStorage(); + expect(() => { + const accessControl = new AccessControl(1); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(1, userAddress); + }).toThrow('permission does not exist'); + }); + + test('should not panic on adding permissions', () => { + resetStorage(); + expect(() => { + const accessControl = new AccessControl(1); + accessControl.newPermission('p1'); + accessControl.newPermission('p2'); + accessControl.newPermission('p3'); + accessControl.newPermission('p4'); + accessControl.newPermission('p5'); + accessControl.newPermission('p6'); + accessControl.newPermission('p7'); + accessControl.newPermission('p8'); + }).not.toThrow('Up to 8 permissions should be allowed'); + }); + + test('should panic on adding too many permissions', () => { + resetStorage(); + expect(() => { + const accessControl = new AccessControl(1); + accessControl.newPermission('p1'); + accessControl.newPermission('p2'); + accessControl.newPermission('p3'); + accessControl.newPermission('p4'); + accessControl.newPermission('p5'); + accessControl.newPermission('p6'); + accessControl.newPermission('p7'); + accessControl.newPermission('p8'); + accessControl.newPermission('p9'); + }).toThrow('Permission index overflow'); + }); + + test('should panic on adding permission twice', () => { + resetStorage(); + expect(() => { + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(ADMIN, userAddress); + accessControl.grantPermission(ADMIN, userAddress); + }).toThrow('User already has admin permission'); + }); + + test('should panic on missing must have permission', () => { + resetStorage(); + expect(() => { + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const USER = accessControl.newPermission('user'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(USER, userAddress); + accessControl.mustHavePermission(ADMIN, userAddress); + }).toThrow('User does not have admin permission'); + }); + + test('should handle multiple permissions', () => { + resetStorage(); + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const USER = accessControl.newPermission('user'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(USER, userAddress); + accessControl.mustHaveAnyPermission(ADMIN | USER, userAddress); + }); + + test('should panic on missing must have any permissions', () => { + resetStorage(); + expect(() => { + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const USER = accessControl.newPermission('user'); + const GUEST = accessControl.newPermission('guest'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(GUEST, userAddress); + accessControl.mustHaveAnyPermission(ADMIN | USER, userAddress); + }).toThrow('User does not have admin or user permission'); + }); + + test('should add permissions to user', () => { + resetStorage(); + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const USER = accessControl.newPermission('user'); + const GUEST = accessControl.newPermission('guest'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(USER, userAddress); + accessControl.grantPermission(GUEST, userAddress); + + expect(accessControl.hasPermission(USER, userAddress)).toBeTruthy( + 'User should have user permission', + ); + expect(accessControl.hasPermission(GUEST, userAddress)).toBeTruthy( + 'User should have guest permission', + ); + expect(accessControl.hasPermission(ADMIN, userAddress)).toBeFalsy( + 'User should not have admin permission', + ); + }); + + test('should remove permissions from user', () => { + resetStorage(); + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const USER = accessControl.newPermission('user'); + const GUEST = accessControl.newPermission('guest'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl.grantPermission(USER, userAddress); + accessControl.grantPermission(GUEST, userAddress); + accessControl.grantPermission(ADMIN, userAddress); + + expect(accessControl.hasPermission(USER, userAddress)).toBeTruthy( + 'User should have user permission', + ); + expect(accessControl.hasPermission(GUEST, userAddress)).toBeTruthy( + 'User should have guest permission', + ); + expect(accessControl.hasPermission(ADMIN, userAddress)).toBeTruthy( + 'User should have admin permission', + ); + + accessControl.revokePermission(USER, userAddress); + + expect(accessControl.hasPermission(USER, userAddress)).toBeFalsy( + 'User should not have user permission', + ); + expect(accessControl.hasPermission(GUEST, userAddress)).toBeTruthy( + 'User should have guest permission', + ); + expect(accessControl.hasPermission(ADMIN, userAddress)).toBeTruthy( + 'User should have admin permission', + ); + }); + + test('should return proper throw message', () => { + resetStorage(); + const accessControl = new AccessControl(1); + const ADMIN = accessControl.newPermission('admin'); + const USER = accessControl.newPermission('user'); + const GUEST = accessControl.newPermission('guest'); + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + + // accessControl.mustHavePermission(ADMIN, userAddress); + // accessControl.mustHavePermission(USER, userAddress); + // accessControl.mustHavePermission(GUEST, userAddress); + }); + + test('should handle multiple access control instances', () => { + resetStorage(); + const accessControl1 = new AccessControl(1); + const ADMIN = accessControl1.newPermission('admin'); + + const accessControl2 = new AccessControl(2); + const MECHANIC = accessControl2.newPermission('mechanic'); + + const userAddress = new Address( + 'AU12UBnqTHDQALpocVBnkPNy7y5CndUJQTLutaVDDFgMJcq5kQiKq', + ); + accessControl1.grantPermission(ADMIN, userAddress); + + expect(accessControl2.hasPermission(ADMIN, userAddress)).toBeFalsy( + 'User should not have car mechanic permission', + ); + }); +}); diff --git a/assembly/index.ts b/assembly/index.ts index ec90ecfb..681e2a3f 100644 --- a/assembly/index.ts +++ b/assembly/index.ts @@ -8,6 +8,9 @@ export { collections }; import * as env from './env/index'; export { env }; +import * as security from './security/index'; +export { security }; + // massa std functionalities export * from './std'; diff --git a/assembly/security/accessControl.ts b/assembly/security/accessControl.ts new file mode 100644 index 00000000..a341a0f7 --- /dev/null +++ b/assembly/security/accessControl.ts @@ -0,0 +1,251 @@ +import { fromBytes, toBytes } from '@massalabs/as-types'; +import { Address, Storage } from '../std/index'; + +/** + * Represents a role-based access control object. + * + * @remarks + * Manages roles and permissions using a bitmask approach. Each bit in a bitmask represents a distinct permission. + * + * @typeParam T - Use to size the bitmask. The maximum number of permissions is 8 * sizeof(). + * + * @privateRemarks + * - Permissions are encoded as bits in a bitmask for compact storage and easy manipulation. + * - User access rights are stored and managed in a similar bitmask format. + * - Utilizes blockchain's native storage, with ModuleId as a prefix to differentiate keys + * belonging to different modules. + * + * @example + * ```ts + * import { Context, Address } from '@massalabs/massa-as-sdk'; + * import { Args, stringToBytes } from '@massalabs/as-types'; + * import { AccessControl } from '@massalabs/sc-standards'; + * + * const controller = new AccessControl(1); + * const ADMIN = controller.newPermission('admin'); + * const USER = controller.newPermission('user'); + * + * export function constructor(raw: StaticArray): StaticArray { + * if (!Context.isDeployingContract()) { + * return []; + * } + * + * const args = new Args(raw); + * const adminAddress = args.nextSerializable
().expect('Admin address is missing'); + * const userAddress = args.nextSerializable
().expect('User address is missing'); + * + * controller.grantPermission(ADMIN, adminAddress); + * controller.grantPermission(USER, userAddress); + * + * return []; + * } + * + * export function superSensite(_: StaticArray): StaticArray { + * controller.mustHavePermission(ADMIN, Context.caller()); + * return stringToBytes('Super sensitive data'); + * } + * + * export function internalOnly(_: StaticArray): StaticArray { + * controller.mustHaveAnyPermission(ADMIN | USER, Context.caller()); + * return stringToBytes('Internal data'); + * } + * + * export function publicData(_: StaticArray): StaticArray { + * return stringToBytes('Public data'); + * } + * ``` + */ +export class AccessControl { + // @ts-ignore non-number type + private permissionIndex: u8 = 0; + private permissionsName: string[] = []; + private moduleId: u8; + private errPermissionDoesNotExist: string = 'Permission does not exist.'; + + /** + * + * @param moduleId - The module identifier to differentiate storage keys. + */ + constructor(moduleId: u8) { + this.moduleId = moduleId; + } + + @inline + private _getStorageKey(userAddress: Address): StaticArray { + const key = new StaticArray(1); + key[0] = this.moduleId; + return key.concat(userAddress.serialize()); + } + + @inline + private _getUserAccess(user: Address): T { + const key = this._getStorageKey(user); + return Storage.has(key) ? fromBytes(Storage.get(key)) : 0; + } + + @inline + private _setUserAccess(user: Address, access: T): void { + const key = this._getStorageKey(user); + Storage.set(key, toBytes(access)); + } + + @inline + private _permissionIndexToBitmask(index: u8): T { + return (1 << index); + } + + @inline + private _permissionToName(permission: T): string { + // @ts-ignore arithmetic operations on generic types + return this.permissionsName[permission >> 1]; + } + + /** + * Creates a new permission. + * + * @remarks + * Permissions are dynamically created and not stored in the contract's storage. + * While this optimization reduces storage usage, it also means that the permission + * must be globally defined and consistent. + * + * @param Permission - The name of the permission. + * @returns a number representing the permission. + * + * @throws if the maximum number of permissions is reached. + */ + public newPermission(Permission: string): T { + assert( + this.permissionIndex < sizeof() * 8, + `Maximum number of permissions reached.`, + ); + this.permissionsName.push(Permission); + this.permissionIndex += 1; + return this._permissionIndexToBitmask(this.permissionIndex - 1); + } + + /** + * Add a permission to a user. + * + * @remarks + * Updated permissions are stored in the contract's storage. + * + * @param permission - The permission to grant. + * @param user - The user identified by his address. + * + * @throws if the user already has the permission or if the permission does not exist. + */ + public grantPermission(permission: T, user: Address): void { + assert( + permission < this._permissionIndexToBitmask(this.permissionIndex), + this.errPermissionDoesNotExist, + ); + + const ua = this._getUserAccess(user); + + assert( + // @ts-ignore arithmetic operations on generic types + (ua & permission) != permission, + `User already has '${this._permissionToName(permission)}' permission.`, + ); + // @ts-ignore arithmetic operations on generic types + this._setUserAccess(user, ua | permission); + } + + /** + * Removes a permission from a user. + * + * @remarks + * Updated permissions are stored in the contract's storage. + * + * @param permission - The permission to remove. + * @param user - The user identified by his address. + * + * @throws if the user does not have the permission or if the permission does not exist. + */ + public revokePermission(permission: T, user: Address): void { + assert( + permission < this._permissionIndexToBitmask(this.permissionIndex), + this.errPermissionDoesNotExist, + ); + + const ua = this._getUserAccess(user); + + assert( + // @ts-ignore arithmetic operations on generic types + (ua & permission) == permission, + `User does not have '${this._permissionToName(permission)}' permission.`, + ); + // @ts-ignore arithmetic operations on generic types + this._setUserAccess(user, ua & ~permission); + } + + /** + * Checks if the user has the given permission. + * + * @param permission - The permission bitmask to check. + * @param user - The user identified by his address. + * @returns true if the user has the permission, false otherwise. + * + * @throws if the permission does not exist. + */ + public hasPermission(permission: T, user: Address): boolean { + assert( + permission < this._permissionIndexToBitmask(this.permissionIndex), + this.errPermissionDoesNotExist, + ); + + const ua = this._getUserAccess(user); + // @ts-ignore arithmetic operations on generic types + return (ua & permission) == permission; + } + + /** + * Checks if the user has the given permission. + * + * @param permission - The permission bitmask to check. + * @param user - The user identified by his address. + * + * @throws if the user does not have the permission. + */ + @inline + public mustHavePermission(permission: T, user: Address): void { + assert( + this.hasPermission(permission, user), + `User does not have '${this._permissionToName(permission)}' permission.`, + ); + } + + /** + * Checks if the user has any of the given permissions. + * @param permission - The permission bitmask to check. + * @param user - The user identified by his address. + * @returns true if the user has any of the permissions, false otherwise. + * + * @throws if the permission does not exist. + */ + public hasAnyPermission(permission: T, user: Address): boolean { + assert( + permission < this._permissionIndexToBitmask(this.permissionIndex), + this.errPermissionDoesNotExist, + ); + + const ua = this._getUserAccess(user); + // @ts-ignore arithmetic operations on generic types + return (ua & permission) != 0; + } + + /** + * Checks if the user has any of the given permissions. + * @param permission - The permission bitmask to check. + * @param user - The user identified by his address. + * + * @throws if the user does not have any of the permissions. + */ + @inline + public mustHaveAnyPermission(permission: T, user: Address): void { + assert( + this.hasAnyPermission(permission, user), + `User does not have any of the permissions.`, + ); + } +} diff --git a/assembly/security/index.ts b/assembly/security/index.ts new file mode 100644 index 00000000..8b0dfffe --- /dev/null +++ b/assembly/security/index.ts @@ -0,0 +1,6 @@ +/** + * This namespace is a compilation of utilities functions relative to **smart contract security**. + * + * @module Security + */ +export * from './accessControl'; diff --git a/package-lock.json b/package-lock.json index 07a0d51b..716d6f4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6049,7 +6049,6 @@ }, "node_modules/npm/node_modules/lodash._baseindexof": { "version": "3.1.0", - "dev": true, "inBundle": true, "license": "MIT" }, @@ -6065,19 +6064,16 @@ }, "node_modules/npm/node_modules/lodash._bindcallback": { "version": "3.0.1", - "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/lodash._cacheindexof": { "version": "3.0.2", - "dev": true, "inBundle": true, "license": "MIT" }, "node_modules/npm/node_modules/lodash._createcache": { "version": "3.1.2", - "dev": true, "inBundle": true, "license": "MIT", "dependencies": { @@ -6092,7 +6088,6 @@ }, "node_modules/npm/node_modules/lodash._getnative": { "version": "3.9.1", - "dev": true, "inBundle": true, "license": "MIT" }, @@ -6110,7 +6105,6 @@ }, "node_modules/npm/node_modules/lodash.restparam": { "version": "3.6.1", - "dev": true, "inBundle": true, "license": "MIT" }, @@ -13920,8 +13914,7 @@ }, "lodash._baseindexof": { "version": "3.1.0", - "bundled": true, - "dev": true + "bundled": true }, "lodash._baseuniq": { "version": "4.6.0", @@ -13934,18 +13927,15 @@ }, "lodash._bindcallback": { "version": "3.0.1", - "bundled": true, - "dev": true + "bundled": true }, "lodash._cacheindexof": { "version": "3.0.2", - "bundled": true, - "dev": true + "bundled": true }, "lodash._createcache": { "version": "3.1.2", "bundled": true, - "dev": true, "requires": { "lodash._getnative": "^3.0.0" } @@ -13957,8 +13947,7 @@ }, "lodash._getnative": { "version": "3.9.1", - "bundled": true, - "dev": true + "bundled": true }, "lodash._root": { "version": "3.0.1", @@ -13972,8 +13961,7 @@ }, "lodash.restparam": { "version": "3.6.1", - "bundled": true, - "dev": true + "bundled": true }, "lodash.union": { "version": "4.6.0",