Skip to content

Commit

Permalink
[v17] Make sure we don't break the role editor for some preset roles,…
Browse files Browse the repository at this point in the history
… add support for GH organizations (#51959)

* Make sure we don't break the role editor for some preset roles, add support for GH organizations (#51608)

* Add more fields to the role editor

* Review

* Make sure we don't break the role editor for some preset roles

Adds a test that verifies that the built-in roles, as pulled from our Go
source codes, can be unambiguously translated to the standard editor
model. Also adds one currently missing field (`github_organizations`).

* Fix type-check

* Also remove the now redundant comments in presets.go

* Post-merge fix

* Review

* Increase the test timeout

* Increase the test timeout even more

* Attempt to work around the problem with compiler output

* Switch from testPathIgnorePatterns to watchPathIgnorePatterns

* Add newline to end of Makefile

* Wrap preset role tests into a separate describe block

* Change of approach: check in the generated file

* Revert an accidental change

* One more update to the preset roles

* Final touches

---------

Co-authored-by: Rafał Cieślak <[email protected]>

* Dump the roles for v17

* Once more, dump the roles

---------

Co-authored-by: Rafał Cieślak <[email protected]>
  • Loading branch information
bl-nero and ravicious authored Feb 18, 2025
1 parent 87397c6 commit c865d1d
Show file tree
Hide file tree
Showing 11 changed files with 371 additions and 6 deletions.
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -1834,3 +1834,8 @@ create-github-release:
.PHONY: go-mod-tidy-all
go-mod-tidy-all:
find . -type "f" -name "go.mod" -execdir go mod tidy \;

.PHONY: dump-preset-roles
dump-preset-roles:
GOOS=$(OS) GOARCH=$(ARCH) $(CGOFLAG) go run ./build.assets/dump-preset-roles/main.go
pnpm test web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts
63 changes: 63 additions & 0 deletions build.assets/dump-preset-roles/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Teleport
// Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.

// A tool that dumps preset roles in a JSON file that will later be used in
// TypeScript tests to make sure that the standard role editor can
// unambiguously represent a preset role.

package main

import (
"encoding/json"
"fmt"
"log"
"os"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/services"
)

const filePath = "gen/preset-roles.json"

func main() {
access := services.NewPresetAccessRole()
editor := services.NewPresetEditorRole()
auditor := services.NewPresetAuditorRole()

rolesByName := map[string]types.Role{
access.GetName(): access,
editor.GetName(): editor,
auditor.GetName(): auditor,
}

for _, r := range rolesByName {
err := services.CheckAndSetDefaults(r)
if err != nil {
log.Fatalf("Could not set default values: %s", err)
}
}

rolesJSON, err := json.Marshal(rolesByName)
if err != nil {
log.Fatalf("Could not marshal preset roles as JSON: %s", err)
}

if err = os.WriteFile(filePath, rolesJSON, 0744); err != nil {
log.Fatalf("Could not write JSON for preset roles: %s", err)
}

fmt.Printf("Successfully recreated %s\n", filePath)
}
1 change: 1 addition & 0 deletions gen/preset-roles.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"access":{"kind":"role","version":"v7","metadata":{"name":"access","description":"Access cluster resources","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":true,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":true},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false,"ssh_port_forwarding":{"local":{"enabled":true},"remote":{"enabled":true}}},"allow":{"logins":["{{internal.logins}}"],"node_labels":{"*":"*"},"rules":[{"resources":["event"],"verbs":["list","read"]},{"resources":["session"],"verbs":["read","list"],"where":"contains(session.participants, user.metadata.name)"},{"resources":["instance"],"verbs":["list","read"]},{"resources":["cluster_maintenance_config"],"verbs":["list","read"]}],"kubernetes_groups":["{{internal.kubernetes_groups}}"],"kubernetes_users":["{{internal.kubernetes_users}}"],"app_labels":{"*":"*"},"kubernetes_labels":{"*":"*"},"db_labels":{"*":"*"},"db_names":["{{internal.db_names}}"],"db_users":["{{internal.db_users}}"],"aws_role_arns":["{{internal.aws_role_arns}}"],"windows_desktop_logins":["{{internal.windows_logins}}"],"windows_desktop_labels":{"*":"*"},"azure_identities":["{{internal.azure_identities}}"],"kubernetes_resources":[{"kind":"*","namespace":"*","name":"*","verbs":["*"]}],"gcp_service_accounts":["{{internal.gcp_service_accounts}}"],"db_service_labels":{"*":"*"},"db_roles":["{{internal.db_roles}}"],"github_permissions":[{"orgs":["{{internal.github_orgs}}"]}]},"deny":{}}},"auditor":{"kind":"role","version":"v7","metadata":{"name":"auditor","description":"Review cluster events and replay sessions","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":false,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":false},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false},"allow":{"rules":[{"resources":["session"],"verbs":["list","read"]},{"resources":["event"],"verbs":["list","read"]},{"resources":["session_tracker"],"verbs":["list","read"]},{"resources":["cluster_alert"],"verbs":["list","read"]},{"resources":["instance"],"verbs":["list","read"]},{"resources":["security_report"],"verbs":["list","read","use"]},{"resources":["audit_query"],"verbs":["list","read","use"]},{"resources":["bot_instance"],"verbs":["list","read"]},{"resources":["notification"],"verbs":["list","read"]}]},"deny":{}}},"editor":{"kind":"role","version":"v7","metadata":{"name":"editor","description":"Edit cluster configuration","labels":{"teleport.internal/resource-type":"preset"}},"spec":{"options":{"forward_agent":true,"max_session_ttl":"30h0m0s","cert_format":"standard","enhanced_recording":["command","network"],"record_session":{"desktop":false},"desktop_clipboard":true,"desktop_directory_sharing":true,"pin_source_ip":false,"ssh_file_copy":true,"idp":{"saml":{"enabled":true}},"create_desktop_user":false,"create_db_user":false,"ssh_port_forwarding":{"local":{"enabled":true},"remote":{"enabled":true}}},"allow":{"rules":[{"resources":["user"],"verbs":["list","create","read","update","delete"]},{"resources":["role"],"verbs":["list","create","read","update","delete"]},{"resources":["bot"],"verbs":["list","create","read","update","delete"]},{"resources":["crown_jewel"],"verbs":["list","create","read","update","delete"]},{"resources":["db_object_import_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["oidc"],"verbs":["list","create","read","update","delete"]},{"resources":["saml"],"verbs":["list","create","read","update","delete"]},{"resources":["github"],"verbs":["list","create","read","update","delete"]},{"resources":["oidc_request"],"verbs":["list","create","read","update","delete"]},{"resources":["saml_request"],"verbs":["list","create","read","update","delete"]},{"resources":["github_request"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_audit_config"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_auth_preference"],"verbs":["list","create","read","update","delete"]},{"resources":["auth_connector"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_name"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_networking_config"],"verbs":["list","create","read","update","delete"]},{"resources":["session_recording_config"],"verbs":["list","create","read","update","delete"]},{"resources":["external_audit_storage"],"verbs":["list","create","read","update","delete"]},{"resources":["ui_config"],"verbs":["list","create","read","update","delete"]},{"resources":["trusted_cluster"],"verbs":["list","create","read","update","delete"]},{"resources":["remote_cluster"],"verbs":["list","create","read","update","delete"]},{"resources":["token"],"verbs":["list","create","read","update","delete"]},{"resources":["connection_diagnostic"],"verbs":["list","create","read","update","delete"]},{"resources":["db"],"verbs":["list","create","read","update","delete"]},{"resources":["database_certificate"],"verbs":["list","create","read","update","delete"]},{"resources":["installer"],"verbs":["list","create","read","update","delete"]},{"resources":["device"],"verbs":["list","create","read","update","delete","create_enroll_token","enroll"]},{"resources":["db_service"],"verbs":["list","read"]},{"resources":["instance"],"verbs":["list","read"]},{"resources":["login_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["saml_idp_service_provider"],"verbs":["list","create","read","update","delete"]},{"resources":["user_group"],"verbs":["list","create","read","update","delete"]},{"resources":["plugin"],"verbs":["list","create","read","update","delete"]},{"resources":["okta_import_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["okta_assignment"],"verbs":["list","create","read","update","delete"]},{"resources":["lock"],"verbs":["list","create","read","update","delete"]},{"resources":["integration"],"verbs":["list","create","read","update","delete","use"]},{"resources":["billing"],"verbs":["list","create","read","update","delete"]},{"resources":["cluster_alert"],"verbs":["list","create","read","update","delete"]},{"resources":["access_list"],"verbs":["list","create","read","update","delete"]},{"resources":["node"],"verbs":["list","create","read","update","delete"]},{"resources":["discovery_config"],"verbs":["list","create","read","update","delete"]},{"resources":["security_report"],"verbs":["list","create","read","update","delete","use"]},{"resources":["audit_query"],"verbs":["list","create","read","update","delete","use"]},{"resources":["access_graph"],"verbs":["list","create","read","update","delete"]},{"resources":["server_info"],"verbs":["list","create","read","update","delete"]},{"resources":["access_monitoring_rule"],"verbs":["list","create","read","update","delete"]},{"resources":["app_server"],"verbs":["list","create","read","update","delete"]},{"resources":["vnet_config"],"verbs":["list","create","read","update","delete"]},{"resources":["bot_instance"],"verbs":["list","create","read","update","delete"]},{"resources":["access_graph_settings"],"verbs":["list","create","read","update","delete"]},{"resources":["spiffe_federation"],"verbs":["list","create","read","update","delete"]},{"resources":["notification"],"verbs":["list","create","read","update","delete"]},{"resources":["static_host_user"],"verbs":["list","create","read","update","delete"]},{"resources":["user_task"],"verbs":["list","create","read","update","delete"]},{"resources":["aws_identity_center"],"verbs":["list","create","read","update","delete"]},{"resources":["contact"],"verbs":["list","create","read","update","delete"]},{"resources":["workload_identity"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_version"],"verbs":["list","create","read","update","delete"]},{"resources":["autoupdate_config"],"verbs":["list","create","read","update","delete"]},{"resources":["git_server"],"verbs":["list","create","read","update","delete"]},{"resources":["workload_identity_x509_revocation"],"verbs":["list","create","read","update","delete"]}]},"deny":{}}}}
48 changes: 48 additions & 0 deletions lib/services/presets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
package services

