Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
11 changes: 9 additions & 2 deletions api/src/access-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@

import AccessControl from 'accesscontrol'

// Domain fields that only super_admin may set on create/update. Add new
// super-admin-only domain fields here — mutations enforce this via
// auth.getDeniedFields, which reads the grants' attrs below (see
// api/src/auth/utils/get-denied-fields.js).
const SUPER_ADMIN_ONLY_DOMAIN_FIELDS = ['archived', 'ignoreRua', 'highAvailability']
const domainAttrsExcludingSuperAdminFields = ['*', ...SUPER_ADMIN_ONLY_DOMAIN_FIELDS.map((field) => `!${field}`)]

const ac = new AccessControl()

ac.grant('user').createOwn('csv').readOwn('affiliation').createOwn('scan-request').readOwn('organization')

ac.grant('admin')
.extend('user')
.createOwn('domain')
.updateOwn('domain', ['*', '!archived'])
.createOwn('domain', domainAttrsExcludingSuperAdminFields)
.updateOwn('domain', domainAttrsExcludingSuperAdminFields)
.deleteOwn('domain')
.updateOwn('organization', ['*', '!externalId', '!externallyManaged'])
.readOwn('log')
Expand Down
20 changes: 20 additions & 0 deletions api/src/auth/utils/get-denied-fields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import ac from '../../access-control'

