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',