From e94646864581852c8490d00b0b2b04c794143ffb Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Fri, 17 Jan 2025 16:41:35 +0100 Subject: [PATCH] Add remaining role fields to support the built-in roles in the editor --- .../StandardEditor/AccessRules.test.tsx | 2 + .../RoleEditor/StandardEditor/AccessRules.tsx | 27 ++++++++- .../StandardEditor/Resources.test.tsx | 45 ++++++++++++-- .../RoleEditor/StandardEditor/Resources.tsx | 58 +++++++++++-------- .../StandardEditor/standardmodel.test.ts | 39 +++++++++++++ .../StandardEditor/standardmodel.ts | 39 ++++++++++++- .../StandardEditor/validation.test.ts | 13 ++++- .../RoleEditor/StandardEditor/validation.ts | 1 + .../teleport/src/services/resources/types.ts | 3 + 9 files changed, 191 insertions(+), 36 deletions(-) diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx index 0eabc61fd90db..c35aba23e10b9 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx @@ -58,6 +58,7 @@ describe('AccessRules', () => { 'list', 'read', ]); + await user.type(screen.getByLabelText('Condition'), 'some-condition'); expect(modelRef).toHaveBeenLastCalledWith([ { id: expect.any(String), @@ -69,6 +70,7 @@ describe('AccessRules', () => { { label: 'list', value: 'list' }, { label: 'read', value: 'read' }, ], + where: 'some-condition', }, ] as RuleModel[]); }); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx index a477d87523a16..158f164cabedb 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx @@ -18,12 +18,14 @@ import { memo } from 'react'; import { components, MultiValueProps } from 'react-select'; -import styled from 'styled-components'; +import styled, { useTheme } from 'styled-components'; import { ButtonSecondary } from 'design/Button'; import Flex from 'design/Flex'; import { Plus } from 'design/Icon'; +import Text from 'design/Text'; import { HoverTooltip } from 'design/Tooltip'; +import FieldInput from 'shared/components/FieldInput'; import { FieldSelect, FieldSelectCreatable, @@ -78,7 +80,8 @@ const AccessRule = memo(function AccessRule({ validation, dispatch, }: SectionPropsWithDispatch) { - const { id, resources, verbs } = value; + const { id, resources, verbs, where } = value; + const theme = useTheme(); function setRule(rule: RuleModel) { dispatch({ type: 'set-access-rule', payload: rule }); } @@ -112,6 +115,26 @@ const AccessRule = memo(function AccessRule({ value={verbs} onChange={v => setRule({ ...value, verbs: v })} rule={precomputed(validation.fields.verbs)} + /> + + Additional condition, expressed using the{' '} + + Teleport predicate language + + + } + tooltipSticky + disabled={isProcessing} + value={where} + onChange={e => setRule({ ...value, where: e.target.value })} mb={0} /> diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx index 62ff7b15997bc..61f1be419af87 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx @@ -141,6 +141,13 @@ describe('KubernetesAccessSection', () => { await user.type(screen.getByPlaceholderText('label key'), 'some-key'); await user.type(screen.getByPlaceholderText('label value'), 'some-value'); + await selectEvent.create(screen.getByLabelText('Users'), 'joe', { + createOptionText: 'User: joe', + }); + await selectEvent.create(screen.getByLabelText('Users'), 'mary', { + createOptionText: 'User: mary', + }); + await user.click(screen.getByRole('button', { name: 'Add a Resource' })); expect( reactSelectValueContainer(screen.getByLabelText('Kind')) @@ -178,6 +185,10 @@ describe('KubernetesAccessSection', () => { roleVersion: 'v7', }, ], + users: [ + expect.objectContaining({ value: 'joe' }), + expect.objectContaining({ value: 'mary' }), + ], roleVersion: 'v7', } as KubernetesAccess); }); @@ -391,9 +402,12 @@ describe('DatabaseAccessSection', () => { test('editing', async () => { const { user, onChange } = setup(); - await user.click(screen.getByRole('button', { name: 'Add a Label' })); - await user.type(screen.getByPlaceholderText('label key'), 'env'); - await user.type(screen.getByPlaceholderText('label value'), 'prod'); + + const labels = within(screen.getByRole('group', { name: 'Labels' })); + await user.click(labels.getByRole('button', { name: 'Add a Label' })); + await user.type(labels.getByPlaceholderText('label key'), 'env'); + await user.type(labels.getByPlaceholderText('label value'), 'prod'); + await selectEvent.create(screen.getByLabelText('Database Names'), 'stuff', { createOptionText: 'Database Name: stuff', }); @@ -403,6 +417,16 @@ describe('DatabaseAccessSection', () => { await selectEvent.create(screen.getByLabelText('Database Roles'), 'admin', { createOptionText: 'Database Role: admin', }); + + const dbServiceLabels = within( + screen.getByRole('group', { name: 'Database Service Labels' }) + ); + await user.click( + dbServiceLabels.getByRole('button', { name: 'Add a Label' }) + ); + await user.type(dbServiceLabels.getByPlaceholderText('label key'), 'foo'); + await user.type(dbServiceLabels.getByPlaceholderText('label value'), 'bar'); + expect(onChange).toHaveBeenLastCalledWith({ kind: 'db', labels: [{ name: 'env', value: 'prod' }], @@ -418,18 +442,29 @@ describe('DatabaseAccessSection', () => { expect.objectContaining({ value: '{{internal.db_users}}' }), expect.objectContaining({ label: 'mary', value: 'mary' }), ], + dbServiceLabels: [{ name: 'foo', value: 'bar' }], } as DatabaseAccess); }); test('validation', async () => { const { user, validator } = setup(); - await user.click(screen.getByRole('button', { name: 'Add a Label' })); + const labels = within(screen.getByRole('group', { name: 'Labels' })); + await user.click(labels.getByRole('button', { name: 'Add a Label' })); + const dbServiceLabelsGroup = within( + screen.getByRole('group', { name: 'Database Service Labels' }) + ); + await user.click( + dbServiceLabelsGroup.getByRole('button', { name: 'Add a Label' }) + ); await selectEvent.create(screen.getByLabelText('Database Roles'), '*', { createOptionText: 'Database Role: *', }); act(() => validator.validate()); expect( - screen.getByPlaceholderText('label key') + labels.getByPlaceholderText('label key') + ).toHaveAccessibleDescription('required'); + expect( + dbServiceLabelsGroup.getByPlaceholderText('label key') ).toHaveAccessibleDescription('required'); expect( screen.getByText('Wildcard is not allowed in database roles') diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx index 5b59d78b35b51..b08919b6f81a9 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx @@ -25,7 +25,7 @@ import ButtonIcon from 'design/ButtonIcon'; import Flex from 'design/Flex'; import { Add, Plus, Trash } from 'design/Icon'; import { Mark } from 'design/Mark'; -import Text, { H4 } from 'design/Text'; +import { H4 } from 'design/Text'; import FieldInput from 'shared/components/FieldInput'; import { FieldMultiInput } from 'shared/components/FieldMultiInput/FieldMultiInput'; import { @@ -232,10 +232,8 @@ export function ServerAccessSection({ }: SectionProps) { return ( <> - - Labels - onChange?.({ ...value, labels })} @@ -281,10 +279,21 @@ export function KubernetesAccessSection({ onChange={groups => onChange?.({ ...value, groups })} /> - - Labels - + `User: ${label}`} + components={{ + DropdownIndicator: null, + }} + openMenuOnClick={false} + value={value.users} + onChange={users => onChange?.({ ...value, users })} + /> + ) { return ( - - - Labels - - onChange?.({ ...value, labels })} - rule={precomputed(validation.fields.labels)} - /> - + onChange?.({ ...value, labels })} + rule={precomputed(validation.fields.labels)} + /> - - Labels - onChange?.({ ...value, labels })} @@ -537,7 +540,14 @@ export function DatabaseAccessSection({ value={value.roles} onChange={roles => onChange?.({ ...value, roles })} rule={precomputed(validation.fields.roles)} - mb={0} + /> + onChange?.({ ...value, dbServiceLabels })} + rule={precomputed(validation.fields.dbServiceLabels)} /> ); @@ -552,10 +562,8 @@ export function WindowsDesktopAccessSection({ return ( <> - - Labels - onChange?.({ ...value, labels })} diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts index 91d96b1638086..397722ff14b73 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts @@ -197,6 +197,7 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([ db_names: ['stuff', 'knickknacks'], db_users: ['joe', 'mary'], db_roles: ['admin', 'auditor'], + db_service_labels: { foo: 'bar' }, }, }, }, @@ -218,6 +219,7 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([ { label: 'admin', value: 'admin' }, { label: 'auditor', value: 'auditor' }, ], + dbServiceLabels: [{ name: 'foo', value: 'bar' }], }, ], }, @@ -440,6 +442,7 @@ describe('roleToRoleEditorModel', () => { groups: [], labels: [], resources: [], + users: [], roleVersion: defaultRoleVersion, }); @@ -868,6 +871,7 @@ describe('roleToRoleEditorModel', () => { name: 'some-node', }, ], + kubernetes_users: ['alice', 'bob'], }, }, }) @@ -902,6 +906,10 @@ describe('roleToRoleEditorModel', () => { roleVersion: defaultRoleVersion, }, ], + users: [ + { label: 'alice', value: 'alice' }, + { label: 'bob', value: 'bob' }, + ], roleVersion: defaultRoleVersion, }, ], @@ -948,6 +956,11 @@ describe('roleToRoleEditorModel', () => { verbs: ['read', 'list'], }, { resources: [ResourceKind.Lock], verbs: ['create'] }, + { + resources: [ResourceKind.Session], + verbs: ['read', 'list'], + where: 'contains(session.participants, user.metadata.name)', + }, ], }, }, @@ -962,11 +975,19 @@ describe('roleToRoleEditorModel', () => { resourceKindOptionsMap.get(ResourceKind.DatabaseService), ], verbs: [verbOptionsMap.get('read'), verbOptionsMap.get('list')], + where: '', }, { id: expect.any(String), resources: [resourceKindOptionsMap.get(ResourceKind.Lock)], verbs: [verbOptionsMap.get('create')], + where: '', + }, + { + id: expect.any(String), + resources: [resourceKindOptionsMap.get(ResourceKind.Session)], + verbs: [verbOptionsMap.get('read'), verbOptionsMap.get('list')], + where: 'contains(session.participants, user.metadata.name)', }, ], } as RoleEditorModel); @@ -1042,6 +1063,10 @@ describe('roleEditorModelToRole', () => { roleVersion: defaultRoleVersion, }, ], + users: [ + { label: 'alice', value: 'alice' }, + { label: 'bob', value: 'bob' }, + ], roleVersion: defaultRoleVersion, }, ], @@ -1067,6 +1092,7 @@ describe('roleEditorModelToRole', () => { verbs: [], }, ], + kubernetes_users: ['alice', 'bob'], }, }, } as Role); @@ -1084,11 +1110,19 @@ describe('roleEditorModelToRole', () => { resourceKindOptionsMap.get(ResourceKind.DatabaseService), ], verbs: [verbOptionsMap.get('read'), verbOptionsMap.get('list')], + where: '', }, { id: 'dummy-id-2', resources: [resourceKindOptionsMap.get(ResourceKind.Lock)], verbs: [verbOptionsMap.get('create')], + where: '', + }, + { + id: expect.any(String), + resources: [resourceKindOptionsMap.get(ResourceKind.Session)], + verbs: [verbOptionsMap.get('read'), verbOptionsMap.get('list')], + where: 'contains(session.participants, user.metadata.name)', }, ], }) @@ -1100,6 +1134,11 @@ describe('roleEditorModelToRole', () => { rules: [ { resources: ['user', 'db_service'], verbs: ['read', 'list'] }, { resources: ['lock'], verbs: ['create'] }, + { + resources: ['session'], + verbs: ['read', 'list'], + where: 'contains(session.participants, user.metadata.name)', + }, ], }, }, diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts index e0814f0731089..11a2d1fcc9326 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts @@ -114,6 +114,7 @@ export type KubernetesAccess = ResourceAccessBase<'kube_cluster'> & { groups: readonly Option[]; labels: UILabel[]; resources: KubernetesResourceModel[]; + users: readonly Option[]; /** * Version of the role that owns this section. Required to propagate it to @@ -265,6 +266,7 @@ export type DatabaseAccess = ResourceAccessBase<'db'> & { names: readonly Option[]; users: readonly Option[]; roles: readonly Option[]; + dbServiceLabels: UILabel[]; }; export type WindowsDesktopAccess = ResourceAccessBase<'windows_desktop'> & { @@ -283,6 +285,7 @@ export type RuleModel = { */ resources: readonly ResourceKindOption[]; verbs: readonly VerbOption[]; + where: string; }; export type OptionsModel = { @@ -453,6 +456,7 @@ export function newResourceAccess( groups: [stringToOption('{{internal.kubernetes_groups}}')], labels: [], resources: [], + users: [], roleVersion, }; case 'app': @@ -470,6 +474,7 @@ export function newResourceAccess( names: [stringToOption('{{internal.db_names}}')], users: [stringToOption('{{internal.db_users}}')], roles: [stringToOption('{{internal.db_roles}}')], + dbServiceLabels: [], }; case 'windows_desktop': return { @@ -500,6 +505,7 @@ export function newRuleModel(): RuleModel { id: crypto.randomUUID(), resources: [], verbs: [], + where: '', }; } @@ -579,9 +585,11 @@ function roleConditionsToModel( db_names, db_users, db_roles, + db_service_labels, windows_desktop_labels, windows_desktop_logins, + kubernetes_users, rules, @@ -606,12 +614,21 @@ function roleConditionsToModel( model: kubeResourcesModel, requiresReset: kubernetesResourcesRequireReset, } = kubernetesResourcesToModel(kubernetes_resources, roleVersion); - if (someNonEmpty(kubeGroupsModel, kubeLabelsModel, kubeResourcesModel)) { + const kubeUsersModel = stringsToOptions(kubernetes_users ?? []); + if ( + someNonEmpty( + kubeGroupsModel, + kubeLabelsModel, + kubeResourcesModel, + kubeUsersModel + ) + ) { resources.push({ kind: 'kube_cluster', groups: kubeGroupsModel, labels: kubeLabelsModel, resources: kubeResourcesModel, + users: kubeUsersModel, roleVersion, }); } @@ -641,13 +658,23 @@ function roleConditionsToModel( const dbNamesModel = db_names ?? []; const dbUsersModel = db_users ?? []; const dbRolesModel = db_roles ?? []; - if (someNonEmpty(dbLabelsModel, dbNamesModel, dbUsersModel, dbRolesModel)) { + const dbServiceLabelsModel = labelsToModel(db_service_labels); + if ( + someNonEmpty( + dbLabelsModel, + dbNamesModel, + dbUsersModel, + dbRolesModel, + dbServiceLabelsModel + ) + ) { resources.push({ kind: 'db', labels: dbLabelsModel, names: stringsToOptions(dbNamesModel), users: stringsToOptions(dbUsersModel), roles: stringsToOptions(dbRolesModel), + dbServiceLabels: dbServiceLabelsModel, }); } @@ -761,7 +788,7 @@ function rulesToModel(rules: Rule[]): { } function ruleToModel(rule: Rule): { model: RuleModel; requiresReset: boolean } { - const { resources = [], verbs = [], ...unsupported } = rule; + const { resources = [], verbs = [], where = '', ...unsupported } = rule; const resourcesModel = resources.map( k => resourceKindOptionsMap.get(k) ?? { label: k, value: k } ); @@ -774,6 +801,7 @@ function ruleToModel(rule: Rule): { model: RuleModel; requiresReset: boolean } { id: crypto.randomUUID(), resources: resourcesModel, verbs: knownVerbsModel, + where, }, requiresReset, }; @@ -970,6 +998,7 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { verbs: optionsToStrings(verbs), }) ); + role.spec.allow.kubernetes_users = optionsToStrings(res.users); break; case 'app': @@ -984,6 +1013,9 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { role.spec.allow.db_names = optionsToStrings(res.names); role.spec.allow.db_users = optionsToStrings(res.users); role.spec.allow.db_roles = optionsToStrings(res.roles); + role.spec.allow.db_service_labels = labelsModelToLabels( + res.dbServiceLabels + ); break; case 'windows_desktop': @@ -1002,6 +1034,7 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { role.spec.allow.rules = roleModel.rules.map(role => ({ resources: role.resources.map(r => r.value), verbs: role.verbs.map(v => v.value), + where: role.where || undefined, })); } diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.test.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.test.ts index afcdce5eceaa6..829b725bcca5c 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.test.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.test.ts @@ -66,6 +66,7 @@ describe('validateRoleEditorModel', () => { kind: 'kube_cluster', labels: [{ name: 'foo', value: 'bar' }], groups: [], + users: [], resources: [ { id: 'dummy-id', @@ -96,6 +97,7 @@ describe('validateRoleEditorModel', () => { roles: [{ label: 'some-role', value: 'some-role' }], names: [], users: [], + dbServiceLabels: [{ name: 'asdf', value: 'qwer' }], }, { kind: 'windows_desktop', @@ -108,6 +110,7 @@ describe('validateRoleEditorModel', () => { id: 'dummy-id', resources: [{ label: ResourceKind.Node, value: ResourceKind.Node }], verbs: [{ label: '*', value: '*' }], + where: '', }, ]; const result = validateRoleEditorModel(model, undefined, undefined); @@ -146,6 +149,7 @@ describe('validateRoleEditorModel', () => { kind: 'kube_cluster', groups: [], labels: [], + users: [], resources: [ { ...newKubernetesResourceModel(defaultRoleVersion), @@ -178,6 +182,7 @@ describe('validateRoleEditorModel', () => { kind: 'kube_cluster', groups: [], labels: [], + users: [], roleVersion, resources: [ { @@ -214,6 +219,7 @@ describe('validateRoleEditorModel', () => { id: 'dummy-id', resources: [], verbs: [{ label: '*', value: '*' }], + where: '', }, ]; const result = validateRoleEditorModel(model, undefined, undefined); @@ -243,7 +249,12 @@ describe('validateResourceAccess', () => { describe('validateAccessRule', () => { it('reuses previously computed results', () => { - const rule: RuleModel = { id: 'some-id', resources: [], verbs: [] }; + const rule: RuleModel = { + id: 'some-id', + resources: [], + verbs: [], + where: '', + }; const result1 = validateAccessRule(rule, undefined, undefined); const result2 = validateAccessRule(rule, rule, result1); expect(result2).toBe(result1); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts index a1292e06e5e7c..13857647b461f 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts @@ -287,6 +287,7 @@ export type AppAccessValidationResult = RuleSetValidationResult< const databaseAccessValidationRules = { labels: nonEmptyLabels, roles: noWildcardOptions('Wildcard is not allowed in database roles'), + dbServiceLabels: nonEmptyLabels, }; export type DatabaseAccessValidationResult = RuleSetValidationResult< typeof databaseAccessValidationRules diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index e5b4219338838..9a5d315045de3 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -80,6 +80,7 @@ export type RoleConditions = { kubernetes_groups?: string[]; kubernetes_labels?: Labels; kubernetes_resources?: KubernetesResource[]; + kubernetes_users?: string[]; app_labels?: Labels; aws_role_arns?: string[]; @@ -90,6 +91,7 @@ export type RoleConditions = { db_names?: string[]; db_users?: string[]; db_roles?: string[]; + db_service_labels?: Labels; windows_desktop_labels?: Labels; windows_desktop_logins?: string[]; @@ -163,6 +165,7 @@ export type KubernetesVerb = export type Rule = { resources?: ResourceKind[]; verbs?: Verb[]; + where?: string; }; export enum ResourceKind {