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;
};