diff --git a/api/client/webclient/webconfig.go b/api/client/webclient/webconfig.go index 90fa9436951c8..432995a7f2c96 100644 --- a/api/client/webclient/webconfig.go +++ b/api/client/webclient/webconfig.go @@ -100,6 +100,11 @@ type WebConfig struct { // IsPolicyEnabled is true if [Features.Policy] = true // Deprecated, use entitlements IsPolicyEnabled bool `json:"isPolicyEnabled"` + // TODO (avatus) delete in v18 + // IsPolicyRoleVisualizerEnabled is the graph visualizer for diffs made + // when editing roles in the Web UI. This defaults to true, but has an environment + // variable to turn off if needed TELEPORT_UNSTABLE_DISABLE_ROLE_VISUALIZER=true + IsPolicyRoleVisualizerEnabled bool `json:"isPolicyRoleVisualizerEnabled"` // featureLimits define limits for features. // Typically used with feature teasers if feature is not enabled for the // product type eg: Team product contains teasers to upgrade to Enterprise. diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 9f9479d9548f5..d1a93ed279adb 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -34,6 +34,7 @@ import ( "net" "net/http" "net/url" + "os" "slices" "strconv" "strings" @@ -1866,6 +1867,8 @@ func (h *Handler) getWebConfig(w http.ResponseWriter, r *http.Request, p httprou } } + disableRoleVisualizer, _ := strconv.ParseBool(os.Getenv("TELEPORT_UNSTABLE_DISABLE_ROLE_VISUALIZER")) + webCfg := webclient.WebConfig{ Edition: modules.GetModules().BuildType(), Auth: authSettings, @@ -1874,6 +1877,7 @@ func (h *Handler) getWebConfig(w http.ResponseWriter, r *http.Request, p httprou TunnelPublicAddress: tunnelPublicAddr, RecoveryCodesEnabled: clusterFeatures.GetRecoveryCodes(), UI: h.getUIConfig(r.Context()), + IsPolicyRoleVisualizerEnabled: !disableRoleVisualizer, IsDashboard: services.IsDashboard(clusterFeatures), IsTeam: false, IsUsageBasedBilling: clusterFeatures.GetIsUsageBased(), diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index d4e81dc3adb74..5c419ce46d709 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -4827,6 +4827,7 @@ func TestGetWebConfig_WithEntitlements(t *testing.T) { TunnelPublicAddress: "", RecoveryCodesEnabled: false, UI: webclient.UIConfig{}, + IsPolicyRoleVisualizerEnabled: true, IsDashboard: false, IsUsageBasedBilling: false, AutomaticUpgradesTargetVersion: "", @@ -5008,7 +5009,8 @@ func TestGetWebConfig_LegacyFeatureLimits(t *testing.T) { string(entitlements.UsageReporting): {Enabled: false}, string(entitlements.LicenseAutoUpdate): {Enabled: false}, }, - PlayableDatabaseProtocols: player.SupportedDatabaseProtocols, + PlayableDatabaseProtocols: player.SupportedDatabaseProtocols, + IsPolicyRoleVisualizerEnabled: true, } clt := env.proxies[0].newClient(t) diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx index 32a2440f800dc..09a685170785b 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx @@ -16,13 +16,15 @@ * along with this program. If not, see . */ -import { useId, useState } from 'react'; +import { useCallback, useEffect, useId, useState } from 'react'; import { Alert, Box, Flex } from 'design'; import Validation, { Validator } from 'shared/components/Validation'; import { useAsync } from 'shared/hooks/useAsync'; +import cfg from 'teleport/config'; import { Role, RoleWithYaml } from 'teleport/services/resources'; +import { storageService } from 'teleport/services/storageService'; import { CaptureEvent, userEventService } from 'teleport/services/userEvent'; import { yamlService } from 'teleport/services/yaml'; import { YamlSupportedResourceKind } from 'teleport/services/yaml/types'; @@ -46,6 +48,7 @@ export type RoleEditorProps = { originalRole?: RoleWithYaml; onCancel?(): void; onSave?(r: Partial): Promise; + onRoleUpdate?(r: Role): void; }; /** @@ -57,7 +60,10 @@ export const RoleEditor = ({ originalRole, onCancel, onSave, + onRoleUpdate, }: RoleEditorProps) => { + const roleTesterEnabled = + cfg.isPolicyEnabled && storageService.getAccessGraphRoleTesterEnabled(); const idPrefix = useId(); // These IDs are needed to connect accessibility attributes between the // standard/YAML tab switcher and the switched panels. @@ -66,6 +72,12 @@ export const RoleEditor = ({ const [standardModel, dispatch] = useStandardModel(originalRole?.object); + useEffect(() => { + if (standardModel.validationResult.isValid) { + onRoleUpdate?.(roleEditorModelToRole(standardModel.roleModel)); + } + }, [standardModel, onRoleUpdate]); + const [yamlModel, setYamlModel] = useState({ content: originalRole?.yaml ?? '', isDirty: !originalRole, // New role is dirty by default. @@ -87,6 +99,20 @@ export const RoleEditor = ({ return roleToRoleEditorModel(parsedRole, originalRole?.object); }); + // The standard editor will automatically preview the changes based on state updates + // but the yaml editor needs to be told when to update (the preview button) + const handleYamlPreview = useCallback(async () => { + if (!onRoleUpdate) { + return; + } + // error will be handled by the parseYaml attempt. we only continue if parsed returns a value (success) + const [parsed] = await parseYaml(); + if (!parsed) { + return; + } + onRoleUpdate(roleEditorModelToRole(parsed)); + }, [onRoleUpdate, parseYaml]); + // Converts standard editor model to a YAML representation. const [yamlifyAttempt, yamlifyRole] = useAsync( async () => @@ -216,6 +242,7 @@ export const RoleEditor = ({ isProcessing={isProcessing} onCancel={handleCancel} originalRole={originalRole} + onPreview={roleTesterEnabled ? handleYamlPreview : undefined} /> )} diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditorAdapter.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditorAdapter.tsx index 9200afa7168e8..d616dd61c0701 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditorAdapter.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditorAdapter.tsx @@ -16,13 +16,14 @@ * along with this program. If not, see . */ -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTheme } from 'styled-components'; import { Danger } from 'design/Alert'; import Flex from 'design/Flex'; import { Indicator } from 'design/Indicator'; import { useAsync } from 'shared/hooks/useAsync'; +import { debounce } from 'shared/utils/highbar'; import { State as ResourcesState } from 'teleport/components/useResources'; import { Role, RoleWithYaml } from 'teleport/services/resources'; @@ -68,6 +69,11 @@ export function RoleEditorAdapter({ convertToRole(originalContent); }, [originalContent]); + const onRoleUpdate = useCallback( + debounce(role => roleDiffProps?.updateRoleDiff(role), 500), + [] + ); + return ( {convertAttempt.statusText} )} + {roleDiffProps?.errorMessage && ( + {roleDiffProps.errorMessage} + )} {convertAttempt.status === 'success' && ( )} - - {/* TODO (avatus) this component will not be rendered until the Access Diff feature is implemented */} - {roleDiffProps ? ( - - ) : ( + {roleDiffProps ? ( + roleDiffProps.roleDiffElement + ) : ( + - )} - + + )} ); } diff --git a/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx b/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx index cee440e390b6c..831b58c78a78c 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx @@ -25,13 +25,17 @@ import useTeleport from 'teleport/useTeleport'; export const EditorSaveCancelButton = ({ onSave, + onPreview, onCancel, - disabled, + saveDisabled, + previewDisabled = true, isEditing, }: { onSave?(): void; + onPreview?(): void; onCancel?(): void; - disabled: boolean; + saveDisabled: boolean; + previewDisabled?: boolean; isEditing?: boolean; }) => { const ctx = useTeleport(); @@ -45,6 +49,36 @@ export const EditorSaveCancelButton = ({ hoverTooltipContent = 'You do not have access to create roles'; } + const saveButton = ( + + + + {isEditing ? 'Save Changes' : 'Create Role'} + + + + ); + const cancelButton = ( + + Cancel + + ); + + const previewButton = ( + + Preview + + ); + return ( - - - - {isEditing ? 'Save Changes' : 'Create Role'} - - - - - Cancel - + {saveButton} + {onPreview ? previewButton : cancelButton} ); }; diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx index bd2b808c5e665..ffc7768afe165 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx @@ -214,7 +214,7 @@ export const StandardEditor = ({ handleSave()} onCancel={onCancel} - disabled={ + saveDisabled={ isProcessing || standardEditorModel.roleModel.requiresReset || !standardEditorModel.isDirty diff --git a/web/packages/teleport/src/Roles/RoleEditor/YamlEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/YamlEditor.tsx index be02e057e013a..59b450cfc71e5 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/YamlEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/YamlEditor.tsx @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { useState } from 'react'; + import { Flex } from 'design'; import TextEditor from 'shared/components/TextEditor'; @@ -30,6 +32,7 @@ type YamlEditorProps = { isProcessing: boolean; onChange?(y: YamlEditorModel): void; onSave?(content: string): void; + onPreview?(): void; onCancel?(): void; }; @@ -39,13 +42,25 @@ export const YamlEditor = ({ yamlEditorModel, onChange, onSave, + onPreview, onCancel, }: YamlEditorProps) => { const isEditing = !!originalRole; + const [wasPreviewed, setHasPreviewed] = useState(!onPreview); const handleSave = () => onSave?.(yamlEditorModel.content); + const handlePreview = () => { + // handlePreview should only be called if `onPreview` exists, but adding + // the extra safety here to protect against potential misuse + onPreview?.(); + setHasPreviewed(true); + }; + function handleSetYaml(newContent: string) { + if (onPreview) { + setHasPreviewed(false); + } onChange?.({ isDirty: originalRole?.yaml !== newContent, content: newContent, @@ -63,8 +78,12 @@ export const YamlEditor = ({ diff --git a/web/packages/teleport/src/Roles/Roles.test.tsx b/web/packages/teleport/src/Roles/Roles.test.tsx index 7b61fc0b111be..5cc7ce1bd7b92 100644 --- a/web/packages/teleport/src/Roles/Roles.test.tsx +++ b/web/packages/teleport/src/Roles/Roles.test.tsx @@ -271,19 +271,25 @@ test('renders the role diff component', async () => { list: true, }, }); - const RoleDiffComponent = () =>
i am rendered
; + const roleDiffElement =
i am rendered
; + render( null }} + roleDiffProps={{ + roleDiffElement, + updateRoleDiff: () => null, + errorMessage: 'there is an error here', + }} /> ); await openEditor(); expect(screen.getByText('i am rendered')).toBeInTheDocument(); + expect(screen.getByText('there is an error here')).toBeInTheDocument(); }); async function openEditor() { diff --git a/web/packages/teleport/src/Roles/Roles.tsx b/web/packages/teleport/src/Roles/Roles.tsx index f2f1feea19f80..96d9003013aa5 100644 --- a/web/packages/teleport/src/Roles/Roles.tsx +++ b/web/packages/teleport/src/Roles/Roles.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { ComponentType, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import styled from 'styled-components'; import { Alert, Box, Button, Flex, H3, Link } from 'design'; @@ -50,8 +50,9 @@ import { State, useRoles } from './useRoles'; // RoleDiffProps are an optional set of props to render the role diff visualizer. type RoleDiffProps = { - RoleDiffComponent: ComponentType; - updateRoleDiff: (role: Role) => Promise; + roleDiffElement: React.ReactNode; + updateRoleDiff: (role: Role) => void; + errorMessage: string; }; export type RolesProps = { diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index cb498f24be9ff..26fa696189d2e 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -52,6 +52,10 @@ const cfg = { edition: 'oss', isCloud: false, automaticUpgrades: false, + // TODO (avatus) this is a temporary escape hatch. Delete in v18 + // The role diff visualizer can be disabled by setting TELEPORT_UNSTABLE_DISABLE_ROLE_VISUALIZER=true + // in the proxy service + isPolicyRoleVisualizerEnabled: true, automaticUpgradesTargetVersion: '', // isDashboard is used generally when we want to hide features that can't be hidden by RBAC in // the case of a self-hosted license tenant dashboard. diff --git a/web/packages/teleport/src/services/storageService/storageService.ts b/web/packages/teleport/src/services/storageService/storageService.ts index 5243108d87417..01034612de47c 100644 --- a/web/packages/teleport/src/services/storageService/storageService.ts +++ b/web/packages/teleport/src/services/storageService/storageService.ts @@ -265,6 +265,13 @@ export const storageService = { return this.getParsedJSONValue(KeysEnum.ACCESS_GRAPH_SQL_ENABLED, false); }, + getAccessGraphRoleTesterEnabled(): boolean { + return this.getParsedJSONValue( + KeysEnum.ACCESS_GRAPH_ROLE_TESTER_ENABLED, + false + ); + }, + getExternalAuditStorageCtaDisabled(): boolean { return this.getParsedJSONValue( KeysEnum.EXTERNAL_AUDIT_STORAGE_CTA_DISABLED, diff --git a/web/packages/teleport/src/services/storageService/types.ts b/web/packages/teleport/src/services/storageService/types.ts index f919b637c92f0..87c643e7b0cdf 100644 --- a/web/packages/teleport/src/services/storageService/types.ts +++ b/web/packages/teleport/src/services/storageService/types.ts @@ -30,6 +30,8 @@ export const KeysEnum = { ACCESS_GRAPH_QUERY: 'grv_teleport_access_graph_query', ACCESS_GRAPH_ENABLED: 'grv_teleport_access_graph_enabled', ACCESS_GRAPH_SQL_ENABLED: 'grv_teleport_access_graph_sql_enabled', + ACCESS_GRAPH_ROLE_TESTER_ENABLED: + 'grv_teleport_access_graph_role_tester_enabled', ACCESS_LIST_PREFERENCES: 'grv_teleport_access_list_preferences', EXTERNAL_AUDIT_STORAGE_CTA_DISABLED: 'grv_teleport_external_audit_storage_disabled',