@@ -39,7 +38,7 @@ export default class DashboardLayout extends React.Component {
trigger={
- {getOrganization()?.name || 'Select Organization'}
+ {organization?.name || 'Select Organization'}
}
@@ -84,28 +83,22 @@ export default class DashboardLayout extends React.Component {
Settings
- {userHasAccess(this.context.user, {
- endpoint: 'applications',
- permission: 'read',
- scope: 'global',
- }) && (
-
-
-
- Audit Trail
-
-
-
- Applications
+
+
+
+ Audit Trail
+
+
+
+ Applications
+
+
+
+
+ API Docs
-
-
-
- API Docs
-
-
-
- )}
+
+
Log Out
diff --git a/services/web/src/modals/OrganizationSelector.js b/services/web/src/modals/OrganizationSelector.js
index 02d30ed3d..4195f2bfc 100644
--- a/services/web/src/modals/OrganizationSelector.js
+++ b/services/web/src/modals/OrganizationSelector.js
@@ -9,19 +9,19 @@ import SearchDropdown from 'components/SearchDropdown';
import { request } from 'utils/api';
import { userHasAccess } from 'utils/permissions';
+import { getOrganization, setOrganization } from 'utils/organization';
@modal
@withSession
export default class OrganizationSelector extends React.Component {
fetchOrganizations = async (body) => {
const { user } = this.context;
- if (
- userHasAccess(user, {
- endpoint: 'organizations',
- permission: 'read',
- scope: 'global',
- })
- ) {
+ const hasGlobal = userHasAccess(user, {
+ endpoint: 'organizations',
+ permission: 'read',
+ scope: 'global',
+ });
+ if (hasGlobal) {
const { data } = await request({
method: 'POST',
path: '/1/organizations/search',
@@ -39,8 +39,8 @@ export default class OrganizationSelector extends React.Component {
};
onChange = (evt, { value }) => {
- this.context.setOrganization(value);
this.props.close();
+ setOrganization(value.id);
};
render() {
@@ -52,7 +52,7 @@ export default class OrganizationSelector extends React.Component {
fluid
clearable
placeholder="Viewing all organizations"
- value={this.context.getOrganization()}
+ value={getOrganization()}
onDataNeeded={this.fetchOrganizations}
onChange={this.onChange}
/>
diff --git a/services/web/src/stores/session.js b/services/web/src/stores/session.js
index d341e4b64..9bf72cb90 100644
--- a/services/web/src/stores/session.js
+++ b/services/web/src/stores/session.js
@@ -3,6 +3,7 @@ import { merge, omit } from 'lodash';
import { withRouter } from 'react-router-dom';
import { request, hasToken, setToken } from 'utils/api';
+import { getOrganization, setOrganization } from 'utils/organization';
import { trackSession } from 'utils/analytics';
import { captureError } from 'utils/sentry';
import { wrapContext } from 'utils/hoc';
@@ -22,8 +23,8 @@ export class SessionProvider extends React.PureComponent {
error: null,
ready: false,
loading: true,
- stored: this.loadStored(),
organization: null,
+ stored: this.loadStored(),
};
}
@@ -191,37 +192,22 @@ export class SessionProvider extends React.PureComponent {
// Organizations
loadOrganization = async () => {
- const organizationId = this.state.stored['organizationId'];
- if (organizationId) {
+ const organization = getOrganization();
+ if (organization) {
try {
const { data } = await request({
method: 'GET',
- path: `/1/organizations/${organizationId}`,
+ path: `/1/organizations/${organization}`,
});
return data;
} catch (err) {
if (err.status < 500) {
- this.removeStored('organizationId');
+ setOrganization(null);
}
}
}
};
- setOrganization = (organization) => {
- if (organization) {
- this.setStored('organizationId', organization.id);
- } else {
- this.removeStored('organizationId');
- }
- // Organizations may affect the context of all pages as well as
- // persistent header/footer so need to do a hard-reload of the app.
- window.location.reload();
- };
-
- getOrganization = () => {
- return this.state.organization;
- };
-
// Session storage
setStored = (key, data) => {
@@ -305,8 +291,6 @@ export class SessionProvider extends React.PureComponent {
hasRole: this.hasRole,
isAdmin: this.isAdmin,
pushRedirect: this.pushRedirect,
- setOrganization: this.setOrganization,
- getOrganization: this.getOrganization,
}}>
{this.props.children}
diff --git a/services/web/src/utils/__tests__/permissions.test.js b/services/web/src/utils/__tests__/permissions.test.js
new file mode 100644
index 000000000..a2cc4d1c3
--- /dev/null
+++ b/services/web/src/utils/__tests__/permissions.test.js
@@ -0,0 +1,279 @@
+const { userHasAccess } = require('../permissions');
+
+const organization1Id = '662f11c8af6870637eab9f0f';
+const organization2Id = '662f11c8af6870637eab9f0d';
+
+const superAdmin = {
+ email: 'admin@permissions.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ roles: [
+ {
+ scope: 'global',
+ role: 'superAdmin',
+ roleDefinition: {
+ name: 'Super Admin',
+ allowScopes: ['global'],
+ permissions: {
+ applications: 'all',
+ auditEntries: 'all',
+ organizations: 'all',
+ products: 'all',
+ roles: 'all',
+ shops: 'all',
+ users: 'all',
+ },
+ allowAuthenticationOnRoles: ['admin', 'viewer'],
+ },
+ },
+ ],
+};
+
+const admin = {
+ email: 'admin@permissions.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ roles: [
+ {
+ scope: 'organization',
+ role: 'admin',
+ scopeRef: organization1Id,
+ roleDefinition: {
+ name: 'Admin',
+ allowScopes: ['organization'],
+ permissions: {
+ auditEntries: 'read',
+ applications: 'all',
+ products: 'all',
+ roles: 'all',
+ shops: 'all',
+ users: 'all',
+ },
+ },
+ },
+ ],
+};
+
+const viewer = {
+ email: 'viewer@permissions.com',
+ firstName: 'John',
+ lastName: 'Doe',
+ roles: [
+ {
+ scope: 'organization',
+ role: 'viewer',
+ scopeRef: organization1Id,
+ roleDefinition: {
+ name: 'Viewer',
+ allowScopes: ['organization'],
+ permissions: {
+ applications: 'read',
+ auditEntries: 'read',
+ products: 'read',
+ shops: 'read',
+ users: 'read',
+ },
+ },
+ },
+ ],
+};
+
+describe('userHasAccess', () => {
+ it('should validate correctly for super admin', async () => {
+ expect(
+ userHasAccess(superAdmin, {
+ scope: 'global',
+ permission: 'read',
+ endpoint: 'users',
+ })
+ ).toBe(true);
+
+ expect(
+ userHasAccess(superAdmin, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: '123',
+ })
+ ).toBe(true);
+
+ expect(
+ userHasAccess(superAdmin, {
+ scope: 'global',
+ permission: 'read',
+ endpoint: 'unknown',
+ })
+ ).toBe(false);
+
+ expect(
+ userHasAccess(superAdmin, {
+ scope: 'global',
+ permission: 'write',
+ endpoint: 'users',
+ })
+ ).toBe(true);
+
+ expect(
+ userHasAccess(superAdmin, {
+ scope: 'global',
+ permission: 'write',
+ endpoint: 'users',
+ })
+ ).toBe(true);
+ });
+
+ it('should validate correctly for organization admin', async () => {
+ expect(
+ userHasAccess(admin, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: organization1Id,
+ })
+ ).toBe(true);
+
+ expect(
+ userHasAccess(admin, {
+ scope: 'global',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: organization1Id,
+ })
+ ).toBe(false);
+
+ expect(
+ userHasAccess(admin, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: organization2Id,
+ })
+ ).toBe(false);
+
+ expect(
+ userHasAccess(admin, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'unknown',
+ scopeRef: organization1Id,
+ })
+ ).toBe(false);
+
+ expect(
+ userHasAccess(admin, {
+ scope: 'organization',
+ permission: 'write',
+ endpoint: 'users',
+ scopeRef: organization1Id,
+ })
+ ).toBe(true);
+
+ expect(
+ userHasAccess(admin, {
+ scope: 'organization',
+ permission: 'write',
+ endpoint: 'users',
+ scopeRef: organization2Id,
+ })
+ ).toBe(false);
+
+ expect(
+ userHasAccess(admin, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: organization2Id,
+ })
+ ).toBe(false);
+ });
+
+ it('should validate correctly for organization viewer', async () => {
+ expect(
+ userHasAccess(viewer, {
+ scope: 'global',
+ permission: 'read',
+ endpoint: 'users',
+ })
+ ).toBe(false);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'global',
+ permission: 'read',
+ endpoint: 'unknown',
+ })
+ ).toBe(false);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'global',
+ permission: 'write',
+ endpoint: 'users',
+ })
+ ).toBe(false);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: organization1Id,
+ })
+ ).toBe(true);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'users',
+ scopeRef: organization2Id,
+ })
+ ).toBe(false);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'organization',
+ permission: 'read',
+ endpoint: 'unknown',
+ scopeRef: organization1Id,
+ })
+ ).toBe(false);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'organization',
+ permission: 'write',
+ endpoint: 'users',
+ scopeRef: organization1Id,
+ })
+ ).toBe(false);
+ expect(
+ userHasAccess(viewer, {
+ scope: 'organization',
+ permission: 'write',
+ endpoint: 'users',
+ scopeRef: organization1Id,
+ })
+ ).toBe(false);
+ });
+
+ it('should assume global access', async () => {
+ expect(
+ userHasAccess(superAdmin, {
+ permission: 'read',
+ endpoint: 'users',
+ })
+ ).toBe(true);
+
+ expect(
+ userHasAccess(superAdmin, {
+ permission: 'read',
+ endpoint: 'unknown',
+ })
+ ).toBe(false);
+ });
+
+ it('should not error when no user passed', async () => {
+ expect(
+ userHasAccess(null, {
+ scope: 'global',
+ permission: 'read',
+ endpoint: 'users',
+ })
+ ).toBe(false);
+ });
+});
diff --git a/services/web/src/utils/api/request.js b/services/web/src/utils/api/request.js
index 07e3aa782..b414e4777 100644
--- a/services/web/src/utils/api/request.js
+++ b/services/web/src/utils/api/request.js
@@ -1,5 +1,7 @@
import { API_KEY, API_URL } from 'utils/env';
+import { getOrganization } from 'utils/organization';
+
import { trackRequest } from '../analytics';
import { fetchWithTimeout } from '../fetch';
import { ApiError, ApiParseError } from './errors';
@@ -12,6 +14,7 @@ export default async function request(options) {
let { body } = options;
const token = options.token || getToken();
+ const organization = getOrganization();
const headers = Object.assign(
{
@@ -23,6 +26,9 @@ export default async function request(options) {
'Api-Record': 'on',
}),
'API-Key': API_KEY,
+ ...(organization && {
+ Organization: organization,
+ }),
},
options.headers
);
diff --git a/services/web/src/utils/organization.js b/services/web/src/utils/organization.js
new file mode 100644
index 000000000..c7086c61b
--- /dev/null
+++ b/services/web/src/utils/organization.js
@@ -0,0 +1,19 @@
+import { localStorage } from './storage';
+
+const KEY = 'organizationId';
+
+export function getOrganization() {
+ return localStorage.getItem(KEY);
+}
+
+export function setOrganization(id) {
+ if (id) {
+ localStorage.setItem(KEY, id);
+ } else {
+ localStorage.removeItem(KEY);
+ }
+
+ // Organizations may affect the context of all pages as well as
+ // persistent header/footer so need to do a hard-reload of the app.
+ window.location.reload();
+}
diff --git a/services/web/src/utils/permissions.js b/services/web/src/utils/permissions.js
index e76d54c91..70398b28e 100644
--- a/services/web/src/utils/permissions.js
+++ b/services/web/src/utils/permissions.js
@@ -1,70 +1,48 @@
-function meetsLevel(permissionValue, permission) {
- if (permissionValue === 'none') {
+const VALID_SCOPES = ['global', 'organization'];
+
+// Note: this function is derived from the API and meant
+// to be kept in sync. It is slightly modified to not use
+// mongoose utility methods.
+export function userHasAccess(user, options) {
+ if (!user) {
return false;
}
- if (permissionValue === 'read-write' && permission === 'write') {
- return true;
- }
- if (permissionValue === 'read-write' && permission === 'read') {
- return true;
- }
- if (permissionValue === 'read' && permission === 'read') {
- return true;
- }
- return false;
-}
-export function userHasAccess(user, { endpoint, permission, scope, scopeRef }) {
+ const { endpoint, permission, scope = 'global', scopeRef } = options;
if (!endpoint) {
throw new Error('Expected endpoint (e.g. users)');
- }
- if (!permission) {
+ } else if (!permission) {
throw new Error('Expected permission (e.g. read)');
+ } else if (!scope) {
+ throw new Error('Expected scope (e.g. organization)');
+ } else if (!VALID_SCOPES.includes(scope)) {
+ throw new Error('Invalid scope');
}
- if (!scope) {
- throw new Error('Expected scope (e.g. account)');
- }
- const roles = [];
- // Gather all relevant roles
- for (const roleRef of user.roles) {
- if (roleRef.scope === 'global') {
- const role = roleRef.roleDefinition;
- if (!role) {
- continue;
- }
- roles.push(role);
- } else {
- if (roleRef.scope !== scope) {
- continue;
- }
- // Only include scopeRef roles (e.g. matching organization ID) when not global scope
- if (scope !== 'global') {
- if (!scopeRef) {
- continue;
- }
- if (!roleRef.scopeRef) {
- continue;
- }
- const roleTargetId = roleRef.scopeRef.toString();
- if (scopeRef.toString() !== roleTargetId) {
- continue;
- }
- }
- const role = roleRef.roleDefinition;
- if (!role) {
- continue;
+
+ return user.roles.some((r) => {
+ if (scope === 'global' && r.scope !== 'global') {
+ return false;
+ } else if (scope === 'organization' && r.scope === 'organization') {
+ if (r.scopeRef !== scopeRef) {
+ return false;
}
- roles.push(role);
}
- }
- let hasAccess = false;
- for (const role of roles) {
- const permissionValue = role.permissions[endpoint] || 'none';
- if (meetsLevel(permissionValue, permission)) {
- hasAccess = true;
+
+ const definition = r.roleDefinition;
+ const allowed = definition?.permissions?.[endpoint];
+
+ if (!definition) {
+ throw new Error(`Unknown role "${r.role}".`);
}
- }
- return hasAccess;
+
+ if (Array.isArray(allowed)) {
+ return allowed.includes(permission);
+ } else if (allowed === permission || allowed === 'all') {
+ return true;
+ } else {
+ return false;
+ }
+ });
}
export function userCanSwitchOrganizations(user) {