Skip to content

Commit

Permalink
SALTO-4062: Support Custom Roles in Okta (#6933)
Browse files Browse the repository at this point in the history
  • Loading branch information
shir-reifenberg authored Dec 19, 2024
1 parent 769c1c6 commit 8139dea
Show file tree
Hide file tree
Showing 12 changed files with 516 additions and 4 deletions.
8 changes: 8 additions & 0 deletions packages/okta-adapter/e2e_test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,13 @@ const createChangesForDeploy = async (
removePoweredByOkta: true,
},
})
const role = createInstance({
typeName: ROLE_TYPE_NAME,
types,
valuesOverride: {
label: createName('role'),
},
})
const identityProvider = createInstance({
typeName: IDENTITY_PROVIDER_TYPE_NAME,
types,
Expand Down Expand Up @@ -540,6 +547,7 @@ const createChangesForDeploy = async (
toChange({ after: app }),
toChange({ after: appGroupAssignment }),
toChange({ after: brand }),
toChange({ after: role }),
toChange({ after: identityProvider }),
toChange({ after: authServerPolicyA }),
toChange({ after: authServerPolicyB }),
Expand Down
20 changes: 20 additions & 0 deletions packages/okta-adapter/e2e_test/mock_elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
PASSWORD_RULE_TYPE_NAME,
AUTHORIZATION_POLICY,
AUTHORIZATION_POLICY_RULE,
ROLE_TYPE_NAME,
} from '../src/constants'

export const mockDefaultValues: Record<string, Values> = {
Expand Down Expand Up @@ -419,4 +420,23 @@ export const mockDefaultValues: Record<string, Values> = {
},
},
},
[ROLE_TYPE_NAME]: {
description: 'deploy',
permissions: [
{
label: 'okta.groups.manage',
},
{
label: 'okta.users.groupMembership.manage',
},
{
label: 'okta.users.userprofile.manage',
conditions: {
include: {
'okta_ResourceAttribute_User_Profile@fdd': ['profileUrl', 'zipCode', 'city'],
},
},
},
],
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2024 Salto Labs Ltd.
* Licensed under the Salto Terms of Use (the "License");
* You may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.salto.io/terms-of-use
*
* CERTAIN THIRD PARTY SOFTWARE MAY BE CONTAINED IN PORTIONS OF THE SOFTWARE. See NOTICE FILE AT https://github.com/salto-io/salto/blob/main/NOTICES
*/
import { ChangeValidator, getChangeData, isInstanceChange, InstanceElement, Change } from '@salto-io/adapter-api'
import { ROLE_TYPE_NAME } from '../constants'
import { ROLE_TYPE_TO_LABEL } from '../filters/standard_roles'

const STANDARD_ROLE_TYPES = new Set(Object.keys(ROLE_TYPE_TO_LABEL))
const isStandardRoleChange = (change: Change<InstanceElement>): boolean =>
getChangeData(change).value.type !== undefined && STANDARD_ROLE_TYPES.has(getChangeData(change).value.type)

/**
* Block deployments of standard Okta roles
*/
export const standardRoleDeployments: ChangeValidator = async changes =>
changes
.filter(isInstanceChange)
.filter(change => getChangeData(change).elemID.typeName === ROLE_TYPE_NAME)
.filter(isStandardRoleChange)
.map(getChangeData)
.map(instance => ({
elemID: instance.elemID,
severity: 'Error' as const,
message: 'Standard roles cannot be deployed',
detailedMessage: 'Okta does not support deployments of standard roles.',
}))
86 changes: 85 additions & 1 deletion packages/okta-adapter/src/definitions/deploy/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
ERROR_PAGE_TYPE_NAME,
AUTHORIZATION_SERVER,
GROUP_RULE_TYPE_NAME,
ROLE_TYPE_NAME,
} from '../../constants'
import {
APP_POLICIES,
Expand All @@ -60,6 +61,7 @@ import { isCustomApp } from '../fetch/types/application'
import { addBrandIdToRequest } from './types/email_domain'
import { isSystemScope } from './types/authorization_servers'
import { isActiveGroupRuleChange } from './types/group_rules'
import { adjustRoleAdditionChange, isPermissionChangeOfAddedRole, shouldUpdateRolePermission } from './types/roles'

const log = logger(module)

Expand Down Expand Up @@ -1316,6 +1318,83 @@ const createCustomizations = (): Record<string, InstanceDeployApiDefinitions> =>
},
},
},
[ROLE_TYPE_NAME]: {
requestsByAction: {
customizations: {
add: [
{
request: {
endpoint: { path: '/api/v1/iam/roles', method: 'post' },
transformation: {
adjust: adjustRoleAdditionChange,
},
},
},
],
modify: [
{
request: {
endpoint: { path: '/api/v1/iam/roles/{id}', method: 'put' },
transformation: { omit: ['id', 'permissions'] },
},
},
],
remove: [
{
request: {
endpoint: { path: '/api/v1/iam/roles/{id}', method: 'delete' },
},
},
],
},
},
recurseIntoPath: [
{
fieldPath: ['permissions'],
typeName: 'Permission',
changeIdFields: ['label'],
onActions: ['add', 'modify'],
},
],
},
Permission: {
requestsByAction: {
customizations: {
add: [
{
request: {
endpoint: { path: '/api/v1/iam/roles/{parent_id}/permissions/{label}', method: 'post' },
transformation: { omit: ['label'] },
},
},
],
modify: [
{
request: {
endpoint: { path: '/api/v1/iam/roles/{parent_id}/permissions/{label}', method: 'put' },
transformation: { omit: ['label'] },
},
condition: {
custom: shouldUpdateRolePermission,
},
},
],
remove: [
{
request: {
endpoint: { path: '/api/v1/iam/roles/{parent_id}/permissions/{label}', method: 'delete' },
},
},
],
},
},
toActionNames: ({ change, changeGroup }) => {
if (isAdditionChange(change) && isPermissionChangeOfAddedRole(change, changeGroup)) {
return ['modify']
}
return [change.action]
},
},
}

