Add support for Linux desktop labels and logins in role editor (#68048)

* Add support for Linux desktop labels and logins in role editor

* test fix
This commit is contained in:
Przemko Robakowski
2026-08-03 12:16:59 +00:00
committed by GitHub
parent d875508a5d
commit 02e35b560b
13 changed files with 289 additions and 3 deletions
+4
View File
@@ -644,6 +644,10 @@ const (
// allowed Windows Desktop logins for local accounts.
TraitInternalWindowsLoginsVariable = "{{internal.windows_logins}}"
// TraitInternalLinuxDesktopLoginsVariable is the variable used to store
// allowed Linux Desktop logins for local accounts.
TraitInternalLinuxDesktopLoginsVariable = "{{internal.linux_desktop_logins}}"
// TraitInternalKubeGroupsVariable is the variable used to store allowed
// kubernetes groups for local accounts.
TraitInternalKubeGroupsVariable = "{{internal.kubernetes_groups}}"
+6
View File
@@ -150,6 +150,12 @@
"tools": [
"{{internal.mcp_tools}}"
]
},
"linux_desktop_logins": [
"{{internal.linux_desktop_logins}}"
],
"linux_desktop_labels": {
"*": "*"
}
},
"deny": {}
+2
View File
@@ -291,6 +291,7 @@ func NewPresetAccessRole() types.Role {
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
WindowsDesktopLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
LinuxDesktopLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseServiceLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseNames: []string{teleport.TraitInternalDBNamesVariable},
@@ -330,6 +331,7 @@ func NewPresetAccessRole() types.Role {
// YAML.
role.SetLogins(types.Allow, []string{teleport.TraitInternalLoginsVariable})
role.SetWindowsLogins(types.Allow, []string{teleport.TraitInternalWindowsLoginsVariable})
role.SetLinuxDesktopLogins(types.Allow, []string{teleport.TraitInternalLinuxDesktopLoginsVariable})
role.SetKubeUsers(types.Allow, []string{teleport.TraitInternalKubeUsersVariable})
role.SetKubeGroups(types.Allow, []string{teleport.TraitInternalKubeGroupsVariable})
role.SetAWSRoleARNs(types.Allow, []string{teleport.TraitInternalAWSRoleARNs})
@@ -25,6 +25,7 @@ import {
DatabaseAccessSection,
GitHubOrganizationAccessSection,
KubernetesAccessSection,
LinuxDesktopAccessSection,
ServerAccessSection,
WindowsDesktopAccessSection,
} from './Resources';
@@ -55,6 +56,7 @@ const meta: Meta<StoryProps> = {
'app',
'db',
'windows_desktop',
'linux_desktop',
'git_server',
],
},
@@ -146,6 +148,12 @@ function getResourceSectionStates(kind: ResourceAccessKind) {
defaultValue: newResourceAccess('windows_desktop', defaultRoleVersion),
};
case 'linux_desktop':
return {
component: LinuxDesktopAccessSection,
defaultValue: newResourceAccess('linux_desktop', defaultRoleVersion),
};
default:
kind satisfies never;
}
@@ -29,6 +29,7 @@ import {
DatabaseAccessSection,
GitHubOrganizationAccessSection,
KubernetesAccessSection,
LinuxDesktopAccessSection,
ServerAccessSection,
WindowsDesktopAccessSection,
} from './Resources';
@@ -42,6 +43,8 @@ import {
GitHubOrganizationAccessInputFields,
KubernetesAccess,
KubernetesAccessInputFields,
LinuxDesktopAccess,
LinuxDesktopAccessInputFields,
newResourceAccess,
ServerAccess,
ServerAccessInputFields,
@@ -724,6 +727,73 @@ describe('WindowsDesktopAccessSection', () => {
});
});
describe('LinuxDesktopAccessSection', () => {
const setup = (visibleInputFields?: LinuxDesktopAccessInputFields) => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<
LinuxDesktopAccess,
ResourceAccessValidationResult,
LinuxDesktopAccessInputFields
>
component={LinuxDesktopAccessSection}
defaultValue={newResourceAccess('linux_desktop', defaultRoleVersion)}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateResourceAccess}
visibleInputFields={visibleInputFields}
/>
);
return { user: userEvent.setup(), onChange, validator };
};
test('editing', async () => {
const { user, onChange } = setup();
await user.type(screen.getByPlaceholderText('label key'), 'os');
await user.type(screen.getByPlaceholderText('label value'), 'ubuntu');
await selectEvent.create(screen.getByLabelText('Logins'), 'alice', {
createOptionText: 'Login: alice',
});
expect(onChange).toHaveBeenLastCalledWith({
kind: 'linux_desktop',
labels: [{ name: 'os', value: 'ubuntu' }],
logins: [
expect.objectContaining({ value: '{{internal.logins}}' }),
expect.objectContaining({ label: 'alice', value: 'alice' }),
],
hideValidationErrors: true,
} as LinuxDesktopAccess);
});
test('validation', async () => {
const { user, validator } = setup();
await user.type(screen.getByPlaceholderText('label value'), 'some-value');
act(() => validator.validate());
expect(
screen.getByPlaceholderText('label key')
).toHaveAccessibleDescription('required');
});
test('hide all input fields', async () => {
setup({ labels: false, logins: false });
expect(screen.queryByPlaceholderText('label key')).not.toBeInTheDocument();
expect(
screen.queryByPlaceholderText('label value')
).not.toBeInTheDocument();
expect(screen.queryByLabelText(/logins/i)).not.toBeInTheDocument();
});
test('hide one input field', async () => {
setup({ labels: true, logins: false });
expect(screen.getByPlaceholderText('label key')).toBeInTheDocument();
expect(screen.getByPlaceholderText('label value')).toBeInTheDocument();
expect(screen.queryByLabelText(/logins/i)).not.toBeInTheDocument();
});
});
describe('GitHubOrganizationAccessSection', () => {
const setup = (visibleInputFields?: GitHubOrganizationAccessInputFields) => {
const onChange = jest.fn();
@@ -61,6 +61,8 @@ import {
KubernetesResourceModel,
kubernetesVerbOptions,
newKubernetesResourceModel,
LinuxDesktopAccess,
LinuxDesktopAccessInputFields,
ResourceAccess,
ResourceAccessKind,
ServerAccess,
@@ -76,6 +78,7 @@ import {
GitHubOrganizationAccessValidationResult,
KubernetesAccessValidationResult,
KubernetesResourceValidationResult,
LinuxDesktopAccessValidationResult,
ResourceAccessValidationResult,
ServerAccessValidationResult,
v7kubernetesClusterWideResourceKinds,
@@ -174,6 +177,7 @@ const allResourceAccessKinds: ResourceAccessKind[] = [
'app',
'db',
'windows_desktop',
'linux_desktop',
'git_server',
];
@@ -205,6 +209,10 @@ export const resourceAccessSections: Record<
title: 'Windows Desktop Access',
component: WindowsDesktopAccessSection,
},
linux_desktop: {
title: 'Linux Desktop Access',
component: LinuxDesktopAccessSection,
},
git_server: {
title: 'GitHub Organization Access',
component: GitHubOrganizationAccessSection,
@@ -931,6 +939,61 @@ export function WindowsDesktopAccessSection({
);
}
export function LinuxDesktopAccessSection({
value,
isProcessing,
validation,
onChange,
readOnly = false,
visibleInputFields,
}: SectionProps<
LinuxDesktopAccess,
LinuxDesktopAccessValidationResult,
LinuxDesktopAccessInputFields
>) {
const show: LinuxDesktopAccessInputFields = visibleInputFields ?? {
labels: true,
logins: true,
};
return (
<>
{show.labels && (
<Box mb={3}>
<LabelsInput
atLeastOneRow
legend="Labels"
disableBtns={isProcessing}
labels={value.labels}
setLabels={labels => onChange?.({ ...value, labels })}
readOnly={readOnly}
rule={readOnly ? undefined : precomputed(validation.fields.labels)}
/>
</Box>
)}
{show.logins && (
<FieldSelectCreatable
isMulti
label="Logins"
placeholder={readOnly ? '' : 'Type a login and press Enter'}
toolTipContent="List of Linux logins allowed to use for desktop sessions."
isDisabled={isProcessing}
formatCreateLabel={label => `Login: ${label}`}
components={{
DropdownIndicator: null,
}}
openMenuOnClick={false}
value={value.logins}
onChange={logins => onChange?.({ ...value, logins: [...logins] })}
menuPosition="fixed"
readOnly={readOnly}
rule={readOnly ? undefined : precomputed(validation.fields.logins)}
/>
)}
</>
);
}
export function GitHubOrganizationAccessSection({
value,
isProcessing,
@@ -86,6 +86,7 @@ test('adding and removing sections', async () => {
'Application Access',
'Database Access',
'Windows Desktop Access',
'Linux Desktop Access',
'GitHub Organization Access',
]);
@@ -100,6 +101,7 @@ test('adding and removing sections', async () => {
'Application Access',
'Database Access',
'Windows Desktop Access',
'Linux Desktop Access',
'GitHub Organization Access',
]);
@@ -303,6 +303,34 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([
},
},
{
name: 'Linux desktop access',
role: {
...minimalRole(),
spec: {
...minimalRole().spec,
allow: {
linux_desktop_labels: { os: 'ubuntu' },
linux_desktop_logins: ['alice', 'bob'],
},
},
},
model: {
...minimalRoleModel(),
resources: [
{
kind: 'linux_desktop',
labels: [{ name: 'os', value: 'ubuntu' }],
logins: [
{ label: 'alice', value: 'alice' },
{ label: 'bob', value: 'bob' },
],
hideValidationErrors: false,
},
],
},
},
{
name: 'GitHub organizations',
role: {
@@ -140,6 +140,7 @@ export type ResourceAccess =
| AppAccess
| DatabaseAccess
| WindowsDesktopAccess
| LinuxDesktopAccess
| GitHubOrganizationAccess;
/**
@@ -165,6 +166,7 @@ export type ResourceAccessKind =
| 'app'
| 'db'
| 'windows_desktop'
| 'linux_desktop'
| 'git_server';
/*
@@ -463,6 +465,20 @@ export type WindowsDesktopAccessInputFields =
export type WindowsDesktopAccess = ResourceAccessBase<'windows_desktop'> &
WindowsDesktopAccessFields;
/*
* Models for the linux desktop resource access section.
*/
type LinuxDesktopAccessFields = {
labels: UILabel[];
logins: Option[];
};
export type LinuxDesktopAccessInputFields =
FieldTypesToBoolean<LinuxDesktopAccessFields>;
export type LinuxDesktopAccess = ResourceAccessBase<'linux_desktop'> &
LinuxDesktopAccessFields;
/*
* Models for the git server resource access section.
*/
@@ -651,6 +667,11 @@ export function newResourceAccess(
roleVersion: RoleVersion
): WindowsDesktopAccess;
export function newResourceAccess(
kind: 'linux_desktop',
roleVersion: RoleVersion
): LinuxDesktopAccess;
export function newResourceAccess(
kind: 'git_server',
roleVersion: RoleVersion
@@ -710,6 +731,13 @@ export function newResourceAccess(
logins: [stringToOption('{{internal.windows_logins}}')],
hideValidationErrors: true,
};
case 'linux_desktop':
return {
kind: 'linux_desktop',
labels: [],
logins: [stringToOption('{{internal.logins}}')],
hideValidationErrors: true,
};
case 'git_server':
return {
kind: 'git_server',
@@ -865,6 +893,10 @@ function roleConditionsToModel(
windows_desktop_labels,
windows_desktop_logins,
// Linux desktop access
linux_desktop_labels,
linux_desktop_logins,
// GitHub organization access
github_permissions,
@@ -990,6 +1022,17 @@ function roleConditionsToModel(
});
}
const linuxDesktopLabelsModel = labelsToModel(linux_desktop_labels);
const linuxDesktopLoginsModel = stringsToOptions(linux_desktop_logins ?? []);
if (someNonEmpty(linuxDesktopLabelsModel, linuxDesktopLoginsModel)) {
resources.push({
kind: 'linux_desktop',
labels: linuxDesktopLabelsModel,
logins: linuxDesktopLoginsModel,
hideValidationErrors: false,
});
}
const {
model: gitHubOrganizationsModel,
conversionErrors: gitHubOrganizationConversionErrors,
@@ -1627,6 +1670,11 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role {
role.spec.allow.windows_desktop_logins = optionsToStrings(res.logins);
break;
case 'linux_desktop':
role.spec.allow.linux_desktop_labels = labelsModelToLabels(res.labels);
role.spec.allow.linux_desktop_logins = optionsToStrings(res.logins);
break;
case 'git_server':
const orgs = optionsToStrings(res.organizations);
if (orgs.length > 0) {
@@ -111,6 +111,12 @@ describe('validateRoleEditorModel', () => {
logins: [],
hideValidationErrors: false,
},
{
kind: 'linux_desktop',
labels: [{ name: 'foo', value: 'bar' }],
logins: [],
hideValidationErrors: false,
},
];
model.rules = [
{
@@ -146,7 +152,14 @@ describe('validateRoleEditorModel', () => {
];
const result = validateRoleEditorModel(model, undefined, undefined);
expect(result.metadata.valid).toBe(true);
expect(validity(result.resources)).toEqual([true, true, true, true, true]);
expect(validity(result.resources)).toEqual([
true,
true,
true,
true,
true,
true,
]);
expect(validity(result.rules)).toEqual([true, true]);
expect(result.isValid).toBe(true);
});
@@ -215,6 +228,12 @@ describe('validateRoleEditorModel', () => {
logins: [],
hideValidationErrors: false,
},
{
kind: 'linux_desktop',
labels: [],
logins: [],
hideValidationErrors: false,
},
];
const result = validateRoleEditorModel(model, undefined, undefined);
expect(validity(result.resources)).toEqual([
@@ -224,6 +243,7 @@ describe('validateRoleEditorModel', () => {
false,
false,
false,
false,
]);
expect(result.isValid).toBe(false);
});
@@ -44,6 +44,7 @@ import {
ServerAccess,
supportsKubernetesCustomResources,
VerbModel,
LinuxDesktopAccess,
WindowsDesktopAccess,
} from './standardmodel';
@@ -175,6 +176,8 @@ export function validateResourceAccess(
return validateDatabaseAccess(resource);
case 'windows_desktop':
return validateWindowsDesktopAccess(resource);
case 'linux_desktop':
return validateLinuxDesktopAccess(resource);
case 'git_server':
return runRules(resource, gitHubOrganizationAccessValidationRules);
default:
@@ -188,6 +191,7 @@ export type ResourceAccessValidationResult =
| AppAccessValidationResult
| DatabaseAccessValidationResult
| WindowsDesktopAccessValidationResult
| LinuxDesktopAccessValidationResult
| GitHubOrganizationAccessValidationResult;
const validKubernetesResource = (res: KubernetesResourceModel) => () => {
@@ -551,6 +555,27 @@ export type WindowsDesktopAccessValidationResult = RuleSetValidationResult<
typeof windowsDesktopAccessValidationRules
>;
const validateLinuxDesktopAccess = (
a: LinuxDesktopAccess
): LinuxDesktopAccessValidationResult => {
const result = runRules(a, linuxDesktopAccessValidationRules);
if (a.labels.length === 0 && a.logins.length === 0) {
result.valid = false;
result.message = 'At least one label or login required';
result.fields.labels.valid = false;
result.fields.logins.valid = false;
}
return result;
};
const linuxDesktopAccessValidationRules = {
labels: nonEmptyLabels,
logins: alwaysValid,
};
export type LinuxDesktopAccessValidationResult = RuleSetValidationResult<
typeof linuxDesktopAccessValidationRules
>;
const gitHubOrganizationAccessValidationRules = {
organizations: requiredField<Option>('At least one organization required'),
};
@@ -226,7 +226,7 @@ export const SupportSectionCard = ({
);
};
const IconBox = styled('span', {
export const IconBox = styled('span', {
base: {
lineHeight: 0,
padding: 2,
@@ -171,6 +171,15 @@ export type WindowsDesktopResourceAccess = {
windows_desktop_logins?: string[];
};
/**
* Fields related to linux desktop access.
*/
export type LinuxDesktopResourceAccess = {
linux_desktop_labels?: Labels;
linux_desktop_logins?: 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.
@@ -182,7 +191,8 @@ export type RoleConditions = {
GitHubResourceAccess &
KubernetesResourceAccess &
ServerResourceAccess &
WindowsDesktopResourceAccess;
WindowsDesktopResourceAccess &
LinuxDesktopResourceAccess;
export type Labels = Record<string, string | string[]>;