import (
"encoding/json"
"os"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -781,3 +783,49 @@ func TestAddRoleDefaults(t *testing.T) {
})
}
}

func TestPresetRolesDumped(t *testing.T) {
// This test ensures that the most recent version of selected preset roles
// has been correctly dumped to a generated JSON file. We use a generated
// file, because it's simpler to load it from a TypeScript test this way,
// rather than calling a Go binary.

// First, get the required roles, as defined in our codebase, and set their
// defaults.
access := NewPresetAccessRole()
editor := NewPresetEditorRole()
auditor := NewPresetAuditorRole()
rolesByName := map[string]types.Role{
access.GetName(): access,
editor.GetName(): editor,
auditor.GetName(): auditor,
}
for _, r := range rolesByName {
err := CheckAndSetDefaults(r)
require.NoError(t, err)
}

// Next, dump them all to JSON and parse them back again. This step is
// necessary, because unmarshaling isn't precisely the opposite of
// marshaling, and comparing raw roles to their unmarshaled counterparts
// still lead to some discrepancies. We can't also directly compare JSON
// blobs, as it's hard to reason whether this process is entirely
// deterministic.
bytes, err := json.Marshal(rolesByName)
require.NoError(t, err)
var recreatedRolesByName map[string]types.RoleV6
err = json.Unmarshal(bytes, &recreatedRolesByName)
require.NoError(t, err)

// Read the roles defined in the generated file.
bytes, err = os.ReadFile("../../gen/preset-roles.json")
require.NoError(t, err)
var rolesFromFile map[string]types.RoleV6
err = json.Unmarshal(bytes, &rolesFromFile)
require.NoError(t, err)

// Finally, compare the roles.
require.Equal(t, rolesFromFile, recreatedRolesByName,
"The dumped preset roles differ from their representation in code. Please run:\n"+
"make dump-preset-roles")
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { RoleVersion } from 'teleport/services/resources';
import {
AppAccessSection,
DatabaseAccessSection,
GitHubOrganizationAccessSection,
KubernetesAccessSection,
ServerAccessSection,
WindowsDesktopAccessSection,
Expand All @@ -35,13 +36,15 @@ import {
AppAccess,
DatabaseAccess,
defaultRoleVersion,
GitHubOrganizationAccess,
KubernetesAccess,
newResourceAccess,
ServerAccess,
WindowsDesktopAccess,
} from './standardmodel';
import { StatefulSection } from './StatefulSection';
import {
GitHubOrganizationAccessValidationResult,
ResourceAccessValidationResult,
validateResourceAccess,
} from './validation';
Expand Down Expand Up @@ -518,6 +521,46 @@ describe('WindowsDesktopAccessSection', () => {
});
});

