diff --git a/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx b/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx index 52c3255215e33..2ee25880cedad 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx @@ -52,7 +52,11 @@ export const EditorHeader = ({ return ( -

{isCreating ? 'Create a New Role' : role?.metadata.name}

+

+ {isCreating + ? 'Create a New Role' + : `Edit Role ${role?.metadata.name}`} +

{isProcessing && } diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx index 291db0397b80a..38e2902ac2878 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx @@ -60,6 +60,8 @@ export const RoleEditor = ({ onDelete, }: RoleEditorProps) => { const idPrefix = useId(); + // These IDs are needed to connect accessibility attributes between the + // standard/YAML tab switcher and the switched panels. const standardEditorId = `${idPrefix}-standard`; const yamlEditorId = `${idPrefix}-yaml`; diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx index db7f899b99fdb..59a7af9928081 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx @@ -36,7 +36,6 @@ import { roleToRoleEditorModel, ServerAccessSpec, StandardEditorModel, - validateAccessSpec, WindowsDesktopAccessSpec, } from './standardmodel'; import { @@ -49,6 +48,7 @@ import { StandardEditorProps, WindowsDesktopAccessSpecSection, } from './StandardEditor'; +import { validateAccessSpec } from './validation'; const TestStandardEditor = (props: Partial) => { const ctx = createTeleportContext(); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx index 2fae996bbe931..bf1567ee235cd 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -69,6 +69,8 @@ import { AppAccessSpec, DatabaseAccessSpec, WindowsDesktopAccessSpec, +} from './standardmodel'; +import { validateRoleEditorModel, MetadataValidationResult, AccessSpecValidationResult, @@ -78,7 +80,7 @@ import { AppSpecValidationResult, DatabaseSpecValidationResult, WindowsDesktopSpecValidationResult, -} from './standardmodel'; +} from './validation'; import { EditorSaveCancelButton } from './Shared'; import { RequiresResetToStandard } from './RequiresResetToStandard'; @@ -206,17 +208,20 @@ export const StandardEditor = ({ key: StandardEditorTab.Overview, title: 'Overview', controls: overviewTabId, - status: validation.metadata.valid - ? undefined - : validationErrorTabStatus, + status: + validator.state.validating && !validation.metadata.valid + ? validationErrorTabStatus + : undefined, }, { key: StandardEditorTab.Resources, title: 'Resources', controls: resourcesTabId, - status: validation.accessSpecs.every(s => s.valid) - ? undefined - : validationErrorTabStatus, + status: + validator.state.validating && + validation.accessSpecs.some(s => !s.valid) + ? validationErrorTabStatus + : undefined, }, { key: StandardEditorTab.AdminRules, @@ -613,7 +618,7 @@ export function KubernetesAccessSpecSection({ onChange?.({ diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts index 70cd233bbf083..f50158add2ba6 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts @@ -18,13 +18,6 @@ import { equalsDeep } from 'shared/utils/highbar'; import { Option } from 'shared/components/Select'; -import { - arrayOf, - requiredField, - RuleSetValidationResult, - runRules, - ValidationResult, -} from 'shared/components/Validation/rules'; import { KubernetesResource, @@ -32,10 +25,7 @@ import { Role, RoleConditions, } from 'teleport/services/resources'; -import { - nonEmptyLabels, - Label as UILabel, -} from 'teleport/components/LabelsInput/LabelsInput'; +import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput'; import { KubernetesResourceKind, KubernetesVerb, @@ -120,14 +110,6 @@ export type KubernetesResourceModel = { }; type KubernetesResourceKindOption = Option; -const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [ - 'namespace', - 'kube_node', - 'persistentvolume', - 'clusterrole', - 'clusterrolebinding', - 'certificatesigningrequest', -]; /** * All possible resource kind drop-down options. This array needs to be kept in @@ -587,124 +569,3 @@ export function hasModifiedFields( ignoreUndefined: true, }); } - -export function validateRoleEditorModel({ - metadata, - accessSpecs, -}: RoleEditorModel) { - return { - metadata: validateMetadata(metadata), - accessSpecs: accessSpecs.map(validateAccessSpec), - }; -} - -function validateMetadata(model: MetadataModel): MetadataValidationResult { - return runRules(model, metadataRules); -} - -const metadataRules = { name: requiredField('Role name is required') }; -export type MetadataValidationResult = RuleSetValidationResult< - typeof metadataRules ->; - -export function validateAccessSpec( - spec: AccessSpec -): AccessSpecValidationResult { - const { kind } = spec; - switch (kind) { - case 'kube_cluster': - return runRules(spec, kubernetesValidationRules); - case 'node': - return runRules(spec, serverValidationRules); - case 'app': - return runRules(spec, appSpecValidationRules); - case 'db': - return runRules(spec, databaseSpecValidationRules); - case 'windows_desktop': - return runRules(spec, windowsDesktopSpecValidationRules); - default: - kind satisfies never; - } -} - -export type AccessSpecValidationResult = - | ServerSpecValidationResult - | KubernetesSpecValidationResult - | AppSpecValidationResult - | DatabaseSpecValidationResult - | WindowsDesktopSpecValidationResult; - -const validKubernetesResource = (res: KubernetesResourceModel) => () => { - const name = requiredField( - 'Resource name is required, use "*" for any resource' - )(res.name)(); - const namespace = kubernetesClusterWideResourceKinds.includes(res.kind.value) - ? { valid: true } - : requiredField('Namespace is required for resources of this kind')( - res.namespace - )(); - return { - valid: name.valid && namespace.valid, - name, - namespace, - }; -}; -export type KubernetesResourceValidationResult = { - name: ValidationResult; - namespace: ValidationResult; -}; - -const kubernetesValidationRules = { - labels: nonEmptyLabels, - resources: arrayOf(validKubernetesResource), -}; -export type KubernetesSpecValidationResult = RuleSetValidationResult< - typeof kubernetesValidationRules ->; - -const noWildcard = (message: string) => (value: string) => () => { - const valid = value !== '*'; - return { valid, message: valid ? '' : message }; -}; - -const noWildcardOptions = (message: string) => (options: Option[]) => () => { - const valid = options.every(o => o.value !== '*'); - return { valid, message: valid ? '' : message }; -}; - -const serverValidationRules = { - labels: nonEmptyLabels, - logins: noWildcardOptions('Wildcard is not allowed in logins'), -}; -export type ServerSpecValidationResult = RuleSetValidationResult< - typeof serverValidationRules ->; - -const appSpecValidationRules = { - labels: nonEmptyLabels, - awsRoleARNs: arrayOf(noWildcard('Wildcard is not allowed in AWS role ARNs')), - azureIdentities: arrayOf( - noWildcard('Wildcard is not allowed in Azure identities') - ), - gcpServiceAccounts: arrayOf( - noWildcard('Wildcard is not allowed in GCP service accounts') - ), -}; -export type AppSpecValidationResult = RuleSetValidationResult< - typeof appSpecValidationRules ->; - -const databaseSpecValidationRules = { - labels: nonEmptyLabels, - roles: noWildcardOptions('Wildcard is not allowed in database roles'), -}; -export type DatabaseSpecValidationResult = RuleSetValidationResult< - typeof databaseSpecValidationRules ->; - -const windowsDesktopSpecValidationRules = { - labels: nonEmptyLabels, -}; -export type WindowsDesktopSpecValidationResult = RuleSetValidationResult< - typeof windowsDesktopSpecValidationRules ->; diff --git a/web/packages/teleport/src/Roles/RoleEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/validation.ts new file mode 100644 index 0000000000000..95cde89ed036c --- /dev/null +++ b/web/packages/teleport/src/Roles/RoleEditor/validation.ts @@ -0,0 +1,168 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + arrayOf, + requiredField, + RuleSetValidationResult, + runRules, + ValidationResult, +} from 'shared/components/Validation/rules'; + +import { Option } from 'shared/components/Select'; + +import { KubernetesResourceKind } from 'teleport/services/resources'; + +import { nonEmptyLabels } from 'teleport/components/LabelsInput/LabelsInput'; + +import { + AccessSpec, + KubernetesResourceModel, + MetadataModel, + RoleEditorModel, +} from './standardmodel'; + +const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [ + 'namespace', + 'kube_node', + 'persistentvolume', + 'clusterrole', + 'clusterrolebinding', + 'certificatesigningrequest', +]; + +export function validateRoleEditorModel({ + metadata, + accessSpecs, +}: RoleEditorModel) { + return { + metadata: validateMetadata(metadata), + accessSpecs: accessSpecs.map(validateAccessSpec), + }; +} + +function validateMetadata(model: MetadataModel): MetadataValidationResult { + return runRules(model, metadataRules); +} + +const metadataRules = { name: requiredField('Role name is required') }; +export type MetadataValidationResult = RuleSetValidationResult< + typeof metadataRules +>; + +export function validateAccessSpec( + spec: AccessSpec +): AccessSpecValidationResult { + const { kind } = spec; + switch (kind) { + case 'kube_cluster': + return runRules(spec, kubernetesValidationRules); + case 'node': + return runRules(spec, serverValidationRules); + case 'app': + return runRules(spec, appSpecValidationRules); + case 'db': + return runRules(spec, databaseSpecValidationRules); + case 'windows_desktop': + return runRules(spec, windowsDesktopSpecValidationRules); + default: + kind satisfies never; + } +} + +export type AccessSpecValidationResult = + | ServerSpecValidationResult + | KubernetesSpecValidationResult + | AppSpecValidationResult + | DatabaseSpecValidationResult + | WindowsDesktopSpecValidationResult; + +const validKubernetesResource = (res: KubernetesResourceModel) => () => { + const name = requiredField( + 'Resource name is required, use "*" for any resource' + )(res.name)(); + const namespace = kubernetesClusterWideResourceKinds.includes(res.kind.value) + ? { valid: true } + : requiredField('Namespace is required for resources of this kind')( + res.namespace + )(); + return { + valid: name.valid && namespace.valid, + name, + namespace, + }; +}; +export type KubernetesResourceValidationResult = { + name: ValidationResult; + namespace: ValidationResult; +}; + +const kubernetesValidationRules = { + labels: nonEmptyLabels, + resources: arrayOf(validKubernetesResource), +}; +export type KubernetesSpecValidationResult = RuleSetValidationResult< + typeof kubernetesValidationRules +>; + +const noWildcard = (message: string) => (value: string) => () => { + const valid = value !== '*'; + return { valid, message: valid ? '' : message }; +}; + +const noWildcardOptions = (message: string) => (options: Option[]) => () => { + const valid = options.every(o => o.value !== '*'); + return { valid, message: valid ? '' : message }; +}; + +const serverValidationRules = { + labels: nonEmptyLabels, + logins: noWildcardOptions('Wildcard is not allowed in logins'), +}; +export type ServerSpecValidationResult = RuleSetValidationResult< + typeof serverValidationRules +>; + +const appSpecValidationRules = { + labels: nonEmptyLabels, + awsRoleARNs: arrayOf(noWildcard('Wildcard is not allowed in AWS role ARNs')), + azureIdentities: arrayOf( + noWildcard('Wildcard is not allowed in Azure identities') + ), + gcpServiceAccounts: arrayOf( + noWildcard('Wildcard is not allowed in GCP service accounts') + ), +}; +export type AppSpecValidationResult = RuleSetValidationResult< + typeof appSpecValidationRules +>; + +const databaseSpecValidationRules = { + labels: nonEmptyLabels, + roles: noWildcardOptions('Wildcard is not allowed in database roles'), +}; +export type DatabaseSpecValidationResult = RuleSetValidationResult< + typeof databaseSpecValidationRules +>; + +const windowsDesktopSpecValidationRules = { + labels: nonEmptyLabels, +}; +export type WindowsDesktopSpecValidationResult = RuleSetValidationResult< + typeof windowsDesktopSpecValidationRules +>; diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index c14366ac518c9..c5ab066e2c894 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -94,6 +94,12 @@ export type KubernetesResource = { verbs?: KubernetesVerb[]; }; +/** + * Supported Kubernetes resource kinds. This type needs to be kept in sync with + * `KubernetesResourcesKinds` in `api/types/constants.go, as well as + * `kubernetesResourceKindOptions` in + * `web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts`. + */ export type KubernetesResourceKind = | '*' | 'pod' @@ -118,6 +124,12 @@ export type KubernetesResourceKind = | 'certificatesigningrequest' | 'ingress'; +/** + * Supported Kubernetes resource verbs. This type needs to be kept in sync with + * `KubernetesVerbs` in `api/types/constants.go, as well as + * `kubernetesVerbOptions` in + * `web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts`. + */ export type KubernetesVerb = | '*' | 'get'