// Returns the names of fields present in `args` that `permission` is not allowed
// to set on `resource`, per the grants defined in `access-control.js`. Checks both
// the "own" and "any" possession levels for `action` ('create' or 'update'), so a
// role holding an unrestricted "any" grant (e.g. super_admin) is never blocked by
// a more restrictive "own" grant it also inherits.
export const getDeniedFields = ({ permission, resource, action, args }) => {
const ownPermission = ac.can(permission)[`${action}Own`](resource)
const anyPermission = ac.can(permission)[`${action}Any`](resource)
const effectivePermission = anyPermission.granted ? anyPermission : ownPermission

// `args` from graphql-js input coercion has a null prototype; accesscontrol's
// filter (via the `notation` package) only recognizes plain objects, so it
// silently treats a null-prototype object as empty. Spread into a plain
// object first so filtering actually works.
const plainArgs = { ...args }

return Object.keys(plainArgs).filter((field) => !(field in effectivePermission.filter(plainArgs)))
}
1 change: 1 addition & 0 deletions api/src/auth/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './generate-jwt'
export * from './get-denied-fields'
export * from './salted-hash'
export * from './verify-jwt'
2 changes: 2 additions & 0 deletions api/src/create-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
checkSuperAdmin,
checkUserBelongsToOrg,
checkUserIsAdminForUser,
getDeniedFields,
tokenize,
saltedHash,
superAdminRequired,
Expand Down Expand Up @@ -109,6 +110,7 @@ export async function createContext({
userKey,
query,
}),
getDeniedFields,
loginRequiredBool,
tokenize,
tfaRequired: tfaRequired({ i18n }),
Expand Down
230 changes: 230 additions & 0 deletions api/src/domain/mutations/__tests__/create-domain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
verifiedRequired,
tfaRequired,
checkDomainPermission,
getDeniedFields,
AuthDataSource,
} from '../../../auth'
import { loadDkimSelectorsByDomainId, loadDomainByDomain } from '../../loaders'
Expand Down Expand Up @@ -64,6 +65,10 @@ const withDataSources = (contextValue) => {

return {
...contextValue,
auth: {
...contextValue?.auth,
getDeniedFields: contextValue?.auth?.getDeniedFields || getDeniedFields,
},
dataSources: {
...contextValue?.dataSources,
domain: domainDataSource,
Expand Down Expand Up @@ -658,6 +663,231 @@ describe('create a domain', () => {
])
})
})
describe('given super admin only field restrictions', () => {
// Regression test: archived/highAvailability must only be settable by super_admin on create.
describe.each([
['archived', 'archived: true'],
['highAvailability', 'highAvailability: true'],
])('%s field', (fieldName, fieldInput) => {
describe('user has admin permission level', () => {
beforeEach(async () => {
await collections.affiliations.save({
_from: org._id,
_to: user._id,
permission: 'admin',
})
})
it('returns a permission denied error', async () => {
const response = await graphql({
schema,
source: `
mutation {
createDomain(
input: {
orgId: "${toGlobalId('organization', org._key)}"
domain: "${fieldName}-admin.gc.ca"
assetState: APPROVED
${fieldInput}
}
) {
result {
... on Domain {
id
}
... on DomainError {
code
description
}
}
}
}
`,
rootValue: null,
contextValue: {
i18n,
request: {
language: 'en',
},
query,
collections: collectionNames,
transaction,
userKey: user._key,
publish: jest.fn(),
auth: {
checkDomainPermission: checkDomainPermission({
i18n,
userKey: user._key,
query,
}),
checkPermission: checkPermission({ userKey: user._key, query }),
saltedHash: saltedHash(HASHING_SECRET),
userRequired: userRequired({
userKey: user._key,
loadUserByKey: loadUserByKey({ query }),
}),
checkSuperAdmin: checkSuperAdmin({ userKey: user._key, query }),
verifiedRequired: verifiedRequired({}),
tfaRequired: tfaRequired({}),
},
dataSources: {
auth: new AuthDataSource({ query, userKey: user._key }),
organization: new OrganizationDataSource({
query,
userKey: user._key,
i18n,
language: 'en',
cleanseInput,
loginRequiredBool: true,
transaction,
collections: collectionNames,
}),
},
loaders: {
loadDkimSelectorsByDomainId: loadDkimSelectorsByDomainId({
query,
userKey: user._key,
cleanseInput,
i18n,
auth: { loginRequiredBool: true },
}),
loadDomainByDomain: loadDomainByDomain({ query }),
loadOrgByKey: loadOrgByKey({ query, language: 'en' }),
loadOrgConnectionsByDomainId: loadOrgConnectionsByDomainId({
query,
language: 'en',
userKey: user._key,
cleanseInput,
auth: { loginRequiredBool: true },
}),
loadUserByKey: loadUserByKey({ query }),
},
validators: { cleanseInput, slugify },
},
})

const expectedResponse = {
data: {
createDomain: {
result: {
code: 403,
description: 'Permission Denied: Please contact super admin for help with creating domain.',
},
},
},
}

expect(response).toEqual(expectedResponse)
expect(consoleOutput).toEqual([
`User: ${user._key} attempted to create a domain with a super admin only field in: treasury-board-secretariat, however they do not have permission to do so.`,
])
})
})
describe('user has super_admin permission level', () => {
beforeEach(async () => {
await collections.affiliations.save({
_from: org._id,
_to: user._id,
permission: 'super_admin',
})
})
it('successfully creates the domain with the field set', async () => {
const response = await graphql({
schema,
source: `
mutation {
createDomain(
input: {
orgId: "${toGlobalId('organization', org._key)}"
domain: "${fieldName}-super-admin.gc.ca"
assetState: APPROVED
${fieldInput}
}
) {
result {
... on Domain {
id
domain
}
... on DomainError {
code
description
}
}
}
}
`,
rootValue: null,
contextValue: {
i18n,
request: {
language: 'en',
},
query,
collections: collectionNames,
transaction,
userKey: user._key,
publish: jest.fn(),
auth: {
checkDomainPermission: checkDomainPermission({
i18n,
userKey: user._key,
query,
}),
checkPermission: checkPermission({ userKey: user._key, query }),
saltedHash: saltedHash(HASHING_SECRET),
userRequired: userRequired({
userKey: user._key,
loadUserByKey: loadUserByKey({ query }),
}),
checkSuperAdmin: checkSuperAdmin({ userKey: user._key, query }),
verifiedRequired: verifiedRequired({}),
tfaRequired: tfaRequired({}),
},
dataSources: {
auth: new AuthDataSource({ query, userKey: user._key }),
organization: new OrganizationDataSource({
query,
userKey: user._key,
i18n,
language: 'en',
cleanseInput,
loginRequiredBool: true,
transaction,
collections: collectionNames,
}),
},
loaders: {
loadDkimSelectorsByDomainId: loadDkimSelectorsByDomainId({
query,
userKey: user._key,
cleanseInput,
i18n,
auth: { loginRequiredBool: true },
}),
loadDomainByDomain: loadDomainByDomain({ query }),
loadOrgByKey: loadOrgByKey({ query, language: 'en' }),
loadOrgConnectionsByDomainId: loadOrgConnectionsByDomainId({
query,
language: 'en',
userKey: user._key,
cleanseInput,
auth: { loginRequiredBool: true },
}),
loadUserByKey: loadUserByKey({ query }),
},
validators: { cleanseInput, slugify },
},
})

const expectedDomain = `${fieldName}-super-admin.gc.ca`.toLowerCase()
expect(response.data.createDomain.result.domain).toEqual(expectedDomain)

const insertedDomain = await loadDomainByDomain({ query }).load(expectedDomain)
expect(insertedDomain[fieldName]).toEqual(true)
})
})
})
})
describe('domain can be created in a different organization', () => {
let secondOrg
beforeEach(async () => {
Expand Down
Loading