return _.merge(standardRequestDefinitions, customDefinitions)
Expand All @@ -1340,5 +1419,10 @@ export const createDeployDefinitions = (): DeployApiDefinitions => ({
},
customizations: createCustomizations(),
},
dependencies: [],
dependencies: [
{
first: { type: ROLE_TYPE_NAME, action: 'add' },
second: { type: 'Permission', action: 'modify' },
},
],
})
83 changes: 83 additions & 0 deletions packages/okta-adapter/src/definitions/deploy/types/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2024 Salto Labs Ltd.
* Licensed under the Salto Terms of Use (the "License");
* You may not use this file except in compliance with the License. You may obtain a copy of the License at https://www.salto.io/terms-of-use
*
* CERTAIN THIRD PARTY SOFTWARE MAY BE CONTAINED IN PORTIONS OF THE SOFTWARE. See NOTICE FILE AT https://github.com/salto-io/salto/blob/main/NOTICES
*/

import _ from 'lodash'
import { definitions } from '@salto-io/adapter-components'
import { getParents, safeJsonStringify, validatePlainObject } from '@salto-io/adapter-utils'
import {
Change,
ChangeGroup,
InstanceElement,
getChangeData,
isAdditionChange,
isReferenceExpression,
} from '@salto-io/adapter-api'
import { logger } from '@salto-io/logging'
import { ROLE_TYPE_NAME } from '../../../constants'

const log = logger(module)

