From 0e122bf7b7b4ee783009e45eda6c859f4baaba3d Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Tue, 19 Nov 2024 14:35:58 +0100 Subject: [PATCH] Add database and Windows access sections to the role editor (#47992) * Add database and Windows access sections to the role editor * review * Fix ta typo --- .../shared/components/FieldSelect/shared.tsx | 3 + .../Roles/RoleEditor/StandardEditor.test.tsx | 69 +++- .../src/Roles/RoleEditor/StandardEditor.tsx | 127 ++++++- .../Roles/RoleEditor/standardmodel.test.ts | 359 +++++++++--------- .../src/Roles/RoleEditor/standardmodel.ts | 131 +++++-- .../teleport/src/services/resources/types.ts | 8 + 6 files changed, 495 insertions(+), 202 deletions(-) diff --git a/web/packages/shared/components/FieldSelect/shared.tsx b/web/packages/shared/components/FieldSelect/shared.tsx index 03857ea3ac2ee..72a43e14920c8 100644 --- a/web/packages/shared/components/FieldSelect/shared.tsx +++ b/web/packages/shared/components/FieldSelect/shared.tsx @@ -202,6 +202,7 @@ export function splitSelectProps< onChange, onInputChange, onKeyDown, + openMenuOnClick, options, placeholder, rule, @@ -239,6 +240,7 @@ export function splitSelectProps< onInputChange, onKeyDown, options, + openMenuOnClick, placeholder, stylesConfig, value, @@ -285,6 +287,7 @@ type KeysRemovedFromOthers = | 'onChange' | 'onInputChange' | 'onKeyDown' + | 'openMenuOnClick' | 'options' | 'placeholder' | 'rule' diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx index eec4ede5b71eb..4497cbea441d7 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx @@ -29,20 +29,24 @@ import { createTeleportContext } from 'teleport/mocks/contexts'; import { AccessSpec, AppAccessSpec, + DatabaseAccessSpec, KubernetesAccessSpec, newAccessSpec, newRole, roleToRoleEditorModel, ServerAccessSpec, StandardEditorModel, + WindowsDesktopAccessSpec, } from './standardmodel'; import { AppAccessSpecSection, + DatabaseAccessSpecSection, KubernetesAccessSpecSection, SectionProps, ServerAccessSpecSection, StandardEditor, StandardEditorProps, + WindowsDesktopAccessSpecSection, } from './StandardEditor'; const TestStandardEditor = (props: Partial) => { @@ -76,6 +80,8 @@ test('adding and removing sections', async () => { 'Kubernetes', 'Servers', 'Applications', + 'Databases', + 'Windows Desktops', ]); await user.click(screen.getByRole('menuitem', { name: 'Servers' })); @@ -84,7 +90,12 @@ test('adding and removing sections', async () => { await user.click( screen.getByRole('button', { name: 'Add New Specifications' }) ); - expect(getAllMenuItemNames()).toEqual(['Kubernetes', 'Applications']); + expect(getAllMenuItemNames()).toEqual([ + 'Kubernetes', + 'Applications', + 'Databases', + 'Windows Desktops', + ]); await user.click(screen.getByRole('menuitem', { name: 'Kubernetes' })); expect(getAllSectionNames()).toEqual([ @@ -353,6 +364,62 @@ test('AppAccessSpecSection', async () => { } as AppAccessSpec); }); +test('DatabaseAccessSpecSection', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + component={DatabaseAccessSpecSection} + defaultValue={newAccessSpec('db')} + onChange={onChange} + /> + ); + + 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'); + await selectEvent.create(screen.getByLabelText('Database Names'), 'stuff', { + createOptionText: 'Database Name: stuff', + }); + await selectEvent.create(screen.getByLabelText('Database Users'), 'mary', { + createOptionText: 'Database User: mary', + }); + await selectEvent.create(screen.getByLabelText('Database Roles'), 'admin', { + createOptionText: 'Database Role: admin', + }); + expect(onChange).toHaveBeenLastCalledWith({ + kind: 'db', + labels: [{ name: 'env', value: 'prod' }], + names: [expect.objectContaining({ label: 'stuff', value: 'stuff' })], + roles: [expect.objectContaining({ label: 'admin', value: 'admin' })], + users: [expect.objectContaining({ label: 'mary', value: 'mary' })], + } as DatabaseAccessSpec); +}); + +test('WindowsDesktopAccessSpecSection', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + component={WindowsDesktopAccessSpecSection} + defaultValue={newAccessSpec('windows_desktop')} + onChange={onChange} + /> + ); + + await user.click(screen.getByRole('button', { name: 'Add a Label' })); + await user.type(screen.getByPlaceholderText('label key'), 'os'); + await user.type(screen.getByPlaceholderText('label value'), 'win-xp'); + await selectEvent.create(screen.getByLabelText('Logins'), 'julio', { + createOptionText: 'Login: julio', + }); + expect(onChange).toHaveBeenLastCalledWith({ + kind: 'windows_desktop', + labels: [{ name: 'os', value: 'win-xp' }], + logins: [expect.objectContaining({ label: 'julio', value: 'julio' })], + } as WindowsDesktopAccessSpec); +}); + const reactSelectValueContainer = (input: HTMLInputElement) => // eslint-disable-next-line testing-library/no-node-access input.closest('.react-select__value-container'); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx index c88498deaa0ef..01789e1f2f837 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -63,6 +63,8 @@ import { kubernetesVerbOptions, KubernetesResourceModel, AppAccessSpec, + DatabaseAccessSpec, + WindowsDesktopAccessSpec, } from './standardmodel'; import { EditorSaveCancelButton } from './Shared'; import { RequiresResetToStandard } from './RequiresResetToStandard'; @@ -359,7 +361,13 @@ const Section = ({ /** * All access spec kinds, in order of appearance in the resource kind dropdown. */ -const allAccessSpecKinds: AccessSpecKind[] = ['kube_cluster', 'node', 'app']; +const allAccessSpecKinds: AccessSpecKind[] = [ + 'kube_cluster', + 'node', + 'app', + 'db', + 'windows_desktop', +]; /** Maps access specification kind to UI component configuration. */ const specSections: Record< @@ -385,6 +393,16 @@ const specSections: Record< tooltip: 'Configures access to applications', component: AppAccessSpecSection, }, + db: { + title: 'Databases', + tooltip: 'Configures access to databases', + component: DatabaseAccessSpecSection, + }, + windows_desktop: { + title: 'Windows Desktops', + tooltip: 'Configures access to Windows desktops', + component: WindowsDesktopAccessSpecSection, + }, }; /** @@ -436,6 +454,7 @@ export function ServerAccessSpecSection({ components={{ DropdownIndicator: null, }} + openMenuOnClick={false} value={value.logins} onChange={logins => onChange?.({ ...value, logins })} mt={3} @@ -460,6 +479,7 @@ export function KubernetesAccessSpecSection({ components={{ DropdownIndicator: null, }} + openMenuOnClick={false} value={value.groups} onChange={groups => onChange?.({ ...value, groups })} /> @@ -636,6 +656,111 @@ export function AppAccessSpecSection({ ); } +export function DatabaseAccessSpecSection({ + value, + isProcessing, + onChange, +}: SectionProps) { + return ( + <> + + + Labels + + onChange?.({ ...value, labels })} + /> + + + List of database names that this role is allowed to connect to. + Special value * means any name. + + } + isDisabled={isProcessing} + formatCreateLabel={label => `Database Name: ${label}`} + components={{ + DropdownIndicator: null, + }} + openMenuOnClick={false} + value={value.names} + onChange={names => onChange?.({ ...value, names })} + /> + + List of database users that this role is allowed to connect as. + Special value * means any user. + + } + isDisabled={isProcessing} + formatCreateLabel={label => `Database User: ${label}`} + components={{ + DropdownIndicator: null, + }} + openMenuOnClick={false} + value={value.users} + onChange={users => onChange?.({ ...value, users })} + /> + `Database Role: ${label}`} + components={{ + DropdownIndicator: null, + }} + openMenuOnClick={false} + value={value.roles} + onChange={roles => onChange?.({ ...value, roles })} + mb={0} + /> + + ); +} + +export function WindowsDesktopAccessSpecSection({ + value, + isProcessing, + onChange, +}: SectionProps) { + return ( + <> + + + Labels + + onChange?.({ ...value, labels })} + /> + + `Login: ${label}`} + components={{ + DropdownIndicator: null, + }} + openMenuOnClick={false} + value={value.logins} + onChange={logins => onChange?.({ ...value, logins })} + /> + + ); +} + export const EditorWrapper = styled(Box)<{ mute?: boolean }>` opacity: ${p => (p.mute ? 0.4 : 1)}; pointer-events: ${p => (p.mute ? 'none' : '')}; diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts index 03a1b32f5d81f..616e36673bbfe 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts @@ -40,11 +40,177 @@ const minimalRoleModel = (): RoleEditorModel => ({ requiresReset: false, }); -describe('roleToRoleEditorModel', () => { - it('converts a minimal role', () => { - expect(roleToRoleEditorModel(minimalRole())).toEqual(minimalRoleModel()); +// These tests make sure that role to model and model to role conversions are +// symmetrical in typical cases. +describe.each<{ name: string; role: Role; model: RoleEditorModel }>([ + { name: 'minimal role', role: minimalRole(), model: minimalRoleModel() }, + + { + name: 'metadata', + role: { + ...minimalRole(), + metadata: { + name: 'role-name', + description: 'role-description', + }, + }, + model: { + ...minimalRoleModel(), + metadata: { + name: 'role-name', + description: 'role-description', + }, + }, + }, + + { + name: 'server access spec', + role: { + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + node_labels: { foo: 'bar' }, + logins: ['root', 'cthulhu', 'sandman'], + }, + }, + }, + model: { + ...minimalRoleModel(), + accessSpecs: [ + { + kind: 'node', + labels: [{ name: 'foo', value: 'bar' }], + logins: [ + { label: 'root', value: 'root' }, + { label: 'cthulhu', value: 'cthulhu' }, + { label: 'sandman', value: 'sandman' }, + ], + }, + ], + }, + }, + + { + name: 'app access spec', + role: { + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + app_labels: { foo: 'bar' }, + aws_role_arns: [ + 'arn:aws:iam::123456789012:role/role1', + 'arn:aws:iam::123456789012:role/role2', + ], + azure_identities: [ + '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1', + '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id2', + ], + gcp_service_accounts: [ + 'account1@some-project.iam.gserviceaccount.com', + 'account2@some-project.iam.gserviceaccount.com', + ], + }, + }, + }, + model: { + ...minimalRoleModel(), + accessSpecs: [ + { + kind: 'app', + labels: [{ name: 'foo', value: 'bar' }], + awsRoleARNs: [ + 'arn:aws:iam::123456789012:role/role1', + 'arn:aws:iam::123456789012:role/role2', + ], + azureIdentities: [ + '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1', + '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id2', + ], + gcpServiceAccounts: [ + 'account1@some-project.iam.gserviceaccount.com', + 'account2@some-project.iam.gserviceaccount.com', + ], + }, + ], + }, + }, + + { + name: 'database access spec', + role: { + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + db_labels: { env: 'prod' }, + db_names: ['stuff', 'knickknacks'], + db_users: ['joe', 'mary'], + db_roles: ['admin', 'auditor'], + }, + }, + }, + model: { + ...minimalRoleModel(), + accessSpecs: [ + { + kind: 'db', + labels: [{ name: 'env', value: 'prod' }], + names: [ + { label: 'stuff', value: 'stuff' }, + { label: 'knickknacks', value: 'knickknacks' }, + ], + users: [ + { label: 'joe', value: 'joe' }, + { label: 'mary', value: 'mary' }, + ], + roles: [ + { label: 'admin', value: 'admin' }, + { label: 'auditor', value: 'auditor' }, + ], + }, + ], + }, + }, + + { + name: 'Windows desktop access spec', + role: { + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + windows_desktop_labels: { os: 'WindowsForWorkgroups' }, + windows_desktop_logins: ['alice', 'bob'], + }, + }, + }, + model: { + ...minimalRoleModel(), + accessSpecs: [ + { + kind: 'windows_desktop', + labels: [{ name: 'os', value: 'WindowsForWorkgroups' }], + logins: [ + { label: 'alice', value: 'alice' }, + { label: 'bob', value: 'bob' }, + ], + }, + ], + }, + }, +])('$name', ({ role, model }) => { + it('is converted to a model', () => { + expect(roleToRoleEditorModel(role)).toEqual(model); }); + it('is created from a model', () => { + expect(roleEditorModelToRole(model)).toEqual(role); + }); +}); + +describe('roleToRoleEditorModel', () => { it('detects unknown fields', () => { const minRole = minimalRole(); const roleModelWithReset: RoleEditorModel = { @@ -162,43 +328,28 @@ describe('roleToRoleEditorModel', () => { } as RoleEditorModel); }); - it('converts metadata', () => { - expect( - roleToRoleEditorModel({ - ...minimalRole(), - metadata: { - name: 'role-name', - description: 'role-description', - }, - }) - ).toEqual({ - ...minimalRoleModel(), - metadata: { - name: 'role-name', - description: 'role-description', - }, - } as RoleEditorModel); - }); - it('preserves original revision', () => { - const exampleRole = () => ({ + const rev = '5d7e724b-a52c-4c12-9372-60a8d1af5d33'; + const originalRev = '9c2d5732-c514-46c3-b18d-2009b65af7b8'; + const exampleRole = (revision: string) => ({ ...minimalRole(), metadata: { name: 'role-name', - revision: '5d7e724b-a52c-4c12-9372-60a8d1af5d33', + revision, }, }); expect( roleToRoleEditorModel( - exampleRole(), - exampleRole() // original + exampleRole(rev), + exampleRole(originalRev) // original ) ).toEqual({ ...minimalRoleModel(), metadata: { name: 'role-name', - revision: '5d7e724b-a52c-4c12-9372-60a8d1af5d33', + revision: originalRev, }, + requiresReset: true, } as RoleEditorModel); }); @@ -230,35 +381,8 @@ describe('roleToRoleEditorModel', () => { } as RoleEditorModel); }); - it('creates a server access spec', () => { - const minRole = minimalRole(); - expect( - roleToRoleEditorModel({ - ...minRole, - spec: { - ...minRole.spec, - allow: { - node_labels: { foo: 'bar' }, - logins: ['root', 'cthulhu', 'sandman'], - }, - }, - }) - ).toEqual({ - ...minimalRoleModel(), - accessSpecs: [ - { - kind: 'node', - labels: [{ name: 'foo', value: 'bar' }], - logins: [ - { label: 'root', value: 'root' }, - { label: 'cthulhu', value: 'cthulhu' }, - { label: 'sandman', value: 'sandman' }, - ], - }, - ], - } as RoleEditorModel); - }); - + // This case has to be tested separately because of dynamic resource ID + // generation. it('creates a Kubernetes access spec', () => { const minRole = minimalRole(); expect( @@ -319,7 +443,8 @@ describe('roleToRoleEditorModel', () => { } as RoleEditorModel); }); - it('creates an app access spec', () => { + // Make sure that some fields are optional. + it('creates a minimal app access spec', () => { const minRole = minimalRole(); expect( roleToRoleEditorModel({ @@ -343,50 +468,6 @@ describe('roleToRoleEditorModel', () => { }, ], } as RoleEditorModel); - - expect( - roleToRoleEditorModel({ - ...minRole, - spec: { - ...minRole.spec, - allow: { - app_labels: { foo: 'bar' }, - aws_role_arns: [ - 'arn:aws:iam::123456789012:role/role1', - 'arn:aws:iam::123456789012:role/role2', - ], - azure_identities: [ - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1', - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id2', - ], - gcp_service_accounts: [ - 'account1@some-project.iam.gserviceaccount.com', - 'account2@some-project.iam.gserviceaccount.com', - ], - }, - }, - }) - ).toEqual({ - ...minimalRoleModel(), - accessSpecs: [ - { - kind: 'app', - labels: [{ name: 'foo', value: 'bar' }], - awsRoleARNs: [ - 'arn:aws:iam::123456789012:role/role1', - 'arn:aws:iam::123456789012:role/role2', - ], - azureIdentities: [ - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1', - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id2', - ], - gcpServiceAccounts: [ - 'account1@some-project.iam.gserviceaccount.com', - 'account2@some-project.iam.gserviceaccount.com', - ], - }, - ], - } as RoleEditorModel); }); }); @@ -419,35 +500,8 @@ describe('roleEditorModelToRole', () => { } as Role); }); - it('converts a server access spec', () => { - const minRole = minimalRole(); - expect( - roleEditorModelToRole({ - ...minimalRoleModel(), - accessSpecs: [ - { - kind: 'node', - labels: [{ name: 'foo', value: 'bar' }], - logins: [ - { label: 'root', value: 'root' }, - { label: 'cthulhu', value: 'cthulhu' }, - { label: 'sandman', value: 'sandman' }, - ], - }, - ], - }) - ).toEqual({ - ...minRole, - spec: { - ...minRole.spec, - allow: { - node_labels: { foo: 'bar' }, - logins: ['root', 'cthulhu', 'sandman'], - }, - }, - } as Role); - }); - + // This case has to be tested separately because of dynamic resource ID + // generation. it('converts a Kubernetes access spec', () => { const minRole = minimalRole(); expect( @@ -508,53 +562,6 @@ describe('roleEditorModelToRole', () => { }, } as Role); }); - - it('converts an app access spec', () => { - const minRole = minimalRole(); - expect( - roleEditorModelToRole({ - ...minimalRoleModel(), - accessSpecs: [ - { - kind: 'app', - labels: [{ name: 'foo', value: 'bar' }], - awsRoleARNs: [ - 'arn:aws:iam::123456789012:role/role1', - 'arn:aws:iam::123456789012:role/role2', - ], - azureIdentities: [ - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1', - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id2', - ], - gcpServiceAccounts: [ - 'account1@some-project.iam.gserviceaccount.com', - 'account2@some-project.iam.gserviceaccount.com', - ], - }, - ], - }) - ).toEqual({ - ...minRole, - spec: { - ...minRole.spec, - allow: { - app_labels: { foo: 'bar' }, - aws_role_arns: [ - 'arn:aws:iam::123456789012:role/role1', - 'arn:aws:iam::123456789012:role/role2', - ], - azure_identities: [ - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id1', - '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id2', - ], - gcp_service_accounts: [ - 'account1@some-project.iam.gserviceaccount.com', - 'account2@some-project.iam.gserviceaccount.com', - ], - }, - }, - } as Role); - }); }); test('labelsModelToLabels', () => { diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts index 7cfb0f9cc760e..8dbb945f55583 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts @@ -66,7 +66,9 @@ export type MetadataModel = { export type AccessSpec = | KubernetesAccessSpec | ServerAccessSpec - | AppAccessSpec; + | AppAccessSpec + | DatabaseAccessSpec + | WindowsDesktopAccessSpec; /** * A base for all access specification section models. Contains a type @@ -82,7 +84,12 @@ type AccessSpecBase = { kind: T; }; -export type AccessSpecKind = 'node' | 'kube_cluster' | 'app'; +export type AccessSpecKind = + | 'node' + | 'kube_cluster' + | 'app' + | 'db' + | 'windows_desktop'; /** Model for the Kubernetes access specification section. */ export type KubernetesAccessSpec = AccessSpecBase<'kube_cluster'> & { @@ -205,7 +212,7 @@ export const kubernetesVerbOptions: KubernetesVerbOption[] = [ ] as const ) .toSorted((a, b) => a.localeCompare(b)) - .map(verb => ({ value: verb, label: verb })), + .map(stringToOption), ]; /** Model for the server access specification section. */ @@ -221,6 +228,18 @@ export type AppAccessSpec = AccessSpecBase<'app'> & { gcpServiceAccounts: string[]; }; +export type DatabaseAccessSpec = AccessSpecBase<'db'> & { + labels: UILabel[]; + names: readonly Option[]; + users: readonly Option[]; + roles: readonly Option[]; +}; + +export type WindowsDesktopAccessSpec = AccessSpecBase<'windows_desktop'> & { + labels: UILabel[]; + logins: readonly Option[]; +}; + const roleVersion = 'v7'; /** @@ -244,6 +263,10 @@ export function newRole(): Role { export function newAccessSpec(kind: 'node'): ServerAccessSpec; export function newAccessSpec(kind: 'kube_cluster'): KubernetesAccessSpec; export function newAccessSpec(kind: 'app'): AppAccessSpec; +export function newAccessSpec(kind: 'db'): DatabaseAccessSpec; +export function newAccessSpec( + kind: 'windows_desktop' +): WindowsDesktopAccessSpec; export function newAccessSpec(kind: AccessSpecKind): AppAccessSpec; export function newAccessSpec(kind: AccessSpecKind): AccessSpec { switch (kind) { @@ -259,6 +282,10 @@ export function newAccessSpec(kind: AccessSpecKind): AccessSpec { azureIdentities: [], gcpServiceAccounts: [], }; + case 'db': + return { kind: 'db', labels: [], names: [], users: [], roles: [] }; + case 'windows_desktop': + return { kind: 'windows_desktop', labels: [], logins: [] }; default: kind satisfies never; } @@ -334,17 +361,22 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { aws_role_arns, azure_identities, gcp_service_accounts, + + db_labels, + db_names, + db_users, + db_roles, + + windows_desktop_labels, + windows_desktop_logins, ...rest } = conditions; const accessSpecs: AccessSpec[] = []; const nodeLabelsModel = labelsToModel(node_labels); - const nodeLoginsModel = (logins ?? []).map(login => ({ - label: login, - value: login, - })); - if (nodeLabelsModel.length > 0 || nodeLoginsModel.length > 0) { + const nodeLoginsModel = stringsToOptions(logins ?? []); + if (someNonEmpty(nodeLabelsModel, nodeLoginsModel)) { accessSpecs.push({ kind: 'node', labels: nodeLabelsModel, @@ -352,17 +384,10 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { }); } - const kubeGroupsModel = (kubernetes_groups ?? []).map(group => ({ - label: group, - value: group, - })); + const kubeGroupsModel = stringsToOptions(kubernetes_groups ?? []); const kubeLabelsModel = labelsToModel(kubernetes_labels); const kubeResourcesModel = kubernetesResourcesToModel(kubernetes_resources); - if ( - kubeGroupsModel.length > 0 || - kubeLabelsModel.length > 0 || - kubeResourcesModel.length > 0 - ) { + if (someNonEmpty(kubeGroupsModel, kubeLabelsModel, kubeResourcesModel)) { accessSpecs.push({ kind: 'kube_cluster', groups: kubeGroupsModel, @@ -376,10 +401,12 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { const azureIdentitiesModel = azure_identities ?? []; const gcpServiceAccountsModel = gcp_service_accounts ?? []; if ( - appLabelsModel.length > 0 || - awsRoleARNsModel.length > 0 || - azureIdentitiesModel.length > 0 || - gcpServiceAccountsModel.length > 0 + someNonEmpty( + appLabelsModel, + awsRoleARNsModel, + azureIdentitiesModel, + gcpServiceAccountsModel + ) ) { accessSpecs.push({ kind: 'app', @@ -390,12 +417,42 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { }); } + const dbLabelsModel = labelsToModel(db_labels); + const dbNamesModel = db_names ?? []; + const dbUsersModel = db_users ?? []; + const dbRolesModel = db_roles ?? []; + if (someNonEmpty(dbLabelsModel, dbNamesModel, dbUsersModel, dbRolesModel)) { + accessSpecs.push({ + kind: 'db', + labels: dbLabelsModel, + names: stringsToOptions(dbNamesModel), + users: stringsToOptions(dbUsersModel), + roles: stringsToOptions(dbRolesModel), + }); + } + + const windowsDesktopLabelsModel = labelsToModel(windows_desktop_labels); + const windowsDesktopLoginsModel = stringsToOptions( + windows_desktop_logins ?? [] + ); + if (someNonEmpty(windowsDesktopLabelsModel, windowsDesktopLoginsModel)) { + accessSpecs.push({ + kind: 'windows_desktop', + labels: windowsDesktopLabelsModel, + logins: windowsDesktopLoginsModel, + }); + } + return { accessSpecs, requiresReset: !isEmpty(rest), }; } +function someNonEmpty(...arr: any[][]): boolean { + return arr.some(x => x.length > 0); +} + /** * Converts a set of labels, as represented in the role resource, to a list of * `LabelInput` value models. @@ -414,6 +471,14 @@ export function labelsToModel(labels: Labels | undefined): UILabel[] { }); } +function stringToOption(s: T): Option { + return { label: s, value: s }; +} + +function stringsToOptions(arr: T[]): Option[] { + return arr.map(stringToOption); +} + function kubernetesResourcesToModel( resources: KubernetesResource[] | undefined ): KubernetesResourceModel[] { @@ -462,18 +527,18 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { switch (kind) { case 'node': role.spec.allow.node_labels = labelsModelToLabels(spec.labels); - role.spec.allow.logins = spec.logins.map(opt => opt.value); + role.spec.allow.logins = optionsToStrings(spec.logins); break; case 'kube_cluster': - role.spec.allow.kubernetes_groups = spec.groups.map(opt => opt.value); + role.spec.allow.kubernetes_groups = optionsToStrings(spec.groups); role.spec.allow.kubernetes_labels = labelsModelToLabels(spec.labels); role.spec.allow.kubernetes_resources = spec.resources.map( ({ kind, name, namespace, verbs }) => ({ kind: kind.value, name, namespace, - verbs: verbs.map(opt => opt.value), + verbs: optionsToStrings(verbs), }) ); break; @@ -485,6 +550,20 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { role.spec.allow.gcp_service_accounts = spec.gcpServiceAccounts; break; + case 'db': + role.spec.allow.db_labels = labelsModelToLabels(spec.labels); + role.spec.allow.db_names = optionsToStrings(spec.names); + role.spec.allow.db_users = optionsToStrings(spec.users); + role.spec.allow.db_roles = optionsToStrings(spec.roles); + break; + + case 'windows_desktop': + role.spec.allow.windows_desktop_labels = labelsModelToLabels( + spec.labels + ); + role.spec.allow.windows_desktop_logins = optionsToStrings(spec.logins); + break; + default: kind satisfies never; } @@ -511,6 +590,10 @@ export function labelsModelToLabels(uiLabels: UILabel[]): Labels { return labels; } +function optionsToStrings(opts: readonly Option[]): string[] { + return opts.map(opt => opt.value); +} + /** Detects if fields were modified by comparing against the original role. */ export function hasModifiedFields( updated: RoleEditorModel, diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index cf053f2ed89b5..27047d1c9f0b5 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -75,6 +75,14 @@ export type RoleConditions = { aws_role_arns?: string[]; azure_identities?: string[]; gcp_service_accounts?: string[]; + + db_labels?: Labels; + db_names?: string[]; + db_users?: string[]; + db_roles?: string[]; + + windows_desktop_labels?: Labels; + windows_desktop_logins?: string[]; }; export type Labels = Record;