From ac995c96e83730f626d3b2eec3f83f38e70b4ceb Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Fri, 29 Nov 2024 19:28:53 +0100 Subject: [PATCH 01/10] Add admin rule tab to the role editor Also tightens some constraints about standard role editor conformance. --- api/types/constants.go | 5 + .../src/Roles/RoleEditor/RoleEditor.story.tsx | 2 +- .../Roles/RoleEditor/StandardEditor.test.tsx | 94 ++++- .../src/Roles/RoleEditor/StandardEditor.tsx | 100 ++++++ .../Roles/RoleEditor/standardmodel.test.ts | 331 +++++++++++++++--- .../src/Roles/RoleEditor/standardmodel.ts | 159 ++++++++- .../src/Roles/RoleEditor/validation.ts | 14 + .../teleport/src/services/resources/types.ts | 184 ++++++++++ 8 files changed, 805 insertions(+), 84 deletions(-) diff --git a/api/types/constants.go b/api/types/constants.go index e8beb81d0bec0..c7fdb4a472707 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -21,6 +21,11 @@ import ( ) const ( + // The `Kind*` constants in this const block identify resource kinds used for + // storage an/or and access control. Please keep these in sync with the + // `ResourceKind` enum in + // `web/packages/teleport/src/services/resources/types.ts`. + // DefaultAPIGroup is a default group of permissions API, // lets us to add different permission types DefaultAPIGroup = "gravitational.io/teleport" diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx index bf8313ceb4122..abd6ed5393f69 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx @@ -42,7 +42,7 @@ export default { } return ( - + diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx index 59a7af9928081..68ff3009a9298 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx @@ -26,19 +26,22 @@ import selectEvent from 'react-select-event'; import TeleportContextProvider from 'teleport/TeleportContextProvider'; import { createTeleportContext } from 'teleport/mocks/contexts'; +import { ResourceKind } from 'teleport/services/resources'; + import { - AccessSpec, AppAccessSpec, DatabaseAccessSpec, KubernetesAccessSpec, newAccessSpec, newRole, roleToRoleEditorModel, + RuleModel, ServerAccessSpec, StandardEditorModel, WindowsDesktopAccessSpec, } from './standardmodel'; import { + AdminRules, AppAccessSpecSection, DatabaseAccessSpecSection, KubernetesAccessSpecSection, @@ -48,7 +51,12 @@ import { StandardEditorProps, WindowsDesktopAccessSpecSection, } from './StandardEditor'; -import { validateAccessSpec } from './validation'; +import { + AccessSpecValidationResult, + AdminRuleValidationResult, + validateAccessSpec, + validateAdminRule, +} from './validation'; const TestStandardEditor = (props: Partial) => { const ctx = createTeleportContext(); @@ -165,19 +173,21 @@ const getSectionByName = (name: string) => // eslint-disable-next-line testing-library/no-node-access screen.getByRole('heading', { level: 3, name }).closest('details'); -const StatefulSection = ({ +function StatefulSection({ defaultValue, component: Component, onChange, validatorRef, + validate, }: { defaultValue: S; component: React.ComponentType>; onChange(spec: S): void; validatorRef?(v: Validator): void; -}) => { + validate(arg: S): V; +}) { const [model, setModel] = useState(defaultValue); - const validation = validateAccessSpec(model); + const validation = validate(model); return ( {({ validator }) => { @@ -196,20 +206,21 @@ const StatefulSection = ({ }} ); -}; +} describe('ServerAccessSpecSection', () => { const setup = () => { const onChange = jest.fn(); let validator: Validator; render( - + component={ServerAccessSpecSection} defaultValue={newAccessSpec('node')} onChange={onChange} validatorRef={v => { validator = v; }} + validate={validateAccessSpec} /> ); return { user: userEvent.setup(), onChange, validator }; @@ -258,13 +269,14 @@ describe('KubernetesAccessSpecSection', () => { const onChange = jest.fn(); let validator: Validator; render( - + component={KubernetesAccessSpecSection} defaultValue={newAccessSpec('kube_cluster')} onChange={onChange} validatorRef={v => { validator = v; }} + validate={validateAccessSpec} /> ); return { user: userEvent.setup(), onChange, validator }; @@ -399,13 +411,14 @@ describe('AppAccessSpecSection', () => { const onChange = jest.fn(); let validator: Validator; render( - + component={AppAccessSpecSection} defaultValue={newAccessSpec('app')} onChange={onChange} validatorRef={v => { validator = v; }} + validate={validateAccessSpec} /> ); return { user: userEvent.setup(), onChange, validator }; @@ -476,13 +489,14 @@ describe('DatabaseAccessSpecSection', () => { const onChange = jest.fn(); let validator: Validator; render( - + component={DatabaseAccessSpecSection} defaultValue={newAccessSpec('db')} onChange={onChange} validatorRef={v => { validator = v; }} + validate={validateAccessSpec} /> ); return { user: userEvent.setup(), onChange, validator }; @@ -532,13 +546,14 @@ describe('WindowsDesktopAccessSpecSection', () => { const onChange = jest.fn(); let validator: Validator; render( - + component={WindowsDesktopAccessSpecSection} defaultValue={newAccessSpec('windows_desktop')} onChange={onChange} validatorRef={v => { validator = v; }} + validate={validateAccessSpec} /> ); return { user: userEvent.setup(), onChange, validator }; @@ -569,6 +584,63 @@ describe('WindowsDesktopAccessSpecSection', () => { }); }); +describe('AdminRules', () => { + const setup = () => { + const onChange = jest.fn(); + let validator: Validator; + render( + + component={AdminRules} + defaultValue={[]} + onChange={onChange} + validatorRef={v => { + validator = v; + }} + validate={rules => rules.map(validateAdminRule)} + /> + ); + return { user: userEvent.setup(), onChange, validator }; + }; + + test('editing', async () => { + const { user, onChange } = setup(); + await user.click(screen.getByRole('button', { name: 'Add New' })); + await selectEvent.select(screen.getByLabelText('Resources'), [ + 'db', + 'node', + ]); + await selectEvent.select(screen.getByLabelText('Permissions'), [ + 'list', + 'read', + ]); + expect(onChange).toHaveBeenLastCalledWith([ + { + id: expect.any(String), + resources: [ + { label: ResourceKind.Database, value: 'db' }, + { label: ResourceKind.Node, value: 'node' }, + ], + verbs: [ + { label: 'list', value: 'list' }, + { label: 'read', value: 'read' }, + ], + }, + ] as RuleModel[]); + }); + + test('validation', async () => { + const { user, validator } = setup(); + await user.click(screen.getByRole('button', { name: 'Add New' })); + act(() => validator.validate()); + expect( + screen.getByText('At least one resource kind is required') + ).toBeInTheDocument(); + expect( + screen.getByText('At least one permission is required') + ).toBeInTheDocument(); + }); +}); + 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 bf1567ee235cd..46a53abf472b6 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -69,6 +69,10 @@ import { AppAccessSpec, DatabaseAccessSpec, WindowsDesktopAccessSpec, + RuleModel, + resourceKindOptions, + verbOptions, + newRuleModel, } from './standardmodel'; import { validateRoleEditorModel, @@ -80,6 +84,7 @@ import { AppSpecValidationResult, DatabaseSpecValidationResult, WindowsDesktopSpecValidationResult, + AdminRuleValidationResult, } from './validation'; import { EditorSaveCancelButton } from './Shared'; import { RequiresResetToStandard } from './RequiresResetToStandard'; @@ -188,6 +193,13 @@ export const StandardEditor = ({ }); } + function setRules(rules: RuleModel[]) { + handleChange({ + ...standardEditorModel.roleModel, + rules, + }); + } + return ( {({ validator }) => ( @@ -227,6 +239,11 @@ export const StandardEditor = ({ key: StandardEditorTab.AdminRules, title: 'Admin Rules', controls: adminRulesTabId, + status: + validator.state.validating && + validation.rules.some(s => !s.valid) + ? validationErrorTabStatus + : undefined, }, { key: StandardEditorTab.Options, @@ -306,6 +323,20 @@ export const StandardEditor = ({ +
+ +
handleSave(validator)} @@ -896,6 +927,75 @@ export function WindowsDesktopAccessSpecSection({ ); } +export function AdminRules({ + value, + isProcessing, + validation, + onChange, +}: SectionProps) { + function addRule() { + onChange?.([...value, newRuleModel()]); + } + function setRule(rule: RuleModel) { + onChange?.(value.map(r => (r.id === rule.id ? rule : r))); + } + return ( + + {value.map((rule, i) => ( + + ))} + + + Add New + + + ); +} + +function AdminRule({ + value, + isProcessing, + validation, + onChange, +}: SectionProps) { + const { resources, verbs } = value; + const theme = useTheme(); + return ( + + onChange?.({ ...value, resources: r })} + rule={precomputed(validation.fields.resources)} + /> + onChange?.({ ...value, verbs: v })} + rule={precomputed(validation.fields.verbs)} + mb={0} + /> + + ); +} + 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 616e36673bbfe..157eee00c85e3 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts @@ -16,7 +16,12 @@ * along with this program. If not, see . */ -import { Role } from 'teleport/services/resources'; +import { + KubernetesResource, + ResourceKind, + Role, + Rule, +} from 'teleport/services/resources'; import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput'; @@ -25,6 +30,7 @@ import { Labels } from 'teleport/services/resources'; import { labelsModelToLabels, labelsToModel, + newAccessSpec, RoleEditorModel, roleEditorModelToRole, roleToRoleEditorModel, @@ -37,6 +43,7 @@ const minimalRole = () => const minimalRoleModel = (): RoleEditorModel => ({ metadata: { name: 'foobar' }, accessSpecs: [], + rules: [], requiresReset: false, }); @@ -211,88 +218,217 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([ }); describe('roleToRoleEditorModel', () => { - it('detects unknown fields', () => { - const minRole = minimalRole(); - const roleModelWithReset: RoleEditorModel = { - ...minimalRoleModel(), - requiresReset: true, - }; - - expect(roleToRoleEditorModel(minRole).requiresReset).toEqual(false); - - expect( - roleToRoleEditorModel({ ...minRole, unknownField: 123 } as Role) - ).toEqual(roleModelWithReset); + const minRole = minimalRole(); + const roleModelWithReset: RoleEditorModel = { + ...minimalRoleModel(), + requiresReset: true, + }; + + test.each<{ name: string; role: Role; model?: RoleEditorModel }>([ + { + name: 'unknown fields in Role', + role: { ...minRole, unknownField: 123 } as Role, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unknown fields in metadata', + role: { ...minRole, metadata: { name: 'foobar', unknownField: 123 }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unknown fields in spec', + role: { ...minRole, spec: { ...minRole.spec, unknownField: 123 }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unknown fields in spec.allow', + role: { ...minRole, spec: { ...minRole.spec, allow: { ...minRole.spec.allow, unknownField: 123 }, }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unknown fields in KubernetesResource', + role: { ...minRole, spec: { ...minRole.spec, - deny: { ...minRole.spec.deny, unknownField: 123 }, + allow: { + ...minRole.spec.allow, + kubernetes_resources: [ + { kind: 'job', unknownField: 123 } as KubernetesResource, + ], + }, }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + model: { + ...roleModelWithReset, + accessSpecs: [ + { + ...newAccessSpec('kube_cluster'), + resources: [expect.any(Object)], + }, + ], + }, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unsupported resource kind in KubernetesResource', + role: { ...minRole, spec: { ...minRole.spec, - deny: { ...minRole.spec.deny, unknownField: 123 }, + allow: { + ...minRole.spec.allow, + kubernetes_resources: [ + { kind: 'illegal' } as unknown as KubernetesResource, + { kind: 'job' }, + ], + }, }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + model: { + ...roleModelWithReset, + accessSpecs: [ + { + ...newAccessSpec('kube_cluster'), + resources: [ + expect.objectContaining({ kind: { value: 'job', label: 'Job' } }), + ], + }, + ], + }, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unsupported verb in KubernetesResource', + role: { ...minRole, spec: { ...minRole.spec, - options: { ...minRole.spec.options, unknownField: 123 }, + allow: { + ...minRole.spec.allow, + kubernetes_resources: [ + { + kind: '*', + verbs: ['illegal', 'get'], + } as unknown as KubernetesResource, + ], + }, }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + model: { + ...roleModelWithReset, + accessSpecs: [ + { + ...newAccessSpec('kube_cluster'), + resources: [ + expect.objectContaining({ + verbs: [{ value: 'get', label: 'get' }], + }), + ], + }, + ], + }, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unknown fields in Rule', + role: { ...minRole, spec: { ...minRole.spec, - options: { - ...minRole.spec.options, - idp: { saml: { enabled: true }, unknownField: 123 }, + allow: { + ...minRole.spec.allow, + rules: [{ unknownField: 123 } as Rule], }, }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + model: { + ...roleModelWithReset, + rules: [expect.any(Object)], + }, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unsupported resource kind in Rule', + role: { + ...minRole, + spec: { + ...minRole.spec, + allow: { + ...minRole.spec.allow, + rules: [{ resources: ['illegal', 'node'] } as unknown as Rule], + }, + }, + } as Role, + model: { + ...roleModelWithReset, + rules: [ + expect.objectContaining({ + resources: [{ value: 'node', label: 'node' }], + }), + ], + }, + }, + + { + name: 'unsupported verb in Rule', + role: { + ...minRole, + spec: { + ...minRole.spec, + allow: { + ...minRole.spec.allow, + rules: [{ verbs: ['illegal', 'create'] } as unknown as Rule], + }, + }, + } as Role, + model: { + ...roleModelWithReset, + rules: [ + expect.objectContaining({ + verbs: [{ value: 'create', label: 'create' }], + }), + ], + }, + }, + + { + name: 'unknown fields in spec.deny', + role: { + ...minRole, + spec: { + ...minRole.spec, + deny: { ...minRole.spec.deny, unknownField: 123 }, + }, + } as Role, + }, + + { + name: 'unknown fields in spec.options', + role: { + ...minRole, + spec: { + ...minRole.spec, + options: { ...minRole.spec.options, unknownField: 123 }, + }, + } as Role, + }, + + { + name: 'unknown fields in spec.options.idp.saml', + role: { ...minRole, spec: { ...minRole.spec, @@ -301,11 +437,12 @@ describe('roleToRoleEditorModel', () => { idp: { saml: { enabled: true, unknownField: 123 } }, }, }, - } as Role) - ).toEqual(roleModelWithReset); + } as Role, + }, - expect( - roleToRoleEditorModel({ + { + name: 'unknown fields in spec.options.record_session', + role: { ...minRole, spec: { ...minRole.spec, @@ -317,9 +454,14 @@ describe('roleToRoleEditorModel', () => { }, }, }, - } as Role) - ).toEqual(roleModelWithReset); - }); + } as Role, + }, + ])( + 'requires reset because of $name', + ({ role, model = roleModelWithReset }) => { + expect(roleToRoleEditorModel(role)).toEqual(model); + } + ); test('version change requires reset', () => { expect(roleToRoleEditorModel({ ...minimalRole(), version: 'v1' })).toEqual({ @@ -471,6 +613,46 @@ describe('roleToRoleEditorModel', () => { }); }); +it('creates a rule model', () => { + expect( + roleToRoleEditorModel({ + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + rules: [ + { + resources: [ResourceKind.User, ResourceKind.DatabaseService], + verbs: ['read', 'list'], + }, + { resources: [ResourceKind.Lock], verbs: ['create'] }, + ], + }, + }, + }) + ).toEqual({ + ...minimalRoleModel(), + rules: [ + { + id: expect.any(String), + resources: [ + { label: 'user', value: 'user' }, + { label: 'db_service', value: 'db_service' }, + ], + verbs: [ + { label: 'read', value: 'read' }, + { label: 'list', value: 'list' }, + ], + }, + { + id: expect.any(String), + resources: [{ label: 'lock', value: 'lock' }], + verbs: [{ label: 'create', value: 'create' }], + }, + ], + } as RoleEditorModel); +}); + test('labelsToModel', () => { expect(labelsToModel({ foo: 'bar', doubleFoo: ['bar1', 'bar2'] })).toEqual([ { name: 'foo', value: 'bar' }, @@ -562,6 +744,43 @@ describe('roleEditorModelToRole', () => { }, } as Role); }); + + it('converts a rule model', () => { + expect( + roleEditorModelToRole({ + ...minimalRoleModel(), + rules: [ + { + id: 'dummy-id-1', + resources: [ + { label: 'user', value: ResourceKind.User }, + { label: 'db_service', value: ResourceKind.DatabaseService }, + ], + verbs: [ + { label: 'read', value: 'read' }, + { label: 'list', value: 'list' }, + ], + }, + { + id: 'dummy-id-2', + resources: [{ label: 'lock', value: ResourceKind.Lock }], + verbs: [{ label: 'create', value: 'create' }], + }, + ], + }) + ).toEqual({ + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + rules: [ + { resources: ['user', 'db_service'], verbs: ['read', 'list'] }, + { resources: ['lock'], verbs: ['create'] }, + ], + }, + }, + } 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 f50158add2ba6..75fdb18277263 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts @@ -29,6 +29,9 @@ import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput'; import { KubernetesResourceKind, KubernetesVerb, + ResourceKind, + Rule, + Verb, } from 'teleport/services/resources/types'; import { defaultOptions } from './withDefaults'; @@ -49,6 +52,7 @@ export type StandardEditorModel = { export type RoleEditorModel = { metadata: MetadataModel; accessSpecs: AccessSpec[]; + rules: RuleModel[]; /** * Indicates whether the current resource, as described by YAML, is * accurately represented by this editor model. If it's not, the user needs @@ -150,6 +154,13 @@ export const kubernetesResourceKindOptions: KubernetesResourceKindOption[] = [ ).toSorted((a, b) => a.label.localeCompare(b.label)), ]; +const optionsToMap = (opts: Option[]) => + new Map(opts.map(o => [o.value, o])); + +const kubernetesResourceKindOptionsMap = optionsToMap( + kubernetesResourceKindOptions +); + type KubernetesVerbOption = Option; /** * All possible Kubernetes verb drop-down options. This array needs to be kept @@ -180,6 +191,33 @@ export const kubernetesVerbOptions: KubernetesVerbOption[] = [ .toSorted((a, b) => a.localeCompare(b)) .map(stringToOption), ]; +const kubernetesVerbOptionsMap = optionsToMap(kubernetesVerbOptions); + +type ResourceKindOption = Option; +export const resourceKindOptions: ResourceKindOption[] = Object.values( + ResourceKind +) + .toSorted() + .map(stringToOption); +const resourceKindOptionsMap = optionsToMap(resourceKindOptions); + +type VerbOption = Option; +export const verbOptions: VerbOption[] = ( + [ + '*', + 'create', + 'create_enroll_token', + 'delete', + 'enroll', + 'list', + 'read', + 'readnosecrets', + 'rotate', + 'update', + 'use', + ] as const +).map(stringToOption); +const verbOptionsMap = optionsToMap(verbOptions); /** Model for the server access specification section. */ export type ServerAccessSpec = AccessSpecBase<'node'> & { @@ -206,6 +244,13 @@ export type WindowsDesktopAccessSpec = AccessSpecBase<'windows_desktop'> & { logins: readonly Option[]; }; +export type RuleModel = { + /** Autogenerated ID to be used with the `key` property. */ + id: string; + resources: readonly ResourceKindOption[]; + verbs: readonly VerbOption[]; +}; + const roleVersion = 'v7'; /** @@ -267,6 +312,14 @@ export function newKubernetesResourceModel(): KubernetesResourceModel { }; } +export function newRuleModel(): RuleModel { + return { + id: crypto.randomUUID(), + resources: [], + verbs: [], + }; +} + /** * Converts a role to its in-editor UI model representation. The resulting * model may be marked as requiring reset if the role contains unsupported @@ -283,8 +336,11 @@ export function roleToRoleEditorModel( const { kind, metadata, spec, version, ...rest } = role; const { name, description, revision, ...mRest } = metadata; const { allow, deny, options, ...sRest } = spec; - const { accessSpecs, requiresReset: allowRequiresReset } = - roleConditionsToAccessSpecs(allow); + const { + accessSpecs, + rules, + requiresReset: allowRequiresReset, + } = roleConditionsToModel(allow); return { metadata: { @@ -293,6 +349,7 @@ export function roleToRoleEditorModel( revision: originalRole?.metadata?.revision, }, accessSpecs, + rules, requiresReset: revision !== originalRole?.metadata?.revision || version !== roleVersion || @@ -311,8 +368,9 @@ export function roleToRoleEditorModel( * Converts a `RoleConditions` instance (an "allow" or "deny" section, to be * specific) to a list of access specification models. */ -function roleConditionsToAccessSpecs(conditions: RoleConditions): { +function roleConditionsToModel(conditions: RoleConditions): { accessSpecs: AccessSpec[]; + rules: RuleModel[]; requiresReset: boolean; } { const { @@ -335,6 +393,9 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { windows_desktop_labels, windows_desktop_logins, + + rules, + ...rest } = conditions; @@ -352,7 +413,10 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { const kubeGroupsModel = stringsToOptions(kubernetes_groups ?? []); const kubeLabelsModel = labelsToModel(kubernetes_labels); - const kubeResourcesModel = kubernetesResourcesToModel(kubernetes_resources); + const { + model: kubeResourcesModel, + requiresReset: kubernetesResourcesRequireReset, + } = kubernetesResourcesToModel(kubernetes_resources); if (someNonEmpty(kubeGroupsModel, kubeLabelsModel, kubeResourcesModel)) { accessSpecs.push({ kind: 'kube_cluster', @@ -409,9 +473,14 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): { }); } + const { model: rulesModel, requiresReset: rulesRequireReset } = + rulesToModel(rules); + return { accessSpecs, - requiresReset: !isEmpty(rest), + rules: rulesModel, + requiresReset: + kubernetesResourcesRequireReset || rulesRequireReset || !isEmpty(rest), }; } @@ -447,18 +516,69 @@ function stringsToOptions(arr: T[]): Option[] { function kubernetesResourcesToModel( resources: KubernetesResource[] | undefined -): KubernetesResourceModel[] { - return (resources ?? []).map( - ({ kind, name, namespace = '', verbs = [] }) => ({ +): { model: KubernetesResourceModel[]; requiresReset: boolean } { + const result = (resources ?? []).map(kubernetesResourceToModel); + return { + model: result.map(r => r.model).filter(m => m !== undefined), + requiresReset: result.some(r => r.requiresReset), + }; +} + +function kubernetesResourceToModel(res: KubernetesResource): { + model?: KubernetesResourceModel; + requiresReset: boolean; +} { + const { kind, name, namespace = '', verbs = [], ...rest } = res; + const kindOption = kubernetesResourceKindOptionsMap.get(kind); + const verbOptions = verbs.map(verb => kubernetesVerbOptionsMap.get(verb)); + const knownVerbOptions = verbOptions.filter(v => v !== undefined); + return { + model: + kindOption !== undefined + ? { + id: crypto.randomUUID(), + kind: kindOption, + name, + namespace, + verbs: knownVerbOptions, + } + : undefined, + requiresReset: + kindOption === undefined || + verbOptions.length !== knownVerbOptions.length || + !isEmpty(rest), + }; +} + +function rulesToModel(rules: Rule[]): { + model: RuleModel[]; + requiresReset: boolean; +} { + const result = (rules ?? []).map(ruleToModel); + return { + model: result.map(r => r.model), + requiresReset: result.some(r => r.requiresReset), + }; +} + +function ruleToModel(rule: Rule): { model: RuleModel; requiresReset: boolean } { + const { resources = [], verbs = [], ...others } = rule; + const resourcesModel = resources.map(k => resourceKindOptionsMap.get(k)); + const knownResourcesModel = resourcesModel.filter(m => m !== undefined); + const verbsModel = verbs.map(v => verbOptionsMap.get(v)); + const knownVerbsModel = verbsModel.filter(m => m !== undefined); + const requiresReset = + !isEmpty(others) || + knownResourcesModel.length !== resourcesModel.length || + knownVerbsModel.length !== verbs.length; + return { + model: { id: crypto.randomUUID(), - kind: kubernetesResourceKindOptions.find(o => o.value === kind), - name, - namespace, - verbs: verbs.map(verb => - kubernetesVerbOptions.find(o => o.value === verb) - ), - }) - ); + resources: knownResourcesModel, + verbs: knownVerbsModel, + }, + requiresReset, + }; } function isEmpty(obj: object) { @@ -535,6 +655,13 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { } } + if (roleModel.rules.length > 0) { + role.spec.allow.rules = roleModel.rules.map(role => ({ + resources: role.resources.map(r => r.value), + verbs: role.verbs.map(v => v.value), + })); + } + return role; } diff --git a/web/packages/teleport/src/Roles/RoleEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/validation.ts index 95cde89ed036c..158ad619553cb 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/validation.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/validation.ts @@ -35,6 +35,7 @@ import { KubernetesResourceModel, MetadataModel, RoleEditorModel, + RuleModel, } from './standardmodel'; const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [ @@ -49,10 +50,12 @@ const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [ export function validateRoleEditorModel({ metadata, accessSpecs, + rules, }: RoleEditorModel) { return { metadata: validateMetadata(metadata), accessSpecs: accessSpecs.map(validateAccessSpec), + rules: rules.map(validateAdminRule), }; } @@ -166,3 +169,14 @@ const windowsDesktopSpecValidationRules = { export type WindowsDesktopSpecValidationResult = RuleSetValidationResult< typeof windowsDesktopSpecValidationRules >; + +export const validateAdminRule = (adminRule: RuleModel) => + runRules(adminRule, adminRuleValidationRules); + +const adminRuleValidationRules = { + resources: requiredField('At least one resource kind is required'), + verbs: requiredField('At least one permission is required'), +}; +export type AdminRuleValidationResult = RuleSetValidationResult< + typeof adminRuleValidationRules +>; diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index c5ab066e2c894..c5a17f313e872 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -83,6 +83,8 @@ export type RoleConditions = { windows_desktop_labels?: Labels; windows_desktop_logins?: string[]; + + rules?: Rule[]; }; export type Labels = Record; @@ -143,6 +145,188 @@ export type KubernetesVerb = | 'exec' | 'portforward'; +export type Rule = { + resources?: ResourceKind[]; + verbs?: Verb[]; +}; + +export enum ResourceKind { + Wildcard = '*', + + // This list was taken from all of the `Kind*` constants in + // `api/types/constants.go`. Please keep these in sync. + + // Resources backed by objects in the backend database. + AccessGraphSecretAuthorizedKey = 'access_graph_authorized_key', + AccessGraphSecretPrivateKey = 'access_graph_private_key', + AccessGraphSettings = 'access_graph_settings', + AccessList = 'access_list', + AccessListMember = 'access_list_member', + AccessListReview = 'access_list_review', + AccessMonitoringRule = 'access_monitoring_rule', + AccessRequest = 'access_request', + App = 'app', + AppOrSAMLIdPServiceProvider = 'app_server_or_saml_idp_sp', + AppServer = 'app_server', + AuditQuery = 'audit_query', + AuthServer = 'auth_server', + AutoUpdateAgentRollout = 'autoupdate_agent_rollout', + AutoUpdateConfig = 'autoupdate_config', + AutoUpdateVersion = 'autoupdate_version', + Bot = 'bot', + BotInstance = 'bot_instance', + CertAuthority = 'cert_authority', + ClusterAlert = 'cluster_alert', + ClusterAuditConfig = 'cluster_audit_config', + ClusterAuthPreference = 'cluster_auth_preference', + ClusterMaintenanceConfig = 'cluster_maintenance_config', + ClusterName = 'cluster_name', + ClusterNetworkingConfig = 'cluster_networking_config', + ConnectionDiagnostic = 'connection_diagnostic', + CrownJewel = 'crown_jewel', + Database = 'db', + DatabaseObject = 'db_object', + DatabaseObjectImportRule = 'db_object_import_rule', + DatabaseServer = 'db_server', + DatabaseService = 'db_service', + Device = 'device', + DiscoveryConfig = 'discovery_config', + DynamicWindowsDesktop = 'dynamic_windows_desktop', + ExternalAuditStorage = 'external_audit_storage', + GitServer = 'git_server', + // Ignoring duplicate: KindGithub = "github" + GithubConnector = 'github', + GlobalNotification = 'global_notification', + HeadlessAuthentication = 'headless_authentication', + Identity = 'identity', + IdentityCenterAccount = 'aws_ic_account', + IdentityCenterAccountAssignment = 'aws_ic_account_assignment', + IdentityCenterPermissionSet = 'aws_ic_permission_set', + IdentityCenterPrincipalAssignment = 'aws_ic_principal_assignment', + Installer = 'installer', + Instance = 'instance', + Integration = 'integration', + KubeCertificateSigningRequest = 'certificatesigningrequest', + KubeClusterRole = 'clusterrole', + KubeClusterRoleBinding = 'clusterrolebinding', + KubeConfigmap = 'configmap', + KubeCronjob = 'cronjob', + KubeDaemonSet = 'daemonset', + KubeDeployment = 'deployment', + KubeIngress = 'ingress', + KubeJob = 'job', + KubeNamespace = 'namespace', + KubeNode = 'kube_node', + KubePersistentVolume = 'persistentvolume', + KubePersistentVolumeClaim = 'persistentvolumeclaim', + KubePod = 'pod', + KubeReplicaSet = 'replicaset', + KubeRole = 'kube_role', + KubeRoleBinding = 'rolebinding', + KubeSecret = 'secret', + KubeServer = 'kube_server', + KubeService = 'service', + KubeServiceAccount = 'serviceaccount', + KubeStatefulset = 'statefulset', + KubeWaitingContainer = 'kube_ephemeral_container', + KubernetesCluster = 'kube_cluster', + License = 'license', + Lock = 'lock', + LoginRule = 'login_rule', + MFADevice = 'mfa_device', + // Ignoring duplicate: KindNamespace = "namespace" + NetworkRestrictions = 'network_restrictions', + Node = 'node', + Notification = 'notification', + // Ignoring duplicate: KindOIDC = "oidc" + OIDCConnector = 'oidc', + OktaAssignment = 'okta_assignment', + OktaImportRule = 'okta_import_rule', + Plugin = 'plugin', + PluginData = 'plugin_data', + PluginStaticCredentials = 'plugin_static_credentials', + ProvisioningPrincipalState = 'provisioning_principal_state', + Proxy = 'proxy', + RecoveryCodes = 'recovery_codes', + RemoteCluster = 'remote_cluster', + ReverseTunnel = 'tunnel', + Role = 'role', + // Ignoring duplicate: KindSAML = "saml" + SAMLConnector = 'saml', + SAMLIdPServiceProvider = 'saml_idp_service_provider', + SPIFFEFederation = 'spiffe_federation', + SecurityReport = 'security_report', + SecurityReportCostLimiter = 'security_report_cost_limiter', + SecurityReportState = 'security_report_state', + Semaphore = 'semaphore', + ServerInfo = 'server_info', + SessionRecordingConfig = 'session_recording_config', + SessionTracker = 'session_tracker', + State = 'state', + StaticHostUser = 'static_host_user', + StaticTokens = 'static_tokens', + Token = 'token', + TrustedCluster = 'trusted_cluster', + TunnelConnection = 'tunnel_connection', + UIConfig = 'ui_config', + User = 'user', + UserGroup = 'user_group', + UserLastSeenNotification = 'user_last_seen_notification', + UserLoginState = 'user_login_state', + UserNotificationState = 'user_notification_state', + UserTask = 'user_task', + UserToken = 'user_token', + UserTokenSecrets = 'user_token_secrets', + VnetConfig = 'vnet_config', + WatchStatus = 'watch_status', + WebSession = 'web_session', + WebToken = 'web_token', + WindowsDesktop = 'windows_desktop', + WindowsDesktopService = 'windows_desktop_service', + + // Resources that have no actual data representation, but serve for checking + // access to various features. + AccessGraph = 'access_graph', + AccessPluginData = 'access_plugin_data', + AuthConnector = 'auth_connector', + Billing = 'billing', + ClusterConfig = 'cluster_config', + Connectors = 'connectors', + DatabaseCertificate = 'database_certificate', + Download = 'download', + Event = 'event', + GithubRequest = 'github_request', + HostCert = 'host_cert', + IdentityCenter = 'aws_identity_center', + JWT = 'jwt', + OIDCRequest = 'oidc_request', + SAMLRequest = 'saml_request', + SSHSession = 'ssh_session', + Session = 'session', + UnifiedResource = 'unified_resource', + UsageEvent = 'usage_event', + + // For completeness: these kind constants were not included here, as they + // refer to resource subkind names that are not used for access control. + // + // KindAppSession = "app_session" + // KindSAMLIdPSession = "saml_idp_session" + // KindSnowflakeSession = "snowflake_session" +} + +export type Verb = + | '*' + | 'create' + | 'create_enroll_token' + | 'delete' + | 'enroll' + | 'list' + | 'read' + | 'readnosecrets' + | 'rotate' + | 'update' + | 'use'; + /** * Teleport role options in full format, as returned from Teleport API. Note * that its fields follow the snake case convention to match the wire format. From 0bacc2f77f33f1b5154691bdae9863b4a2c6ad6e Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Fri, 6 Dec 2024 10:28:23 +0100 Subject: [PATCH 02/10] Add a way to delete an admin rule --- .../src/Roles/RoleEditor/StandardEditor.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx index 46a53abf472b6..64192868b3a5a 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -939,6 +939,9 @@ export function AdminRules({ function setRule(rule: RuleModel) { onChange?.(value.map(r => (r.id === rule.id ? rule : r))); } + function removeRule(id: string) { + onChange?.(value.filter(r => r.id !== id)); + } return ( {value.map((rule, i) => ( @@ -948,6 +951,7 @@ export function AdminRules({ value={rule} onChange={setRule} validation={validation[i]} + onRemove={() => removeRule(rule.id)} /> ))} @@ -963,15 +967,20 @@ function AdminRule({ isProcessing, validation, onChange, -}: SectionProps) { + onRemove, +}: SectionProps & { + onRemove?(): void; +}) { const { resources, verbs } = value; const theme = useTheme(); return ( - - + ); } From a1b1c2ebc07820d5767d0f4bf9a63d9561925bd0 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Wed, 4 Dec 2024 18:58:08 +0100 Subject: [PATCH 03/10] Add the options panel to role editor --- web/packages/design/src/index.ts | 2 +- .../src/Roles/RoleEditor/StandardEditor.tsx | 210 ++++++++++++++++++ .../Roles/RoleEditor/standardmodel.test.ts | 128 +++++++++++ .../src/Roles/RoleEditor/standardmodel.ts | 160 ++++++++++++- .../teleport/src/services/resources/types.ts | 16 ++ 5 files changed, 506 insertions(+), 10 deletions(-) diff --git a/web/packages/design/src/index.ts b/web/packages/design/src/index.ts index f5260ba2ac1c7..89c45fa08a326 100644 --- a/web/packages/design/src/index.ts +++ b/web/packages/design/src/index.ts @@ -34,7 +34,7 @@ import CardSuccess, { CardSuccessLogin } from './CardSuccess'; import { Indicator } from './Indicator'; import Input from './Input'; import Label from './Label'; -import LabelInput from './LabelInput'; +import { LabelInput } from './LabelInput'; import LabelState from './LabelState'; import Link from './Link'; import { Mark } from './Mark'; diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx index 64192868b3a5a..951f476e120b9 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -24,6 +24,8 @@ import { Flex, H3, H4, + Input, + LabelInput, Mark, Text, } from 'design'; @@ -46,6 +48,10 @@ import { } from 'shared/components/FieldSelect'; import { SlideTabs } from 'design/SlideTabs'; +import { RadioGroup } from 'design/RadioGroup'; + +import Select from 'shared/components/Select'; + import { Role, RoleWithYaml } from 'teleport/services/resources'; import { LabelsInput } from 'teleport/components/LabelsInput'; @@ -73,6 +79,9 @@ import { resourceKindOptions, verbOptions, newRuleModel, + OptionsModel, + requireMFATypeOptions, + createHostUserModeOptions, } from './standardmodel'; import { validateRoleEditorModel, @@ -200,6 +209,13 @@ export const StandardEditor = ({ }); } + function setOptions(options: OptionsModel) { + handleChange({ + ...standardEditorModel, + options, + }); + } + return ( {({ validator }) => ( @@ -337,6 +353,18 @@ export const StandardEditor = ({ validation={validation.rules} /> +
+ +
handleSave(validator)} @@ -1005,6 +1033,188 @@ function AdminRule({ ); } +function Options({ + value, + isProcessing, + onChange, +}: SectionProps) { + const theme = useTheme(); + const id = useId(); + const maxSessionTTLId = `${id}-max-session-ttl`; + const clientIdleTimeoutId = `${id}-client-idle-timeout`; + const requireMFATypeId = `${id}-require-mfa-type`; + const createHostUserModeId = `${id}-create-host-user-mode`; + return ( + +

+ Global Settings +

+ + Max Session TTL + onChange({ ...value, maxSessionTTL: e.target.value })} + /> + + + Client Idle Timeout + + + onChange({ ...value, clientIdleTimeout: e.target.value }) + } + /> + +
Disconnect When Certificate Expires
+ onChange({ ...value, disconnectExpiredCert: d })} + /> + + Require Session MFA + onChange?.({ ...value, createHostUserMode: m })} + /> + +

+ Database +

+ +
Create Database User
+ onChange({ ...value, createDBUser: c })} + /> + + {/* TODO(bl-nero): a bug in YAML unmarshalling backend breaks this option. Fix it. */} + {/* + Create Database User Mode + + -
Disconnect When Certificate Expires
+ Disconnect When Certificate Expires onChange?.({ ...value, requireMFAType: t })} /> -

- SSH -

+ SSH Create Host User Mode @@ -1114,71 +1093,61 @@ function Options({ onChange={m => onChange?.({ ...value, createHostUserMode: m })} /> -

- Database -

+ Database -
Create Database User
+ Create Database User onChange({ ...value, createDBUser: c })} /> - {/* TODO(bl-nero): a bug in YAML unmarshalling backend breaks this option. Fix it. */} - {/* - Create Database User Mode - -