/**
* When adding a custom role, we must specify permissions for the role.
* In the role POST request, the permission object must be "flattened" to a list of permission labels.
* permission conditions, if exist, must be added in a separate request, therefore we store those in the shared context.
*/
export const adjustRoleAdditionChange: definitions.AdjustFunction<
definitions.deploy.ChangeAndExtendedContext
> = async ({ value, context }) => {
const { sharedContext, change } = context
validatePlainObject(value, ROLE_TYPE_NAME)
const permissions = _.get(value, 'permissions')
if (!_.isArray(permissions)) {
log.error('expected permissions to be an array, instead got %s', safeJsonStringify(permissions))
throw new Error('missing permissions for role')
}
const mappedPermissions = permissions.map(permission => {
if (_.isPlainObject(permission?.conditions)) {
_.set(sharedContext, [getChangeData(change).elemID.getFullName(), permission.label], true)
}
return permission.label
})
return {
value: {
...value,
permissions: mappedPermissions,
},
}
}

export const isPermissionChangeOfAddedRole = (
change: Change<InstanceElement>,
changeGroup: Readonly<ChangeGroup>,
): boolean => {
const parent = getParents(getChangeData(change))[0]
const parentName = isReferenceExpression(parent) ? parent.elemID.getFullName() : undefined
const parentChange = changeGroup.changes.find(c => getChangeData(c).elemID.getFullName() === parentName)
return parentChange !== undefined && isAdditionChange(parentChange)
}

/**
* Condition to determine if we should update the role permission.
* role permission should be updated on Role modification changes, or on Role addition changes that contains permissions with conditions.
*/
export const shouldUpdateRolePermission: definitions.deploy.DeployRequestCondition['custom'] =
() =>
({ change, changeGroup, sharedContext }) => {
const inst = getChangeData(change)
const parent = getParents(inst)[0]
const parentName = isReferenceExpression(parent) ? parent.elemID.getFullName() : undefined
if (parentName !== undefined && isPermissionChangeOfAddedRole(change, changeGroup)) {
// only make request for permission that their "conditions" were not deployed yet
if (_.get(sharedContext, [parentName, inst.value.label]) === true) {
log.debug('deploying permission condition for %s', getChangeData(change).elemID.getFullName())
return true
}
return false
}
return true
}
32 changes: 30 additions & 2 deletions packages/okta-adapter/src/definitions/fetch/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,13 +938,41 @@ const createCustomizations = ({
},
Role: {
requests: [{ endpoint: { path: '/api/v1/iam/roles' }, transformation: { root: 'roles' } }],
resource: { directFetch: true },
resource: {
directFetch: true,
recurseInto: {
permissions: {
typeName: 'Permission',
context: { args: { id: { root: 'id' } } },
},
},
},
element: {
topLevel: {
isTopLevel: true,
elemID: { parts: [{ fieldName: 'label' }] },
},
fieldCustomizations: { id: { hide: true }, _links: { omit: true } },
fieldCustomizations: {
id: { hide: true },
_links: { omit: true },
permissions: { fieldType: 'list<Permission>', sort: { properties: [{ path: 'label' }] } },
},
},
},
Permission: {
requests: [
{
endpoint: { path: '/api/v1/iam/roles/{id}/permissions' },
transformation: { root: 'permissions' },
},
],
resource: { directFetch: false, serviceIDFields: ['label'] },
element: {
fieldCustomizations: {
created: { omit: true },
lastUpdated: { omit: true },
_links: { omit: true },
},
},
},
ResourceSet: {
Expand Down
2 changes: 1 addition & 1 deletion packages/okta-adapter/src/filters/standard_roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const { RECORDS_PATH } = elementUtils
* Source for standard roles definitions:
* https://developer.okta.com/docs/concepts/role-assignment/#standard-role-types
*/
const ROLE_TYPE_TO_LABEL: Record<string, string> = {
export const ROLE_TYPE_TO_LABEL: Record<string, string> = {
API_ACCESS_MANAGEMENT_ADMIN: 'API Access Management Administrator',
APP_ADMIN: 'Application Administrator',
GROUP_MEMBERSHIP_ADMIN: 'Group Membership Administrator',
Expand Down
Loading

0 comments on commit 8139dea

Please sign in to comment.