diff --git a/docs/CustomServerCode.md b/docs/CustomServerCode.md index 5aaa35365..abd061c0a 100644 --- a/docs/CustomServerCode.md +++ b/docs/CustomServerCode.md @@ -62,6 +62,9 @@ This returns a promise that resolves to the user permissions for the given userI #### getApiKeyPermissions(apiKey) This returns a promise that resolves to the api key permissions for the given apiKey +#### getUserPermissionsForRole(roleName, userId) +This returns a promise that resolves to the roleName permissions if the user given by userId has this role + #### apiKeyMiddleware(req, res, next) This is a default `customApiKeyMiddleware`, which is useful to avoid having to create your own default one. diff --git a/src/config-loader/env.ts b/src/config-loader/env.ts index 5c48811e6..f8c2c381d 100644 --- a/src/config-loader/env.ts +++ b/src/config-loader/env.ts @@ -47,6 +47,7 @@ export const cache = { userPermissions: false as CacheOpts, apiKeyPermissions: false as CacheOpts, apiKeyActorId: false as CacheOpts, + rolePermissions: false as CacheOpts, }; import { boolVar } from '@balena/env-parsing'; diff --git a/src/sbvr-api/permissions.ts b/src/sbvr-api/permissions.ts index f7663bc23..8d7aa74ed 100644 --- a/src/sbvr-api/permissions.ts +++ b/src/sbvr-api/permissions.ts @@ -1273,6 +1273,99 @@ export const getUserPermissions = async ( } }; +export const getUserPermissionsForRole = async ( + userId: number, + roleName: string, + tx?: Tx, +): Promise => { + try { + return await $getUserPermissionsForRole(userId, roleName, tx); + } catch (err: unknown) { + sbvrUtils.api.Auth.logger.error( + `Error loading role permissions for ${userId} ${roleName}`, + err, + ); + throw err; + } +}; + +const $getUserPermissionsForRole = (() => { + const getUserPermissionsForRoleQuery = _.once(() => + sbvrUtils.api.Auth.prepare<{ userId: number; roleName: string }>({ + resource: 'permission', + passthrough: { + req: rootRead, + }, + options: { + $select: 'name', + $filter: { + is_of__role: { + $any: { + $alias: 'rhp', + $expr: { + rhp: { + role: { + $any: { + $alias: 'r', + $expr: { + r: { + name: { '@': 'roleName' }, + is_of__user: { + $any: { + $alias: 'uhr', + $expr: { + uhr: { user: { '@': 'userId' } }, + $or: [ + { + uhr: { expiry_date: null }, + }, + { + uhr: { + expiry_date: { $gt: { $now: null } }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + // We orderby to increase the hit rate for the `_checkPermissions` memoisation + $orderby: { + name: 'asc', + }, + }, + }), + ); + + return env.createCache( + 'rolePermissions', + async (userId: number, roleName: string, tx?: Tx) => { + const permissions = (await getUserPermissionsForRoleQuery()( + { + userId, + roleName, + }, + undefined, + { tx }, + )) as Array<{ name: string }>; + return permissions.map((permission) => permission.name); + }, + { + primitive: true, + promise: true, + normalizer: ([userId, roleName]) => `${userId}${roleName}`, + }, + ); +})(); + const $getApiKeyPermissions = (() => { const getApiKeyPermissionsQuery = _.once(() => sbvrUtils.api.Auth.prepare<{ apiKey: string }>({ diff --git a/test/08-sbvrApi.test.ts b/test/08-sbvrApi.test.ts new file mode 100644 index 000000000..c8f4f4ffa --- /dev/null +++ b/test/08-sbvrApi.test.ts @@ -0,0 +1,105 @@ +import supertest from 'supertest'; +import { expect } from 'chai'; +const configPath = __dirname + '/fixtures/08-sbvrApi/config.js'; +import { testInit, testDeInit, testLocalServer } from './lib/test-init'; +import * as pine from '../src/server-glue/module'; +import { PineTest } from 'pinejs-client-supertest'; +import type { AnyObject } from 'pinejs-client-core'; + +const { permissions } = pine; + +describe('08 sbvrApi', function () { + let pineServer: Awaited>; + let pineTest: PineTest; + before(async () => { + pineServer = await testInit({ + configPath, + deleteDb: true, + exposeAuthEndpoints: true, + }); + pineTest = new PineTest({}, { app: testLocalServer }); + }); + + after(async () => { + testDeInit(pineServer); + }); + + describe('permissions.getUserPermissionsForRole', () => { + let guestId: number; + const testPermissions = ['test.permission1', 'test.permission2']; + before(async () => { + guestId = await getUserId('guest'); + const permissionIds = await createPermissions(testPermissions); + const roleId = await createRole('test', permissionIds); + await grantRoleToUser(roleId, guestId); + }); + + it(`should be able to get a specific role's permissions if role exists and user has it`, async () => { + // pine API only exists on the process it is currently running. + // We use custom endpoints to expose the specific funcionality being tested + const response = await supertest(testLocalServer) + .get('/auth-test/getUserPermissionsForRole') + .send({ userId: guestId, roleName: 'test' }); + expect(response.body).to.deep.equal(testPermissions); + }); + }); + + const getUserId = async (username: string): Promise => { + const { + d: [{ id }], + } = await doAuthRequest('GET', 'user', { username }); + return id; + }; + + const createPermissions = async ( + permissionNames: string[], + ): Promise => { + return ( + await Promise.all( + permissionNames.map(async (permissionName) => + doAuthRequest('POST', 'permission', { name: permissionName }), + ), + ) + ).map((response) => response.id); + }; + + const createRole = async ( + roleName: string, + permissionIds: number[], + ): Promise => { + const { id: roleId } = await doAuthRequest('POST', 'role', { + name: roleName, + }); + await Promise.all( + permissionIds.map(async (permissionId) => + doAuthRequest('POST', 'role__has__permission', { + role: roleId, + permission: permissionId, + }), + ), + ); + + return roleId; + }; + + const grantRoleToUser = async (role: number, user: number) => { + await doAuthRequest('POST', 'user__has__role', { role, user }); + }; + + const doAuthRequest = async ( + method: 'GET' | 'POST', + resource: string, + body: AnyObject, + ) => { + return ( + await pineTest.request({ + apiPrefix: 'Auth/', + method, + resource, + body, + passthrough: { req: permissions.root }, + options: { returnResource: false }, + }) + ).body; + }; +}); diff --git a/test/fixtures/08-sbvrApi/basic.sbvr b/test/fixtures/08-sbvrApi/basic.sbvr new file mode 100644 index 000000000..243bc83cd --- /dev/null +++ b/test/fixtures/08-sbvrApi/basic.sbvr @@ -0,0 +1,4 @@ +Vocabulary: basic + +Term: name + Concept Type: Short Text (Type) diff --git a/test/fixtures/08-sbvrApi/config.ts b/test/fixtures/08-sbvrApi/config.ts new file mode 100644 index 000000000..ca3392003 --- /dev/null +++ b/test/fixtures/08-sbvrApi/config.ts @@ -0,0 +1,22 @@ +import type { ConfigLoader } from '../../../src/server-glue/module'; + +const apiRoot = 'basic'; +const modelName = 'basic'; +const modelFile = __dirname + '/basic.sbvr'; + +export default { + models: [ + { + modelName, + modelFile, + apiRoot, + }, + ], + users: [ + { + username: 'guest', + password: ' ', + permissions: ['resource.all'], + }, + ], +} as ConfigLoader.Config; diff --git a/test/lib/pine-in-process.ts b/test/lib/pine-in-process.ts index 981e5e155..108be9630 100644 --- a/test/lib/pine-in-process.ts +++ b/test/lib/pine-in-process.ts @@ -14,6 +14,7 @@ export async function forkInit() { processArgs.listenPort, processArgs.deleteDb, processArgs.withLoginRoute, + processArgs.exposeAuthEndpoints, ); // load hooks diff --git a/test/lib/pine-init.ts b/test/lib/pine-init.ts index 4c9450997..ff63d42be 100644 --- a/test/lib/pine-init.ts +++ b/test/lib/pine-init.ts @@ -8,6 +8,7 @@ export type PineTestOptions = { hooksPath?: string; routesPath?: string; withLoginRoute?: boolean; + exposeAuthEndpoints?: boolean; deleteDb: boolean; listenPort: number; }; @@ -17,11 +18,28 @@ export async function init( initPort: number, deleteDb: boolean = false, withLoginRoute: boolean = false, + exposeAuthEndpoints: boolean = false, ) { const app = express(); app.use(express.urlencoded({ extended: true })); app.use(express.json()); + if (exposeAuthEndpoints) { + app.all('/Auth/*', pine.sbvrUtils.handleODataRequest); + + // pine object that can actually call the helper functions we want to test only exist on the server process + // in order to be able to invoke these functions (in a different process) and properly test we create a custom + // endpoint which allows us to execute the api function we are testing + app.get('/auth-test/getUserPermissionsForRole', async (req, res) => { + const body = req.body as { userId: number; roleName: string }; + const permissions = await pine.permissions.getUserPermissionsForRole( + body.userId, + body.roleName, + ); + res.status(200).send(permissions); + }); + } + if (withLoginRoute) { /* eslint-disable @typescript-eslint/no-var-requires */ const expressSession: typeof ExpressSession = require('express-session'); diff --git a/test/lib/test-init.ts b/test/lib/test-init.ts index c4b0aea16..229cd6af1 100644 --- a/test/lib/test-init.ts +++ b/test/lib/test-init.ts @@ -17,6 +17,7 @@ export async function testInit( hooksPath: options.hooksPath, routesPath: options.routesPath, withLoginRoute: options.withLoginRoute, + exposeAuthEndpoints: options.exposeAuthEndpoints, }; const testServer = fork( __dirname + '/pine-in-process.ts',