diff --git a/.env.example b/.env.example index 6701e4b..4d430a8 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ VUE_APP_I18N_FALLBACK_LOCALE=en VUE_APP_CACHE_MAX_AGE=3600 VUE_APP_VIEW_SIZE=15 VUE_APP_BASE_URL= -VUE_APP_PERMISSION_ID= +VUE_APP_PERMISSION_ID="PICKING_APP_VIEW" VUE_APP_ALIAS={} VUE_APP_DEFAULT_LOG_LEVEL="error" VUE_APP_LOGIN_URL="http://launchpad.hotwax.io/login" diff --git a/package-lock.json b/package-lock.json index e6f6884..d0a70a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@capacitor/android": "^2.5.0", "@capacitor/core": "^2.4.7", "@capacitor/ios": "^2.5.0", + "@casl/ability": "^6.0.0", "@hotwax/app-version-info": "^1.0.0", "@hotwax/apps-theme": "^1.2.6", "@hotwax/dxp-components": "^1.12.2", @@ -18,6 +19,7 @@ "@ionic/core": "~7.6.0", "@ionic/vue": "~7.6.0", "@ionic/vue-router": "~7.6.0", + "boon-js": "^2.0.3", "core-js": "^3.6.5", "luxon": "^3.2.0", "mitt": "^2.1.0", @@ -1931,6 +1933,17 @@ "@capacitor/core": "~2.5.0" } }, + "node_modules/@casl/ability": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.1.tgz", + "integrity": "sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4158,6 +4171,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz", + "integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@vue/babel-helper-vue-jsx-merge-props": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.4.0.tgz", @@ -5976,6 +6020,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, + "node_modules/boon-js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/boon-js/-/boon-js-2.0.5.tgz", + "integrity": "sha512-Ck9YXckVbbIjpZPxtKXJ5TcT+ptU4E9lJy2X6hBKsR2nZqg026eHf9aw3KGXoFbfO4coRLaW9ql0tWrBrqJt1A==" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", diff --git a/package.json b/package.json index ab7d335..06ac4b0 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@capacitor/android": "^2.5.0", "@capacitor/core": "^2.4.7", "@capacitor/ios": "^2.5.0", + "@casl/ability": "^6.0.0", "@hotwax/app-version-info": "^1.0.0", "@hotwax/apps-theme": "^1.2.6", "@hotwax/dxp-components": "^1.12.2", @@ -22,6 +23,7 @@ "@ionic/core": "~7.6.0", "@ionic/vue": "~7.6.0", "@ionic/vue-router": "~7.6.0", + "boon-js": "^2.0.3", "core-js": "^3.6.5", "luxon": "^3.2.0", "mitt": "^2.1.0", diff --git a/src/authorization/Actions.ts b/src/authorization/Actions.ts new file mode 100644 index 0000000..bb36c01 --- /dev/null +++ b/src/authorization/Actions.ts @@ -0,0 +1,3 @@ +export default { + "PICKING_APP_VIEW": "PICKING_APP_VIEW" +} as any \ No newline at end of file diff --git a/src/authorization/Rules.ts b/src/authorization/Rules.ts new file mode 100644 index 0000000..bb36c01 --- /dev/null +++ b/src/authorization/Rules.ts @@ -0,0 +1,3 @@ +export default { + "PICKING_APP_VIEW": "PICKING_APP_VIEW" +} as any \ No newline at end of file diff --git a/src/authorization/index.ts b/src/authorization/index.ts new file mode 100644 index 0000000..4ac0f6a --- /dev/null +++ b/src/authorization/index.ts @@ -0,0 +1,124 @@ +import { AbilityBuilder, PureAbility } from '@casl/ability'; +import { getEvaluator, parse } from 'boon-js'; +import { Tokens } from 'boon-js/lib/types' + +// TODO Improve this +// We will move this code to an external plugin and use below Actions and Rules accordlingly +let Actions = {} as any; +let Rules = {} as any; + +// We are using CASL library to define permissions. +// Instead of using Action-Subject based authorisation we are going with Claim based Authorization. +// We would be defining the permissions for each action and case, map with server permissiosn based upon certain rules. +// https://casl.js.org/v5/en/cookbook/claim-authorization +// Following the comment of Sergii Stotskyi, author of CASL +// https://github.com/stalniy/casl/issues/525 +// We are defining a PureAbility and creating an instance with AbilityBuilder. +type ClaimBasedAbility = PureAbility; +const { build } = new AbilityBuilder(PureAbility); +const ability = build(); + +/** + * The method returns list of permissions required for the rules. We are having set of rules, + * through which app permissions are defined based upon the server permissions. + * When getting server permissions, as all the permissions are not be required. + * Specific permissions used defining the rules are extracted and sent to server. + * @returns permissions + */ +const getServerPermissionsFromRules = () => { + // Iterate for each rule + const permissions = Object.keys(Rules).reduce((permissions: any, rule: any) => { + const permissionRule = Rules[rule]; + // some rules may be empty, no permission is required from server + if (permissionRule) { + // Each rule may have multiple permissions along with operators + // Boon js parse rules into tokens, each token may be operator or server permission + // permissionId will have token name as identifier. + const permissionTokens = parse(permissionRule); + permissions = permissionTokens.reduce((permissions: any, permissionToken: any) => { + // Token object with name as identifier has permissionId + if (Tokens.IDENTIFIER === permissionToken.name) { + permissions.add(permissionToken.value); + } + return permissions; + }, permissions) + } + return permissions; + }, new Set()) + return [...permissions]; +} + +/** + * The method is used to prepare app permissions from the server permissions. + * Rules could be defined such that each app permission could be defined based upon certain one or more server permissions. + * @param serverPermissions + * @returns appPermissions + */ +const prepareAppPermissions = (serverPermissions: any) => { + const serverPermissionsInput = serverPermissions.reduce((serverPermissionsInput: any, permission: any) => { + serverPermissionsInput[permission] = true; + return serverPermissionsInput; + }, {}) + // Boonjs evaluator needs server permissions as object with permissionId and boolean value + // Each rule is passed to evaluator along with the server permissions + // if the server permissions and rule matches, app permission is added to list + const permissions = Object.keys(Rules).reduce((permissions: any, rule: any) => { + const permissionRule = Rules[rule]; + // If for any app permission, we have empty rule we user is assigned the permission + // If rule is not defined, the app permisions is still evaluated or provided to all the users. + if (!permissionRule || (permissionRule && getEvaluator(permissionRule)(serverPermissionsInput))) { + permissions.push(rule); + } + return permissions; + }, []) + const { can, rules } = new AbilityBuilder(PureAbility); + permissions.map((permission: any) => { + can(permission); + }) + return rules; +} + +/** + * + * Sets the current app permissions. This should be used after perparing the app permissions from the server permissions + * @param permissions + * @returns + */ +const setPermissions = (permissions: any) => { + // If the user has passed undefined or null, it should not break the code + if (!permissions) permissions = []; + ability.update(permissions) + return true; +}; + +/** + * Resets the permissions list. Used for cases like logout + */ +const resetPermissions = () => setPermissions([]); + +/** + * + * @param permission + * @returns + */ +const hasPermission = (permission: string) => ability.can(permission); + +export { Actions, getServerPermissionsFromRules, hasPermission, prepareAppPermissions, resetPermissions, setPermissions}; + +// TODO Move this code to an external plugin, to be used across the apps +export default { + install(app: any, options: any) { + + // Rules and Actions could be app and OMS package specific + Rules = options.rules; + Actions = options.actions; + + // TODO Check why global properties is not working and apply across. + app.config.globalProperties.$permission = this; + }, + getServerPermissionsFromRules, + hasPermission, + prepareAppPermissions, + resetPermissions, + setPermissions +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index a86e763..7370dba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,9 @@ import store from './store' import { dxpComponents } from '@hotwax/dxp-components' import { login, logout, loader } from './user-utils'; import { getConfig, getProductIdentificationPref, initialise, setProductIdentificationPref } from '@/adapter' +import permissionPlugin from '@/authorization'; +import permissionRules from '@/authorization/Rules'; +import permissionActions from '@/authorization/Actions'; import localeMessages from './locales'; const app = createApp(App) @@ -40,6 +43,10 @@ const app = createApp(App) .use(router) .use(i18n) .use(store) + .use(permissionPlugin, { + rules: permissionRules, + actions: permissionActions + }) .use(dxpComponents, { defaultImgUrl: require("@/assets/images/defaultImage.png"), login, diff --git a/src/services/UserService.ts b/src/services/UserService.ts index 19ed018..18f2d58 100644 --- a/src/services/UserService.ts +++ b/src/services/UserService.ts @@ -12,14 +12,93 @@ const login = async (username: string, password: string): Promise => { }); } -const checkPermission = async (payload: any): Promise => { +const getUserPermissions = async (payload: any, token: any): Promise => { const baseURL = store.getters['user/getBaseUrl']; - return client({ - url: "checkPermission", - method: "post", - baseURL: baseURL, - ...payload - }); + let serverPermissions = [] as any; + + // If the server specific permission list doesn't exist, getting server permissions will be of no use + // It means there are no rules yet depending upon the server permissions. + if (payload.permissionIds && payload.permissionIds.length == 0) return serverPermissions; + // TODO pass specific permissionIds + let resp; + // TODO Make it configurable from the environment variables. + // Though this might not be an server specific configuration, + // we will be adding it to environment variable for easy configuration at app level + const viewSize = 200; + + try { + const params = { + "viewIndex": 0, + viewSize, + permissionIds: payload.permissionIds + } + resp = await client({ + url: "getPermissions", + method: "post", + baseURL, + data: params, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }) + if (resp.status === 200 && resp.data.docs?.length && !hasError(resp)) { + serverPermissions = resp.data.docs.map((permission: any) => permission.permissionId); + const total = resp.data.count; + const remainingPermissions = total - serverPermissions.length; + if (remainingPermissions > 0) { + // We need to get all the remaining permissions + const apiCallsNeeded = Math.floor(remainingPermissions / viewSize) + (remainingPermissions % viewSize != 0 ? 1 : 0); + const responses = await Promise.all([...Array(apiCallsNeeded).keys()].map(async (index: any) => { + const response = await client({ + url: "getPermissions", + method: "post", + baseURL, + data: { + "viewIndex": index + 1, + viewSize, + permissionIds: payload.permissionIds + }, + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }) + if (!hasError(response)) { + return Promise.resolve(response); + } else { + return Promise.reject(response); + } + })) + const permissionResponses = { + success: [], + failed: [] + } + responses.reduce((permissionResponses: any, permissionResponse: any) => { + if (permissionResponse.status !== 200 || hasError(permissionResponse) || !permissionResponse.data?.docs) { + permissionResponses.failed.push(permissionResponse); + } else { + permissionResponses.success.push(permissionResponse); + } + return permissionResponses; + }, permissionResponses) + + serverPermissions = permissionResponses.success.reduce((serverPermissions: any, response: any) => { + serverPermissions.push(...response.data.docs.map((permission: any) => permission.permissionId)); + return serverPermissions; + }, serverPermissions) + + // If partial permissions are received and we still allow user to login, some of the functionality might not work related to the permissions missed. + // Show toast to user intimiting about the failure + // Allow user to login + // TODO Implement Retry or improve experience with show in progress icon and allowing login only if all the data related to user profile is fetched. + if (permissionResponses.failed.length > 0) Promise.reject("Something went wrong while getting complete user permissions."); + } + } + return serverPermissions; + } catch (error: any) { + return Promise.reject(error); + } } const getCurrentEComStore = async (token: any, facilityId: any): Promise => { @@ -88,6 +167,6 @@ export const UserService = { getAvailableTimeZones, getProfile, setUserTimeZone, - checkPermission, - getCurrentEComStore + getCurrentEComStore, + getUserPermissions } \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 83de4ec..c614de2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -8,7 +8,7 @@ import userModule from './modules/user'; import picklistModule from "./modules/picklist" import productModule from "./modules/product" import partyModule from './modules/party' - +import { setPermissions } from "@/authorization" // TODO check how to register it from the components only // Handle same module registering multiple time on page refresh @@ -39,6 +39,8 @@ const store = createStore({ }, }) +setPermissions(store.getters['user/getUserPermissions']); + export default store export function useStore(): typeof store { return useVuexStore() diff --git a/src/store/modules/user/UserState.ts b/src/store/modules/user/UserState.ts index fde0b6a..7c00c46 100644 --- a/src/store/modules/user/UserState.ts +++ b/src/store/modules/user/UserState.ts @@ -6,4 +6,5 @@ export default interface UserState { picklistItemSortBy: string; pwaState: any; currentEComStore: any; + permissions: any; } \ No newline at end of file diff --git a/src/store/modules/user/actions.ts b/src/store/modules/user/actions.ts index d0ccdbf..f3cc8e0 100644 --- a/src/store/modules/user/actions.ts +++ b/src/store/modules/user/actions.ts @@ -8,6 +8,7 @@ import { translate } from '@/i18n' import { Settings } from 'luxon'; import { hasError, logout, updateInstanceUrl, updateToken, resetConfig } from '@/adapter' import { useAuthStore, useProductIdentificationStore } from '@hotwax/dxp-components' +import { getServerPermissionsFromRules, prepareAppPermissions, resetPermissions, setPermissions } from '@/authorization' import emitter from '@/event-bus' import router from '@/router'; @@ -22,33 +23,36 @@ const actions: ActionTree = { dispatch("setUserInstanceUrl", oms); if (token) { const permissionId = process.env.VUE_APP_PERMISSION_ID; + // Prepare permissions list + const serverPermissionsFromRules = getServerPermissionsFromRules(); + if (permissionId) serverPermissionsFromRules.push(permissionId); + + const serverPermissions = await UserService.getUserPermissions({ + permissionIds: [...new Set(serverPermissionsFromRules)] + }, token); + const appPermissions = prepareAppPermissions(serverPermissions); + + // Checking if the user has permission to access the app + // If there is no configuration, the permission check is not enabled if (permissionId) { - const checkPermissionResponse = await UserService.checkPermission({ - data: { - permissionId - }, - headers: { - Authorization: 'Bearer ' + token, - 'Content-Type': 'application/json' - } - }); - - if (checkPermissionResponse.status === 200 && !hasError(checkPermissionResponse) && checkPermissionResponse.data && checkPermissionResponse.data.hasPermission) { - commit(types.USER_TOKEN_CHANGED, { newToken: token }) - updateToken(token) - await dispatch('getProfile', token) - } else { + // As the token is not yet set in the state passing token headers explicitly + // TODO Abstract this out, how token is handled should be part of the method not the callee + const hasPermission = appPermissions.some((appPermission: any) => appPermission.action === permissionId); + // If there are any errors or permission check fails do not allow user to login + if (!hasPermission) { const permissionError = 'You do not have permission to access the app.'; showToast(translate(permissionError)); console.error("error", permissionError); return Promise.reject(new Error(permissionError)); } - } else { - commit(types.USER_TOKEN_CHANGED, { newToken: token }) - updateToken(token) - await dispatch('getProfile', token) } + updateToken(token) + setPermissions(appPermissions); + commit(types.USER_PERMISSIONS_UPDATED, appPermissions); + commit(types.USER_TOKEN_CHANGED, { newToken: token }) + await dispatch('getProfile', token) + // accessing picklist ID from router as route cannot be accessed here const picklistId = router.currentRoute.value.query.picklistId if (picklistId) { @@ -94,6 +98,7 @@ const actions: ActionTree = { const authStore = useAuthStore() // TODO add any other tasks if need commit(types.USER_END_SESSION) + resetPermissions(); resetConfig(); this.dispatch('picklist/setFilters', { hideCompletedPicklists: true, diff --git a/src/store/modules/user/getters.ts b/src/store/modules/user/getters.ts index 095016b..13bff11 100644 --- a/src/store/modules/user/getters.ts +++ b/src/store/modules/user/getters.ts @@ -36,5 +36,8 @@ const getters: GetterTree = { if (!baseURL) baseURL = state.instanceUrl; return baseURL.startsWith('http') ? baseURL.includes('/api') ? baseURL : `${baseURL}/api/` : `https://${baseURL}.hotwax.io/api/`; }, + getUserPermissions(state) { + return state.permissions; + }, } export default getters; \ No newline at end of file diff --git a/src/store/modules/user/index.ts b/src/store/modules/user/index.ts index 708caf2..b3cabe8 100644 --- a/src/store/modules/user/index.ts +++ b/src/store/modules/user/index.ts @@ -17,7 +17,8 @@ const userModule: Module = { pwaState: { updateExists: false, registration: null, - } + }, + permissions: [], }, getters, actions, diff --git a/src/store/modules/user/mutation-types.ts b/src/store/modules/user/mutation-types.ts index 8463893..dd292bc 100644 --- a/src/store/modules/user/mutation-types.ts +++ b/src/store/modules/user/mutation-types.ts @@ -7,3 +7,4 @@ export const USER_INSTANCE_URL_UPDATED = SN_USER + '/INSTANCE_URL_UPDATED' export const USER_SORTBY_UPDATED = SN_USER + '/SORTBY_UPDATED' export const USER_PWA_STATE_UPDATED = SN_USER + '/PWA_STATE_UPDATED' export const USER_CURRENT_ECOM_STORE_UPDATED = SN_USER + '/CURRENT_ECOM_STORE_UPDATED' +export const USER_PERMISSIONS_UPDATED = SN_USER + '/PERMISSIONS_UPDATED' diff --git a/src/store/modules/user/mutations.ts b/src/store/modules/user/mutations.ts index 6123318..6748df1 100644 --- a/src/store/modules/user/mutations.ts +++ b/src/store/modules/user/mutations.ts @@ -29,6 +29,9 @@ const mutations: MutationTree = { }, [types.USER_CURRENT_ECOM_STORE_UPDATED] (state, payload) { state.currentEComStore = payload - } + }, + [types.USER_PERMISSIONS_UPDATED](state, payload) { + state.permissions = payload + }, } export default mutations; \ No newline at end of file