diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx index eaa98ef0a6511..e1dbace8c97d5 100644 --- a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx @@ -54,7 +54,7 @@ export function FieldMultiInput({ // Index of the input to be focused after the next rendering. const toFocus = useRef(); - const setFocus = element => { + const setFocus = (element: HTMLInputElement) => { element?.focus(); toFocus.current = undefined; }; diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx index b0009118a55a6..eec4ede5b71eb 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx @@ -28,13 +28,16 @@ import { createTeleportContext } from 'teleport/mocks/contexts'; import { AccessSpec, + AppAccessSpec, KubernetesAccessSpec, + newAccessSpec, newRole, roleToRoleEditorModel, ServerAccessSpec, StandardEditorModel, } from './standardmodel'; import { + AppAccessSpecSection, KubernetesAccessSpecSection, SectionProps, ServerAccessSpecSection, @@ -69,7 +72,11 @@ test('adding and removing sections', async () => { await user.click( screen.getByRole('button', { name: 'Add New Specifications' }) ); - expect(getAllMenuItemNames()).toEqual(['Kubernetes', 'Servers']); + expect(getAllMenuItemNames()).toEqual([ + 'Kubernetes', + 'Servers', + 'Applications', + ]); await user.click(screen.getByRole('menuitem', { name: 'Servers' })); expect(getAllSectionNames()).toEqual(['Role Metadata', 'Servers']); @@ -77,7 +84,7 @@ test('adding and removing sections', async () => { await user.click( screen.getByRole('button', { name: 'Add New Specifications' }) ); - expect(getAllMenuItemNames()).toEqual(['Kubernetes']); + expect(getAllMenuItemNames()).toEqual(['Kubernetes', 'Applications']); await user.click(screen.getByRole('menuitem', { name: 'Kubernetes' })); expect(getAllSectionNames()).toEqual([ @@ -154,17 +161,13 @@ const StatefulSection = ({ ); }; -test('editing server access specs', async () => { +test('ServerAccessSpecSection', async () => { const user = userEvent.setup(); const onChange = jest.fn(); render( component={ServerAccessSpecSection} - defaultValue={{ - kind: 'node', - labels: [], - logins: [], - }} + defaultValue={newAccessSpec('node')} onChange={onChange} /> ); @@ -194,12 +197,7 @@ describe('KubernetesAccessSpecSection', () => { render( component={KubernetesAccessSpecSection} - defaultValue={{ - kind: 'kube_cluster', - groups: [], - labels: [], - resources: [], - }} + defaultValue={newAccessSpec('kube_cluster')} onChange={onChange} /> ); @@ -312,6 +310,49 @@ describe('KubernetesAccessSpecSection', () => { }); }); +test('AppAccessSpecSection', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + + component={AppAccessSpecSection} + defaultValue={newAccessSpec('app')} + 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 user.type( + within(screen.getByRole('group', { name: 'AWS Role ARNs' })).getByRole( + 'textbox' + ), + 'arn:aws:iam::123456789012:role/admin' + ); + await user.type( + within(screen.getByRole('group', { name: 'Azure Identities' })).getByRole( + 'textbox' + ), + '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin' + ); + await user.type( + within( + screen.getByRole('group', { name: 'GCP Service Accounts' }) + ).getByRole('textbox'), + 'admin@some-project.iam.gserviceaccount.com' + ); + expect(onChange).toHaveBeenLastCalledWith({ + kind: 'app', + labels: [{ name: 'env', value: 'prod' }], + awsRoleARNs: ['arn:aws:iam::123456789012:role/admin'], + azureIdentities: [ + '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin', + ], + gcpServiceAccounts: ['admin@some-project.iam.gserviceaccount.com'], + } as AppAccessSpec); +}); + 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 7a19f03ca43b3..c88498deaa0ef 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -45,6 +45,8 @@ import { Role, RoleWithYaml } from 'teleport/services/resources'; import { LabelsInput } from 'teleport/components/LabelsInput'; +import { FieldMultiInput } from '../../../../shared/components/FieldMultiInput/FieldMultiInput'; + import { roleEditorModelToRole, hasModifiedFields, @@ -60,6 +62,7 @@ import { kubernetesResourceKindOptions, kubernetesVerbOptions, KubernetesResourceModel, + AppAccessSpec, } from './standardmodel'; import { EditorSaveCancelButton } from './Shared'; import { RequiresResetToStandard } from './RequiresResetToStandard'; @@ -356,7 +359,7 @@ const Section = ({ /** * All access spec kinds, in order of appearance in the resource kind dropdown. */ -const allAccessSpecKinds: AccessSpecKind[] = ['kube_cluster', 'node']; +const allAccessSpecKinds: AccessSpecKind[] = ['kube_cluster', 'node', 'app']; /** Maps access specification kind to UI component configuration. */ const specSections: Record< @@ -377,6 +380,11 @@ const specSections: Record< tooltip: 'Configures access to SSH servers', component: ServerAccessSpecSection, }, + app: { + title: 'Applications', + tooltip: 'Configures access to applications', + component: AppAccessSpecSection, + }, }; /** @@ -589,6 +597,45 @@ function KubernetesResourceView({ ); } +export function AppAccessSpecSection({ + value, + isProcessing, + onChange, +}: SectionProps) { + return ( + + + + Labels + + onChange?.({ ...value, labels })} + /> + + onChange?.({ ...value, awsRoleARNs: arns })} + /> + onChange?.({ ...value, azureIdentities: ids })} + /> + onChange?.({ ...value, gcpServiceAccounts: accts })} + /> + + ); +} + 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 f95d9fe7c183d..03a1b32f5d81f 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts @@ -318,6 +318,76 @@ describe('roleToRoleEditorModel', () => { ], } as RoleEditorModel); }); + + it('creates an app access spec', () => { + const minRole = minimalRole(); + expect( + roleToRoleEditorModel({ + ...minRole, + spec: { + ...minRole.spec, + allow: { + app_labels: { foo: 'bar' }, + }, + }, + }) + ).toEqual({ + ...minimalRoleModel(), + accessSpecs: [ + { + kind: 'app', + labels: [{ name: 'foo', value: 'bar' }], + awsRoleARNs: [], + azureIdentities: [], + gcpServiceAccounts: [], + }, + ], + } 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); + }); }); test('labelsToModel', () => { @@ -438,6 +508,53 @@ 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 9c13913315d8f..7cfb0f9cc760e 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts @@ -63,7 +63,10 @@ export type MetadataModel = { }; /** A model for access specifications section. */ -export type AccessSpec = KubernetesAccessSpec | ServerAccessSpec; +export type AccessSpec = + | KubernetesAccessSpec + | ServerAccessSpec + | AppAccessSpec; /** * A base for all access specification section models. Contains a type @@ -79,7 +82,7 @@ type AccessSpecBase = { kind: T; }; -export type AccessSpecKind = 'node' | 'kube_cluster'; +export type AccessSpecKind = 'node' | 'kube_cluster' | 'app'; /** Model for the Kubernetes access specification section. */ export type KubernetesAccessSpec = AccessSpecBase<'kube_cluster'> & { @@ -211,6 +214,13 @@ export type ServerAccessSpec = AccessSpecBase<'node'> & { logins: readonly Option[]; }; +export type AppAccessSpec = AccessSpecBase<'app'> & { + labels: UILabel[]; + awsRoleARNs: string[]; + azureIdentities: string[]; + gcpServiceAccounts: string[]; +}; + const roleVersion = 'v7'; /** @@ -231,12 +241,24 @@ 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: AccessSpecKind): AppAccessSpec; export function newAccessSpec(kind: AccessSpecKind): AccessSpec { switch (kind) { case 'node': return { kind: 'node', labels: [], logins: [] }; case 'kube_cluster': return { kind: 'kube_cluster', groups: [], labels: [], resources: [] }; + case 'app': + return { + kind: 'app', + labels: [], + awsRoleARNs: [], + azureIdentities: [], + gcpServiceAccounts: [], + }; default: kind satisfies never; } @@ -303,11 +325,18 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { const { node_labels, logins, + kubernetes_groups, kubernetes_labels, kubernetes_resources, + + app_labels, + aws_role_arns, + azure_identities, + gcp_service_accounts, ...rest } = conditions; + const accessSpecs: AccessSpec[] = []; const nodeLabelsModel = labelsToModel(node_labels); @@ -341,6 +370,26 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { resources: kubeResourcesModel, }); } + + const appLabelsModel = labelsToModel(app_labels); + const awsRoleARNsModel = aws_role_arns ?? []; + const azureIdentitiesModel = azure_identities ?? []; + const gcpServiceAccountsModel = gcp_service_accounts ?? []; + if ( + appLabelsModel.length > 0 || + awsRoleARNsModel.length > 0 || + azureIdentitiesModel.length > 0 || + gcpServiceAccountsModel.length > 0 + ) { + accessSpecs.push({ + kind: 'app', + labels: appLabelsModel, + awsRoleARNs: awsRoleARNsModel, + azureIdentities: azureIdentitiesModel, + gcpServiceAccounts: gcpServiceAccountsModel, + }); + } + return { accessSpecs, requiresReset: !isEmpty(rest), @@ -411,12 +460,12 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { for (const spec of roleModel.accessSpecs) { const { kind } = spec; switch (kind) { - case 'node': { + case 'node': role.spec.allow.node_labels = labelsModelToLabels(spec.labels); role.spec.allow.logins = spec.logins.map(opt => opt.value); break; - } - case 'kube_cluster': { + + case 'kube_cluster': role.spec.allow.kubernetes_groups = spec.groups.map(opt => opt.value); role.spec.allow.kubernetes_labels = labelsModelToLabels(spec.labels); role.spec.allow.kubernetes_resources = spec.resources.map( @@ -428,7 +477,14 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { }) ); break; - } + + case 'app': + role.spec.allow.app_labels = labelsModelToLabels(spec.labels); + role.spec.allow.aws_role_arns = spec.awsRoleARNs; + role.spec.allow.azure_identities = spec.azureIdentities; + role.spec.allow.gcp_service_accounts = spec.gcpServiceAccounts; + break; + default: kind satisfies never; } diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index 3c366d24aac01..cf053f2ed89b5 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -59,12 +59,22 @@ export type Role = { version: string; }; +/** + * A set of conditions that must be matched to allow or deny access. Fields + * follow the snake case convention to match the wire format. + */ export type RoleConditions = { node_labels?: Labels; logins?: string[]; + kubernetes_groups?: string[]; kubernetes_labels?: Labels; kubernetes_resources?: KubernetesResource[]; + + app_labels?: Labels; + aws_role_arns?: string[]; + azure_identities?: string[]; + gcp_service_accounts?: string[]; }; export type Labels = Record;