describe('GitHubOrganizationAccessSection', () => {
const setup = () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<
GitHubOrganizationAccess,
GitHubOrganizationAccessValidationResult
>
component={GitHubOrganizationAccessSection}
defaultValue={newResourceAccess('git_server', defaultRoleVersion)}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateResourceAccess}
/>
);
return { user: userEvent.setup(), onChange, validator };
};

test('editing', async () => {
const { onChange } = setup();
await selectEvent.create(
screen.getByLabelText('Organization Names'),
'illuminati',
{
createOptionText: 'Organization: illuminati',
}
);
expect(onChange).toHaveBeenLastCalledWith({
kind: 'git_server',
organizations: [
expect.objectContaining({ value: '{{internal.github_orgs}}' }),
expect.objectContaining({ label: 'illuminati', value: 'illuminati' }),
],
} as GitHubOrganizationAccess);
});
});

const reactSelectValueContainer = (input: HTMLInputElement) =>
// eslint-disable-next-line testing-library/no-node-access
input.closest('.react-select__value-container');
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { SectionBox, SectionProps, SectionPropsWithDispatch } from './sections';
import {
AppAccess,
DatabaseAccess,
GitHubOrganizationAccess,
KubernetesAccess,
kubernetesResourceKindOptions,
KubernetesResourceModel,
Expand All @@ -54,6 +55,7 @@ import {
import {
AppAccessValidationResult,
DatabaseAccessValidationResult,
GitHubOrganizationAccessValidationResult,
KubernetesAccessValidationResult,
KubernetesResourceValidationResult,
ResourceAccessValidationResult,
Expand Down Expand Up @@ -140,6 +142,7 @@ const allResourceAccessKinds: ResourceAccessKind[] = [
'app',
'db',
'windows_desktop',
'git_server',
];

/** Maps resource access kind to UI component configuration. */
Expand Down Expand Up @@ -176,6 +179,11 @@ export const resourceAccessSections: Record<
tooltip: 'Configures access to Windows desktops',
component: WindowsDesktopAccessSection,
},
git_server: {
title: 'GitHub Organizations',
tooltip: 'Configures access to GitHub organizations and their repositories',
component: GitHubOrganizationAccessSection,
},
};

/**
Expand Down Expand Up @@ -595,6 +603,32 @@ export function WindowsDesktopAccessSection({
);
}

export function GitHubOrganizationAccessSection({
value,
isProcessing,
onChange,
}: SectionProps<
GitHubOrganizationAccess,
GitHubOrganizationAccessValidationResult
>) {
return (
<FieldSelectCreatable
isMulti
label="Organization Names"
toolTipContent="A list of GitHub organization names that this role is allowed to use"
placeholder="Type an organization name and press Enter"
isDisabled={isProcessing}
formatCreateLabel={label => `Organization: ${label}`}
components={{
DropdownIndicator: null,
}}
openMenuOnClick={false}
value={value.organizations}
onChange={organizations => onChange?.({ ...value, organizations })}
/>
);
}

// TODO(bl-nero): This should ideally use tonal neutral 1 from the opposite
// theme as background.
const MarkInverse = styled(Mark)`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ test('adding and removing sections', async () => {
'Applications',
'Databases',
'Windows Desktops',
'GitHub Organizations',
]);

await user.click(screen.getByRole('menuitem', { name: 'Servers' }));
Expand All @@ -76,6 +77,7 @@ test('adding and removing sections', async () => {
'Applications',
'Databases',
'Windows Desktops',
'GitHub Organizations',
]);

await user.click(screen.getByRole('menuitem', { name: 'Kubernetes' }));
Expand Down
Loading

0 comments on commit c865d1d

Please sign in to comment.