From 5ae00885790be4db216ef6964c28b8d26cb4e254 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Tue, 4 Feb 2025 14:55:58 +0100 Subject: [PATCH] Make sure we don't break the role editor for some preset roles, add support for GH organizations (#51608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- Makefile | 5 ++ build.assets/dump-preset-roles/main.go | 63 ++++++++++++++ gen/preset-roles.json | 1 + lib/services/presets_test.go | 48 +++++++++++ .../StandardEditor/Resources.test.tsx | 43 ++++++++++ .../RoleEditor/StandardEditor/Resources.tsx | 34 ++++++++ .../StandardEditor/StandardEditor.test.tsx | 2 + .../StandardEditor/standardmodel.test.ts | 86 +++++++++++++++++++ .../StandardEditor/standardmodel.ts | 80 ++++++++++++++++- .../RoleEditor/StandardEditor/validation.ts | 7 +- .../teleport/src/services/resources/types.ts | 8 +- 11 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 build.assets/dump-preset-roles/main.go create mode 100755 gen/preset-roles.json diff --git a/Makefile b/Makefile index ede5433c3a5..701309b00c4 100644 --- a/Makefile +++ b/Makefile @@ -1843,3 +1843,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 diff --git a/build.assets/dump-preset-roles/main.go b/build.assets/dump-preset-roles/main.go new file mode 100644 index 00000000000..f4ac330853a --- /dev/null +++ b/build.assets/dump-preset-roles/main.go @@ -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 . + +// 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) +} diff --git a/gen/preset-roles.json b/gen/preset-roles.json new file mode 100755 index 00000000000..5a33b2d8c2f --- /dev/null +++ b/gen/preset-roles.json @@ -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":["autoupdate_agent_rollout"],"verbs":["list","read"]},{"resources":["git_server"],"verbs":["list","create","read","update","delete"]}]},"deny":{}}}} \ No newline at end of file diff --git a/lib/services/presets_test.go b/lib/services/presets_test.go index 0c33b5866db..ef1e60b4aa1 100644 --- a/lib/services/presets_test.go +++ b/lib/services/presets_test.go @@ -20,6 +20,8 @@ package services import ( "context" + "encoding/json" + "os" "testing" "github.com/google/go-cmp/cmp" @@ -782,3 +784,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") +} diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx index 61f1be419af..26b74ca8191 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx @@ -27,6 +27,7 @@ import { RoleVersion } from 'teleport/services/resources'; import { AppAccessSection, DatabaseAccessSection, + GitHubOrganizationAccessSection, KubernetesAccessSection, ServerAccessSection, WindowsDesktopAccessSection, @@ -35,6 +36,7 @@ import { AppAccess, DatabaseAccess, defaultRoleVersion, + GitHubOrganizationAccess, KubernetesAccess, newResourceAccess, ServerAccess, @@ -42,6 +44,7 @@ import { } from './standardmodel'; import { StatefulSection } from './StatefulSection'; import { + GitHubOrganizationAccessValidationResult, ResourceAccessValidationResult, validateResourceAccess, } from './validation'; @@ -518,6 +521,46 @@ describe('WindowsDesktopAccessSection', () => { }); }); +describe('GitHubOrganizationAccessSection', () => { + const setup = () => { + const onChange = jest.fn(); + let validator: Validator; + render( + + 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'); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx index c85c6f51521..f939f2d5ad7 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx @@ -41,6 +41,7 @@ import { SectionBox, SectionProps, SectionPropsWithDispatch } from './sections'; import { AppAccess, DatabaseAccess, + GitHubOrganizationAccess, KubernetesAccess, kubernetesResourceKindOptions, KubernetesResourceModel, @@ -54,6 +55,7 @@ import { import { AppAccessValidationResult, DatabaseAccessValidationResult, + GitHubOrganizationAccessValidationResult, KubernetesAccessValidationResult, KubernetesResourceValidationResult, ResourceAccessValidationResult, @@ -140,6 +142,7 @@ const allResourceAccessKinds: ResourceAccessKind[] = [ 'app', 'db', 'windows_desktop', + 'git_server', ]; /** Maps resource access kind to UI component configuration. */ @@ -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, + }, }; /** @@ -595,6 +603,32 @@ export function WindowsDesktopAccessSection({ ); } +export function GitHubOrganizationAccessSection({ + value, + isProcessing, + onChange, +}: SectionProps< + GitHubOrganizationAccess, + GitHubOrganizationAccessValidationResult +>) { + return ( + `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)` diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx index 84dde93a3aa..341c4399fd9 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx @@ -63,6 +63,7 @@ test('adding and removing sections', async () => { 'Applications', 'Databases', 'Windows Desktops', + 'GitHub Organizations', ]); await user.click(screen.getByRole('menuitem', { name: 'Servers' })); @@ -76,6 +77,7 @@ test('adding and removing sections', async () => { 'Applications', 'Databases', 'Windows Desktops', + 'GitHub Organizations', ]); await user.click(screen.getByRole('menuitem', { name: 'Kubernetes' })); diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts index 397722ff14b..2af4b2789a9 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.test.ts @@ -20,6 +20,7 @@ import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput'; import { CreateDBUserMode, CreateHostUserMode, + GitHubPermission, KubernetesResource, Labels, RequireMFAType, @@ -31,6 +32,7 @@ import { SSHPortForwarding, } from 'teleport/services/resources'; +import presetRoles from '../../../../../../../gen/preset-roles.json'; import { createDBUserModeOptionsMap, createHostUserModeOptionsMap, @@ -252,6 +254,31 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([ }, }, + { + name: 'GitHub organization', + role: { + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + github_permissions: [{ orgs: ['illuminati', 'reptilians'] }], + }, + }, + }, + model: { + ...minimalRoleModel(), + resources: [ + { + kind: 'git_server', + organizations: [ + { label: 'illuminati', value: 'illuminati' }, + { label: 'reptilians', value: 'reptilians' }, + ], + }, + ], + }, + }, + { name: 'Options object', role: { @@ -564,6 +591,31 @@ describe('roleToRoleEditorModel', () => { }, }, + { + name: 'unknown field in github_permissions', + role: { + ...minRole, + spec: { + ...minRole.spec, + allow: { + ...minRole.spec.allow, + github_permissions: [ + { orgs: ['foo'], unknownField: 123 } as GitHubPermission, + ], + }, + }, + }, + model: { + ...roleModelWithReset, + resources: [ + { + kind: 'git_server', + organizations: [{ label: 'foo', value: 'foo' }], + }, + ], + }, + }, + { name: 'unknown fields in Rule', role: { @@ -992,6 +1044,40 @@ describe('roleToRoleEditorModel', () => { ], } as RoleEditorModel); }); + + test('multiple github_permissions', () => { + expect( + roleToRoleEditorModel({ + ...minimalRole(), + spec: { + ...minimalRole().spec, + allow: { + ...minimalRole().spec.allow, + github_permissions: [{ orgs: ['foo'] }, { orgs: ['bar'] }], + }, + }, + }) + ).toEqual({ + ...minimalRoleModel(), + resources: [ + { + kind: 'git_server', + organizations: [ + { label: 'foo', value: 'foo' }, + { label: 'bar', value: 'bar' }, + ], + }, + ], + } as RoleEditorModel); + }); + + it.each(['access', 'editor', 'auditor'])( + 'supports the preset "%s" role', + roleName => { + const { requiresReset } = roleToRoleEditorModel(presetRoles[roleName]); + expect(requiresReset).toBe(false); + } + ); }); test('labelsToModel', () => { diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts index 11a2d1fcc93..70fa183bbaf 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/standardmodel.ts @@ -29,6 +29,7 @@ import { import { CreateDBUserMode, CreateHostUserMode, + GitHubPermission, KubernetesResourceKind, KubernetesVerb, RequireMFAType, @@ -87,7 +88,8 @@ export type ResourceAccess = | ServerAccess | AppAccess | DatabaseAccess - | WindowsDesktopAccess; + | WindowsDesktopAccess + | GitHubOrganizationAccess; /** * A base for all resource section models. Contains a type discriminator field. @@ -95,7 +97,7 @@ export type ResourceAccess = type ResourceAccessBase = { /** * Determines kind of resource that is accessed using this spec. Intended to - * be mostly consistent with UnifiedResources.kind, but that has no real + * be mostly consistent with UnifiedResourceKind, but that has no real * meaning on the server side; we needed some discriminator, so we picked * this one. */ @@ -107,7 +109,8 @@ export type ResourceAccessKind = | 'kube_cluster' | 'app' | 'db' - | 'windows_desktop'; + | 'windows_desktop' + | 'git_server'; /** Model for the Kubernetes resource section. */ export type KubernetesAccess = ResourceAccessBase<'kube_cluster'> & { @@ -274,6 +277,10 @@ export type WindowsDesktopAccess = ResourceAccessBase<'windows_desktop'> & { logins: readonly Option[]; }; +export type GitHubOrganizationAccess = ResourceAccessBase<'git_server'> & { + organizations: readonly Option[]; +}; + export type RuleModel = { /** Autogenerated ID to be used with the `key` property. */ id: string; @@ -434,6 +441,11 @@ export function newResourceAccess( roleVersion: RoleVersion ): WindowsDesktopAccess; +export function newResourceAccess( + kind: 'git_server', + roleVersion: RoleVersion +): GitHubOrganizationAccess; + export function newResourceAccess( kind: ResourceAccessKind, roleVersion: RoleVersion @@ -482,6 +494,11 @@ export function newResourceAccess( labels: [], logins: [stringToOption('{{internal.windows_logins}}')], }; + case 'git_server': + return { + kind: 'git_server', + organizations: [stringToOption('{{internal.github_orgs}}')], + }; default: kind satisfies never; } @@ -569,28 +586,37 @@ function roleConditionsToModel( roleVersion: RoleVersion ): Pick { const { + // Server access node_labels, logins, + // Kubernetes access kubernetes_groups, kubernetes_labels, kubernetes_resources, + kubernetes_users, + // App access app_labels, aws_role_arns, azure_identities, gcp_service_accounts, + // Database access db_labels, db_names, db_users, db_roles, db_service_labels, + // Windows desktop access windows_desktop_labels, windows_desktop_logins, - kubernetes_users, + // GitHub organization access + github_permissions, + + // Access rules rules, ...unsupportedConditions @@ -690,6 +716,17 @@ function roleConditionsToModel( }); } + const { + model: gitHubOrganizationsModel, + requiresReset: gitHubOrgsRequireReset, + } = gitHubOrganizationsToModel(github_permissions); + if (someNonEmpty(gitHubOrganizationsModel)) { + resources.push({ + kind: 'git_server', + organizations: gitHubOrganizationsModel, + }); + } + const { model: rulesModel, requiresReset: rulesRequireReset } = rulesToModel(rules); @@ -698,6 +735,7 @@ function roleConditionsToModel( rules: rulesModel, requiresReset: kubernetesResourcesRequireReset || + gitHubOrgsRequireReset || rulesRequireReset || !isEmpty(unsupportedConditions), }; @@ -776,6 +814,34 @@ function kubernetesResourceToModel( }; } +/** + * Converts a {@link GitHubPermission} array to a list of organizations. + * Technically, there can be more than one `GitHubPermission` object, but we + * simply glue the underlying organization arrays into one. Note that the + * object's semantics may further be extended to the point where there's a + * difference between multiple such objects and one object containing all the + * organizations; however, since this would require adding additional fields to + * the `GitHubPermission` object (otherwise, such change wouldn't make sense), + * this function is protected anyway from attempting to interpret such an + * extended object, and it would return `requiresReset: true` anyway. + */ +function gitHubOrganizationsToModel(gitHubPermissions: GitHubPermission[]): { + model: Option[]; + requiresReset: boolean; +} { + const permissions = gitHubPermissions ?? []; + let requiresReset = false; + const model: Option[] = []; + for (const { orgs, ...rest } of permissions) { + if (!isEmpty(rest)) { + requiresReset = true; + } + model.push(...stringsToOptions(orgs)); + } + + return { model, requiresReset }; +} + function rulesToModel(rules: Rule[]): { model: RuleModel[]; requiresReset: boolean; @@ -1025,6 +1091,12 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role { role.spec.allow.windows_desktop_logins = optionsToStrings(res.logins); break; + case 'git_server': + role.spec.allow.github_permissions = [ + { orgs: optionsToStrings(res.organizations) }, + ]; + break; + default: kind satisfies never; } diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts index 13857647b46..318fa704c7b 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/validation.ts @@ -162,6 +162,8 @@ export function validateResourceAccess( return runRules(resource, databaseAccessValidationRules); case 'windows_desktop': return runRules(resource, windowsDesktopAccessValidationRules); + case 'git_server': + return { valid: true }; default: kind satisfies never; } @@ -172,7 +174,8 @@ export type ResourceAccessValidationResult = | KubernetesAccessValidationResult | AppAccessValidationResult | DatabaseAccessValidationResult - | WindowsDesktopAccessValidationResult; + | WindowsDesktopAccessValidationResult + | GitHubOrganizationAccessValidationResult; const validKubernetesResource = (res: KubernetesResourceModel) => () => { const kind = validKubernetesKind(res.kind.value, res.roleVersion); @@ -300,6 +303,8 @@ export type WindowsDesktopAccessValidationResult = RuleSetValidationResult< typeof windowsDesktopAccessValidationRules >; +export type GitHubOrganizationAccessValidationResult = ValidationResult; + export function validateAccessRuleList( rules: RuleModel[], previousRules: RuleModel[], diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index 9a5d315045d..3915e9035fd 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -96,6 +96,8 @@ export type RoleConditions = { windows_desktop_labels?: Labels; windows_desktop_logins?: string[]; + github_permissions?: GitHubPermission[]; + rules?: Rule[]; }; @@ -345,6 +347,10 @@ export type Verb = | 'update' | 'use'; +export type GitHubPermission = { + orgs?: string[]; +}; + /** * 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. @@ -372,7 +378,7 @@ export type RoleOptions = { ssh_port_forwarding?: SSHPortForwarding; port_forwarding?: boolean; record_session: { - default: SessionRecordingMode; + default?: SessionRecordingMode; ssh?: SessionRecordingMode; desktop: boolean; };