Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: check authorities in app adapter [LIBS-370] #757

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions adapter/src/components/AppWrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import React from 'react'
import { useCurrentUserLocale } from '../utils/useLocale.js'
import { useVerifyLatestUser } from '../utils/useVerifyLatestUser.js'
import { Alerts } from './Alerts.js'
import { AuthBoundary } from './AuthBoundary.js'
import { ConnectedHeaderBar } from './ConnectedHeaderBar.js'
import { ErrorBoundary } from './ErrorBoundary.js'
import { LoadingMask } from './LoadingMask.js'
import { styles } from './styles/AppWrapper.style.js'

export const AppWrapper = ({ children }) => {
const { loading: localeLoading } = useCurrentUserLocale()
const { loading: latestUserLoading } = useVerifyLatestUser()
KaiVandivier marked this conversation as resolved.
Show resolved Hide resolved
const { loading: latestUserLoading, user } = useVerifyLatestUser()

if (localeLoading || latestUserLoading) {
return <LoadingMask />
Expand All @@ -22,7 +23,7 @@ export const AppWrapper = ({ children }) => {
<ConnectedHeaderBar />
<div className="app-shell-app">
<ErrorBoundary onRetry={() => window.location.reload()}>
{children}
<AuthBoundary user={user}>{children}</AuthBoundary>
</ErrorBoundary>
</div>
<Alerts />
Expand Down
50 changes: 50 additions & 0 deletions adapter/src/components/AuthBoundary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useConfig } from '@dhis2/app-runtime'
import { CenteredContent, NoticeBox } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'
import i18n from '../locales'

const IS_PRODUCTION_ENV = process.env.NODE_ENV === 'production'
const APP_MANAGER_AUTHORITY = 'M_dhis-web-maintenance-appmanager'
const REQUIRED_APP_AUTHORITY = process.env.REACT_APP_DHIS2_APP_AUTH_NAME
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in CLI


const isAppAvailable = (authorities) => {
// Skip check on dev
// TODO: should we check on dev environments too?
if (!IS_PRODUCTION_ENV) {
return true
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious what others think -- most app devs probably have admin privileges, but if they don't have app management authority, it's possible that someone might end up working on an a new app which they don't have authorities for.

Someone might be able to bypass their authorities limitations by running an app locally though?...

Currently I guess we don't do any checks like this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(You can make testing easier by commenting out the return true to run it on dev)

Copy link
Contributor

@HendrikThePendric HendrikThePendric Oct 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially wasn't sold on the idea of always returning true in a development environment, but think I'm coming around to it.

Full reload From SW
Production Server renders no-access page App-shell renders no-access view
Development Dev can access app Dev can access app

This is actually very consistent and I like it.

// Check for three possible authorities
return authorities.some((authority) =>
['ALL', APP_MANAGER_AUTHORITY, REQUIRED_APP_AUTHORITY].includes(
authority
)
)
}

/**
* Block the app if the user doesn't have the correct permissions to view this
* app.
*/
export function AuthBoundary({ user, children }) {
const { appName } = useConfig()

return isAppAvailable(user.authorities) ? (
children
) : (
<CenteredContent>
<NoticeBox error title={i18n.t('Forbidden')}>
{i18n.t(
"You don't have access to the {{appName}} app. Contact your system administrator if this seems to be an error.",
{ appName }
)}
</NoticeBox>
</CenteredContent>
KaiVandivier marked this conversation as resolved.
Show resolved Hide resolved
)
}
AuthBoundary.propTypes = {
children: PropTypes.node,
user: PropTypes.shape({
authorities: PropTypes.arrayOf(PropTypes.string),
}),
}
7 changes: 3 additions & 4 deletions adapter/src/utils/useVerifyLatestUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useState } from 'react'
const USER_QUERY = {
user: {
resource: 'me',
params: { fields: ['id'] },
params: { fields: ['id', 'username', 'authorities'] },
},
}

Expand All @@ -22,7 +22,7 @@ const LATEST_USER_KEY = 'dhis2.latestUser'
export function useVerifyLatestUser() {
const { pwaEnabled } = useConfig()
const [finished, setFinished] = useState(false)
const { loading, error } = useDataQuery(USER_QUERY, {
const { loading, error, data } = useDataQuery(USER_QUERY, {
onComplete: async (data) => {
const latestUserId = localStorage.getItem(LATEST_USER_KEY)
const currentUserId = data.user.id
Expand All @@ -38,10 +38,9 @@ export function useVerifyLatestUser() {
setFinished(true)
},
})

if (error) {
throw new Error('Failed to fetch user ID: ' + error)
}

return { loading: loading || !finished }
return { loading: loading || !finished, user: data?.user }
}
30 changes: 30 additions & 0 deletions cli/src/lib/formatAppAuthName.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const APP_AUTH_PREFIX = 'M_'
const DHIS_WEB = 'dhis-web-'

/**
* Returns the string that identifies the 'App view permission'
* required to view the app
*
* Ex: coreApp && name = 'data-visualizer': authName = 'M_dhis-web-data-visualizer'
* Ex: name = 'pwa-example': authName = 'M_pwaexample'
* Ex: name = 'BNA Action Tracker': authName = 'M_BNA_Action_Tracker'
*/
const formatAppAuthName = (config) => {
if (config.coreApp) {
// TODO: Verify this formatting - are there any transformations,
// or do we trust that it's lower-case and hyphenated?
return APP_AUTH_PREFIX + DHIS_WEB + config.name
}
Copy link
Contributor Author

@KaiVandivier KaiVandivier Oct 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious about whether this formatting of core app names is correct?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the user app I am also inferring specific types of authorities based on their naming scheme. This has been in production for about 4 years now, so this suggests to me the naming scheme is consistent enough. You can also review the response of this requests: https://play.dhis2.org/dev/api/40/authorities?fields=id,name&paging=false

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do need to do transformation here, as the "name" in the d2 config is NOT necessarily what's used for the authority (or URL name) - see here. We already replicate this logic in the platform here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see we're doing that below - but we should also do it for core apps.

Copy link
Contributor Author

@KaiVandivier KaiVandivier Oct 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, notice that the formatting is different between those two things you linked, namely around hyphens -- core apps seem to be getting a different treatment? But I didn't find what exactly it is

Copy link
Contributor Author

@KaiVandivier KaiVandivier Oct 20, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some formatting insurance in this commit


// This formatting is drawn from https://github.com/dhis2/dhis2-core/blob/master/dhis-2/dhis-api/src/main/java/org/hisp/dhis/appmanager/App.java#L494-L499
// (replaceAll is only introduced in Node 15)
return (
APP_AUTH_PREFIX +
config.name
.trim()
.replace(/[^a-zA-Z0-9\s]/g, '')
.replace(/\s/g, '_')
)
}

module.exports = formatAppAuthName
26 changes: 26 additions & 0 deletions cli/src/lib/formatAppAuthName.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const formatAppAuthName = require('./formatAppAuthName.js')

describe('core app handling', () => {
it('should handle core apps', () => {
const config = { coreApp: true, name: 'data-visualizer' }
const formattedAuthName = formatAppAuthName(config)

expect(formattedAuthName).toBe('M_dhis-web-data-visualizer')
})
})

describe('non-core app handling', () => {
it('should handle app names with hyphens', () => {
const config = { name: 'hyphenated-string-example' }
const formattedAuthName = formatAppAuthName(config)

expect(formattedAuthName).toBe('M_hyphenatedstringexample')
})

it('should handle app names with capitals and spaces', () => {
const config = { name: 'Multi Word App Name' }
const formattedAuthName = formatAppAuthName(config)

expect(formattedAuthName).toBe('M_Multi_Word_App_Name')
})
})
2 changes: 2 additions & 0 deletions cli/src/lib/shell/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { exec } = require('@dhis2/cli-helpers-engine')
const formatAppAuthName = require('../formatAppAuthName')
const { getPWAEnvVars } = require('../pwa')
const bootstrap = require('./bootstrap')
const getEnv = require('./env')
Expand All @@ -7,6 +8,7 @@ module.exports = ({ config, paths }) => {
const baseEnvVars = {
name: config.title,
version: config.version,
auth_name: formatAppAuthName(config),
}

return {
Expand Down
12 changes: 7 additions & 5 deletions pwa/src/service-worker/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,18 @@ export function setUpServiceWorker() {
const navigationRouteHandler = ({ request }) => {
return fetch(request)
.then((response) => {
if (response.type === 'opaqueredirect') {
// It's sending a redirect to the login page. Return
// that to the client
if (response.type === 'opaqueredirect' || !response.ok) {
// It's sending a redirect to the login page,
// or an 'unauthorized'/'forbidden' response.
// Return that to the client
return response
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass 403 responses to the client in production, instead of returning index.html


// Otherwise return precached index.html
return matchPrecache(indexUrl)
})
.catch(() => {
// Request failed (maybe offline). Return cached response
// Request failed (probably offline). Return cached response
return matchPrecache(indexUrl)
})
}
Expand Down Expand Up @@ -143,7 +144,8 @@ export function setUpServiceWorker() {
({ url }) =>
PRODUCTION_ENV &&
urlMeetsAppShellCachingCriteria(url) &&
fileExtensionRegexp.test(url.pathname),
fileExtensionRegexp.test(url.pathname) &&
!/\.(json|action)/.test(url.pathname), // don't SWR these
KaiVandivier marked this conversation as resolved.
Show resolved Hide resolved
new StaleWhileRevalidate({ cacheName: 'other-assets' })
)

Expand Down