Files
teleport/lib/services/role_test.go
T
Julia Ogris 677b0d6e1c types: Declare all app_resources fields (#69500)
* types: Declare all app_resources fields in the proto

Declare the eight reserved AppResource fields (paths, methods, where,
allow_encoded, allow_code, allow_reason, deny_code_hint,
deny_reason_hint) and the AppResourcesExpressions role condition, and
regenerate the proto stubs, the role CRDs, the Terraform schema and
docs, and the derived equality functions. Declaring the fields before
the fine-grained matcher merges lets every client of this version name
them, so a client that round-trips a role written by a newer auth
preserves the fields instead of silently dropping them on write-back,
which would widen the rule.

Tighten IsAllowAllOnly to require every declared field besides
allow_all to be unset. Without this, a rule such as {allow_all: true,
methods: [GET]} written by a newer auth would pass the check once the
fields are named, and this version would treat a method-restricted
rule as unrestricted.

Add GetAppResourcesExpressions to the Role interface, and read it in
decideMinimalV9 so the app agent denies a request governed by a
predicate it cannot evaluate. A predicate restricts the rules it
accompanies, so a role that pairs allow_all with one is not
unrestricted, and a deny-side predicate blocks another role's
allow_all the same way a deny-side rule does. Auth already treats both
as restricting when it downgrades a role for a pre-v9 client, so
without this the agent and auth disagree about the same role.

Strip AppResourcesExpressions in the v9-to-v8 downgrade alongside
AppResources, and reject it at write, so a role can neither reach a
pre-v9 client with the restriction silently removed nor be stored
while this version cannot enforce it.

* docs: List the declared app_resources fields

Honour the docs review on the app-resources reference page. A run of
inline code-style field names inside a sentence is hard to scan, so the
eight rejected `app_resources` fields become a list, and
`app_resources_expressions` gets its own sentence rather than trailing
the same run.

* buf: Trim the reserved-deletion except comment

Drop the two sentences explaining why buf's ignore lists cannot scope the
except. The rule name and the removal condition are what a reader needs
here, and the scoping attempts are recorded on the pull request.

* types: Reword the app_resources_expressions and Methods docs

Honour the review on the proto field comments. Drop "desugared" from
`AppResourcesExpressions`, which needs the RFD to gloss, and state the
relation between the two forms directly instead. Open `Methods` with the
plural, matching `Paths` on the row above.

Regenerate the proto stubs, the role CRDs, the Terraform schema and docs.
2026-08-12 05:19:01 +00:00

12099 lines
365 KiB
Go

/*
* Teleport
* Copyright (C) 2023 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/>.
*/
package services
import (
"cmp"
"context"
"encoding/json"
"fmt"
"net"
"slices"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
gocmp "github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/gravitational/teleport"
"github.com/gravitational/teleport/api/constants"
apidefaults "github.com/gravitational/teleport/api/defaults"
beamsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/beams/v1"
headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
linuxdesktopv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/linuxdesktop/v1"
"github.com/gravitational/teleport/api/types"
apievents "github.com/gravitational/teleport/api/types/events"
"github.com/gravitational/teleport/api/types/wrappers"
apiutils "github.com/gravitational/teleport/api/utils"
"github.com/gravitational/teleport/api/utils/sshutils"
"github.com/gravitational/teleport/lib/fixtures"
"github.com/gravitational/teleport/lib/sshca"
"github.com/gravitational/teleport/lib/tlsca"
)
const matchAllExpression = `"" == ""`
// TestConnAndSessLimits verifies that role sets correctly calculate
// a user's MaxConnections and MaxSessions values from multiple
// roles with different individual values. These are tested together since
// both values use the same resolution rules.
func TestConnAndSessLimits(t *testing.T) {
tts := []struct {
desc string
vals []int64
want int64
}{
{
desc: "smallest nonzero value is selected from mixed values",
vals: []int64{8, 6, 7, 5, 3, 0, 9},
want: 3,
},
{
desc: "smallest value selected from all nonzero values",
vals: []int64{5, 6, 7, 8},
want: 5,
},
{
desc: "all zero values results in a zero value",
vals: []int64{0, 0, 0, 0, 0, 0, 0},
want: 0,
},
}
for ti, tt := range tts {
cmt := fmt.Sprintf("test case %d: %s", ti, tt.desc)
var set RoleSet
for i, val := range tt.vals {
role := &types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: fmt.Sprintf("role-%d", i),
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxConnections: val,
MaxSessions: val,
},
},
}
require.NoError(t, role.CheckAndSetDefaults(), cmt)
set = append(set, role)
}
require.Equal(t, tt.want, set.MaxConnections(), cmt)
require.Equal(t, tt.want, set.MaxSessions(), cmt)
}
}
func TestRoleParse(t *testing.T) {
testCases := []struct {
name string
in string
role types.RoleV6
error error
matchMessage string
}{
{
name: "no input, should not parse",
in: ``,
role: types.RoleV6{},
error: trace.BadParameter("empty input"),
},
{
name: "validation error, no name",
in: `{}`,
role: types.RoleV6{},
error: trace.BadParameter("failed to validate: name: name is required"),
},
{
name: "validation error, no name",
in: `{"kind": "role"}`,
role: types.RoleV6{},
error: trace.BadParameter("failed to validate: name: name is required"),
},
{
name: "validation error, missing resources",
in: `{
"kind": "role",
"version": "v3",
"metadata": {"name": "name1"},
"spec": {
"allow": {
"node_labels": {"a": "b"},
"namespaces": ["default"],
"rules": [
{
"verbs": ["read", "list"]
}
]
}
}
}`,
error: trace.BadParameter(""),
matchMessage: "missing resources",
},
{
name: "validation error, missing verbs",
in: `{
"kind": "role",
"version": "v3",
"metadata": {"name": "name1"},
"spec": {
"allow": {
"node_labels": {"a": "b"},
"namespaces": ["default"],
"rules": [
{
"resources": ["role"]
}
]
}
}
}`,
error: trace.BadParameter(""),
matchMessage: "missing verbs",
},
{
name: "validation error, missing namespace in pod names",
in: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "name1"},
"spec": {
"allow": {
"kubernetes_labels": {"a": "b"},
"kubernetes_resources": [{"kind":"pod","name": "*","namespace": ""}]
}
}
}`,
error: trace.BadParameter(""),
matchMessage: "KubernetesResource kind \"pod\" must include Namespace",
},
{
name: "validation error, invalid kubernetes_resources kind",
in: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "name1"},
"spec": {
"allow": {
"request": {
"kubernetes_resources": [{"kind":"abcd"}]
}
}
}
}`,
error: trace.BadParameter(""),
matchMessage: "kind \"abcd\" is not supported in role version \"v6\"",
},
{
name: "validation error, kubernetes_resources kind namespace not supported in v6",
in: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "name1"},
"spec": {
"allow": {
"request": {
"kubernetes_resources": [{"kind":"namespace"}]
}
}
}
}`,
error: trace.BadParameter(""),
matchMessage: "not supported in role version \"v6\"",
},
{
name: "validation error, missing podname in pod names",
in: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "name1"},
"spec": {
"allow": {
"kubernetes_labels": {"a": "b"},
"kubernetes_resources": [{"kind":"pod","name": "","namespace": "*"}]
}
}
}`,
error: trace.BadParameter(""),
matchMessage: "KubernetesResource must include Name",
},
{
name: "validation error, no version",
in: `{"kind": "role", "metadata": {"name": "defrole"}, "spec": {}}`,
role: types.RoleV6{},
error: trace.BadParameter(""),
matchMessage: `role version "" is not supported`,
},
{
name: "validation error, bad version",
in: `{"kind": "role", "version": "v2", "metadata": {"name": "defrole"}, "spec": {}}`,
role: types.RoleV6{},
error: trace.BadParameter(""),
matchMessage: `role version "v2" is not supported`,
},
{
name: "v3 role with no spec gets v3 defaults",
in: `{"kind": "role", "version": "v3", "metadata": {"name": "defrole"}, "spec": {}}`,
role: types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: "defrole",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
MaxSessionTTL: types.NewDuration(apidefaults.MaxCertDuration),
RecordSession: &types.RecordSession{
Default: constants.SessionRecordingModeBestEffort,
Desktop: types.NewBoolOption(true),
},
BPF: apidefaults.EnhancedEvents(),
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
CreateDesktopUser: types.NewBoolOption(false),
CreateHostUser: nil,
CreateDatabaseUser: types.NewBoolOption(false),
SSHFileCopy: types.NewBoolOption(true),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
},
},
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
Namespaces: []string{apidefaults.Namespace},
KubernetesResources: []types.KubernetesResource{
{
Kind: types.KindKubePod,
Namespace: types.Wildcard,
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
},
error: nil,
},
{
name: "v4 role gets v4 defaults",
in: `{"kind": "role", "version": "v4", "metadata": {"name": "defrole"}, "spec": {}}`,
role: types.RoleV6{
Kind: types.KindRole,
Version: types.V4,
Metadata: types.Metadata{
Name: "defrole",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
MaxSessionTTL: types.NewDuration(apidefaults.MaxCertDuration),
RecordSession: &types.RecordSession{
Default: constants.SessionRecordingModeBestEffort,
Desktop: types.NewBoolOption(true),
},
BPF: apidefaults.EnhancedEvents(),
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
CreateDesktopUser: types.NewBoolOption(false),
CreateHostUser: nil,
CreateDatabaseUser: types.NewBoolOption(false),
SSHFileCopy: types.NewBoolOption(true),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
},
},
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
},
error: nil,
},
{
name: "full valid role v6",
in: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "name1", "labels": {"a-b": "c"}},
"spec": {
"options": {
"cert_format": "standard",
"max_session_ttl": "20h",
"port_forwarding": true,
"client_idle_timeout": "17m",
"disconnect_expired_cert": "yes",
"enhanced_recording": ["command", "network"],
"desktop_clipboard": true,
"desktop_directory_sharing": true,
"ssh_file_copy" : false
},
"allow": {
"request": {
"kubernetes_resources": [{"kind":"pod"}]
},
"node_labels": {"a": "b", "c-d": "e"},
"app_labels": {"a": "b", "c-d": "e"},
"group_labels": {"a": "b", "c-d": "e"},
"kubernetes_labels": {"a": "b", "c-d": "e"},
"db_labels": {"a": "b", "c-d": "e"},
"db_names": ["postgres"],
"db_users": ["postgres"],
"namespaces": ["default"],
"rules": [
{
"resources": ["role"],
"verbs": ["read", "list"],
"where": "contains(user.spec.traits[\"groups\"], \"prod\")",
"actions": [
"log(\"info\", \"log entry\")"
]
}
]
},
"deny": {
"request": {
"kubernetes_resources": [{"kind":"pod"}]
},
"logins": ["c"]
}
}
}`,
role: types.RoleV6{
Kind: types.KindRole,
Version: types.V6,
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
Labels: map[string]string{"a-b": "c"},
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
MaxSessionTTL: types.NewDuration(20 * time.Hour),
PortForwarding: types.NewBoolOption(true),
RecordSession: &types.RecordSession{
Default: constants.SessionRecordingModeBestEffort,
Desktop: types.NewBoolOption(true),
},
ClientIdleTimeout: types.NewDuration(17 * time.Minute),
DisconnectExpiredCert: types.NewBool(true),
BPF: apidefaults.EnhancedEvents(),
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
CreateDesktopUser: types.NewBoolOption(false),
CreateDatabaseUser: types.NewBoolOption(false),
CreateHostUser: nil,
SSHFileCopy: types.NewBoolOption(false),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
},
},
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
AppLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
GroupLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
KubernetesLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
DatabaseLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
DatabaseNames: []string{"postgres"},
DatabaseUsers: []string{"postgres"},
Namespaces: []string{"default"},
Rules: []types.Rule{
{
Resources: []string{types.KindRole},
Verbs: []string{types.VerbRead, types.VerbList},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
Actions: []string{
"log(\"info\", \"log entry\")",
},
},
},
Request: &types.AccessRequestConditions{
KubernetesResources: []types.RequestKubernetesResource{
{Kind: types.KindKubePod},
},
},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Logins: []string{"c"},
Request: &types.AccessRequestConditions{
KubernetesResources: []types.RequestKubernetesResource{
{Kind: types.KindKubePod},
},
},
},
},
},
error: nil,
},
{
name: "full valid role",
in: `{
"kind": "role",
"version": "v3",
"metadata": {"name": "name1", "labels": {"a-b": "c"}},
"spec": {
"options": {
"cert_format": "standard",
"max_session_ttl": "20h",
"port_forwarding": true,
"client_idle_timeout": "17m",
"disconnect_expired_cert": "yes",
"enhanced_recording": ["command", "network"],
"desktop_clipboard": true,
"desktop_directory_sharing": true,
"ssh_file_copy" : false
},
"allow": {
"node_labels": {"a": "b", "c-d": "e"},
"app_labels": {"a": "b", "c-d": "e"},
"group_labels": {"a": "b", "c-d": "e"},
"kubernetes_labels": {"a": "b", "c-d": "e"},
"db_labels": {"a": "b", "c-d": "e"},
"db_names": ["postgres"],
"db_users": ["postgres"],
"namespaces": ["default"],
"rules": [
{
"resources": ["role"],
"verbs": ["read", "list"],
"where": "contains(user.spec.traits[\"groups\"], \"prod\")",
"actions": [
"log(\"info\", \"log entry\")"
]
}
]
},
"deny": {
"logins": ["c"]
}
}
}`,
role: types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
Labels: map[string]string{"a-b": "c"},
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
MaxSessionTTL: types.NewDuration(20 * time.Hour),
PortForwarding: types.NewBoolOption(true),
RecordSession: &types.RecordSession{
Default: constants.SessionRecordingModeBestEffort,
Desktop: types.NewBoolOption(true),
},
ClientIdleTimeout: types.NewDuration(17 * time.Minute),
DisconnectExpiredCert: types.NewBool(true),
BPF: apidefaults.EnhancedEvents(),
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
CreateDesktopUser: types.NewBoolOption(false),
CreateHostUser: nil,
CreateDatabaseUser: types.NewBoolOption(false),
SSHFileCopy: types.NewBoolOption(false),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
},
},
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
AppLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
GroupLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
KubernetesLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
DatabaseLabels: types.Labels{"a": []string{"b"}, "c-d": []string{"e"}},
DatabaseNames: []string{"postgres"},
DatabaseUsers: []string{"postgres"},
KubernetesResources: []types.KubernetesResource{
{
Kind: types.KindKubePod,
Namespace: types.Wildcard,
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
Namespaces: []string{"default"},
Rules: []types.Rule{
{
Resources: []string{types.KindRole},
Verbs: []string{types.VerbRead, types.VerbList},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
Actions: []string{
"log(\"info\", \"log entry\")",
},
},
},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Logins: []string{"c"},
},
},
},
error: nil,
},
{
name: "alternative options form",
in: `{
"kind": "role",
"version": "v3",
"metadata": {"name": "name1"},
"spec": {
"options": {
"cert_format": "standard",
"max_session_ttl": "20h",
"port_forwarding": "yes",
"forward_agent": "yes",
"client_idle_timeout": "never",
"disconnect_expired_cert": "no",
"enhanced_recording": ["command", "network"],
"desktop_clipboard": true,
"desktop_directory_sharing": true,
"ssh_file_copy" : true
},
"allow": {
"node_labels": {"a": "b"},
"app_labels": {"a": "b"},
"group_labels": {"a": "b"},
"kubernetes_labels": {"c": "d"},
"db_labels": {"e": "f"},
"namespaces": ["default"],
"rules": [
{
"resources": ["role"],
"verbs": ["read", "list"],
"where": "contains(user.spec.traits[\"groups\"], \"prod\")",
"actions": [
"log(\"info\", \"log entry\")"
]
}
]
},
"deny": {
"logins": ["c"]
}
}
}`,
role: types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
ForwardAgent: types.NewBool(true),
MaxSessionTTL: types.NewDuration(20 * time.Hour),
PortForwarding: types.NewBoolOption(true),
RecordSession: &types.RecordSession{
Default: constants.SessionRecordingModeBestEffort,
Desktop: types.NewBoolOption(true),
},
ClientIdleTimeout: types.NewDuration(0),
DisconnectExpiredCert: types.NewBool(false),
BPF: apidefaults.EnhancedEvents(),
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
CreateDesktopUser: types.NewBoolOption(false),
CreateHostUser: nil,
CreateDatabaseUser: types.NewBoolOption(false),
SSHFileCopy: types.NewBoolOption(true),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
},
},
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"a": []string{"b"}},
AppLabels: types.Labels{"a": []string{"b"}},
GroupLabels: types.Labels{"a": []string{"b"}},
KubernetesLabels: types.Labels{"c": []string{"d"}},
DatabaseLabels: types.Labels{"e": []string{"f"}},
Namespaces: []string{"default"},
KubernetesResources: []types.KubernetesResource{
{
Kind: types.KindKubePod,
Namespace: types.Wildcard,
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
Rules: []types.Rule{
{
Resources: []string{types.KindRole},
Verbs: []string{types.VerbRead, types.VerbList},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
Actions: []string{
"log(\"info\", \"log entry\")",
},
},
},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Logins: []string{"c"},
},
},
},
error: nil,
},
{
name: "non-scalar and scalar values of labels",
in: `{
"kind": "role",
"version": "v3",
"metadata": {"name": "name1"},
"spec": {
"options": {
"cert_format": "standard",
"max_session_ttl": "20h",
"port_forwarding": "yes",
"forward_agent": "yes",
"client_idle_timeout": "never",
"disconnect_expired_cert": "no",
"enhanced_recording": ["command", "network"],
"desktop_clipboard": true,
"desktop_directory_sharing": true,
"ssh_file_copy" : true
},
"allow": {
"node_labels": {"a": "b", "key": ["val"], "key2": ["val2", "val3"]},
"app_labels": {"a": "b", "key": ["val"], "key2": ["val2", "val3"]},
"kubernetes_labels": {"a": "b", "key": ["val"], "key2": ["val2", "val3"]},
"db_labels": {"a": "b", "key": ["val"], "key2": ["val2", "val3"]}
},
"deny": {
"logins": ["c"]
}
}
}`,
role: types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
ForwardAgent: types.NewBool(true),
MaxSessionTTL: types.NewDuration(20 * time.Hour),
PortForwarding: types.NewBoolOption(true),
RecordSession: &types.RecordSession{
Default: constants.SessionRecordingModeBestEffort,
Desktop: types.NewBoolOption(true),
},
ClientIdleTimeout: types.NewDuration(0),
DisconnectExpiredCert: types.NewBool(false),
BPF: apidefaults.EnhancedEvents(),
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
CreateDesktopUser: types.NewBoolOption(false),
CreateHostUser: nil,
CreateDatabaseUser: types.NewBoolOption(false),
SSHFileCopy: types.NewBoolOption(true),
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(true),
},
},
},
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: types.KindKubePod,
Namespace: types.Wildcard,
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
NodeLabels: types.Labels{
"a": []string{"b"},
"key": []string{"val"},
"key2": []string{"val2", "val3"},
},
AppLabels: types.Labels{
"a": []string{"b"},
"key": []string{"val"},
"key2": []string{"val2", "val3"},
},
KubernetesLabels: types.Labels{
"a": []string{"b"},
"key": []string{"val"},
"key2": []string{"val2", "val3"},
},
DatabaseLabels: types.Labels{
"a": []string{"b"},
"key": []string{"val"},
"key2": []string{"val2", "val3"},
},
Namespaces: []string{"default"},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Logins: []string{"c"},
},
},
},
error: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
role, err := UnmarshalRole([]byte(tc.in))
if tc.error != nil {
require.Error(t, err)
if tc.matchMessage != "" {
require.Contains(t, err.Error(), tc.matchMessage)
}
} else {
require.NoError(t, err)
require.Equal(t, &tc.role, role)
err := ValidateRole(role)
require.NoError(t, err)
out, err := json.Marshal(role)
require.NoError(t, err)
role2, err := UnmarshalRole(out)
require.NoError(t, err)
require.Equal(t, &tc.role, role2)
}
})
}
}
func TestValidateRole(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec types.RoleSpecV6
expectError error
expectErrorContains []string
}{
{
name: "valid syntax",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{`{{external["http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"]}}`},
},
},
},
{
name: "invalid role condition login syntax",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{"{{foo"},
},
},
expectErrorContains: []string{
"parsing allow.logins expression",
`"{{foo" is using template brackets '{{' or '}}', however expression does not parse`,
},
},
{
name: "unsupported function in actions",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{`{{external["http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"]}}`},
Rules: []types.Rule{
{
Resources: []string{"role"},
Verbs: []string{"read", "list"},
Where: "containz(user.spec.traits[\"groups\"], \"prod\")",
},
},
},
},
expectErrorContains: []string{
"parsing allow.rules[0]",
"could not parse 'where' rule",
"unsupported function: containz",
},
},
{
name: "unsupported function for non session",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{"roles"},
Verbs: []string{"read", "list"},
Where: "can_view()",
},
},
},
},
expectErrorContains: []string{
"parsing allow.rules[0]",
"could not parse 'where' rule",
"unsupported function: can_view",
},
},
{
name: "supported function for session",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{"session"},
Verbs: []string{"read", "list"},
Where: "can_view()",
},
},
},
},
},
{
name: "valid impersonate.where",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Impersonate: &types.ImpersonateConditions{
Users: []string{"user"},
Roles: []string{"role"},
Where: `contains(user.spec.traits["groups"], "prod") && ` +
`equals(impersonate_user.metadata.name, "alice") && ` +
`equals(impersonate_role.metadata.name, "auditor")`,
},
},
},
},
{
name: "invalid impersonate.where",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Impersonate: &types.ImpersonateConditions{
Users: []string{"user"},
Roles: []string{"role"},
Where: `containz(user.spec.traits["groups"], "prod")`,
},
},
},
expectErrorContains: []string{
"allow.impersonate.where: invalid expression",
"unsupported function: containz",
},
},
{
name: "valid rules.actions",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{"role"},
Verbs: []string{"read", "list"},
Actions: []string{`log("info", "log entry")`},
},
},
},
},
},
{
name: "invalid rules.actions",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{"role"},
Verbs: []string{"read", "list"},
Actions: []string{`zzz("info", "log entry")`},
},
},
},
},
expectErrorContains: []string{
"parsing allow.rules[0]",
"could not parse action",
"unsupported function: zzz",
},
},
{
name: "valid require_session_join.filter",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
RequireSessionJoin: []*types.SessionRequirePolicy{{
Name: "test",
Filter: `contains(user.roles, "admin") && ` +
`contains(user.spec.roles, "lead") && ` +
`equals(user.name, "alice") && ` +
`equals(user.spec.name, "alice")`,
Kinds: []string{string(types.SSHSessionKind)},
Modes: []string{string(types.SessionModeratorMode)},
Count: 1,
}},
},
},
},
{
name: "invalid require_session_join.filter",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
RequireSessionJoin: []*types.SessionRequirePolicy{{
Name: "test",
Filter: `badfunc(user.spec.traits["groups"], "prod")`,
Kinds: []string{string(types.SSHSessionKind)},
Modes: []string{string(types.SessionModeratorMode)},
Count: 1,
}},
},
},
expectErrorContains: []string{
"require_session_join[0]: invalid filter",
"unsupported function: badfunc",
},
},
{
name: "nil require_session_join entry is skipped",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
RequireSessionJoin: []*types.SessionRequirePolicy{nil},
},
},
},
{
name: "nil cert_extensions entry is skipped",
spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertExtensions: []*types.CertExtension{nil},
},
},
},
{
name: "valid review_requests.where",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
ReviewRequests: &types.AccessReviewConditions{
Where: `contains(reviewer.roles, "approver")`,
},
},
},
},
{
name: "invalid review_requests.where",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
ReviewRequests: &types.AccessReviewConditions{
Where: `contains(user.spec.traits["groups"], "prod")`,
},
},
},
expectErrorContains: []string{
"invalid review predicate",
},
},
{
name: "valid request.thresholds.filter",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Thresholds: []types.AccessReviewThreshold{
{Filter: `contains(reviewer.roles, "lead")`},
},
},
},
},
},
{
name: "invalid request.thresholds.filter",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Thresholds: []types.AccessReviewThreshold{
{Filter: `contains(user.spec.traits["groups"], "prod")`},
},
},
},
},
expectErrorContains: []string{
"invalid threshold predicate",
},
},
{
name: "wildcard not allowed in database_roles",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
DatabaseRoles: []string{types.Wildcard},
},
},
expectError: trace.BadParameter("wildcard is not allowed in allow.database_roles"),
},
{
name: "unsupported function in labels",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{"test"},
NodeLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
AppLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
KubernetesLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
DatabaseLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
WindowsDesktopLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
ClusterLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
BeamLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
WorkloadIdentityLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
},
Deny: types.RoleConditions{
Logins: []string{"test"},
NodeLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
AppLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
KubernetesLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
DatabaseLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
WindowsDesktopLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
ClusterLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
BeamLabels: types.Labels{
"owner": {"{{email.localz(external.email)}}"},
},
},
},
expectErrorContains: []string{
"parsing allow.node_labels template expression",
"parsing allow.app_labels template expression",
"parsing allow.kubernetes_labels template expression",
"parsing allow.db_labels template expression",
"parsing allow.windows_desktop_labels template expression",
"parsing allow.cluster_labels template expression",
"parsing allow.beam_labels template expression",
"parsing allow.workload_identity_labels template expression",
"parsing deny.node_labels template expression",
"parsing deny.app_labels template expression",
"parsing deny.kubernetes_labels template expression",
"parsing deny.db_labels template expression",
"parsing deny.windows_desktop_labels template expression",
"parsing deny.cluster_labels template expression",
"parsing deny.beam_labels template expression",
"unsupported function: email.localz",
},
},
{
name: "unsupported function in labels expression",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
ClusterLabelsExpression: `containz(labels["env"], "staging")`,
NodeLabelsExpression: `containz(labels["env"], "staging")`,
AppLabelsExpression: `containz(labels["env"], "staging")`,
KubernetesLabelsExpression: `containz(labels["env"], "staging")`,
DatabaseLabelsExpression: `containz(labels["env"], "staging")`,
DatabaseServiceLabelsExpression: `containz(labels["env"], "staging")`,
WindowsDesktopLabelsExpression: `containz(labels["env"], "staging")`,
GroupLabelsExpression: `containz(labels["env"], "staging")`,
BeamLabelsExpression: `containz(labels["env"], "staging")`,
},
Deny: types.RoleConditions{
ClusterLabelsExpression: `containz(labels["env"], "staging")`,
NodeLabelsExpression: `containz(labels["env"], "staging")`,
AppLabelsExpression: `containz(labels["env"], "staging")`,
KubernetesLabelsExpression: `containz(labels["env"], "staging")`,
DatabaseLabelsExpression: `containz(labels["env"], "staging")`,
DatabaseServiceLabelsExpression: `containz(labels["env"], "staging")`,
WindowsDesktopLabelsExpression: `containz(labels["env"], "staging")`,
GroupLabelsExpression: `containz(labels["env"], "staging")`,
BeamLabelsExpression: `containz(labels["env"], "staging")`,
},
},
expectErrorContains: []string{
"parsing allow.node_labels_expression",
"parsing allow.app_labels_expression",
"parsing allow.kubernetes_labels_expression",
"parsing allow.db_labels_expression",
"parsing allow.windows_desktop_labels_expression",
"parsing allow.cluster_labels_expression",
"parsing allow.beam_labels_expression",
"parsing deny.node_labels_expression",
"parsing deny.app_labels_expression",
"parsing deny.kubernetes_labels_expression",
"parsing deny.db_labels_expression",
"parsing deny.windows_desktop_labels_expression",
"parsing deny.cluster_labels_expression",
"parsing deny.beam_labels_expression",
"unsupported function: containz",
},
},
{
name: "unsupported function in slice fields",
spec: types.RoleSpecV6{
Options: types.RoleOptions{
CertExtensions: []*types.CertExtension{
{
Name: "name",
Value: "{{email.localz(external.email)}}",
},
},
},
Allow: types.RoleConditions{
Logins: []string{"{{email.localz(external.email)}}"},
WindowsDesktopLogins: []string{"{{email.localz(external.email)}}"},
LinuxDesktopLogins: []string{"{{email.localz(external.email)}}"},
AWSRoleARNs: []string{"{{email.localz(external.email)}}"},
AzureIdentities: []string{"{{email.localz(external.email)}}"},
GCPServiceAccounts: []string{"{{email.localz(external.email)}}"},
KubeGroups: []string{"{{email.localz(external.email)}}"},
KubeUsers: []string{"{{email.localz(external.email)}}"},
DatabaseNames: []string{"{{email.localz(external.email)}}"},
DatabaseUsers: []string{"{{email.localz(external.email)}}"},
DatabaseRoles: []string{"{{email.localz(external.email)}}"},
HostGroups: []string{"{{email.localz(external.email)}}"},
HostSudoers: []string{"{{email.localz(external.email)}}"},
DesktopGroups: []string{"{{email.localz(external.email)}}"},
Impersonate: &types.ImpersonateConditions{
Users: []string{"{{email.localz(external.email)}}"},
Roles: []string{"{{email.localz(external.email)}}"},
},
GitHubPermissions: []types.GitHubPermission{
{Organizations: []string{"{{email.localz(external.email)}}"}},
},
MCP: &types.MCPPermissions{
Tools: []string{"{{email.localz(external.email)}}"},
},
},
Deny: types.RoleConditions{
Logins: []string{"{{email.localz(external.email)}}"},
WindowsDesktopLogins: []string{"{{email.localz(external.email)}}"},
LinuxDesktopLogins: []string{"{{email.localz(external.email)}}"},
AWSRoleARNs: []string{"{{email.localz(external.email)}}"},
AzureIdentities: []string{"{{email.localz(external.email)}}"},
GCPServiceAccounts: []string{"{{email.localz(external.email)}}"},
KubeGroups: []string{"{{email.localz(external.email)}}"},
KubeUsers: []string{"{{email.localz(external.email)}}"},
DatabaseNames: []string{"{{email.localz(external.email)}}"},
DatabaseUsers: []string{"{{email.localz(external.email)}}"},
DatabaseRoles: []string{"{{email.localz(external.email)}}"},
HostGroups: []string{"{{email.localz(external.email)}}"},
HostSudoers: []string{"{{email.localz(external.email)}}"},
DesktopGroups: []string{"{{email.localz(external.email)}}"},
Impersonate: &types.ImpersonateConditions{
Users: []string{"{{email.localz(external.email)}}"},
Roles: []string{"{{email.localz(external.email)}}"},
},
},
},
expectErrorContains: []string{
"parsing options.cert_extensions[0].value expression",
"parsing allow.logins expression",
"parsing allow.windows_desktop_logins expression",
"parsing allow.linux_desktop_logins expression",
"parsing allow.aws_role_arns expression",
"parsing allow.azure_identities expression",
"parsing allow.gcp_service_accounts expression",
"parsing allow.kubernetes_groups expression",
"parsing allow.kubernetes_users expression",
"parsing allow.db_names expression",
"parsing allow.db_users expression",
"parsing allow.db_roles expression",
"parsing allow.host_groups expression",
"parsing allow.host_sudoers expression",
"parsing allow.desktop_groups expression",
"parsing allow.impersonate.users expression",
"parsing allow.impersonate.roles expression",
"parsing allow.github_permissions[0].organizations expression",
`parsing allow.mcp.tools[0] "{{email.localz(external.email)}}"`,
"parsing deny.logins expression",
"parsing deny.windows_desktop_logins expression",
"parsing deny.linux_desktop_logins expression",
"parsing deny.aws_role_arns expression",
"parsing deny.azure_identities expression",
"parsing deny.gcp_service_accounts expression",
"parsing deny.kubernetes_groups expression",
"parsing deny.kubernetes_users expression",
"parsing deny.db_names expression",
"parsing deny.db_users expression",
"parsing deny.db_roles expression",
"parsing deny.host_groups expression",
"parsing deny.host_sudoers expression",
"parsing deny.desktop_groups expression",
"parsing deny.impersonate.users expression",
"parsing deny.impersonate.roles expression",
"unsupported function: email.localz",
},
},
{
name: "unsupported syntax in kubernetes_resources",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "{{external.namespace",
Name: "{{email.localz(external.email)}}",
Verbs: []string{"{{external.verbs"},
APIGroup: types.Wildcard,
},
},
},
Deny: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "{{external.namespace",
Name: "{{email.localz(external.email)}}",
Verbs: []string{"{{external.verbs"},
APIGroup: types.Wildcard,
},
},
},
},
expectErrorContains: []string{
"parsing allow.kubernetes_resources[0].namespace expression",
"parsing allow.kubernetes_resources[0].name expression",
"parsing allow.kubernetes_resources[0].verbs expression",
"parsing deny.kubernetes_resources[0].namespace expression",
"parsing deny.kubernetes_resources[0].name expression",
"parsing deny.kubernetes_resources[0].verbs expression",
"unsupported function: email.localz",
},
},
{
name: "wildcard not allowed in role-list fields",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
SearchAsRoles: []string{types.Wildcard},
},
ReviewRequests: &types.AccessReviewConditions{
PreviewAsRoles: []string{types.Wildcard},
},
},
Deny: types.RoleConditions{
Request: &types.AccessRequestConditions{
SearchAsRoles: []string{types.Wildcard},
},
ReviewRequests: &types.AccessReviewConditions{
PreviewAsRoles: []string{types.Wildcard},
},
},
},
expectErrorContains: []string{
"wildcard is not allowed in allow.request.search_as_roles",
"wildcard is not allowed in allow.review_requests.preview_as_roles",
"wildcard is not allowed in deny.request.search_as_roles",
"wildcard is not allowed in deny.review_requests.preview_as_roles",
},
},
{
name: "valid require_session_join",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
RequireSessionJoin: []*types.SessionRequirePolicy{{
Name: "p",
Count: 2,
Kinds: []string{string(types.SSHSessionKind), string(types.KubernetesSessionKind)},
Modes: []string{string(types.SessionModeratorMode)},
OnLeave: string(types.OnSessionLeavePause),
}},
},
},
},
{
name: "wildcard kind allowed in session policies",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
RequireSessionJoin: []*types.SessionRequirePolicy{{
Kinds: []string{types.Wildcard},
Modes: []string{string(types.SessionModeratorMode)},
}},
JoinSessions: []*types.SessionJoinPolicy{{
Kinds: []string{types.Wildcard},
Modes: []string{string(types.SessionObserverMode)},
}},
},
},
},
{
name: "invalid require_session_join fields",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
RequireSessionJoin: []*types.SessionRequirePolicy{{
Count: -1,
Kinds: []string{"random"},
Modes: []string{"random"},
OnLeave: "Pause",
}},
},
},
expectErrorContains: []string{
"require_session_join[0]: count cannot be negative",
`require_session_join[0]: invalid session kind "random"`,
`require_session_join[0]: invalid participant mode "random"`,
`require_session_join[0]: invalid on_leave action "Pause"`,
},
},
{
name: "valid join_sessions",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
JoinSessions: []*types.SessionJoinPolicy{{
Name: "p",
Roles: []string{"observer"},
Kinds: []string{string(types.SSHSessionKind)},
Modes: []string{string(types.SessionObserverMode)},
}},
},
},
},
{
name: "invalid join_sessions fields",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
JoinSessions: []*types.SessionJoinPolicy{{
Kinds: []string{"random"},
Modes: []string{"random"},
}},
},
},
expectErrorContains: []string{
`join_sessions[0]: invalid session kind "random"`,
`join_sessions[0]: invalid participant mode "random"`,
},
},
{
name: "all errors are aggregated",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{"{{badfunc(x)}}"},
Request: &types.AccessRequestConditions{
SearchAsRoles: []string{types.Wildcard},
},
JoinSessions: []*types.SessionJoinPolicy{{
Kinds: []string{"random_kind"},
}},
},
},
expectErrorContains: []string{
"parsing allow.logins expression",
"wildcard is not allowed in allow.request.search_as_roles",
`join_sessions[0]: invalid session kind "random_kind"`,
},
},
{
name: "all access predicate errors are aggregated",
spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
Thresholds: []types.AccessReviewThreshold{
{Filter: `containz(reviewer.roles, "x")`},
{Filter: `equalz(reviewer.name, "alice")`},
},
MaxDuration: types.Duration(30 * 24 * time.Hour),
},
ReviewRequests: &types.AccessReviewConditions{
Where: `containz(reviewer.roles, "y")`,
},
},
Deny: types.RoleConditions{
Request: &types.AccessRequestConditions{
Thresholds: []types.AccessReviewThreshold{{Filter: ""}},
},
ReviewRequests: &types.AccessReviewConditions{
Where: `containz(reviewer.roles, "z")`,
},
},
},
expectErrorContains: []string{
"deny.request cannot contain thresholds",
"invalid threshold predicate at allow.request.thresholds[0]",
"invalid threshold predicate at allow.request.thresholds[1]",
"invalid review predicate at allow.review_requests.where",
"invalid review predicate at deny.review_requests.where",
"max access duration must be less than or equal to",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateRole(&types.RoleV6{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Version: types.V8,
Spec: tc.spec,
})
if tc.expectError != nil {
require.ErrorIs(t, err, tc.expectError)
return
}
if len(tc.expectErrorContains) > 0 {
require.Error(t, err)
for _, msg := range tc.expectErrorContains {
require.ErrorContains(t, err, msg)
}
return
}
require.NoError(t, err, trace.DebugReport(err))
})
}
}
// BenchmarkValidateRole benchmarks the performance of ValidateRole.
//
// $ go test ./lib/services -bench BenchmarkValidateRole -v -run xxx
// goos: darwin
// goarch: amd64
// pkg: github.com/gravitational/teleport/lib/services
// cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
// BenchmarkValidateRole
// BenchmarkValidateRole-16 14630 80205 ns/op
// PASS
// ok github.com/gravitational/teleport/lib/services 3.030s
func BenchmarkValidateRole(b *testing.B) {
role, err := types.NewRole("test", types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{"{{email.local(external.email)}}"},
WindowsDesktopLogins: []string{"{{email.local(external.email)}}"},
AWSRoleARNs: []string{"{{email.local(external.email)}}"},
AzureIdentities: []string{"{{email.local(external.email)}}"},
GCPServiceAccounts: []string{"{{email.local(external.email)}}"},
KubeGroups: []string{"{{email.local(external.email)}}"},
KubeUsers: []string{"{{email.local(external.email)}}"},
DatabaseNames: []string{"{{email.local(external.email)}}"},
DatabaseUsers: []string{"{{email.local(external.email)}}"},
HostGroups: []string{"{{email.local(external.email)}}"},
HostSudoers: []string{"{{email.local(external.email)}}"},
DesktopGroups: []string{"{{email.local(external.email)}}"},
Impersonate: &types.ImpersonateConditions{
Users: []string{"{{email.local(external.email)}}"},
Roles: []string{"{{email.local(external.email)}}"},
},
NodeLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
AppLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
KubernetesLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
DatabaseLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
WindowsDesktopLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
ClusterLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
BeamLabels: types.Labels{"env": {`{{regexp.replace(external["allow-envs"], "^env-(.*)$", "$1")}}`}},
Rules: []types.Rule{
{
Resources: []string{types.KindRole},
Verbs: []string{types.VerbRead, types.VerbList},
Where: `contains(user.spec.traits["groups"], "prod")`,
},
{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead, types.VerbList},
Where: "contains(session.participants, user.metadata.name)",
},
},
},
})
require.NoError(b, err)
for b.Loop() {
require.NoError(b, ValidateRole(role))
}
}
func TestUnmarshalRoleV9DeniesAppAccessForUnknownField(t *testing.T) {
// A stored allow_all rule that also carries a field this version does not
// recognize must not read as unrestricted access. JSON drops the unknown
// field, so the guard empties the rule instead of serving a bare allow_all.
widened := `{
"kind": "role",
"version": "v9",
"metadata": {"name": "test"},
"spec": {"allow": {"app_resources": [{"allow_all": true, "future_paths": ["/admin"]}]}}
}`
role, err := UnmarshalRole([]byte(widened))
require.NoError(t, err)
require.False(t,
types.AppResourcesAllowAll(role.GetAppResources(types.Allow), role.GetAppResources(types.Deny)),
"a stored allow_all rule with an unknown field must not read as allow-all")
// A clean allow_all rule is left untouched.
clean := `{
"kind": "role",
"version": "v9",
"metadata": {"name": "test"},
"spec": {"allow": {"app_resources": [{"allow_all": true}]}}
}`
role, err = UnmarshalRole([]byte(clean))
require.NoError(t, err)
require.True(t,
types.AppResourcesAllowAll(role.GetAppResources(types.Allow), role.GetAppResources(types.Deny)),
"a clean allow_all rule must read as allow-all")
}
func TestAppResourcesUnknownFieldRejectedOnWrite(t *testing.T) {
t.Parallel()
role := func(rules string) []byte {
return fmt.Appendf(nil, `{"kind": "role", "version": "v9", "metadata": {"name": "test"}, "spec": {"allow": {"app_resources": %s}}}`, rules)
}
for _, rules := range []string{
`[{"future_paths": ["/admin"]}]`,
`[{"allow_all": true, "future_paths": ["/admin"]}]`,
} {
parsed, err := UnmarshalRole(role(rules))
require.NoError(t, err)
require.Error(t, ValidateRole(parsed), "rules %s must not be storable", rules)
}
parsed, err := UnmarshalRole(role(`[{"allow_all": true}]`))
require.NoError(t, err)
require.NoError(t, ValidateRole(parsed))
}
func TestUnmarshalRole(t *testing.T) {
t.Parallel()
tests := []struct {
name string
json string
disallowUnknown bool
expectErrorContains string
}{
{
name: "valid role accepted with DisallowUnknown",
json: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "test"},
"spec": {
"allow": {
"kubernetes_groups": ["system:masters"]
}
}
}`,
disallowUnknown: true,
},
{
name: "unknown field rejected with DisallowUnknown",
json: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "test"},
"spec": {
"allow": {
"kubernetes_group": ["system:masters"]
}
}
}`,
disallowUnknown: true,
expectErrorContains: "kubernetes_group",
},
{
name: "valid role accepted without DisallowUnknown",
json: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "test"},
"spec": {
"allow": {
"kubernetes_groups": ["system:masters"]
}
}
}`,
},
{
name: "unknown field silently accepted without DisallowUnknown",
json: `{
"kind": "role",
"version": "v6",
"metadata": {"name": "test"},
"spec": {
"allow": {
"kubernetes_group": ["system:masters"]
}
}
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var opts []MarshalOption
if tt.disallowUnknown {
opts = append(opts, DisallowUnknown())
}
_, err := UnmarshalRole([]byte(tt.json), opts...)
if tt.expectErrorContains != "" {
require.ErrorContains(t, err, tt.expectErrorContains)
} else {
require.NoError(t, err)
}
})
}
}
// TestMarshalRoleV9RoundTrip guards the UnmarshalRoleV6 version allow-list.
// A v9 role must survive a marshal/unmarshal round-trip with its
// app_resources intact, since backend reads and the cache watcher decode
// roles this way.
func TestMarshalRoleV9RoundTrip(t *testing.T) {
t.Parallel()
role, err := types.NewRoleWithVersion("v9-role", types.V9, types.RoleSpecV6{
Allow: types.RoleConditions{
AppLabels: types.Labels{"env": []string{"dev"}},
AppResources: []types.AppResource{{AllowAll: true}},
},
})
require.NoError(t, err)
data, err := MarshalRole(role)
require.NoError(t, err)
got, err := UnmarshalRole(data)
require.NoError(t, err)
require.Equal(t, types.V9, got.GetVersion())
require.Equal(t, role.GetAppResources(types.Allow), got.GetAppResources(types.Allow))
}
// TestValidateRoleAppResources covers the write-path app_resources
// rules. The field is allow-only and every rule must set allow_all
// and nothing else.
func TestValidateRoleAppResources(t *testing.T) {
t.Parallel()
newV9Role := func(allow, deny []types.AppResource) types.Role {
return &types.RoleV6{
Metadata: types.Metadata{Name: "v9-role"},
Version: types.V9,
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
AppLabels: types.Labels{"env": []string{"dev"}},
AppResources: allow,
},
Deny: types.RoleConditions{AppResources: deny},
},
}
}
err := ValidateRole(newV9Role([]types.AppResource{{AllowAll: true}}, nil))
require.NoError(t, err)
err = ValidateRole(newV9Role(nil, nil))
require.NoError(t, err)
err = ValidateRole(newV9Role([]types.AppResource{{AllowAll: true}, {}}, nil))
require.ErrorContains(t, err, "app_resources[1]: this version implements allow_all only")
err = ValidateRole(newV9Role([]types.AppResource{{AllowAll: true}, {AllowAll: true}}, nil))
require.ErrorContains(t, err, "app_resources: a rule setting allow_all must be the only rule")
// Unknown proto bytes from a newer client must be rejected. The JSON
// marshal into the backend would drop them and widen the rule.
combined := types.AppResource{AllowAll: true, XXX_unrecognized: []byte{0x50, 0x01}}
err = ValidateRole(newV9Role([]types.AppResource{combined}, nil))
require.ErrorContains(t, err, "a rule must set allow_all and nothing else")
err = ValidateRole(newV9Role(nil, []types.AppResource{{AllowAll: true}}))
require.ErrorContains(t, err, "app_resources is not allowed under deny")
// A declared field is rejected at write, so a stored rule is never
// wider than the agent honors.
for name, rule := range map[string]types.AppResource{
"paths": {AllowAll: true, Paths: []string{"/api/**"}},
"methods": {AllowAll: true, Methods: []string{"GET"}},
"where": {AllowAll: true, Where: "true"},
} {
err := ValidateRole(newV9Role([]types.AppResource{rule}, nil))
require.ErrorContains(t, err, "a rule must set allow_all and nothing else", "field %s", name)
}
exprRole := newV9Role([]types.AppResource{{AllowAll: true}}, nil).(*types.RoleV6)
exprRole.Spec.Allow.AppResourcesExpressions = []string{`path.match(literal("api"))`}
err = ValidateRole(exprRole)
require.ErrorContains(t, err, "app_resources_expressions is not supported")
denyExprRole := newV9Role(nil, nil).(*types.RoleV6)
denyExprRole.Spec.Deny.AppResourcesExpressions = []string{"true"}
err = ValidateRole(denyExprRole)
require.ErrorContains(t, err, "app_resources_expressions is not allowed under deny")
}
func TestValidateRoleName(t *testing.T) {
tests := []struct {
name string
roleName string
err error
matchMessage string
}{
{
name: "reserved role name proxy",
roleName: string(types.RoleProxy),
err: trace.BadParameter(""),
matchMessage: "reserved role: \"Proxy\"",
},
{
name: "valid role name test-1",
roleName: "test-1",
},
}
for _, tc := range tests {
err := ValidateRoleName(&types.RoleV6{Metadata: types.Metadata{
Name: tc.roleName,
}})
if tc.err != nil {
require.Error(t, err, tc.name)
if tc.matchMessage != "" {
require.Contains(t, err.Error(), tc.matchMessage)
}
} else {
require.NoError(t, err, tc.name)
}
}
}
// TestLabelCompatibility makes sure that labels
// are serialized in format understood by older servers with
// scalar labels
func TestLabelCompatibility(t *testing.T) {
labels := types.Labels{
"key": []string{"val"},
}
data, err := json.Marshal(labels)
require.NoError(t, err)
var out map[string]string
err = json.Unmarshal(data, &out)
require.NoError(t, err)
require.Equal(t, map[string]string{"key": "val"}, out)
}
func newRole(mut func(*types.RoleV6)) *types.RoleV6 {
r := &types.RoleV6{
Metadata: types.Metadata{
Name: "name",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
WindowsDesktopLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
Namespaces: []string{types.Wildcard},
},
},
}
mut(r)
r.CheckAndSetDefaults()
return r
}
func TestCheckAccessToServer(t *testing.T) {
type check struct {
server types.Server
hasAccess bool
login string
}
serverNoLabels := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "a",
},
}
serverWorker := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "b",
Namespace: apidefaults.Namespace,
Labels: map[string]string{"role": "worker", "status": "follower"},
},
}
namespaceC := "namespace-c"
serverDB := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "c",
Namespace: namespaceC,
Labels: map[string]string{"role": "db", "status": "follower"},
},
}
serverDBWithSuffix := &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Name: "c2",
Namespace: namespaceC,
Labels: map[string]string{"role": "db01", "status": "follower01"},
},
}
testCases := []struct {
name string
roles []*types.RoleV6
checks []check
authSpec types.AuthPreferenceSpecV2
enableDeviceVerification bool
mfaVerified, deviceVerified bool
}{
{
name: "empty role set has access to nothing",
roles: []*types.RoleV6{},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "role is limited to default namespace",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"admin"}
r.Spec.Allow.Namespaces = []string{apidefaults.Namespace}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverNoLabels, login: "admin", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverWorker, login: "admin", hasAccess: true},
{server: serverDB, login: "root", hasAccess: false},
{server: serverDB, login: "admin", hasAccess: false},
},
},
{
name: "role is limited to labels in default namespace",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"admin"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker"}}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverNoLabels, login: "admin", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverWorker, login: "admin", hasAccess: true},
{server: serverDB, login: "root", hasAccess: false},
{server: serverDB, login: "admin", hasAccess: false},
},
},
{
name: "role matches any label out of multiple labels",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"admin"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker2", "worker"}}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverNoLabels, login: "admin", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverWorker, login: "admin", hasAccess: true},
{server: serverDB, login: "root", hasAccess: false},
{server: serverDB, login: "admin", hasAccess: false},
},
},
{
name: "node_labels with empty list value matches nothing",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"admin"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{}}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverNoLabels, login: "admin", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverWorker, login: "admin", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
{server: serverDB, login: "admin", hasAccess: false},
},
},
{
name: "one role is more permissive than another",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"admin"}
r.Spec.Allow.Namespaces = []string{apidefaults.Namespace}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker"}}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root", "admin"}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverNoLabels, login: "admin", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverWorker, login: "admin", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
{server: serverDB, login: "admin", hasAccess: true},
},
},
{
name: "one role needs to access servers sharing the partially same label value",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"admin"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"^db(.*)$"}, "status": []string{"follow*"}}
r.Spec.Allow.Namespaces = []string{namespaceC}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverNoLabels, login: "admin", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverWorker, login: "admin", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
{server: serverDB, login: "admin", hasAccess: true},
{server: serverDBWithSuffix, login: "root", hasAccess: false},
{server: serverDBWithSuffix, login: "admin", hasAccess: true},
},
},
{
name: "no logins means no access",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = nil
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverNoLabels, login: "admin", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverWorker, login: "admin", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
{server: serverDB, login: "admin", hasAccess: false},
},
},
// MFA.
{
name: "one role requires MFA but MFA was not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker"}}
r.Spec.Options.RequireMFAType = types.RequireMFAType_SESSION
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.RequireMFAType = types.RequireMFAType_OFF
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "one role requires MFA and MFA was verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker"}}
r.Spec.Options.RequireMFAType = types.RequireMFAType_SESSION
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.RequireMFAType = types.RequireMFAType_OFF
}),
},
mfaVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "cluster requires MFA but MFA was not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_SESSION,
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "cluster requires MFA and MFA was verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
RequireMFAType: types.RequireMFAType_SESSION,
},
mfaVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
// MFA with private key policy.
{
name: "cluster requires session+hardware key, MFA not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_SESSION_AND_HARDWARE_KEY,
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "cluster requires session+hardware key, MFA verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_SESSION_AND_HARDWARE_KEY,
},
mfaVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "cluster requires hardware key touch, MFA not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "cluster requires hardware key touch, MFA verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
},
mfaVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "cluster requires hardware key pin, MFA not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_PIN,
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "cluster requires hardware key pin, MFA verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_PIN,
},
mfaVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "cluster requires hardware key touch and pin, MFA not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH_AND_PIN,
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "cluster requires hardware key touch and pin, MFA verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
},
authSpec: types.AuthPreferenceSpecV2{
// Functionally equivalent to "session".
RequireMFAType: types.RequireMFAType_HARDWARE_KEY_TOUCH_AND_PIN,
},
mfaVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
// Device Trust.
{
name: "role requires trusted device, device not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
deviceVerified: false,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "role requires trusted device, device verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
deviceVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "role requires trusted device for specific label, device not verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker"}}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
deviceVerified: false,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: false}, // NOK, device not verified
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "role requires trusted device for specific label, device verified",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Allow.NodeLabels = types.Labels{"role": []string{"worker"}}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
deviceVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true}, // OK, device verified
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "device verification disabled",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: false,
deviceVerified: false,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "restrictive role device mode takes precedence",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeOff
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeOptional
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired // wins
}),
},
enableDeviceVerification: true,
deviceVerified: false,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
// MFA + Device verification.
{
name: "MFA and device required, fails MFA",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.RequireMFAType = types.RequireMFAType_SESSION
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
mfaVerified: false,
deviceVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "MFA and device required, fails device",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.RequireMFAType = types.RequireMFAType_SESSION
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
mfaVerified: true,
deviceVerified: false,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: false},
{server: serverDB, login: "root", hasAccess: false},
},
},
{
name: "MFA and device required, passes all",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.RequireMFAType = types.RequireMFAType_SESSION
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.Logins = []string{"root"}
r.Spec.Options.DeviceTrustMode = constants.DeviceTrustModeRequired
}),
},
enableDeviceVerification: true,
mfaVerified: true,
deviceVerified: true,
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: true},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: true},
},
},
{
name: "label expressions",
roles: []*types.RoleV6{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.NodeLabels = nil
r.Spec.Allow.NodeLabelsExpression = `labels.role == "worker" && labels.status == "follower"`
r.Spec.Allow.Logins = []string{"root"}
}),
},
checks: []check{
{server: serverNoLabels, login: "root", hasAccess: false},
{server: serverWorker, login: "root", hasAccess: true},
{server: serverDB, login: "root", hasAccess: false},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
authPref, err := types.NewAuthPreference(tc.authSpec)
require.NoError(t, err, "NewAuthPreference failed")
accessChecker := makeAccessCheckerWithRolePointers(tc.roles)
for j, check := range tc.checks {
comment := fmt.Sprintf("check #%v: user: %v, server: %v, should access: %v", j, check.login, check.server.GetName(), check.hasAccess)
state := accessChecker.GetAccessState(authPref)
state.MFAVerified = tc.mfaVerified
state.EnableDeviceVerification = tc.enableDeviceVerification
state.DeviceVerified = tc.deviceVerified
err := accessChecker.CheckAccess(
check.server,
state,
NewLoginMatcher(check.login))
if check.hasAccess {
require.NoError(t, err, comment)
} else {
require.True(t, trace.IsAccessDenied(err), "Got err = %v/%T, wanted AccessDenied. %v", err, err, comment)
}
}
})
}
}
func TestCheckAccessToRemoteCluster(t *testing.T) {
type check struct {
rc types.RemoteCluster
hasAccess bool
}
rcA := &types.RemoteClusterV3{
Metadata: types.Metadata{
Name: "a",
},
}
rcB := &types.RemoteClusterV3{
Metadata: types.Metadata{
Name: "b",
Labels: map[string]string{"role": "worker", "status": "follower"},
},
}
rcC := &types.RemoteClusterV3{
Metadata: types.Metadata{
Name: "c",
Labels: map[string]string{"role": "db", "status": "follower"},
},
}
require.NoError(t, rcA.CheckAndSetDefaults())
require.NoError(t, rcB.CheckAndSetDefaults())
require.NoError(t, rcC.CheckAndSetDefaults())
testCases := []struct {
name string
roles []types.RoleV6
checks []check
}{
{
name: "empty role set has access to nothing",
roles: []types.RoleV6{},
checks: []check{
{rc: rcA, hasAccess: false},
{rc: rcB, hasAccess: false},
{rc: rcC, hasAccess: false},
},
},
{
name: "role matches any label out of multiple labels",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Logins: []string{"admin"},
ClusterLabels: types.Labels{"role": []string{"worker2", "worker"}},
Namespaces: []string{apidefaults.Namespace},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: false},
{rc: rcB, hasAccess: true},
{rc: rcC, hasAccess: false},
},
},
{
name: "wildcard matches anything",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Logins: []string{"admin"},
ClusterLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
Namespaces: []string{apidefaults.Namespace},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: true},
{rc: rcB, hasAccess: true},
{rc: rcC, hasAccess: true},
},
},
{
name: "role with no labels will match clusters with no labels, but no others",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: true},
{rc: rcB, hasAccess: false},
{rc: rcC, hasAccess: false},
},
},
{
name: "any role in the set with labels in the set makes the set to match labels",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
ClusterLabels: types.Labels{"role": []string{"worker"}},
Namespaces: []string{apidefaults.Namespace},
},
},
},
{
Metadata: types.Metadata{
Name: "name2",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: false},
{rc: rcB, hasAccess: true},
{rc: rcC, hasAccess: false},
},
},
{
name: "cluster_labels with empty list value matches nothing",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Logins: []string{"admin"},
ClusterLabels: types.Labels{"role": []string{}},
Namespaces: []string{apidefaults.Namespace},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: false},
{rc: rcB, hasAccess: false},
{rc: rcC, hasAccess: false},
},
},
{
name: "one role is more permissive than another",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Logins: []string{"admin"},
ClusterLabels: types.Labels{"role": []string{"worker"}},
Namespaces: []string{apidefaults.Namespace},
},
},
},
{
Metadata: types.Metadata{
Name: "name2",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Logins: []string{"root", "admin"},
ClusterLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
Namespaces: []string{types.Wildcard},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: true},
{rc: rcB, hasAccess: true},
{rc: rcC, hasAccess: true},
},
},
{
name: "regexp label match",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.Duration(20 * time.Hour),
},
Allow: types.RoleConditions{
Logins: []string{"admin"},
ClusterLabels: types.Labels{"role": []string{"^db(.*)$"}, "status": []string{"follow*"}},
Namespaces: []string{apidefaults.Namespace},
},
},
},
},
checks: []check{
{rc: rcA, hasAccess: false},
{rc: rcB, hasAccess: false},
{rc: rcC, hasAccess: true},
},
},
{
name: "label expressions",
roles: []types.RoleV6{
*newRole(func(r *types.RoleV6) {
r.Spec.Allow.ClusterLabelsExpression = `labels.role == "worker" && labels.status == "follower"`
}),
},
checks: []check{
{rc: rcA, hasAccess: false},
{rc: rcB, hasAccess: true},
{rc: rcC, hasAccess: false},
},
},
}
for i, tc := range testCases {
accessChecker := makeAccessCheckerWithRoles(tc.roles)
for j, check := range tc.checks {
comment := fmt.Sprintf("test case %v '%v', check %v", i, tc.name, j)
result := accessChecker.CheckAccessToRemoteCluster(check.rc)
if check.hasAccess {
require.NoError(t, result, comment)
} else {
require.True(t, trace.IsAccessDenied(result), fmt.Sprintf("%v: %v", comment, result))
}
}
}
}
func makeAccessCheckerWithRoles(roles []types.RoleV6) AccessChecker {
roleSet := make(RoleSet, len(roles))
for i := range roles {
roleSet[i] = &roles[i]
}
return makeAccessCheckerWithRoleSet(roleSet)
}
func makeAccessCheckerWithRolePointers(roles []*types.RoleV6) AccessChecker {
roleSet := make(RoleSet, len(roles))
for i := range roles {
roleSet[i] = roles[i]
}
return makeAccessCheckerWithRoleSet(roleSet)
}
func makeAccessCheckerWithRoleSet(roleSet RoleSet) AccessChecker {
roleNames := make([]string, len(roleSet))
for i, role := range roleSet {
roleNames[i] = role.GetName()
}
accessInfo := &AccessInfo{
Username: "alice",
Roles: roleNames,
Traits: nil,
AllowedResourceAccessIDs: nil,
}
return NewAccessCheckerWithRoleSet(accessInfo, "clustername", roleSet)
}
func TestCheckRuleAccess(t *testing.T) {
type check struct {
hasAccess bool
verb string
namespace string
rule string
context Context
}
testCases := []struct {
name string
roles []types.RoleV6
checks []check
}{
{
name: "0 - empty role set has access to nothing",
roles: []types.RoleV6{},
checks: []check{
{rule: types.KindUser, verb: types.ActionWrite, namespace: apidefaults.Namespace, hasAccess: false},
},
},
{
name: "1 - user can read session but can't list in default namespace",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
types.NewRule(types.KindSSHSession, []string{types.VerbRead}),
},
},
},
},
},
checks: []check{
{rule: types.KindSSHSession, verb: types.VerbRead, namespace: apidefaults.Namespace, hasAccess: true},
{rule: types.KindSSHSession, verb: types.VerbList, namespace: apidefaults.Namespace, hasAccess: false},
},
},
{
name: "2 - user can read sessions in system namespace and create stuff in default namespace",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{"system"},
Rules: []types.Rule{
types.NewRule(types.KindSSHSession, []string{types.VerbRead}),
},
},
},
},
{
Metadata: types.Metadata{
Name: "name2",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
types.NewRule(types.KindSSHSession, []string{types.VerbCreate, types.VerbRead}),
},
},
},
},
},
checks: []check{
{rule: types.KindSSHSession, verb: types.VerbRead, namespace: apidefaults.Namespace, hasAccess: true},
{rule: types.KindSSHSession, verb: types.VerbCreate, namespace: apidefaults.Namespace, hasAccess: true},
{rule: types.KindSSHSession, verb: types.VerbCreate, namespace: "system", hasAccess: false},
{rule: types.KindRole, verb: types.VerbRead, namespace: apidefaults.Namespace, hasAccess: false},
},
},
{
name: "3 - deny rules override allow rules",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
types.NewRule(types.KindSSHSession, []string{types.VerbCreate}),
},
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
types.NewRule(types.KindSSHSession, []string{types.VerbCreate}),
},
},
},
},
},
checks: []check{
{rule: types.KindSSHSession, verb: types.VerbCreate, namespace: apidefaults.Namespace, hasAccess: false},
},
},
{
name: "4 - user can read sessions if trait matches",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], "prod")`,
},
},
},
},
},
},
checks: []check{
{rule: types.KindSession, verb: types.VerbRead, namespace: apidefaults.Namespace, hasAccess: false},
{rule: types.KindSession, verb: types.VerbList, namespace: apidefaults.Namespace, hasAccess: false},
{
context: Context{
User: &types.UserV2{
Metadata: types.Metadata{
Name: "bob",
},
Spec: types.UserSpecV2{
Traits: map[string][]string{
"group": {"dev", "prod"},
},
},
},
},
rule: types.KindSession,
verb: types.VerbRead,
namespace: apidefaults.Namespace,
hasAccess: true,
},
{
context: Context{
User: &types.UserV2{
Spec: types.UserSpecV2{
Traits: map[string][]string{
"group": {"dev"},
},
},
},
},
rule: types.KindSession,
verb: types.VerbRead,
namespace: apidefaults.Namespace,
hasAccess: false,
},
},
},
{
name: "5 - user can read role if role has label",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
{
Resources: []string{types.KindRole},
Verbs: []string{types.VerbRead},
Where: `equals(resource.metadata.labels["team"], "dev")`,
},
},
},
},
},
},
checks: []check{
{rule: types.KindRole, verb: types.VerbRead, namespace: apidefaults.Namespace, hasAccess: false},
{rule: types.KindRole, verb: types.VerbList, namespace: apidefaults.Namespace, hasAccess: false},
{
context: Context{
Resource: &types.RoleV6{
Metadata: types.Metadata{
Labels: map[string]string{"team": "dev"},
},
},
},
rule: types.KindRole,
verb: types.VerbRead,
namespace: apidefaults.Namespace,
hasAccess: true,
},
},
},
{
name: "More specific rule wins",
roles: []types.RoleV6{
{
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Rules: []types.Rule{
{
Resources: []string{types.Wildcard},
Verbs: []string{types.Wildcard},
},
{
Resources: []string{types.KindRole},
Verbs: []string{types.VerbRead},
Where: `equals(resource.metadata.labels["team"], "dev")`,
},
},
},
},
},
},
checks: []check{
{
context: Context{
Resource: &types.RoleV6{
Metadata: types.Metadata{
Labels: map[string]string{"team": "dev"},
},
},
},
rule: types.KindRole,
verb: types.VerbRead,
namespace: apidefaults.Namespace,
hasAccess: true,
},
},
},
}
for i, tc := range testCases {
var set RoleSet
for i := range tc.roles {
set = append(set, &tc.roles[i])
}
for j, check := range tc.checks {
comment := fmt.Sprintf("test case %v '%v', check %v", i, tc.name, j)
// At least one scenario exercises "default" vs "system" namespaces.
namespace := check.namespace
result := set.CheckAccessToRule(&check.context, namespace, check.rule, check.verb)
if check.hasAccess {
require.NoError(t, result, comment)
} else {
require.True(t, trace.IsAccessDenied(result), comment)
}
}
}
}
func TestDefaultImplicitRules(t *testing.T) {
type check struct {
hasAccess bool
verb string
rule string
context Context
}
testCases := []struct {
name string
role types.Role
checks []check
}{
{
name: "KindIdentityCenter with NewPresetAccessRole",
role: NewPresetAccessRole(),
checks: []check{
{rule: types.KindIdentityCenter, verb: types.VerbRead, hasAccess: true},
{rule: types.KindIdentityCenter, verb: types.VerbList, hasAccess: true},
{rule: types.KindIdentityCenter, verb: types.VerbCreate, hasAccess: false},
{rule: types.KindIdentityCenter, verb: types.VerbUpdate, hasAccess: false},
{rule: types.KindIdentityCenter, verb: types.VerbDelete, hasAccess: false},
},
},
{
name: "KindIdentityCenter with a custom role that does not explicitly target read and list verbs for KindIdentityCenterAccount",
role: newRole(func(r *types.RoleV6) {}),
checks: []check{
{rule: types.KindIdentityCenter, verb: types.VerbRead, hasAccess: true},
{rule: types.KindIdentityCenter, verb: types.VerbList, hasAccess: true},
{rule: types.KindIdentityCenter, verb: types.VerbCreate, hasAccess: false},
{rule: types.KindIdentityCenter, verb: types.VerbUpdate, hasAccess: false},
{rule: types.KindIdentityCenter, verb: types.VerbDelete, hasAccess: false},
},
},
{
name: "KindSAMLIdPServiceProvider with NewPresetAccessRole",
role: NewPresetAccessRole(),
checks: []check{
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbRead, hasAccess: true},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbList, hasAccess: true},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbCreate, hasAccess: false},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbUpdate, hasAccess: false},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbDelete, hasAccess: false},
},
},
{
name: "KindSAMLIdPServiceProvider with a custom role that does not explicitly target read and list verbs for KindSAMLIdPServiceProvider",
role: newRole(func(r *types.RoleV6) {}),
checks: []check{
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbRead, hasAccess: true},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbList, hasAccess: true},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbCreate, hasAccess: false},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbUpdate, hasAccess: false},
{rule: types.KindSAMLIdPServiceProvider, verb: types.VerbDelete, hasAccess: false},
},
},
{
name: "KindGitServer with empty rules",
role: newRole(func(r *types.RoleV6) {
r.Spec.Allow = types.RoleConditions{}
r.Spec.Deny = types.RoleConditions{}
}),
checks: []check{
{rule: types.KindGitServer, verb: types.VerbRead, hasAccess: true},
{rule: types.KindGitServer, verb: types.VerbList, hasAccess: true},
{rule: types.KindGitServer, verb: types.VerbCreate, hasAccess: false},
{rule: types.KindGitServer, verb: types.VerbUpdate, hasAccess: false},
{rule: types.KindGitServer, verb: types.VerbDelete, hasAccess: false},
},
},
}
for _, tc := range testCases {
roleSet := NewRoleSet(tc.role)
for _, check := range tc.checks {
result := roleSet.CheckAccessToRule(&check.context, apidefaults.Namespace, check.rule, check.verb)
if check.hasAccess {
require.NoError(t, result)
} else {
require.True(t, trace.IsAccessDenied(result))
}
}
}
}
func TestMFAVerificationInterval(t *testing.T) {
testCases := []struct {
name string
roles []types.RoleV6
enforce bool
inputTTL time.Duration
expectedTTL time.Duration
}{
{
name: "No roles, no enforcement, zero TTL",
roles: nil,
enforce: false,
inputTTL: 0,
expectedTTL: 0,
},
{
name: "Single role with no MFA requirement, no enforcement, TTL unchanged",
roles: []types.RoleV6{
{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_OFF,
MFAVerificationInterval: 5 * time.Minute,
},
},
},
},
enforce: false,
inputTTL: 10 * time.Minute,
expectedTTL: 10 * time.Minute,
},
{
name: "Single role with MFA requirement, TTL adjusted to MFA verification interval",
roles: []types.RoleV6{
{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
MFAVerificationInterval: 5 * time.Minute,
},
},
},
},
enforce: false,
inputTTL: 10 * time.Minute,
expectedTTL: 5 * time.Minute,
},
{
name: "Multiple roles with varying MFA requirements, TTL adjusted to smallest interval",
roles: []types.RoleV6{
{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
MFAVerificationInterval: 5 * time.Minute,
},
},
},
{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
MFAVerificationInterval: 2 * time.Minute,
},
},
},
},
enforce: false,
inputTTL: 10 * time.Minute,
expectedTTL: 2 * time.Minute,
},
{
name: "Role with MFA off but enforcement is true, TTL adjusted",
roles: []types.RoleV6{
{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_OFF,
MFAVerificationInterval: 5 * time.Minute,
},
},
},
},
enforce: true,
inputTTL: 10 * time.Minute,
expectedTTL: 5 * time.Minute,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var set RoleSet
for i := range tc.roles {
set = append(set, &tc.roles[i])
}
result := set.AdjustMFAVerificationInterval(tc.inputTTL, tc.enforce)
require.Equal(t, tc.expectedTTL, result)
})
}
}
func TestGuessIfAccessIsPossible(t *testing.T) {
// Examples from https://goteleport.com/docs/reference/access-controls/roles/#rbac-for-sessions.
ownSessions, err := types.NewRole("own-sessions", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbList, types.VerbRead},
Where: "contains(session.participants, user.metadata.name)",
},
},
},
})
require.NoError(t, err)
ownSSHSessions, err := types.NewRole("own-ssh-sessions", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.KindSSHSession},
Verbs: []string{types.Wildcard},
},
},
},
Deny: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.KindSSHSession},
Verbs: []string{types.VerbList, types.VerbRead, types.VerbUpdate, types.VerbDelete},
Where: "!contains(ssh_session.participants, user.metadata.name)",
},
},
},
})
require.NoError(t, err)
// Simple, all-or-nothing roles.
readAllSessions, err := types.NewRole("all-sessions", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbList, types.VerbRead},
},
},
},
})
require.NoError(t, err)
allowSSHSessions, err := types.NewRole("all-ssh-sessions", types.RoleSpecV6{
Allow: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.KindSSHSession},
Verbs: []string{types.Wildcard},
},
},
},
})
require.NoError(t, err)
denySSHSessions, err := types.NewRole("deny-ssh-sessions", types.RoleSpecV6{
Deny: types.RoleConditions{
Rules: []types.Rule{
{
Resources: []string{types.KindSSHSession},
Verbs: []string{types.Wildcard},
},
},
},
})
require.NoError(t, err)
type checkAccessParams struct {
ctx Context
resource string
verbs []string
}
tests := []struct {
name string
roles RoleSet
params *checkAccessParams
// wantRuleAccess fully evaluates where conditions, used to determine access
// to specific resources.
wantRuleAccess bool
// wantGuessAccess doesn't evaluate where conditions, used to determine
// access to a category of resources.
wantGuessAccess bool
}{
{
name: "global session list/read allowed",
roles: RoleSet{readAllSessions},
params: &checkAccessParams{
resource: types.KindSession,
verbs: []string{types.VerbList, types.VerbRead},
},
wantRuleAccess: true,
wantGuessAccess: true,
},
{
name: "own session list/read allowed",
roles: RoleSet{ownSessions}, // allowed despite "where" clause in allow rules
params: &checkAccessParams{
resource: types.KindSession,
verbs: []string{types.VerbList, types.VerbRead},
},
wantRuleAccess: false, // where condition needs specific resource
wantGuessAccess: true,
},
{
name: "session list/read denied",
roles: RoleSet{allowSSHSessions, denySSHSessions}, // none mention "session"
params: &checkAccessParams{
resource: types.KindSession,
verbs: []string{types.VerbList, types.VerbRead},
},
},
{
name: "session write denied",
roles: RoleSet{
readAllSessions, // readonly
allowSSHSessions, denySSHSessions, // none mention "session"
},
params: &checkAccessParams{
resource: types.KindSession,
verbs: []string{types.VerbUpdate, types.VerbDelete},
},
},
{
name: "global SSH session list/read allowed",
roles: RoleSet{allowSSHSessions},
params: &checkAccessParams{
resource: types.KindSSHSession,
verbs: []string{types.VerbList, types.VerbRead},
},
wantRuleAccess: true,
wantGuessAccess: true,
},
{
name: "own SSH session list/read allowed",
roles: RoleSet{ownSSHSessions}, // allowed despite "where" clause in deny rules
params: &checkAccessParams{
resource: types.KindSSHSession,
verbs: []string{types.VerbList, types.VerbRead},
},
wantRuleAccess: false, // where condition needs specific resource
wantGuessAccess: true,
},
{
name: "SSH session list/read denied",
roles: RoleSet{
allowSSHSessions, ownSSHSessions,
denySSHSessions, // unconditional deny, takes precedence
},
params: &checkAccessParams{
resource: types.KindSSHSession,
verbs: []string{types.VerbCreate, types.VerbList, types.VerbRead, types.VerbUpdate, types.VerbDelete},
},
},
{
name: "SSH session list/read denied - different role ordering",
roles: RoleSet{
allowSSHSessions,
denySSHSessions, // unconditional deny, takes precedence
ownSSHSessions,
},
params: &checkAccessParams{
resource: types.KindSSHSession,
verbs: []string{types.VerbCreate, types.VerbList, types.VerbRead, types.VerbUpdate, types.VerbDelete},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
params := test.params
for _, verb := range params.verbs {
err := test.roles.CheckAccessToRule(&params.ctx, apidefaults.Namespace, params.resource, verb)
if gotAccess, wantAccess := err == nil, test.wantRuleAccess; gotAccess != wantAccess {
t.Errorf("CheckAccessToRule(verb=%q) returned err = %v=q, wantAccess = %v", verb, err, wantAccess)
}
err = test.roles.GuessIfAccessIsPossible(&params.ctx, apidefaults.Namespace, params.resource, verb)
if gotAccess, wantAccess := err == nil, test.wantGuessAccess; gotAccess != wantAccess {
t.Errorf("GuessIfAccessIsPossible(verb=%q) returned err = %q, wantAccess = %v", verb, err, wantAccess)
}
}
})
}
}
func TestCheckRuleSorting(t *testing.T) {
testCases := []struct {
name string
rules []types.Rule
set RuleSet
}{
{
name: "single rule set sorts OK",
rules: []types.Rule{
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
},
},
set: RuleSet{
types.KindUser: []types.Rule{
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
},
},
},
},
{
name: "rule with where section is more specific",
rules: []types.Rule{
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
},
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
},
},
set: RuleSet{
types.KindUser: []types.Rule{
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
},
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
},
},
},
},
{
name: "rule with action is more specific",
rules: []types.Rule{
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
},
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
Actions: []string{
"log(\"info\", \"log entry\")",
},
},
},
set: RuleSet{
types.KindUser: []types.Rule{
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
Actions: []string{
"log(\"info\", \"log entry\")",
},
},
{
Resources: []string{types.KindUser},
Verbs: []string{types.VerbCreate},
Where: "contains(user.spec.traits[\"groups\"], \"prod\")",
},
},
},
},
}
for i, tc := range testCases {
comment := fmt.Sprintf("test case %v '%v'", i, tc.name)
out := MakeRuleSet(tc.rules)
require.Equal(t, tc.set, out, comment)
}
}
// A trait that expands to a wildcard mixed with other verbs is normalized to
// just the wildcard, since role validation can't catch this post-expansion.
func TestApplyTraits_NormalizesWildcardKubeVerbAfterExpansion(t *testing.T) {
role, err := types.NewRoleWithVersion("kube-verb-tmpl", types.V8, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
KubernetesResources: []types.KubernetesResource{{
Kind: "secrets", APIGroup: types.Wildcard, Namespace: types.Wildcard, Name: types.Wildcard,
Verbs: []string{types.Wildcard},
}},
},
Deny: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{{
Kind: "secrets", APIGroup: types.Wildcard, Namespace: "prod", Name: types.Wildcard,
Verbs: []string{"{{external.kube_verbs}}"},
}},
},
})
require.NoError(t, err)
out, err := ApplyTraits(role, map[string][]string{
"kube_verbs": {types.KubeVerbCreate, types.KubeVerbUpdate, types.KubeVerbDelete, types.Wildcard},
})
require.NoError(t, err)
denied := out.GetKubeResources(types.Deny)
require.Len(t, denied, 1)
require.Equal(t, []string{types.Wildcard}, denied[0].Verbs)
}
func TestApplyTraits(t *testing.T) {
type rule struct {
inLogins []string
outLogins []string
inWindowsLogins []string
outWindowsLogins []string
inRoleARNs []string
outRoleARNs []string
inAzureIdentities []string
outAzureIdentities []string
inGCPServiceAccounts []string
outGCPServiceAccounts []string
inLabels types.Labels
outLabels types.Labels
inKubeLabels types.Labels
outKubeLabels types.Labels
inKubeGroups []string
inKubeResources []types.KubernetesResource
outKubeGroups []string
inKubeUsers []string
outKubeUsers []string
inAppLabels types.Labels
outAppLabels types.Labels
inGroupLabels types.Labels
outGroupLabels types.Labels
inDBLabels types.Labels
outDBLabels types.Labels
inWindowsDesktopLabels types.Labels
outWindowsDesktopLabels types.Labels
inDBNames []string
outDBNames []string
inDBUsers []string
outDBUsers []string
inDBRoles []string
outDBRoles []string
inImpersonate types.ImpersonateConditions
outImpersonate types.ImpersonateConditions
inSudoers []string
outSudoers []string
outKubeResources []types.KubernetesResource
inGitHubPermissions []types.GitHubPermission
outGitHubPermissions []types.GitHubPermission
inMCPPermissions *types.MCPPermissions
outMCPPermissions *types.MCPPermissions
inBeamLabels types.Labels
outBeamLabels types.Labels
}
tests := []struct {
comment string
inTraits map[string][]string
allow rule
deny rule
}{
{
comment: "logins substitute in allow rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{external.foo}}`, "root"},
outLogins: []string{"bar", "root"},
},
},
{
comment: "logins substitute in allow rule with function",
inTraits: map[string][]string{
"foo": {"Bar <[email protected]>"},
},
allow: rule{
inLogins: []string{`{{email.local(external.foo)}}`, "root"},
outLogins: []string{"bar", "root"},
},
},
{
comment: "logins substitute in allow rule with regexp",
inTraits: map[string][]string{
"foo": {"bar-baz"},
},
allow: rule{
inLogins: []string{`{{regexp.replace(external.foo, "^bar-(.*)$", "$1")}}`, "root"},
outLogins: []string{"baz", "root"},
},
},
{
comment: "logins substitute in allow rule with multiple functions and regexps",
inTraits: map[string][]string{
"email": {"[email protected]"},
},
allow: rule{
inLogins: []string{
`{{regexp.replace(external.email, "_", "")}}`,
`{{email.local(external.email)}}`,
`{{regexp.replace(email.local(external.email), "_", "")}}`,
`{{regexp.replace(external.email, "d", "e")}}`,
`{{email.local(regexp.replace(external.email, "d", "e"))}}`,
`{{regexp.replace(regexp.replace(email.local(regexp.replace(external.email, "cd", "z")), "ab", "xy"), "_", "")}}`,
"root",
},
outLogins: []string{
"[email protected]",
"ab_cd",
"abcd",
"[email protected]",
"ab_ce",
"xyz",
"root",
},
},
},
{
comment: "logins substitute in allow rule can have constant expressions",
inTraits: map[string][]string{},
allow: rule{
inLogins: []string{
`{{regexp.replace("[email protected]", "gravitational", "goteleport")}}`,
`{{email.local("[email protected]")}}`,
`{{email.local(regexp.replace("[email protected]", "gravitational", "goteleport"))}}`,
"root",
},
outLogins: []string{
"[email protected]",
"vitor",
"vitor.enes",
"root",
},
},
},
{
comment: "logins substitute in deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
deny: rule{
inLogins: []string{`{{external.foo}}`},
outLogins: []string{"bar"},
},
},
{
comment: "windows logins substitute",
inTraits: map[string][]string{
"windows_logins": {"user"},
"foo": {"bar"},
},
allow: rule{
inWindowsLogins: []string{"{{internal.windows_logins}}"},
outWindowsLogins: []string{"user"},
},
deny: rule{
inWindowsLogins: []string{"{{external.foo}}"},
outWindowsLogins: []string{"bar"},
},
},
{
comment: "invalid windows login",
inTraits: map[string][]string{
"windows_logins": {"test;"},
},
allow: rule{
inWindowsLogins: []string{"Administrator", "{{internal.windows_logins}}"},
outWindowsLogins: []string{"Administrator"},
},
},
{
comment: "AWS role ARN substitute in allow rule",
inTraits: map[string][]string{
"foo": {"bar"},
constants.TraitAWSRoleARNs: {"baz"},
},
allow: rule{
inRoleARNs: []string{"{{external.foo}}", teleport.TraitInternalAWSRoleARNs},
outRoleARNs: []string{"bar", "baz"},
},
},
{
comment: "AWS role ARN substitute in deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
constants.TraitAWSRoleARNs: {"baz"},
},
deny: rule{
inRoleARNs: []string{"{{external.foo}}", teleport.TraitInternalAWSRoleARNs},
outRoleARNs: []string{"bar", "baz"},
},
},
{
comment: "Azure identity substitute in allow rule",
inTraits: map[string][]string{
"foo": {"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/external-foo/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure"},
constants.TraitAzureIdentities: {"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/internal-azure-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure"},
},
deny: rule{
inAzureIdentities: []string{"{{external.foo}}", teleport.TraitInternalAzureIdentities},
outAzureIdentities: []string{
"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/external-foo/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/internal-azure-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
},
},
},
{
comment: "Azure identity substitute in deny rule",
inTraits: map[string][]string{
"foo": {"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/external-foo/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure"},
constants.TraitAzureIdentities: {"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/internal-azure-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure"},
},
deny: rule{
inAzureIdentities: []string{"{{external.foo}}", teleport.TraitInternalAzureIdentities},
outAzureIdentities: []string{
"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/external-foo/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
"/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/internal-azure-identities/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
},
},
},
{
comment: "GCP service account substitute in allow rule",
inTraits: map[string][]string{
"foo": {"bar"},
constants.TraitGCPServiceAccounts: {"baz"},
},
allow: rule{
inGCPServiceAccounts: []string{"{{external.foo}}", teleport.TraitInternalGCPServiceAccounts},
outGCPServiceAccounts: []string{"bar", "baz"},
},
},
{
comment: "GCP service account substitute in deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
constants.TraitGCPServiceAccounts: {"baz"},
},
deny: rule{
inGCPServiceAccounts: []string{"{{external.foo}}", teleport.TraitInternalGCPServiceAccounts},
outGCPServiceAccounts: []string{"bar", "baz"},
},
},
{
comment: "kube group substitute in allow rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inKubeGroups: []string{`{{external.foo}}`, "root"},
outKubeGroups: []string{"bar", "root"},
},
},
{
comment: "kube group substitute in deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
deny: rule{
inKubeGroups: []string{`{{external.foo}}`, "root"},
outKubeGroups: []string{"bar", "root"},
},
},
{
comment: "kube user interpolation in allow rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inKubeUsers: []string{`IAM#{{external.foo}};`},
outKubeUsers: []string{"IAM#bar;"},
},
},
{
comment: "kube user regexp interpolation in allow rule",
inTraits: map[string][]string{
"foo": {"bar-baz"},
},
allow: rule{
inKubeUsers: []string{`IAM#{{regexp.replace(external.foo, "^bar-(.*)$", "$1")}};`},
outKubeUsers: []string{"IAM#baz;"},
},
},
{
comment: "kube users interpolation in deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
deny: rule{
inKubeUsers: []string{`IAM#{{external.foo}};`},
outKubeUsers: []string{"IAM#bar;"},
},
},
{
comment: "database name/user/role external vars in allow rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inDBNames: []string{"{{external.foo}}", "{{external.baz}}", "postgres"},
outDBNames: []string{"bar", "postgres"},
inDBUsers: []string{"{{external.foo}}", "{{external.baz}}", "postgres"},
outDBUsers: []string{"bar", "postgres"},
inDBRoles: []string{"{{external.foo}}", "{{external.baz}}", "postgres"},
outDBRoles: []string{"bar", "postgres"},
},
},
{
comment: "database name/user/role external vars in deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
},
deny: rule{
inDBNames: []string{"{{external.foo}}", "{{external.baz}}", "postgres"},
outDBNames: []string{"bar", "postgres"},
inDBUsers: []string{"{{external.foo}}", "{{external.baz}}", "postgres"},
outDBUsers: []string{"bar", "postgres"},
inDBRoles: []string{"{{external.foo}}", "{{external.baz}}", "postgres"},
outDBRoles: []string{"bar", "postgres"},
},
},
{
comment: "database name/user/role internal vars in allow rule",
inTraits: map[string][]string{
"db_names": {"db1", "db2"},
"db_users": {"alice"},
},
allow: rule{
inDBNames: []string{"{{internal.db_names}}", "{{internal.foo}}", "postgres"},
outDBNames: []string{"db1", "db2", "postgres"},
inDBUsers: []string{"{{internal.db_users}}", "{{internal.foo}}", "postgres"},
outDBUsers: []string{"alice", "postgres"},
inDBRoles: []string{"{{internal.db_roles}}", "{{internal.foo}}", "postgres"},
outDBRoles: []string{"alice", "postgres"},
},
},
{
comment: "database name/user/role internal vars in deny rule",
inTraits: map[string][]string{
"db_names": {"db1", "db2"},
"db_users": {"alice"},
},
deny: rule{
inDBNames: []string{"{{internal.db_names}}", "{{internal.foo}}", "postgres"},
outDBNames: []string{"db1", "db2", "postgres"},
inDBUsers: []string{"{{internal.db_users}}", "{{internal.foo}}", "postgres"},
outDBUsers: []string{"alice", "postgres"},
inDBRoles: []string{"{{internal.db_roles}}", "{{internal.foo}}", "postgres"},
outDBRoles: []string{"alice", "postgres"},
},
},
{
comment: "no variable in logins",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{"root"},
outLogins: []string{"root"},
},
},
{
comment: "invalid variable in logins does not get passed along",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`external.foo}}`},
},
},
{
comment: "invalid function call in logins does not get passed along",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{email.local(external.foo, 1)}}`},
},
},
{
comment: "invalid function call in logins does not get passed along",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{email.local()}}`},
},
},
{
comment: "invalid function call in logins does not get passed along",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{email.local(email.local)}}`, `{{email.local(email.local())}}`},
},
},
{
comment: "invalid regexp in logins does not get passed along",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{regexp.replace(external.foo, "(()", "baz")}}`},
},
},
{
comment: "logins which to not match regexp get filtered out",
inTraits: map[string][]string{
"foo": {"dev-alice", "dev-bob", "prod-charlie"},
},
allow: rule{
inLogins: []string{`{{regexp.replace(external.foo, "^dev-([a-zA-Z]+)$", "$1-admin")}}`},
outLogins: []string{"alice-admin", "bob-admin"},
},
},
{
comment: "variable in logins, none in traits",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{internal.bar}}`, "root"},
outLogins: []string{"root"},
},
},
{
comment: "multiple variables in traits",
inTraits: map[string][]string{
"logins": {"bar", "baz"},
},
allow: rule{
inLogins: []string{`{{internal.logins}}`, "root"},
outLogins: []string{"bar", "baz", "root"},
},
},
{
comment: "deduplicate",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLogins: []string{`{{external.foo}}`, "bar"},
outLogins: []string{"bar"},
},
},
{
comment: "invalid unix login",
inTraits: map[string][]string{
"foo": {"-foo"},
},
allow: rule{
inLogins: []string{`{{external.foo}}`, "bar"},
outLogins: []string{"bar"},
},
},
{
comment: "label substitute in allow and deny rule",
inTraits: map[string][]string{
"foo": {"bar"},
"hello": {"there"},
},
allow: rule{
inLabels: types.Labels{`{{external.foo}}`: []string{"{{external.hello}}"}},
outLabels: types.Labels{`bar`: []string{"there"}},
},
deny: rule{
inLabels: types.Labels{`{{external.hello}}`: []string{"{{external.foo}}"}},
outLabels: types.Labels{`there`: []string{"bar"}},
},
},
{
comment: "missing node variables are set to empty during substitution",
inTraits: map[string][]string{
"foo": {"bar"},
},
allow: rule{
inLabels: types.Labels{
`{{external.foo}}`: []string{"value"},
`{{external.missing}}`: []string{"missing"},
`missing`: []string{"{{external.missing}}", "othervalue"},
},
outLabels: types.Labels{
`bar`: []string{"value"},
"missing": []string{"", "othervalue"},
"": []string{"missing"},
},
},
},
{
comment: "the first variable value is picked for label keys",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inLabels: types.Labels{`{{external.foo}}`: []string{"value"}},
outLabels: types.Labels{`bar`: []string{"value"}},
},
},
{
comment: "all values are expanded for label values",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inLabels: types.Labels{`key`: []string{`{{external.foo}}`}},
outLabels: types.Labels{`key`: []string{"bar", "baz"}},
},
},
{
comment: "values are expanded in kube labels",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inKubeLabels: types.Labels{`key`: []string{`{{external.foo}}`}},
outKubeLabels: types.Labels{`key`: []string{"bar", "baz"}},
},
},
{
comment: "values are expanded in app labels",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inAppLabels: types.Labels{`key`: []string{`{{external.foo}}`}},
outAppLabels: types.Labels{`key`: []string{"bar", "baz"}},
},
},
{
comment: "values are expanded in group labels",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inGroupLabels: types.Labels{`key`: []string{`{{external.foo}}`}},
outGroupLabels: types.Labels{`key`: []string{"bar", "baz"}},
},
},
{
comment: "values are expanded in database labels",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inDBLabels: types.Labels{`key`: []string{`{{external.foo}}`}},
outDBLabels: types.Labels{`key`: []string{"bar", "baz"}},
},
},
{
comment: "values are expanded in windows desktop labels",
inTraits: map[string][]string{
"foo": {"bar", "baz"},
},
allow: rule{
inWindowsDesktopLabels: types.Labels{`key`: []string{`{{external.foo}}`}},
outWindowsDesktopLabels: types.Labels{`key`: []string{"bar", "baz"}},
},
},
{
comment: "impersonate roles",
inTraits: map[string][]string{
"teams": {"devs"},
"users": {"alice", "bob"},
"blocked_users": {"root"},
"blocked_teams": {"admins"},
},
allow: rule{
inImpersonate: types.ImpersonateConditions{
Users: []string{"{{external.users}}"},
Roles: []string{"{{external.teams}}"},
Where: `contains(user.spec.traits, "hello")`,
},
outImpersonate: types.ImpersonateConditions{
Users: []string{"alice", "bob"},
Roles: []string{"devs"},
Where: `contains(user.spec.traits, "hello")`,
},
},
deny: rule{
inImpersonate: types.ImpersonateConditions{
Users: []string{"{{external.blocked_users}}"},
Roles: []string{"{{external.blocked_teams}}"},
},
outImpersonate: types.ImpersonateConditions{
Users: []string{"root"},
Roles: []string{"admins"},
},
},
},
{
comment: "sudoers substitution roles",
inTraits: map[string][]string{
"users": {"alice", "bob"},
},
allow: rule{
inSudoers: []string{"{{external.users}} ALL=(ALL) ALL"},
outSudoers: []string{
"alice ALL=(ALL) ALL",
"bob ALL=(ALL) ALL",
},
},
},
{
comment: "sudoers substitution not found trait",
inTraits: map[string][]string{
"users": {"alice", "bob"},
},
allow: rule{
inSudoers: []string{
"{{external.hello}} ALL=(ALL) ALL",
"{{external.users}} ALL=(test) ALL",
},
outSudoers: []string{
"alice ALL=(test) ALL",
"bob ALL=(test) ALL",
},
},
},
{
comment: "kubernetes namespaces preservation",
inTraits: map[string][]string{
"users": {"alice", "bob"},
},
allow: rule{
inKubeResources: []types.KubernetesResource{
{
Namespace: "default",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
},
outKubeResources: []types.KubernetesResource{
{
Namespace: "default",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
},
},
},
{
comment: "kubernetes namespaces replace rules",
inTraits: map[string][]string{
"namespace": {"kubens1", "kubens2"},
},
allow: rule{
inKubeResources: []types.KubernetesResource{
{
Namespace: "default",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
{
Namespace: "{{external.namespace}}",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
},
outKubeResources: []types.KubernetesResource{
{
Namespace: "default",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
{
Namespace: "kubens1",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
{
Namespace: "kubens2",
Kind: "*",
Name: "name-*",
Verbs: []string{"get", "list"},
},
},
},
},
{
comment: "full kubernetes resources replace rules",
inTraits: map[string][]string{
"namespace": {"kubens1", "kubens2"},
"names": {"kubepod1", "kubepod2"},
"verbs": {"update", "delete"},
},
allow: rule{
inKubeResources: []types.KubernetesResource{
{
Namespace: "{{external.namespace}}",
Kind: "pod",
Name: "{{external.names}}",
Verbs: []string{"{{external.verbs}}", "list"},
},
},
outKubeResources: []types.KubernetesResource{
{
Namespace: "kubens1",
Kind: "pod",
Name: "kubepod1",
Verbs: []string{"update", "delete", "list"},
},
{
Namespace: "kubens1",
Kind: "pod",
Name: "kubepod2",
Verbs: []string{"update", "delete", "list"},
},
{
Namespace: "kubens2",
Kind: "pod",
Name: "kubepod1",
Verbs: []string{"update", "delete", "list"},
},
{
Namespace: "kubens2",
Kind: "pod",
Name: "kubepod2",
Verbs: []string{"update", "delete", "list"},
},
},
},
},
{
// See comment in ApplyValueTraits for why we allow this.
comment: "explicitly allow internal traits referenced via external namespace",
inTraits: map[string][]string{
constants.TraitLogins: {"logins"},
constants.TraitWindowsLogins: {"windowsLogins"},
constants.TraitKubeGroups: {"kubeGroups"},
constants.TraitKubeUsers: {"kubeUsers"},
constants.TraitDBNames: {"dBNames"},
constants.TraitDBUsers: {"dBUsers"},
constants.TraitDBRoles: {"dBRoles"},
constants.TraitAWSRoleARNs: {"aWSRoleARNs"},
constants.TraitAzureIdentities: {"azureIdentities"},
constants.TraitGCPServiceAccounts: {"gCPServiceAccounts"},
constants.TraitJWT: {"jwt"},
},
allow: rule{
inLogins: []string{
"{{external.logins}}",
"{{external.windows_logins}}",
"{{external.kubernetes_groups}}",
"{{external.kubernetes_users}}",
"{{external.db_names}}",
"{{external.db_users}}",
"{{external.db_roles}}",
"{{external.aws_role_arns}}",
"{{external.azure_identities}}",
"{{external.gcp_service_accounts}}",
"{{external.jwt}}",
},
outLogins: []string{
"logins",
"windowsLogins",
"kubeGroups",
"kubeUsers",
"dBNames",
"dBUsers",
"dBRoles",
"aWSRoleARNs",
"azureIdentities",
"gCPServiceAccounts",
"jwt",
},
},
},
{
comment: "full deny kubernetes resources replace rules",
inTraits: map[string][]string{
"namespace": {"kubens1", "kubens2"},
"names": {"kubepod1", "kubepod2"},
"verbs": {"update", "delete"},
},
deny: rule{
inKubeResources: []types.KubernetesResource{
{
Namespace: "{{external.namespace}}",
Kind: "pod",
Name: "{{external.names}}",
Verbs: []string{"{{external.verbs}}", "list"},
},
},
outKubeResources: []types.KubernetesResource{
{
Namespace: "kubens1",
Kind: "pod",
Name: "kubepod1",
Verbs: []string{"update", "delete", "list"},
},
{
Namespace: "kubens1",
Kind: "pod",
Name: "kubepod2",
Verbs: []string{"update", "delete", "list"},
},
{
Namespace: "kubens2",
Kind: "pod",
Name: "kubepod1",
Verbs: []string{"update", "delete", "list"},
},
{
Namespace: "kubens2",
Kind: "pod",
Name: "kubepod2",
Verbs: []string{"update", "delete", "list"},
},
},
},
},
{
comment: "GitHub permissions in allow rule",
inTraits: map[string][]string{
"github_orgs": {"my-org1", "my-org2"},
},
allow: rule{
inGitHubPermissions: []types.GitHubPermission{{
Organizations: []string{"{{internal.github_orgs}}"},
}},
outGitHubPermissions: []types.GitHubPermission{{
Organizations: []string{"my-org1", "my-org2"},
}},
},
},
{
comment: "GitHub permissions in deny rule",
inTraits: map[string][]string{
"orgs": {"my-org1", "my-org2"},
},
deny: rule{
inGitHubPermissions: []types.GitHubPermission{{
Organizations: []string{"{{external.orgs}}"},
}},
outGitHubPermissions: []types.GitHubPermission{{
Organizations: []string{"my-org1", "my-org2"},
}},
},
},
{
comment: "MCP permissions in allow rule",
inTraits: map[string][]string{
"mcp_tools": {"get_*", "search_files"},
},
allow: rule{
inMCPPermissions: &types.MCPPermissions{
Tools: []string{"{{internal.mcp_tools}}"},
},
outMCPPermissions: &types.MCPPermissions{
Tools: []string{"get_*", "search_files"},
},
},
},
{
comment: "MCP permissions in deny rule",
inTraits: map[string][]string{
"mcp_tools": {"get_*", "search_files"},
},
deny: rule{
inMCPPermissions: &types.MCPPermissions{
Tools: []string{"{{internal.mcp_tools}}"},
},
outMCPPermissions: &types.MCPPermissions{
Tools: []string{"get_*", "search_files"},
},
},
},
{
comment: "Beam labels in allow and deny rules",
inTraits: map[string][]string{
"foo": {"bar"},
"baz": {"qux"},
},
allow: rule{
inBeamLabels: types.Labels{
"label1": {"{{external.foo}}"},
},
outBeamLabels: types.Labels{
"label1": {"bar"},
},
},
deny: rule{
inBeamLabels: types.Labels{
"label2": {"{{external.baz}}"},
},
outBeamLabels: types.Labels{
"label2": {"qux"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.comment, func(t *testing.T) {
role := &types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: "name1",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: tt.allow.inLogins,
WindowsDesktopLogins: tt.allow.inWindowsLogins,
NodeLabels: tt.allow.inLabels,
ClusterLabels: tt.allow.inLabels,
KubernetesLabels: tt.allow.inKubeLabels,
KubeGroups: tt.allow.inKubeGroups,
KubeUsers: tt.allow.inKubeUsers,
AppLabels: tt.allow.inAppLabels,
GroupLabels: tt.allow.inGroupLabels,
AWSRoleARNs: tt.allow.inRoleARNs,
AzureIdentities: tt.allow.inAzureIdentities,
GCPServiceAccounts: tt.allow.inGCPServiceAccounts,
DatabaseLabels: tt.allow.inDBLabels,
DatabaseNames: tt.allow.inDBNames,
DatabaseUsers: tt.allow.inDBUsers,
WindowsDesktopLabels: tt.allow.inWindowsDesktopLabels,
Impersonate: &tt.allow.inImpersonate,
HostSudoers: tt.allow.inSudoers,
KubernetesResources: tt.allow.inKubeResources,
GitHubPermissions: tt.allow.inGitHubPermissions,
MCP: tt.allow.inMCPPermissions,
BeamLabels: tt.allow.inBeamLabels,
},
Deny: types.RoleConditions{
Logins: tt.deny.inLogins,
WindowsDesktopLogins: tt.deny.inWindowsLogins,
NodeLabels: tt.deny.inLabels,
ClusterLabels: tt.deny.inLabels,
KubernetesLabels: tt.deny.inKubeLabels,
KubeGroups: tt.deny.inKubeGroups,
KubeUsers: tt.deny.inKubeUsers,
AppLabels: tt.deny.inAppLabels,
GroupLabels: tt.deny.inGroupLabels,
AWSRoleARNs: tt.deny.inRoleARNs,
AzureIdentities: tt.deny.inAzureIdentities,
GCPServiceAccounts: tt.deny.inGCPServiceAccounts,
DatabaseLabels: tt.deny.inDBLabels,
DatabaseNames: tt.deny.inDBNames,
DatabaseUsers: tt.deny.inDBUsers,
WindowsDesktopLabels: tt.deny.inWindowsDesktopLabels,
Impersonate: &tt.deny.inImpersonate,
HostSudoers: tt.deny.outSudoers,
KubernetesResources: tt.deny.inKubeResources,
GitHubPermissions: tt.deny.inGitHubPermissions,
MCP: tt.deny.inMCPPermissions,
BeamLabels: tt.deny.inBeamLabels,
},
},
}
outRole, err := ApplyTraits(role, tt.inTraits)
require.NoError(t, err)
rules := []struct {
condition types.RoleConditionType
spec *rule
}{
{types.Allow, &tt.allow},
{types.Deny, &tt.deny},
}
for _, rule := range rules {
require.Equal(t, rule.spec.outLogins, outRole.GetLogins(rule.condition))
require.Equal(t, rule.spec.outWindowsLogins, outRole.GetWindowsLogins(rule.condition))
require.Equal(t, rule.spec.outLabels, outRole.GetNodeLabels(rule.condition))
require.Equal(t, rule.spec.outLabels, outRole.GetClusterLabels(rule.condition))
require.Equal(t, rule.spec.outKubeLabels, outRole.GetKubernetesLabels(rule.condition))
require.Equal(t, rule.spec.outKubeGroups, outRole.GetKubeGroups(rule.condition))
require.Equal(t, rule.spec.outKubeUsers, outRole.GetKubeUsers(rule.condition))
require.Equal(t, rule.spec.outAppLabels, outRole.GetAppLabels(rule.condition))
require.Equal(t, rule.spec.outGroupLabels, outRole.GetGroupLabels(rule.condition))
require.Equal(t, rule.spec.outRoleARNs, outRole.GetAWSRoleARNs(rule.condition))
require.Equal(t, rule.spec.outAzureIdentities, outRole.GetAzureIdentities(rule.condition))
require.Equal(t, rule.spec.outGCPServiceAccounts, outRole.GetGCPServiceAccounts(rule.condition))
require.Equal(t, rule.spec.outDBLabels, outRole.GetDatabaseLabels(rule.condition))
require.Equal(t, rule.spec.outDBNames, outRole.GetDatabaseNames(rule.condition))
require.Equal(t, rule.spec.outDBUsers, outRole.GetDatabaseUsers(rule.condition))
require.Equal(t, rule.spec.outWindowsDesktopLabels, outRole.GetWindowsDesktopLabels(rule.condition))
require.Equal(t, rule.spec.outImpersonate, outRole.GetImpersonateConditions(rule.condition))
require.Equal(t, rule.spec.outSudoers, outRole.GetHostSudoers(rule.condition))
require.Equal(t, rule.spec.outKubeResources, outRole.GetRoleConditions(rule.condition).KubernetesResources)
require.Equal(t, rule.spec.outGitHubPermissions, outRole.GetRoleConditions(rule.condition).GitHubPermissions)
require.Equal(t, rule.spec.outBeamLabels, outRole.GetRoleConditions(rule.condition).BeamLabels)
}
})
}
}
func TestFetchRolesForUser_ExpandsUserMetadataName(t *testing.T) {
role := newRole(func(r *types.RoleV6) {
r.Metadata.Name = "dev"
r.Spec.Allow.Logins = []string{"{{user.metadata.name}}"}
})
roleSet, err := FetchRolesForUser(mockCurrentUser{
roles: []string{"dev"},
}, mockCurrentUserRoleGetter{
nameToRole: map[string]types.Role{
"dev": role,
},
})
require.NoError(t, err)
require.Equal(t, []string{"mockCurrentUser"}, roleSet[0].GetLogins(types.Allow))
}
// TestExtractFrom makes sure roles and traits are extracted from SSH and TLS
// certificates not services.User.
func TestExtractFrom(t *testing.T) {
ctx := context.Background()
origRoles := []string{"admin"}
origTraits := wrappers.Traits(map[string][]string{
"login": {"foo"},
})
// Create a SSH certificate.
cert, err := sshutils.ParseCertificate([]byte(fixtures.UserCertificateStandard))
require.NoError(t, err)
// Create a TLS identity.
identity := &tlsca.Identity{
Username: "foo",
Groups: origRoles,
Traits: origTraits,
}
// At this point, services.User and the certificate/identity are still in
// sync. The roles and traits returned should be the same as the original.
ident, err := sshca.DecodeIdentity(cert)
require.NoError(t, err)
require.Equal(t, origRoles, ident.Roles)
require.Equal(t, origTraits, ident.Traits)
roles, traits, err := ExtractFromIdentity(ctx, &userGetter{
roles: origRoles,
traits: origTraits,
}, *identity)
require.NoError(t, err)
require.Equal(t, origRoles, roles)
require.Equal(t, origTraits, traits)
// The backend now returns new roles and traits, however because the roles
// and traits are extracted from the certificate/identity, the original
// roles and traits will be returned.
ident, err = sshca.DecodeIdentity(cert)
require.NoError(t, err)
require.Equal(t, origRoles, ident.Roles)
require.Equal(t, origTraits, ident.Traits)
roles, traits, err = ExtractFromIdentity(ctx, &userGetter{
roles: origRoles,
traits: origTraits,
}, *identity)
require.NoError(t, err)
require.Equal(t, roles, origRoles)
require.Equal(t, traits, origTraits)
}
// TestCanCopyFiles verifies the behavior of the RoleSet.CanCopyFiles method
// and the underlying Options.SSHFileCopy role field.
func TestCanCopyFiles(t *testing.T) {
tts := []struct {
name string
values []*types.BoolOption
expect bool
}{
{
name: "nil",
values: []*types.BoolOption{
nil,
},
expect: true,
},
{
name: "false",
values: []*types.BoolOption{
types.NewBoolOption(false),
},
expect: false,
},
{
name: "true",
values: []*types.BoolOption{
types.NewBoolOption(true),
},
expect: true,
},
{
name: "true and false",
values: []*types.BoolOption{
types.NewBoolOption(true),
types.NewBoolOption(false),
},
expect: false,
},
{
name: "nil and true",
values: []*types.BoolOption{
nil,
types.NewBoolOption(true),
},
expect: true,
},
{
name: "nil and false",
values: []*types.BoolOption{
nil,
types.NewBoolOption(false),
},
expect: false,
},
}
for _, tt := range tts {
t.Run(tt.name, func(t *testing.T) {
var roles RoleSet
for _, v := range tt.values {
roles = append(roles, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
SSHFileCopy: v,
},
},
})
}
require.Equal(t, tt.expect, roles.CanCopyFiles())
})
}
}
// TestGetWebTerminalClipboardMode verifies that the RoleSet.GetWebTerminalClipboardMode method calculates the correct clipboard mode from a set of roles.
func TestGetWebTerminalClipboardMode(t *testing.T) {
tests := []struct {
name string
values []types.WebTerminalClipboardMode
expect types.WebTerminalClipboardMode
}{
{
name: "unspecified",
values: []types.WebTerminalClipboardMode{types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_UNSPECIFIED},
expect: types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_UNSPECIFIED,
},
{
name: "unrestricted",
values: []types.WebTerminalClipboardMode{types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_UNRESTRICTED},
expect: types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_UNRESTRICTED,
},
{
name: "no-copy",
values: []types.WebTerminalClipboardMode{types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_NO_COPY},
expect: types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_NO_COPY,
},
{
name: "roles have both unrestricted and no-copy, no-copy takes precedence",
values: []types.WebTerminalClipboardMode{types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_UNRESTRICTED, types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_NO_COPY},
expect: types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_NO_COPY,
},
{
name: "roles have both unspecified and no-copy, no-copy takes precedence",
values: []types.WebTerminalClipboardMode{types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_UNSPECIFIED, types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_NO_COPY},
expect: types.WebTerminalClipboardMode_WEB_TERMINAL_CLIPBOARD_MODE_NO_COPY,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var roles RoleSet
for _, v := range tt.values {
roles = append(roles, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
WebTerminalClipboardMode: v,
},
},
})
}
require.Equal(t, tt.expect, roles.GetWebTerminalClipboardMode())
})
}
}
// TestBoolOptions makes sure that bool options (like agent forwarding and
// port forwarding) can be disabled in a role.
func TestBoolOptions(t *testing.T) {
tests := []struct {
inOptions types.RoleOptions
outCanPortForward bool
outCanForwardAgents bool
outRecordDesktopSessions bool
outDesktopClipboard bool
outDesktopDirectorySharing bool
}{
// Setting options explicitly off should remain off.
{
inOptions: types.RoleOptions{
ForwardAgent: types.NewBool(false),
PortForwarding: types.NewBoolOption(false),
RecordSession: &types.RecordSession{Desktop: types.NewBoolOption(false)},
DesktopClipboard: types.NewBoolOption(false),
DesktopDirectorySharing: types.NewBoolOption(false),
},
outCanPortForward: false,
outCanForwardAgents: false,
outRecordDesktopSessions: false,
outDesktopClipboard: false,
outDesktopDirectorySharing: false,
},
// Not setting options should set port forwarding to true (default enabled),
// agent forwarding false (default disabled),
// desktop session recording to true (default enabled),
// desktop clipboard sharing to true (default enabled),
// and desktop directory sharing to true (default enabled).
{
inOptions: types.RoleOptions{},
outCanPortForward: true,
outCanForwardAgents: false,
outRecordDesktopSessions: true,
outDesktopClipboard: true,
outDesktopDirectorySharing: true,
},
// Explicitly enabling should enable them.
{
inOptions: types.RoleOptions{
ForwardAgent: types.NewBool(true),
PortForwarding: types.NewBoolOption(true),
RecordSession: &types.RecordSession{Desktop: types.NewBoolOption(true)},
DesktopClipboard: types.NewBoolOption(true),
DesktopDirectorySharing: types.NewBoolOption(true),
},
outCanPortForward: true,
outCanForwardAgents: true,
outRecordDesktopSessions: true,
outDesktopClipboard: true,
outDesktopDirectorySharing: true,
},
}
for _, tt := range tests {
set := NewRoleSet(&types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: "role-name",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: tt.inOptions,
},
})
require.Equal(t, tt.outCanPortForward, set.CanPortForward())
require.Equal(t, tt.outCanForwardAgents, set.CanForwardAgents())
require.Equal(t, tt.outRecordDesktopSessions, set.RecordDesktopSession())
require.Equal(t, tt.outDesktopClipboard, set.DesktopClipboard())
require.Equal(t, tt.outDesktopDirectorySharing, set.DesktopDirectorySharing())
}
}
func TestCheckAccessToDatabase(t *testing.T) {
dbStage, err := types.NewDatabaseV3(types.Metadata{
Name: "stage",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbProd, err := types.NewDatabaseV3(types.Metadata{
Name: "prod",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
roleDevStage := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace},
Version: types.V3,
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"stage"}},
DatabaseNames: []string{types.Wildcard},
DatabaseUsers: []string{types.Wildcard},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseNames: []string{"supersecret"},
},
},
}
require.NoError(t, roleDevStage.CheckAndSetDefaults())
roleDevProd := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace},
Version: types.V3,
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabelsExpression: `labels["env"] == "prod"`,
DatabaseNames: []string{"test"},
DatabaseUsers: []string{"dev"},
},
},
}
require.NoError(t, roleDevProd.CheckAndSetDefaults())
roleDevProdWithMFA := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-prod-mfa", Namespace: apidefaults.Namespace},
Version: types.V3,
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"prod"}},
DatabaseNames: []string{"test"},
DatabaseUsers: []string{"dev"},
},
},
}
require.NoError(t, roleDevProdWithMFA.CheckAndSetDefaults())
roleDevProdWithDeviceTrust := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-prod-devicetrust", Namespace: apidefaults.Namespace},
Version: types.V3,
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
DeviceTrustMode: constants.DeviceTrustModeRequired,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"prod"}},
DatabaseNames: []string{"test"},
DatabaseUsers: []string{"dev"},
},
},
}
require.NoError(t, roleDevProdWithDeviceTrust.CheckAndSetDefaults())
// Database labels are not set in allow/deny rules on purpose to test
// that they're set during check and set defaults below.
roleDeny := &types.RoleV6{
Metadata: types.Metadata{Name: "deny", Namespace: apidefaults.Namespace},
Version: types.V3,
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseNames: []string{types.Wildcard},
DatabaseUsers: []string{types.Wildcard},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseNames: []string{"postgres"},
DatabaseUsers: []string{"postgres"},
},
},
}
require.NoError(t, roleDeny.CheckAndSetDefaults())
type access struct {
server types.Database
dbName string
dbUser string
access bool
}
testCases := []struct {
name string
roles RoleSet
access []access
state AccessState
}{
{
name: "developer allowed any username/database in stage database except one database",
roles: RoleSet{roleDevStage, roleDevProd},
access: []access{
{server: dbStage, dbName: "superdb", dbUser: "superuser", access: true},
{server: dbStage, dbName: "test", dbUser: "dev", access: true},
{server: dbStage, dbName: "supersecret", dbUser: "dev", access: false},
},
},
{
name: "developer allowed only specific username/database in prod database",
roles: RoleSet{roleDevStage, roleDevProd},
access: []access{
{server: dbProd, dbName: "superdb", dbUser: "superuser", access: false},
{server: dbProd, dbName: "test", dbUser: "dev", access: true},
{server: dbProd, dbName: "superdb", dbUser: "dev", access: false},
{server: dbProd, dbName: "test", dbUser: "superuser", access: false},
},
},
{
name: "deny role denies access to specific database and user",
roles: RoleSet{roleDeny},
access: []access{
{server: dbProd, dbName: "test", dbUser: "test", access: true},
{server: dbProd, dbName: "postgres", dbUser: "test", access: false},
{server: dbProd, dbName: "test", dbUser: "postgres", access: false},
},
},
{
name: "prod database requires MFA, no MFA provided",
roles: RoleSet{roleDevStage, roleDevProdWithMFA, roleDevProd},
state: AccessState{MFAVerified: false},
access: []access{
{server: dbStage, dbName: "test", dbUser: "dev", access: true},
{server: dbProd, dbName: "test", dbUser: "dev", access: false},
},
},
{
name: "prod database requires MFA, MFA provided",
roles: RoleSet{roleDevStage, roleDevProdWithMFA, roleDevProd},
state: AccessState{MFAVerified: true},
access: []access{
{server: dbStage, dbName: "test", dbUser: "dev", access: true},
{server: dbProd, dbName: "test", dbUser: "dev", access: true},
},
},
{
name: "cluster requires MFA, no MFA provided",
roles: RoleSet{roleDevStage, roleDevProdWithMFA, roleDevProd},
state: AccessState{MFAVerified: false, MFARequired: MFARequiredAlways},
access: []access{},
},
{
name: "cluster requires MFA, MFA provided",
roles: RoleSet{roleDevStage, roleDevProdWithMFA, roleDevProd},
state: AccessState{MFAVerified: true, MFARequired: MFARequiredAlways},
access: []access{
{server: dbStage, dbName: "test", dbUser: "dev", access: true},
{server: dbProd, dbName: "test", dbUser: "dev", access: true},
},
},
{
name: "roles requires trusted device, device not verified",
roles: RoleSet{roleDevStage, roleDevProd, roleDevProdWithDeviceTrust},
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
},
access: []access{
{server: dbStage, dbName: "test", dbUser: "dev", access: true},
{server: dbProd, dbName: "test", dbUser: "dev", access: false},
},
},
{
name: "roles requires trusted device, device verified",
roles: RoleSet{roleDevStage, roleDevProd, roleDevProdWithDeviceTrust},
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: true,
},
access: []access{
{server: dbStage, dbName: "test", dbUser: "dev", access: true},
{server: dbProd, dbName: "test", dbUser: "dev", access: true},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, access := range tc.access {
_, err := tc.roles.checkAccess(access.server, "", wrappers.Traits{}, tc.state,
NewDatabaseUserMatcher(access.server, access.dbUser),
&DatabaseNameMatcher{Name: access.dbName})
if access.access {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
}
}
})
}
}
func TestCheckAccessToDatabaseUser(t *testing.T) {
dbStage, err := types.NewDatabaseV3(types.Metadata{
Name: "stage",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbProd, err := types.NewDatabaseV3(types.Metadata{
Name: "prod",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
roleDevStage := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"stage"}},
DatabaseUsers: []string{types.Wildcard},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"superuser"},
},
},
}
roleDevProd := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabelsExpression: `labels["env"] == "prod"`,
DatabaseUsers: []string{"dev"},
},
},
}
dbRequireAWSRoles, err := types.NewDatabaseV3(types.Metadata{
Name: "dynamodb",
}, types.DatabaseSpecV3{
Protocol: "dynamodb",
URI: "test.xxxxxxx.mongodb.net",
AWS: types.AWS{
AccountID: "123456789012",
Region: "us-east-1",
},
})
require.NoError(t, err)
require.True(t, dbRequireAWSRoles.RequireAWSIAMRolesAsUsers())
roleWithAWSRoles := &types.RoleV6{
Metadata: types.Metadata{Name: "aws-roles", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseUsers: []string{
"allow-role-with-short-name",
"arn:aws:iam::123456789012:role/allow-role-with-full-arn",
},
},
},
}
roleWithUsersAndAWSRoles := &types.RoleV6{
Metadata: types.Metadata{Name: "users-and-aws-roles", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
DatabaseUsers: []string{
"regular-user",
"role/allow-role-with-partial-arn",
"arn:aws:iam::123456789012:role/allow-role-with-full-arn",
},
},
},
}
dbSupportAWSRoles, err := types.NewDatabaseV3(types.Metadata{
Name: "mongo-atlas",
}, types.DatabaseSpecV3{
Protocol: "mongodb",
URI: "test.xxxxxxx.mongodb.net",
MongoAtlas: types.MongoAtlas{
Name: "instance",
},
AWS: types.AWS{
AccountID: "123456789012",
Region: "us-east-1",
},
})
require.NoError(t, err)
require.True(t, dbSupportAWSRoles.SupportAWSIAMRoleARNAsUsers())
dbCockroachStage, err := types.NewDatabaseV3(types.Metadata{
Name: "cockroachdb",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "cockroachdb",
URI: "cockroachdb:26257",
})
require.NoError(t, err)
dbCockroachProd, err := types.NewDatabaseV3(types.Metadata{
Name: "cockroachdb",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "cockroachdb",
URI: "cockroachdb:26257",
})
require.NoError(t, err)
type access struct {
server types.Database
dbUser string
access bool
}
testCases := []struct {
name string
roles RoleSet
access []access
}{
{
name: "developer allowed any username in stage database except superuser",
roles: RoleSet{roleDevStage, roleDevProd},
access: []access{
{server: dbStage, dbUser: "superuser", access: false},
{server: dbStage, dbUser: "dev", access: true},
{server: dbStage, dbUser: "test", access: true},
{server: dbStage, dbUser: "SUPERUSER", access: true},
},
},
{
name: "developer allowed only specific username/database in prod database",
roles: RoleSet{roleDevStage, roleDevProd},
access: []access{
{server: dbProd, dbUser: "superuser", access: false},
{server: dbProd, dbUser: "dev", access: true},
},
},
{
name: "database types require AWS roles as database users",
roles: RoleSet{roleWithAWSRoles},
access: []access{
{server: dbRequireAWSRoles, dbUser: "allow-role-with-short-name", access: true},
{server: dbRequireAWSRoles, dbUser: "allow-role-with-full-arn", access: true},
{server: dbRequireAWSRoles, dbUser: "arn:aws:iam::123456789012:role/allow-role-with-full-arn", access: true},
{server: dbRequireAWSRoles, dbUser: "arn:aws:iam::123456789012:role/allow-role-with-short-name", access: true},
{server: dbRequireAWSRoles, dbUser: "unknown-role-name", access: false},
{server: dbRequireAWSRoles, dbUser: "arn:aws:iam::123456789012:role/unknown-role-name", access: false},
{server: dbRequireAWSRoles, dbUser: "arn:aws:iam::123456789012:user/username", access: false},
{server: dbRequireAWSRoles, dbUser: "arn:aws-cn:iam::123456789012:role/allow-role-with-short-name", access: false},
},
},
{
name: "database types support AWS roles and regular users",
roles: RoleSet{roleWithUsersAndAWSRoles},
access: []access{
{server: dbSupportAWSRoles, dbUser: "role/allow-role-with-partial-arn", access: true},
{server: dbSupportAWSRoles, dbUser: "arn:aws:iam::123456789012:role/allow-role-with-partial-arn", access: true},
{server: dbSupportAWSRoles, dbUser: "role/unknown-role", access: false},
{server: dbSupportAWSRoles, dbUser: "allow-role-with-partial-arn", access: false},
{server: dbSupportAWSRoles, dbUser: "arn:aws:iam::123456789012:role/allow-role-with-full-arn", access: true},
{server: dbSupportAWSRoles, dbUser: "role/allow-role-with-full-arn", access: true},
{server: dbSupportAWSRoles, dbUser: "arn:aws:iam::123456789012:role/unknown-role", access: false},
{server: dbSupportAWSRoles, dbUser: "regular-user", access: true},
{server: dbSupportAWSRoles, dbUser: "role/regular-user", access: false},
{server: dbSupportAWSRoles, dbUser: "arn:aws:iam::123456789012:role/regular-user", access: false},
{server: dbSupportAWSRoles, dbUser: "unknown-user", access: false},
},
},
{
name: "(case-insensitive db) developer allowed any username in stage except superuser",
roles: RoleSet{roleDevStage, roleDevProd},
access: []access{
{server: dbCockroachStage, dbUser: "dev", access: true},
{server: dbCockroachStage, dbUser: "DEV", access: true},
{server: dbCockroachStage, dbUser: "test", access: true},
{server: dbCockroachStage, dbUser: "superuser", access: false},
{server: dbCockroachStage, dbUser: "SUPERUSER", access: false},
},
},
{
name: "(case-insensitive db) developer allowed only specific username/database in prod database",
roles: RoleSet{roleDevStage, roleDevProd},
access: []access{
{server: dbCockroachProd, dbUser: "dev", access: true},
{server: dbCockroachProd, dbUser: "DEV", access: true},
{server: dbCockroachProd, dbUser: "superuser", access: false},
{server: dbCockroachProd, dbUser: "Superuser", access: false},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, access := range tc.access {
_, err := tc.roles.checkAccess(access.server, "", wrappers.Traits{}, AccessState{}, NewDatabaseUserMatcher(access.server, access.dbUser))
if access.access {
require.NoError(t, err, "access check shouldn't have failed for username %q", access.dbUser)
} else {
require.Error(t, err, "access check should have failed for username %q", access.dbUser)
require.True(t, trace.IsAccessDenied(err))
}
}
})
}
}
func TestRoleSetEnumerateDatabaseUsersAndNames(t *testing.T) {
dbStage, err := types.NewDatabaseV3(types.Metadata{
Name: "stage",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbProd, err := types.NewDatabaseV3(types.Metadata{
Name: "prod",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbAutoUser, err := types.NewDatabaseV3(types.Metadata{
Name: "auto-user",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "postgres",
URI: "localhost:5432",
AdminUser: &types.DatabaseAdminUser{
Name: "teleport-admin",
},
})
require.NoError(t, err)
roleDevStage := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-stage", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"stage"}},
DatabaseUsers: []string{types.Wildcard},
DatabaseNames: []string{types.Wildcard},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"root"},
DatabaseNames: []string{"root"},
},
},
}
roleDevProd := &types.RoleV6{
Metadata: types.Metadata{Name: "dev-prod", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"prod"}},
DatabaseUsers: []string{"dev"},
DatabaseNames: []string{"dev"},
},
},
}
roleSales := &types.RoleV6{
Metadata: types.Metadata{Name: "sales-prod", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"sales"}},
DatabaseUsers: []string{"sales"},
DatabaseNames: []string{"sales"},
},
},
}
roleNoDBAccess := &types.RoleV6{
Metadata: types.Metadata{Name: "no_db_access", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"*"},
DatabaseNames: []string{"*"},
},
},
}
roleAllowDenySame := &types.RoleV6{
Metadata: types.Metadata{Name: "allow_deny_same", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
DatabaseLabels: types.Labels{"env": []string{"stage"}},
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"root"},
DatabaseNames: []string{"root"},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseUsers: []string{"root"},
DatabaseNames: []string{"root"},
},
},
}
roleAutoUser := &types.RoleV6{
Metadata: types.Metadata{Name: "auto-user", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"env": []string{"prod"}},
DatabaseRoles: []string{"dev"},
DatabaseNames: []string{"*"},
DatabaseUsers: []string{types.Wildcard},
},
},
}
testCases := []struct {
name string
roles RoleSet
server types.Database
enumDBUserResult EnumerationResult
enumDBNameResult EnumerationResult
}{
{
name: "deny overrides allow",
roles: RoleSet{roleAllowDenySame},
server: dbStage,
enumDBUserResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"root": false},
wildcardAllowed: false,
wildcardDenied: false,
},
enumDBNameResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"root": false},
wildcardAllowed: false,
wildcardDenied: false,
},
},
{
name: "developer allowed any username in stage database except root",
roles: RoleSet{roleDevStage, roleDevProd},
server: dbStage,
enumDBUserResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"root": false},
wildcardAllowed: true,
wildcardDenied: false,
},
enumDBNameResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"root": false},
wildcardAllowed: true,
wildcardDenied: false,
},
},
{
name: "developer allowed only specific username/database in prod database",
roles: RoleSet{roleDevStage, roleDevProd},
server: dbProd,
enumDBUserResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"dev": true, "root": false},
wildcardAllowed: false,
wildcardDenied: false,
},
enumDBNameResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"dev": true, "root": false},
wildcardAllowed: false,
wildcardDenied: false,
},
},
{
name: "there may be users disallowed from all users",
roles: RoleSet{roleDevStage, roleDevProd, roleNoDBAccess},
server: dbProd,
enumDBUserResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"dev": false, "root": false},
wildcardAllowed: false,
wildcardDenied: true,
},
enumDBNameResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"dev": false, "root": false},
wildcardAllowed: false,
wildcardDenied: true,
},
},
{
name: "auto-user provisioning enabled",
roles: RoleSet{roleAutoUser},
server: dbAutoUser,
enumDBUserResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"alice": true},
wildcardAllowed: false,
wildcardDenied: false,
},
enumDBNameResult: EnumerationResult{
allowedDeniedMap: map[string]bool{},
wildcardAllowed: true,
wildcardDenied: false,
},
},
{
name: "role sales does not match the resource and should be skipped",
roles: RoleSet{roleSales, roleDevProd},
server: dbProd,
enumDBUserResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"dev": true},
wildcardAllowed: false,
wildcardDenied: false,
},
enumDBNameResult: EnumerationResult{
allowedDeniedMap: map[string]bool{"dev": true},
wildcardAllowed: false,
wildcardDenied: false,
},
},
}
for _, tc := range testCases {
accessChecker := makeAccessCheckerWithRoleSet(tc.roles)
t.Run(tc.name, func(t *testing.T) {
enumResult, err := accessChecker.EnumerateDatabaseUsers(tc.server)
require.NoError(t, err)
require.Equal(t, tc.enumDBUserResult, enumResult)
enumResult = accessChecker.EnumerateDatabaseNames(tc.server)
require.Equal(t, tc.enumDBNameResult, enumResult)
})
}
}
func TestGetAllowedLoginsForResource(t *testing.T) {
newRole := func(
allowLogins []string,
allowLabels types.Labels,
denyLogins []string,
denyLabels types.Labels,
) *types.RoleV6 {
return &types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Logins: allowLogins,
WindowsDesktopLogins: allowLogins,
AWSRoleARNs: allowLogins,
NodeLabels: allowLabels,
WindowsDesktopLabels: allowLabels,
AppLabels: allowLabels,
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
Logins: denyLogins,
WindowsDesktopLogins: denyLogins,
AWSRoleARNs: denyLogins,
NodeLabels: denyLabels,
WindowsDesktopLabels: denyLabels,
AppLabels: denyLabels,
},
},
}
}
devEnvRole := newRole([]string{"devuser"}, types.Labels{"env": []string{"dev"}}, []string{}, types.Labels{})
prodEnvRole := newRole([]string{"produser"}, types.Labels{"env": []string{"prod"}}, []string{}, types.Labels{})
anyEnvRole := newRole([]string{"anyenvrole"}, types.Labels{"env": []string{"*"}}, []string{}, types.Labels{})
rootUser := newRole([]string{"root"}, types.Labels{"*": []string{"*"}}, []string{}, types.Labels{})
roleWithMultipleLabels := newRole([]string{"multiplelabelsuser"},
types.Labels{
"region": []string{"*"},
"env": []string{"dev"},
}, []string{}, types.Labels{})
tt := []struct {
name string
labels map[string]string
roleSet RoleSet
expectedLogins []string
}{
{
name: "env dev login is added",
labels: map[string]string{
"env": "dev",
},
roleSet: NewRoleSet(devEnvRole),
expectedLogins: []string{"devuser"},
},
{
name: "env prod login is added",
labels: map[string]string{
"env": "prod",
},
roleSet: NewRoleSet(prodEnvRole),
expectedLogins: []string{"produser"},
},
{
name: "only the correct login is added",
labels: map[string]string{
"env": "prod",
},
roleSet: NewRoleSet(prodEnvRole, devEnvRole),
expectedLogins: []string{"produser"},
},
{
name: "logins from role not authorizeds are not added",
labels: map[string]string{
"env": "staging",
},
roleSet: NewRoleSet(devEnvRole, prodEnvRole),
expectedLogins: nil,
},
{
name: "role with wildcard get its logins",
labels: map[string]string{
"env": "prod",
},
roleSet: NewRoleSet(anyEnvRole),
expectedLogins: []string{"anyenvrole"},
},
{
name: "can return multiple logins",
labels: map[string]string{
"env": "prod",
},
roleSet: NewRoleSet(anyEnvRole, prodEnvRole),
expectedLogins: []string{"anyenvrole", "produser"},
},
{
name: "can return multiple logins from same role",
labels: map[string]string{
"env": "prod",
},
roleSet: NewRoleSet(
newRole(
[]string{"role1", "role2", "role3"},
types.Labels{"env": []string{"*"}}, []string{}, types.Labels{}),
),
expectedLogins: []string{"role1", "role2", "role3"},
},
{
name: "works with user with full access",
labels: map[string]string{
"env": "prod",
},
roleSet: NewRoleSet(rootUser),
expectedLogins: []string{"root"},
},
{
name: "works with server with multiple labels",
labels: map[string]string{
"env": "prod",
"region": "us-east-1",
},
roleSet: NewRoleSet(prodEnvRole),
expectedLogins: []string{"produser"},
},
{
name: "don't add login from unrelated labels",
labels: map[string]string{
"env": "dev",
},
roleSet: NewRoleSet(
newRole(
[]string{"anyregionuser"},
types.Labels{"region": []string{"*"}}, []string{}, types.Labels{}),
),
expectedLogins: nil,
},
{
name: "works with roles with multiple labels that role shouldn't access",
labels: map[string]string{
"env": "dev",
},
roleSet: NewRoleSet(roleWithMultipleLabels),
expectedLogins: nil,
},
{
name: "works with roles with multiple labels that role shouldn't access",
labels: map[string]string{
"env": "dev",
"region": "us-west-1",
},
roleSet: NewRoleSet(roleWithMultipleLabels),
expectedLogins: []string{"multiplelabelsuser"},
},
{
name: "works with roles with regular expressions",
labels: map[string]string{
"region": "us-west-1",
},
roleSet: NewRoleSet(
newRole(
[]string{"rolewithregexpuser"},
types.Labels{"region": []string{"^us-west-1|eu-central-1$"}}, []string{}, types.Labels{}),
),
expectedLogins: []string{"rolewithregexpuser"},
},
{
name: "works with denied roles",
labels: map[string]string{
"env": "dev",
},
roleSet: NewRoleSet(
newRole(
[]string{}, types.Labels{}, []string{"devuser"}, types.Labels{"env": []string{"*"}}),
),
expectedLogins: nil,
},
{
name: "works with denied roles of unrelated labels",
labels: map[string]string{
"env": "dev",
},
roleSet: NewRoleSet(
newRole(
[]string{}, types.Labels{}, []string{"devuser"}, types.Labels{"region": []string{"*"}}),
),
expectedLogins: nil,
},
}
for _, tc := range tt {
accessChecker := makeAccessCheckerWithRoleSet(tc.roleSet)
t.Run(tc.name, func(t *testing.T) {
server := mustMakeTestServer(tc.labels)
desktop := mustMakeTestWindowsDesktop(tc.labels)
app := mustMakeTestAWSApp(tc.labels)
serverLogins, err := accessChecker.GetAllowedLoginsForResource(server)
require.NoError(t, err)
desktopLogins, err := accessChecker.GetAllowedLoginsForResource(desktop)
require.NoError(t, err)
awsARNLogins, err := accessChecker.GetAllowedLoginsForResource(app)
require.NoError(t, err)
require.ElementsMatch(t, tc.expectedLogins, serverLogins)
require.ElementsMatch(t, tc.expectedLogins, desktopLogins)
require.ElementsMatch(t, tc.expectedLogins, awsARNLogins)
})
}
}
func TestGetAllowedSearchAsRoles_WithAllowedKubernetesResourceKindFilter(t *testing.T) {
newRole := func(
allowRoles []string,
denyRoles []string,
allowedResources []types.RequestKubernetesResource,
deniedResources []types.RequestKubernetesResource,
) *types.RoleV6 {
return &types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Request: &types.AccessRequestConditions{
SearchAsRoles: allowRoles,
KubernetesResources: allowedResources,
},
},
Deny: types.RoleConditions{
Request: &types.AccessRequestConditions{
SearchAsRoles: denyRoles,
KubernetesResources: deniedResources,
},
},
},
}
}
roleWithNamespace := newRole([]string{"sar1"}, nil, []types.RequestKubernetesResource{{Kind: types.KindNamespace}}, []types.RequestKubernetesResource{})
roleWithSecret := newRole([]string{"sar2"}, nil, []types.RequestKubernetesResource{{Kind: types.KindKubeSecret}}, []types.RequestKubernetesResource{})
roleWithNoConfigure := newRole([]string{"sar3"}, nil, nil, nil)
roleWithDenyRole := newRole([]string{"sar4", "sar5", "sar6", "sar7"}, []string{"sar4", "sar6"}, []types.RequestKubernetesResource{{Kind: types.KindNamespace}, {Kind: types.KindKubePod}}, []types.RequestKubernetesResource{{Kind: types.KindKubePod}})
roleWithDenyWildcard := newRole([]string{"sar10"}, nil, []types.RequestKubernetesResource{{Kind: types.KindNamespace}}, []types.RequestKubernetesResource{{Kind: types.Wildcard}})
roleWithAllowWildcard := newRole([]string{"sar4", "sar5"}, nil, []types.RequestKubernetesResource{{Kind: types.Wildcard}}, nil)
tt := []struct {
name string
roleSet RoleSet
requestType string
expectedAllowedRoles []string
}{
{
name: "single match",
roleSet: NewRoleSet(roleWithNamespace, roleWithSecret),
requestType: types.KindKubeSecret,
expectedAllowedRoles: []string{"sar2"},
},
{
name: "multi match",
roleSet: NewRoleSet(roleWithNamespace, roleWithNoConfigure),
requestType: types.KindNamespace,
expectedAllowedRoles: []string{"sar1", "sar3"},
},
{
name: "wildcard allow",
roleSet: NewRoleSet(roleWithAllowWildcard, roleWithNamespace),
requestType: types.KindNamespace,
expectedAllowedRoles: []string{"sar1", "sar4", "sar5"},
},
{
name: "wildcard deny",
roleSet: NewRoleSet(roleWithAllowWildcard, roleWithDenyWildcard),
requestType: types.KindNamespace,
expectedAllowedRoles: []string{},
},
{
name: "wildcard deny with unconfigured allow",
roleSet: NewRoleSet(roleWithNoConfigure, roleWithDenyWildcard),
requestType: types.KindNamespace,
expectedAllowedRoles: []string{},
},
{
name: "with deny role",
roleSet: NewRoleSet(roleWithDenyRole, roleWithNamespace),
requestType: types.KindNamespace,
expectedAllowedRoles: []string{"sar5", "sar7", "sar1"},
},
}
for _, tc := range tt {
accessChecker := makeAccessCheckerWithRoleSet(tc.roleSet)
t.Run(tc.name, func(t *testing.T) {
allowedRoles := accessChecker.GetAllowedSearchAsRolesForKubeResourceKind(tc.requestType)
require.ElementsMatch(t, tc.expectedAllowedRoles, allowedRoles)
})
}
}
// mustMakeTestServer creates a server with labels and an empty spec.
// It panics in case of an error. Used only for testing
func mustMakeTestServer(labels map[string]string) types.Server {
s, err := types.NewServerWithLabels("server", types.KindNode, types.ServerSpecV2{}, labels)
if err != nil {
panic(err)
}
return s
}
func mustMakeTestWindowsDesktop(labels map[string]string) types.WindowsDesktop {
d, err := types.NewWindowsDesktopV3("desktop", labels, types.WindowsDesktopSpecV3{Addr: "addr"})
if err != nil {
panic(err)
}
return d
}
func mustMakeTestAWSApp(labels map[string]string) types.Application {
app, err := types.NewAppV3(types.Metadata{
Name: "my-app",
Labels: labels,
},
types.AppSpecV3{
URI: "https://some-addr.com",
Cloud: "AWS",
},
)
if err != nil {
panic(err)
}
return app
}
func TestCheckDatabaseRoles(t *testing.T) {
// roleA just allows access to all databases without auto-provisioning.
roleA := &types.RoleV6{
Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
},
}
// roleB allows auto-user provisioning for production database and uses
// label expressions.
roleB := &types.RoleV6{
Metadata: types.Metadata{Name: "roleB", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
DatabaseLabelsExpression: `labels["env"] == "prod"`,
DatabaseRoles: []string{"reader"},
},
Deny: types.RoleConditions{
DatabaseLabelsExpression: `labels["env"] == "prod"`,
DatabaseRoles: []string{"writer"},
},
},
}
// roleC allows auto-user provisioning for metrics database and uses label
// expressions.
roleC := &types.RoleV6{
Metadata: types.Metadata{Name: "roleC", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
DatabaseLabels: types.Labels{"app": []string{"metrics"}},
DatabaseRoles: []string{"reader", "writer"},
},
},
}
// roleD has a bad label expression.
roleD := &types.RoleV6{
Metadata: types.Metadata{Name: "roleD", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
DatabaseLabelsExpression: `a bad expression`,
DatabaseRoles: []string{"reader"},
},
},
}
// roleE is like roleB, allows auto-user provisioning for production database,
// but uses database permissions instead of roles.
roleE := &types.RoleV6{
Metadata: types.Metadata{Name: "roleB", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
DatabaseLabelsExpression: `labels["env"] == "prod"`,
DatabasePermissions: []types.DatabasePermission{
{
Permissions: []string{"SELECT"},
Match: map[string]apiutils.Strings{"*": []string{"*"}},
},
},
},
Deny: types.RoleConditions{
DatabaseLabelsExpression: `labels["env"] == "prod"`,
DatabasePermissions: []types.DatabasePermission{
{
Permissions: []string{"UPDATE", "INSERT", "DELETE"},
Match: map[string]apiutils.Strings{"*": []string{"*"}},
},
},
},
},
}
// roleF is like roleC, allows auto-user provisioning for metrics database,
// but uses database permissions instead of roles.
roleF := &types.RoleV6{
Metadata: types.Metadata{Name: "roleC", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
DatabaseLabels: types.Labels{"app": []string{"metrics"}},
DatabasePermissions: []types.DatabasePermission{
{
Permissions: []string{"SELECT", "UPDATE", "INSERT", "DELETE"},
Match: map[string]apiutils.Strings{"*": []string{"*"}},
},
},
},
},
}
tests := []struct {
name string
roleSet RoleSet
inDatabaseLabels map[string]string
inRequestedRoles []string
outModeError bool
outRolesError bool
outCreateUser bool
outRoles []string
outPermissionsError bool
outAllowPermissions types.DatabasePermissions
outDenyPermissions types.DatabasePermissions
}{
{
name: "no auto-provision roles assigned",
roleSet: RoleSet{roleA},
inDatabaseLabels: map[string]string{"app": "metrics"},
outCreateUser: false,
outRoles: []string{},
},
{
name: "database doesn't match",
roleSet: RoleSet{roleB},
inDatabaseLabels: map[string]string{"env": "test"},
outCreateUser: false,
outRoles: []string{},
},
{
name: "connect to test database, no auto-provisioning",
roleSet: RoleSet{roleA, roleB, roleC},
inDatabaseLabels: map[string]string{"env": "test"},
outCreateUser: false,
outRoles: []string{},
},
{
name: "connect to metrics database, get reader/writer role",
roleSet: RoleSet{roleA, roleB, roleC},
inDatabaseLabels: map[string]string{"app": "metrics"},
outCreateUser: true,
outRoles: []string{"reader", "writer"},
},
{
name: "connect to metrics database, get reader/writer permissions",
roleSet: RoleSet{roleA, roleE, roleF},
inDatabaseLabels: map[string]string{"app": "metrics"},
outCreateUser: true,
outRoles: []string{},
outAllowPermissions: types.DatabasePermissions{
types.DatabasePermission{Permissions: []string{"SELECT", "UPDATE", "INSERT", "DELETE"}, Match: types.Labels{"*": apiutils.Strings{"*"}}},
},
},
{
name: "connect to prod database, get reader role",
roleSet: RoleSet{roleA, roleB, roleC},
inDatabaseLabels: map[string]string{"app": "metrics", "env": "prod"},
outCreateUser: true,
outRoles: []string{"reader"},
},
{
name: "connect to prod database, get reader permissions",
roleSet: RoleSet{roleA, roleE, roleF},
inDatabaseLabels: map[string]string{"app": "metrics", "env": "prod"},
outCreateUser: true,
outRoles: []string{},
// the overlap between outAllowPermissions and outDenyPermissions is expected.
// the permission arithmetic (e.g. removing denied permissions) will be done ba a downstream function.
outAllowPermissions: types.DatabasePermissions{
types.DatabasePermission{Permissions: []string{"SELECT"}, Match: types.Labels{"*": apiutils.Strings{"*"}}},
types.DatabasePermission{Permissions: []string{"SELECT", "UPDATE", "INSERT", "DELETE"}, Match: types.Labels{"*": apiutils.Strings{"*"}}},
},
outDenyPermissions: types.DatabasePermissions{
types.DatabasePermission{Permissions: []string{"UPDATE", "INSERT", "DELETE"}, Match: types.Labels{"*": apiutils.Strings{"*"}}},
},
},
{
name: "connect to metrics database, requested writer role",
roleSet: RoleSet{roleA, roleB, roleC},
inDatabaseLabels: map[string]string{"app": "metrics"},
inRequestedRoles: []string{"writer"},
outCreateUser: true,
outRoles: []string{"writer"},
},
{
name: "requested role denied",
roleSet: RoleSet{roleA, roleB, roleC},
inDatabaseLabels: map[string]string{"app": "metrics", "env": "prod"},
inRequestedRoles: []string{"writer"},
outCreateUser: true,
outRolesError: true,
},
{
name: "check fails",
roleSet: RoleSet{roleD},
inDatabaseLabels: map[string]string{"app": "metrics"},
outModeError: true,
outRolesError: true,
outPermissionsError: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
accessChecker := makeAccessCheckerWithRoleSet(test.roleSet)
database, err := types.NewDatabaseV3(types.Metadata{
Name: "test",
Labels: test.inDatabaseLabels,
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
create, err := accessChecker.DatabaseAutoUserMode(database)
if test.outModeError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, test.outCreateUser, create.IsEnabled())
}
roles, err := accessChecker.CheckDatabaseRoles(database, test.inRequestedRoles)
if test.outRolesError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.ElementsMatch(t, test.outRoles, roles)
}
allow, deny, err := accessChecker.GetDatabasePermissions(database)
if test.outPermissionsError {
require.Error(t, err)
require.Empty(t, allow)
require.Empty(t, deny)
} else {
require.NoError(t, err)
require.Equal(t, test.outAllowPermissions, allow)
require.Equal(t, test.outDenyPermissions, deny)
}
})
}
}
func TestGetCreateDatabaseCreateMode(t *testing.T) {
for name, tc := range map[string]struct {
roleSet RoleSet
expectedMode types.CreateDatabaseUserMode
}{
"disabled": {
roleSet: RoleSet{
&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_OFF,
},
},
},
},
expectedMode: types.CreateDatabaseUserMode_DB_USER_MODE_OFF,
},
"enabled mode take precedence": {
roleSet: RoleSet{
&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_OFF,
},
},
},
&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
},
},
},
expectedMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
"delete mode take precedence": {
roleSet: RoleSet{
&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_BEST_EFFORT_DROP,
},
},
},
&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_OFF,
},
},
},
&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateDatabaseUserMode: types.CreateDatabaseUserMode_DB_USER_MODE_KEEP,
},
},
},
},
expectedMode: types.CreateDatabaseUserMode_DB_USER_MODE_BEST_EFFORT_DROP,
},
} {
t.Run(name, func(t *testing.T) {
require.Equal(t, tc.expectedMode, tc.roleSet.GetCreateDatabaseUserMode())
})
}
}
// TestEncodeCreateDatabaseUserMode guarantees all modes are implemented in the
// encoder/decoder.
func TestEncodeDecodeCreateDatabaseUserMode(t *testing.T) {
for name, rawMode := range types.CreateDatabaseUserMode_value {
t.Run(name, func(t *testing.T) {
mode := types.CreateDatabaseUserMode(rawMode)
t.Run("YAML", func(t *testing.T) {
encoded, err := yaml.Marshal(&mode)
require.NoError(t, err)
var decodedMode types.CreateDatabaseUserMode
require.NoError(t, yaml.Unmarshal(encoded, &decodedMode))
require.Equal(t, mode, decodedMode)
})
t.Run("JSON", func(t *testing.T) {
encoded, err := mode.MarshalJSON()
require.NoError(t, err)
var decodedMode types.CreateDatabaseUserMode
require.NoError(t, decodedMode.UnmarshalJSON(encoded))
require.Equal(t, mode, decodedMode)
})
})
}
}
func TestCheckDatabaseNamesAndUsers(t *testing.T) {
roleEmpty := &types.RoleV6{
Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{MaxSessionTTL: types.Duration(time.Hour)},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
}
roleA := &types.RoleV6{
Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{MaxSessionTTL: types.Duration(2 * time.Hour)},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseNames: []string{"postgres", "main"},
DatabaseUsers: []string{"postgres", "alice"},
},
},
}
roleB := &types.RoleV6{
Metadata: types.Metadata{Name: "roleB", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{MaxSessionTTL: types.Duration(time.Hour)},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseNames: []string{"metrics"},
DatabaseUsers: []string{"bob"},
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseNames: []string{"postgres"},
DatabaseUsers: []string{"postgres"},
},
},
}
testCases := []struct {
name string
roles RoleSet
ttl time.Duration
overrideTTL bool
namesOut []string
usersOut []string
accessDenied bool
notFound bool
}{
{
name: "single role",
roles: RoleSet{roleA},
ttl: time.Hour,
namesOut: []string{"postgres", "main"},
usersOut: []string{"postgres", "alice"},
},
{
name: "combined roles",
roles: RoleSet{roleA, roleB},
ttl: time.Hour,
namesOut: []string{"main", "metrics"},
usersOut: []string{"alice", "bob"},
},
{
name: "ttl doesn't match",
roles: RoleSet{roleA},
ttl: 5 * time.Hour,
accessDenied: true,
},
{
name: "empty role",
roles: RoleSet{roleEmpty},
ttl: time.Hour,
notFound: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
names, users, err := tc.roles.CheckDatabaseNamesAndUsers(tc.ttl, tc.overrideTTL)
if tc.accessDenied {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
} else if tc.notFound {
require.Error(t, err)
require.True(t, trace.IsNotFound(err))
} else {
require.NoError(t, err)
require.ElementsMatch(t, tc.namesOut, names)
require.ElementsMatch(t, tc.usersOut, users)
}
})
}
}
func TestCheckAccessToDatabaseService(t *testing.T) {
dbNoLabels, err := types.NewDatabaseV3(types.Metadata{
Name: "test",
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
dbStage, err := types.NewDatabaseV3(types.Metadata{
Name: "stage",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
DynamicLabels: map[string]types.CommandLabelV2{"arch": {Result: "x86"}},
})
require.NoError(t, err)
dbStage2, err := types.NewDatabaseV3(types.Metadata{
Name: "stage2",
Labels: map[string]string{"env": "stage"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
DynamicLabels: map[string]types.CommandLabelV2{"arch": {Result: "amd64"}},
})
require.NoError(t, err)
dbProd, err := types.NewDatabaseV3(types.Metadata{
Name: "prod",
Labels: map[string]string{"env": "prod"},
}, types.DatabaseSpecV3{
Protocol: "protocol",
URI: "uri",
})
require.NoError(t, err)
roleAdmin := &types.RoleV6{
Metadata: types.Metadata{Name: "admin", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
},
}
roleDev := &types.RoleV6{
Metadata: types.Metadata{Name: "dev", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabelsExpression: `contains(user.spec.traits["allow-env"], labels["env"])`,
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
DatabaseLabels: types.Labels{"arch": []string{"amd64"}},
},
},
}
roleIntern := &types.RoleV6{
Metadata: types.Metadata{Name: "intern", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
}
userTraits := wrappers.Traits{
"allow-env": {"stage"},
}
type access struct {
server types.Database
access bool
}
testCases := []struct {
name string
roles RoleSet
access []access
}{
{
name: "empty role doesn't have access to any databases",
roles: nil,
access: []access{
{server: dbNoLabels, access: false},
{server: dbStage, access: false},
{server: dbStage2, access: false},
{server: dbProd, access: false},
},
},
{
name: "intern doesn't have access to any databases",
roles: RoleSet{roleIntern},
access: []access{
{server: dbNoLabels, access: false},
{server: dbStage, access: false},
{server: dbStage2, access: false},
{server: dbProd, access: false},
},
},
{
name: "developer only has access to one of stage database",
roles: RoleSet{roleDev},
access: []access{
{server: dbNoLabels, access: false},
{server: dbStage, access: true},
{server: dbStage2, access: false},
{server: dbProd, access: false},
},
},
{
name: "admin has access to all databases",
roles: RoleSet{roleAdmin},
access: []access{
{server: dbNoLabels, access: true},
{server: dbStage, access: true},
{server: dbStage2, access: true},
{server: dbProd, access: true},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
for _, access := range tc.access {
_, err := tc.roles.checkAccess(access.server, "", userTraits, AccessState{})
if access.access {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
}
}
})
}
}
// TestCheckAccessToAWSConsole verifies AWS role ARNs access checker.
func TestCheckAccessToAWSConsole(t *testing.T) {
app, err := types.NewAppV3(types.Metadata{
Name: "awsconsole",
}, types.AppSpecV3{
URI: constants.AWSConsoleURL,
})
require.NoError(t, err)
readOnlyARN := "readonly"
fullAccessARN := "fullaccess"
roleNoAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "noaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AWSRoleARNs: []string{},
},
},
}
roleReadOnly := &types.RoleV6{
Metadata: types.Metadata{
Name: "readonly",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabelsExpression: matchAllExpression,
AWSRoleARNs: []string{readOnlyARN},
},
},
}
roleFullAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "fullaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AWSRoleARNs: []string{readOnlyARN, fullAccessARN},
},
},
}
type access struct {
roleARN string
hasAccess bool
}
tests := []struct {
name string
roles RoleSet
access []access
}{
{
name: "empty role set",
roles: nil,
access: []access{
{roleARN: readOnlyARN, hasAccess: false},
{roleARN: fullAccessARN, hasAccess: false},
},
},
{
name: "no access role",
roles: RoleSet{roleNoAccess},
access: []access{
{roleARN: readOnlyARN, hasAccess: false},
{roleARN: fullAccessARN, hasAccess: false},
},
},
{
name: "readonly role",
roles: RoleSet{roleReadOnly},
access: []access{
{roleARN: readOnlyARN, hasAccess: true},
{roleARN: fullAccessARN, hasAccess: false},
},
},
{
name: "full access role",
roles: RoleSet{roleFullAccess},
access: []access{
{roleARN: readOnlyARN, hasAccess: true},
{roleARN: fullAccessARN, hasAccess: true},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, access := range test.access {
_, err := test.roles.checkAccess(
app,
"",
wrappers.Traits{},
AccessState{},
&AWSRoleARNMatcher{RoleARN: access.roleARN})
if access.hasAccess {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
}
}
})
}
}
// TestCheckAccessToAzureCloud verifies Azure identities access checker.
func TestCheckAccessToAzureCloud(t *testing.T) {
app, err := types.NewAppV3(types.Metadata{Name: "azureapp"}, types.AppSpecV3{Cloud: types.CloudAzure})
require.NoError(t, err)
readOnlyIdentity := "readonly"
fullAccessIdentity := "fullaccess"
roleNoAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "noaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AzureIdentities: []string{},
},
},
}
roleReadOnly := &types.RoleV6{
Metadata: types.Metadata{
Name: "readonly",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabelsExpression: matchAllExpression,
AzureIdentities: []string{readOnlyIdentity},
},
},
}
roleFullAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "fullaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AzureIdentities: []string{readOnlyIdentity, fullAccessIdentity},
},
},
}
tests := []struct {
name string
roles RoleSet
access map[string]bool
}{
{
name: "empty role set",
roles: nil,
access: map[string]bool{
readOnlyIdentity: false,
fullAccessIdentity: false,
},
},
{
name: "no access role",
roles: RoleSet{roleNoAccess},
access: map[string]bool{
readOnlyIdentity: false,
fullAccessIdentity: false,
},
},
{
name: "readonly role",
roles: RoleSet{roleReadOnly},
access: map[string]bool{
readOnlyIdentity: true,
fullAccessIdentity: false,
},
},
{
name: "full access role",
roles: RoleSet{roleFullAccess},
access: map[string]bool{
readOnlyIdentity: true,
fullAccessIdentity: true,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for identity, hasAccess := range test.access {
_, err := test.roles.checkAccess(app, "", wrappers.Traits{}, AccessState{}, &AzureIdentityMatcher{Identity: identity})
if hasAccess {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
}
}
})
}
}
// TestCheckAccessToGCP verifies GCP account access checker.
func TestCheckAccessToGCP(t *testing.T) {
app, err := types.NewAppV3(types.Metadata{Name: "azureapp"}, types.AppSpecV3{Cloud: types.CloudAzure})
require.NoError(t, err)
readOnlyAccount := "[email protected]"
fullAccessAccount := "[email protected]"
roleNoAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "noaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
GCPServiceAccounts: []string{},
},
},
}
roleReadOnly := &types.RoleV6{
Metadata: types.Metadata{
Name: "readonly",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
GCPServiceAccounts: []string{readOnlyAccount},
},
},
}
roleFullAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "fullaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabelsExpression: matchAllExpression,
GCPServiceAccounts: []string{readOnlyAccount, fullAccessAccount},
},
},
}
tests := []struct {
name string
roles RoleSet
access map[string]bool
}{
{
name: "empty role set",
roles: nil,
access: map[string]bool{
readOnlyAccount: false,
fullAccessAccount: false,
},
},
{
name: "no access role",
roles: RoleSet{roleNoAccess},
access: map[string]bool{
readOnlyAccount: false,
fullAccessAccount: false,
},
},
{
name: "readonly role",
roles: RoleSet{roleReadOnly},
access: map[string]bool{
readOnlyAccount: true,
fullAccessAccount: false,
},
},
{
name: "full access role",
roles: RoleSet{roleFullAccess},
access: map[string]bool{
readOnlyAccount: true,
fullAccessAccount: true,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for account, hasAccess := range test.access {
_, err := test.roles.checkAccess(app, "", wrappers.Traits{}, AccessState{}, &GCPServiceAccountMatcher{ServiceAccount: account})
if hasAccess {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
}
}
})
}
}
func TestCheckAzureIdentities(t *testing.T) {
readOnlyIdentity := "readonly"
fullAccessIdentity := "fullaccess"
maxSessionTTL := time.Hour * 2
sessionShort := time.Hour * 1
sessionLong := time.Hour * 3
roleNoAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "noaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AzureIdentities: []string{},
},
},
}
roleReadOnly := &types.RoleV6{
Metadata: types.Metadata{
Name: "readonly",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AzureIdentities: []string{readOnlyIdentity},
},
},
}
roleFullAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "fullaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
AzureIdentities: []string{readOnlyIdentity, fullAccessIdentity},
},
},
}
roleDenyReadOnlyIdentity := &types.RoleV6{
Metadata: types.Metadata{
Name: "deny-identity",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AzureIdentities: []string{readOnlyIdentity},
},
},
}
roleDenyReadOnlyIdentityUppercase := &types.RoleV6{
Metadata: types.Metadata{
Name: "deny-identity-upper",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AzureIdentities: []string{strings.ToUpper(readOnlyIdentity)},
},
},
}
roleDenyAll := &types.RoleV6{
Metadata: types.Metadata{
Name: "deny-all",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AzureIdentities: []string{types.Wildcard},
},
},
}
tests := []struct {
name string
roles RoleSet
ttl time.Duration
overrideTTL bool
wantIdentities []string
wantError require.ErrorAssertionFunc
}{
{
name: "empty role set",
roles: nil,
wantError: require.Error,
},
{
name: "no access role",
overrideTTL: true,
roles: RoleSet{roleNoAccess},
wantError: func(t require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "this user cannot access Azure API, has no assigned identities")
},
},
{
name: "readonly role, short session",
overrideTTL: false,
ttl: sessionShort,
roles: RoleSet{roleReadOnly},
wantIdentities: []string{"readonly"},
wantError: require.NoError,
},
{
name: "readonly role, long session",
overrideTTL: false,
ttl: sessionLong,
roles: RoleSet{roleReadOnly},
wantError: func(t require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "this user cannot access Azure API for 3h0m0s")
},
},
{
name: "readonly role, override TTL",
overrideTTL: true,
roles: RoleSet{roleReadOnly},
wantIdentities: []string{"readonly"},
wantError: require.NoError,
},
{
name: "full access role",
overrideTTL: true,
roles: RoleSet{roleFullAccess},
wantIdentities: []string{"fullaccess", "readonly"},
wantError: require.NoError,
},
{
name: "denying a role works",
overrideTTL: true,
roles: RoleSet{roleFullAccess, roleDenyReadOnlyIdentity},
wantIdentities: []string{"fullaccess"},
wantError: require.NoError,
},
{
name: "denying an uppercase role works",
overrideTTL: true,
roles: RoleSet{roleFullAccess, roleDenyReadOnlyIdentityUppercase},
wantIdentities: []string{"fullaccess"},
wantError: require.NoError,
},
{
name: "denying wildcard works",
overrideTTL: true,
roles: RoleSet{roleFullAccess, roleDenyAll},
wantIdentities: nil,
wantError: func(t require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "this user cannot access Azure API, has no assigned identities")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
identities, err := tt.roles.CheckAzureIdentities(tt.ttl, tt.overrideTTL)
require.Equal(t, tt.wantIdentities, identities)
tt.wantError(t, err)
})
}
}
func TestCheckGCPServiceAccounts(t *testing.T) {
readOnlyAccount := "[email protected]"
fullAccessAccount := "[email protected]"
maxSessionTTL := time.Hour * 2
sessionShort := time.Hour * 1
sessionLong := time.Hour * 3
roleNoAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "noaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
GCPServiceAccounts: []string{},
},
},
}
roleReadOnly := &types.RoleV6{
Metadata: types.Metadata{
Name: "readonly",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
GCPServiceAccounts: []string{readOnlyAccount},
},
},
}
roleFullAccess := &types.RoleV6{
Metadata: types.Metadata{
Name: "fullaccess",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
GCPServiceAccounts: []string{readOnlyAccount, fullAccessAccount},
},
},
}
roleDenyReadOnlyAccount := &types.RoleV6{
Metadata: types.Metadata{
Name: "deny-account",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
GCPServiceAccounts: []string{readOnlyAccount},
},
},
}
roleDenyReadOnlyAccountUppercase := &types.RoleV6{
Metadata: types.Metadata{
Name: "deny-account-upper",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
GCPServiceAccounts: []string{strings.ToUpper(readOnlyAccount)},
},
},
}
roleDenyAll := &types.RoleV6{
Metadata: types.Metadata{
Name: "deny-all",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
MaxSessionTTL: types.NewDuration(maxSessionTTL),
},
Deny: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
GCPServiceAccounts: []string{types.Wildcard},
},
},
}
tests := []struct {
name string
roles RoleSet
ttl time.Duration
overrideTTL bool
wantAccounts []string
wantError require.ErrorAssertionFunc
}{
{
name: "empty role set",
roles: nil,
wantError: require.Error,
},
{
name: "no access role",
overrideTTL: true,
roles: RoleSet{roleNoAccess},
wantError: func(t require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "this user cannot request GCP API access, has no assigned service accounts")
},
},
{
name: "readonly role, short session",
overrideTTL: false,
ttl: sessionShort,
roles: RoleSet{roleReadOnly},
wantAccounts: []string{"[email protected]"},
wantError: require.NoError,
},
{
name: "readonly role, long session",
overrideTTL: false,
ttl: sessionLong,
roles: RoleSet{roleReadOnly},
wantError: func(t require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "this user cannot request GCP API access for 3h0m0s")
},
},
{
name: "readonly role, override TTL",
overrideTTL: true,
roles: RoleSet{roleReadOnly},
wantAccounts: []string{"[email protected]"},
wantError: require.NoError,
},
{
name: "full access role",
overrideTTL: true,
roles: RoleSet{roleFullAccess},
wantAccounts: []string{"[email protected]", "[email protected]"},
wantError: require.NoError,
},
{
name: "denying a role works",
overrideTTL: true,
roles: RoleSet{roleFullAccess, roleDenyReadOnlyAccount},
wantAccounts: []string{"[email protected]"},
wantError: require.NoError,
},
{
name: "denying an uppercase role works",
overrideTTL: true,
roles: RoleSet{roleFullAccess, roleDenyReadOnlyAccountUppercase},
wantAccounts: []string{"[email protected]"},
wantError: require.NoError,
},
{
name: "denying wildcard works",
overrideTTL: true,
roles: RoleSet{roleFullAccess, roleDenyAll},
wantAccounts: nil,
wantError: func(t require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "this user cannot request GCP API access, has no assigned service accounts")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
accounts, err := tt.roles.CheckGCPServiceAccounts(tt.ttl, tt.overrideTTL)
require.ElementsMatch(t, tt.wantAccounts, accounts)
tt.wantError(t, err)
})
}
}
func TestCheckAccessToSAMLIdP(t *testing.T) {
createRole := func(t *testing.T, name, version string, spec types.RoleSpecV6) *types.RoleV6 {
t.Helper()
return &types.RoleV6{
Version: version,
Metadata: types.Metadata{
Name: name,
Namespace: apidefaults.Namespace,
},
Spec: spec,
}
}
idpEnabled := func(t *testing.T, enabled bool) *types.IdPOptions {
t.Helper()
return &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(enabled),
},
}
}
roleV8SAMLAllowed := func(t *testing.T) types.RoleConditions {
t.Helper()
return types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{"env": []string{"dev"}},
Rules: []types.Rule{
{
Resources: []string{types.KindSAMLIdPServiceProvider},
Verbs: RO(),
},
},
}
}
sp, err := types.NewSAMLIdPServiceProvider(
types.Metadata{
Name: "saml-app",
Labels: map[string]string{
"env": "dev",
},
},
types.SAMLIdPServiceProviderSpecV1{
EntityID: "https://example.com",
ACSURL: "https://example.com",
RelayState: "test-relay-state",
},
)
require.NoError(t, err)
testCases := []struct {
name string
roles RoleSet
authPrefSamlEnabled bool
state AccessState
errAssertionFunc require.ErrorAssertionFunc
}{
{
name: "no roles",
roles: RoleSet{},
authPrefSamlEnabled: true,
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "No roles assigned to user")
},
},
{
name: "roleV7: no idp option",
roles: RoleSet{
createRole(t, "roleV7NoSAMLOptions", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
Options: types.RoleOptions{},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: require.NoError,
},
{
name: "roleV7: idp option block undefined",
roles: RoleSet{
createRole(t, "roleV7OnlyIdPBlock", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
Options: types.RoleOptions{
IDP: &types.IdPOptions{},
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: require.NoError,
},
{
name: "roleV7: idp saml option block undefined",
roles: RoleSet{
createRole(t, "roleV7OnlySAMLBlock", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
Options: types.RoleOptions{
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{},
},
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: require.NoError,
},
{
name: "roleV7: idp option enabled",
roles: RoleSet{
createRole(t, "roleV7IdPEnabled", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: require.NoError,
},
{
name: "roleV7: idp option disabled",
roles: RoleSet{
createRole(t, "roleV7IdPDisabled", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, false),
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, trace.AccessDenied("user has been denied access to the SAML IdP by role roleV7IdPDisabled"))
},
},
{
name: "roleV7: multiple roles with enabled and disabled idp option",
roles: RoleSet{
createRole(t, "roleV7IdPEnabled", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
createRole(t, "roleV7IdPDisabled", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, false),
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, trace.AccessDenied("user has been denied access to the SAML IdP by role roleV7IdPDisabled"))
},
},
{
name: "roleV7: idp option enabled, but denied at cluster level",
roles: RoleSet{
createRole(t, "roleV7IdPEnabled", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: false,
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, trace.AccessDenied("SAML IdP is disabled at the cluster level"))
},
},
// labels matching
{
name: "roleV7: idp disabled and wildcard labels does not grant access",
roles: RoleSet{
createRole(t, "roleV7labelsNotMatched", types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
Rules: []types.Rule{
{
Resources: []string{types.KindSAMLIdPServiceProvider},
Verbs: RO(),
},
},
},
Options: types.RoleOptions{
IDP: idpEnabled(t, false),
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, trace.AccessDenied("user has been denied access to the SAML IdP by role roleV7labelsNotMatched"))
},
},
{
name: "roleV7: idp enabled and denying label and verbs does not deny access",
roles: RoleSet{
createRole(t, "roleV7labelsNotMatched", types.V7, types.RoleSpecV6{
Deny: types.RoleConditions{
AppLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
Rules: []types.Rule{
{
Resources: []string{types.KindSAMLIdPServiceProvider},
Verbs: RO(),
},
},
},
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: require.NoError,
},
{
name: "roleV8: labels matched, idp disabled is not disable access",
roles: RoleSet{
createRole(t, "roleV8labelsMatched", types.V8, types.RoleSpecV6{
Allow: roleV8SAMLAllowed(t),
Options: types.RoleOptions{
IDP: idpEnabled(t, false),
},
}),
},
authPrefSamlEnabled: true,
errAssertionFunc: require.NoError,
},
{
name: "roleV8: unmatched labels denies access, idp enabled is not applied",
roles: RoleSet{
createRole(t, "roleV8labelsMatched", types.V8, types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
AppLabels: types.Labels{"env": []string{"prod"}},
Rules: []types.Rule{
{
Resources: []string{types.KindSAMLIdPServiceProvider},
Verbs: RO(),
},
},
},
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredAlways,
MFAVerified: true,
},
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorContains(t, err, "User does not have permissions")
},
},
// MFA checks for role v7
{
name: "roleV7: per-session MFA denies unverified MFA",
roles: RoleSet{
createRole(t, "roleV7MFARequired", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredPerRole,
MFAVerified: false,
},
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, ErrSessionMFARequired)
},
},
{
name: "roleV7: per-session MFA allows verified MFA",
roles: RoleSet{
createRole(t, "roleV7MFARequired", types.V7, types.RoleSpecV6{
Allow: roleV8SAMLAllowed(t),
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredPerRole,
MFAVerified: true,
},
errAssertionFunc: require.NoError,
},
{
name: "roleV7: cluster-level MFA denies unverified MFA",
roles: RoleSet{
createRole(t, "roleV7MFARequired", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredAlways,
MFAVerified: false,
},
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, ErrSessionMFARequired)
},
},
{
name: "roleV7: cluster-level MFA allows verified MFA",
roles: RoleSet{
createRole(t, "roleV7MFARequired", types.V7, types.RoleSpecV6{
Options: types.RoleOptions{
IDP: idpEnabled(t, true),
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredAlways,
MFAVerified: true,
},
errAssertionFunc: require.NoError,
},
// MFA checks for role v8
{
name: "roleV8: per-session MFA denies unverified MFA",
roles: RoleSet{
createRole(t, "roleV8MFARequired", types.V8, types.RoleSpecV6{
Allow: roleV8SAMLAllowed(t),
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredPerRole,
MFAVerified: false,
},
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, ErrSessionMFARequired)
},
},
{
name: "roleV8: per-session MFA allows verified MFA",
roles: RoleSet{
createRole(t, "roleV8MFARequired", types.V8, types.RoleSpecV6{
Allow: roleV8SAMLAllowed(t),
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
},
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredPerRole,
MFAVerified: true,
},
errAssertionFunc: require.NoError,
},
{
name: "roleV8: cluster-level MFA denies unverified MFA",
roles: RoleSet{
createRole(t, "roleV8MFARequired", types.V8, types.RoleSpecV6{
Allow: roleV8SAMLAllowed(t),
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredAlways,
MFAVerified: false,
},
errAssertionFunc: func(tt require.TestingT, err error, i ...any) {
require.ErrorIs(t, err, ErrSessionMFARequired)
},
},
{
name: "roleV8: cluster-level MFA allows verified MFA",
roles: RoleSet{
createRole(t, "roleV8MFARequired", types.V8, types.RoleSpecV6{
Allow: roleV8SAMLAllowed(t),
}),
},
authPrefSamlEnabled: true,
state: AccessState{
MFARequired: MFARequiredAlways,
MFAVerified: true,
},
errAssertionFunc: require.NoError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(tc.authPrefSamlEnabled),
},
},
})
require.NoError(t, err)
accessChecker := makeAccessCheckerWithRoleSet(tc.roles)
tc.errAssertionFunc(t, accessChecker.CheckAccessToSAMLIdP(sp, authPref, tc.state))
})
}
}
func TestCheckAccessToKubernetes(t *testing.T) {
clusterNoLabels := &types.KubernetesCluster{
Name: "no-labels",
}
clusterWithLabels := &types.KubernetesCluster{
Name: "no-labels",
StaticLabels: map[string]string{"foo": "bar"},
DynamicLabels: map[string]types.CommandLabelV2{"baz": {Result: "qux"}},
}
wildcardRole := &types.RoleV6{
Metadata: types.Metadata{
Name: "wildcard-labels",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
},
},
}
matchingLabelsRole := &types.RoleV6{
Metadata: types.Metadata{
Name: "matching-labels",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabels: types.Labels{
"foo": apiutils.Strings{"bar"},
"baz": apiutils.Strings{"qux"},
},
},
},
}
matchingLabelsRoleWithMFA := &types.RoleV6{
Metadata: types.Metadata{
Name: "matching-labels-mfa",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
RequireMFAType: types.RequireMFAType_SESSION,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabelsExpression: `labels.foo == "bar" && labels.baz == "qux"`,
},
},
}
matchingLabelsRoleWithDeviceTrust := &types.RoleV6{
Metadata: types.Metadata{
Name: "matching-labels-devicetrust",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
DeviceTrustMode: constants.DeviceTrustModeRequired,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabels: types.Labels{
"foo": apiutils.Strings{"bar"},
"baz": apiutils.Strings{"qux"},
},
},
},
}
matchingLabelsRoleWithDeviceTrustForHumans := &types.RoleV6{
Metadata: types.Metadata{
Name: "matching-labels-devicetrust-humans",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
DeviceTrustMode: constants.DeviceTrustModeRequiredForHumans,
},
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabels: types.Labels{
"foo": apiutils.Strings{"bar"},
"baz": apiutils.Strings{"qux"},
},
},
},
}
noLabelsRole := &types.RoleV6{
Metadata: types.Metadata{
Name: "no-labels",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
},
},
}
mismatchingLabelsRole := &types.RoleV6{
Metadata: types.Metadata{
Name: "mismatching-labels",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
KubernetesLabels: types.Labels{
"qux": apiutils.Strings{"baz"},
"bar": apiutils.Strings{"foo"},
},
},
},
}
testCases := []struct {
name string
roles []*types.RoleV6
cluster *types.KubernetesCluster
state AccessState
hasAccess bool
}{
{
name: "empty role set has access to nothing",
roles: nil,
cluster: clusterNoLabels,
hasAccess: false,
},
{
name: "role with no labels has access to nothing",
roles: []*types.RoleV6{noLabelsRole},
cluster: clusterNoLabels,
hasAccess: false,
},
{
name: "role with wildcard labels matches cluster without labels",
roles: []*types.RoleV6{wildcardRole},
cluster: clusterNoLabels,
hasAccess: true,
},
{
name: "role with wildcard labels matches cluster with labels",
roles: []*types.RoleV6{wildcardRole},
cluster: clusterWithLabels,
hasAccess: true,
},
{
name: "role with labels does not match cluster with no labels",
roles: []*types.RoleV6{matchingLabelsRole},
cluster: clusterNoLabels,
hasAccess: false,
},
{
name: "role with labels matches cluster with labels",
roles: []*types.RoleV6{matchingLabelsRole},
cluster: clusterWithLabels,
hasAccess: true,
},
{
name: "role with mismatched labels does not match cluster with labels",
roles: []*types.RoleV6{mismatchingLabelsRole},
cluster: clusterWithLabels,
hasAccess: false,
},
{
name: "one role in the roleset matches",
roles: []*types.RoleV6{mismatchingLabelsRole, noLabelsRole, matchingLabelsRole},
cluster: clusterWithLabels,
hasAccess: true,
},
{
name: "role requires MFA but MFA not verified",
roles: []*types.RoleV6{matchingLabelsRole, matchingLabelsRoleWithMFA},
cluster: clusterWithLabels,
state: AccessState{MFAVerified: false},
hasAccess: false,
},
{
name: "role requires MFA and MFA verified",
roles: []*types.RoleV6{matchingLabelsRole, matchingLabelsRoleWithMFA},
cluster: clusterWithLabels,
state: AccessState{MFAVerified: true},
hasAccess: true,
},
{
name: "cluster requires MFA but MFA not verified",
roles: []*types.RoleV6{matchingLabelsRole},
cluster: clusterWithLabels,
state: AccessState{MFAVerified: false, MFARequired: MFARequiredAlways},
hasAccess: false,
},
{
name: "role requires MFA and MFA verified",
roles: []*types.RoleV6{matchingLabelsRole},
cluster: clusterWithLabels,
state: AccessState{MFAVerified: true, MFARequired: MFARequiredAlways},
hasAccess: true,
},
{
name: "role requires device trust, device not verified",
roles: []*types.RoleV6{wildcardRole, matchingLabelsRole, matchingLabelsRoleWithDeviceTrust},
cluster: clusterWithLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
},
hasAccess: false,
},
{
name: "role requires device trust, device verified",
roles: []*types.RoleV6{wildcardRole, matchingLabelsRole, matchingLabelsRoleWithDeviceTrust},
cluster: clusterWithLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: true,
},
hasAccess: true,
},
{
name: "role requires device trust, resource doesn't match",
roles: []*types.RoleV6{wildcardRole, matchingLabelsRole, matchingLabelsRoleWithDeviceTrust},
cluster: clusterNoLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
},
hasAccess: true, // doesn't match device trust role
},
{
name: "role requires device trust for all, is bot",
roles: []*types.RoleV6{matchingLabelsRoleWithDeviceTrust},
cluster: clusterWithLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
IsBot: true,
},
hasAccess: false,
},
{
name: "role requires device trust for humans, is bot",
roles: []*types.RoleV6{matchingLabelsRoleWithDeviceTrustForHumans},
cluster: clusterWithLabels,
state: AccessState{
EnableDeviceVerification: true,
DeviceVerified: false,
IsBot: true,
},
hasAccess: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
k8sV3, err := types.NewKubernetesClusterV3FromLegacyCluster(apidefaults.Namespace, tc.cluster)
require.NoError(t, err)
accessChecker := makeAccessCheckerWithRolePointers(tc.roles)
err = accessChecker.CheckAccess(k8sV3, tc.state)
if tc.hasAccess {
require.NoError(t, err)
} else {
require.Error(t, err)
require.True(t, trace.IsAccessDenied(err))
}
})
}
}
func TestDesktopRecordingEnabled(t *testing.T) {
for _, test := range []struct {
desc string
roleSet RoleSet
shouldRecord bool
}{
{
desc: "single role recording disabled",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {
r.SetName("no-record")
r.SetOptions(types.RoleOptions{
RecordSession: &types.RecordSession{Desktop: types.NewBoolOption(false)},
})
}),
),
shouldRecord: false,
},
{
desc: "multiple roles, one requires recording",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
RecordSession: &types.RecordSession{Desktop: types.NewBoolOption(false)},
})
}),
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
RecordSession: &types.RecordSession{Desktop: types.NewBoolOption(false)},
})
}),
// recording defaults to true, so a default role should force recording
newRole(func(r *types.RoleV6) {}),
),
shouldRecord: true,
},
} {
t.Run(test.desc, func(t *testing.T) {
require.Equal(t, test.shouldRecord, test.roleSet.RecordDesktopSession())
})
}
}
func TestDesktopClipboard(t *testing.T) {
for _, test := range []struct {
desc string
roleSet RoleSet
hasClipboard bool
}{
{
desc: "single role, unspecified, defaults true",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {}),
),
hasClipboard: true,
},
{
desc: "single role, explicitly disabled",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
DesktopClipboard: types.NewBoolOption(false),
})
}),
),
hasClipboard: false,
},
{
desc: "multiple conflicting roles, disable wins",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {}),
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
DesktopClipboard: types.NewBoolOption(false),
})
}),
),
hasClipboard: false,
},
} {
t.Run(test.desc, func(t *testing.T) {
require.Equal(t, test.hasClipboard, test.roleSet.DesktopClipboard())
})
}
}
func TestDesktopDirectorySharing(t *testing.T) {
for _, test := range []struct {
desc string
roleSet RoleSet
hasDirectorySharing bool
}{
{
desc: "single role, unspecified, defaults true",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {}),
),
hasDirectorySharing: true,
},
{
desc: "single role, explicitly disabled",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
DesktopDirectorySharing: types.NewBoolOption(false),
})
}),
),
hasDirectorySharing: false,
},
{
desc: "multiple conflicting roles, disable wins",
roleSet: NewRoleSet(
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
DesktopDirectorySharing: types.NewBoolOption(false),
})
}),
newRole(func(r *types.RoleV6) {
r.SetOptions(types.RoleOptions{
DesktopDirectorySharing: types.NewBoolOption(true),
})
}),
),
hasDirectorySharing: false,
},
} {
t.Run(test.desc, func(t *testing.T) {
require.Equal(t, test.hasDirectorySharing, test.roleSet.DesktopDirectorySharing())
})
}
}
func TestCheckAccessToWindowsDesktop(t *testing.T) {
desktopNoLabels := &types.WindowsDesktopV3{
ResourceHeader: types.ResourceHeader{
Kind: types.KindWindowsDesktop,
Metadata: types.Metadata{Name: "no-labels"},
},
}
desktop2012 := &types.WindowsDesktopV3{
ResourceHeader: types.ResourceHeader{
Kind: types.KindWindowsDesktop,
Metadata: types.Metadata{
Name: "win2012",
Labels: map[string]string{"win_version": "2012"},
},
},
}
type check struct {
desktop *types.WindowsDesktopV3
login string
hasAccess bool
}
for _, test := range []struct {
name string
roleSet RoleSet
checks []check
}{
{
name: "no roles, no access",
roleSet: RoleSet{},
checks: []check{
{desktop: desktopNoLabels, login: "admin", hasAccess: false},
{desktop: desktop2012, login: "admin", hasAccess: false},
},
},
{
name: "empty label, no access",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"admin"}
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"role": []string{}}
}),
},
checks: []check{
{desktop: desktopNoLabels, login: "admin", hasAccess: false},
{desktop: desktop2012, login: "admin", hasAccess: false},
},
},
{
name: "single role allows a single login",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"admin"}
}),
},
checks: []check{
{desktop: desktopNoLabels, login: "admin", hasAccess: true},
{desktop: desktop2012, login: "admin", hasAccess: true},
{desktop: desktopNoLabels, login: "foo", hasAccess: false},
{desktop: desktop2012, login: "foo", hasAccess: false},
},
},
{
name: "single role with allowed labels",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"admin"}
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"win_version": []string{"2012"}}
}),
},
checks: []check{
{desktop: desktopNoLabels, login: "admin", hasAccess: false},
{desktop: desktop2012, login: "admin", hasAccess: true},
},
},
{
name: "single role with deny labels",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"admin"}
r.Spec.Deny.Namespaces = []string{apidefaults.Namespace}
r.Spec.Deny.WindowsDesktopLabels = types.Labels{"win_version": []string{"2012"}}
}),
},
checks: []check{
{desktop: desktopNoLabels, login: "admin", hasAccess: true},
{desktop: desktop2012, login: "admin", hasAccess: false},
},
},
{
name: "one role more permissive than another",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"admin"}
r.Spec.Allow.NodeLabels = types.Labels{"win_version": []string{"2012"}}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"root", "admin"}
}),
},
checks: []check{
{desktop: desktopNoLabels, login: "root", hasAccess: true},
{desktop: desktopNoLabels, login: "admin", hasAccess: true},
{desktop: desktop2012, login: "root", hasAccess: true},
{desktop: desktop2012, login: "admin", hasAccess: true},
},
},
{
name: "labels expression more permissive than another role",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLogins = []string{"admin"}
r.Spec.Allow.NodeLabels = types.Labels{"win_version": []string{"2012"}}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = nil
r.Spec.Allow.WindowsDesktopLabelsExpression = matchAllExpression
r.Spec.Allow.WindowsDesktopLogins = []string{"root", "admin"}
}),
},
checks: []check{
{desktop: desktopNoLabels, login: "root", hasAccess: true},
{desktop: desktopNoLabels, login: "admin", hasAccess: true},
{desktop: desktop2012, login: "root", hasAccess: true},
{desktop: desktop2012, login: "admin", hasAccess: true},
},
},
} {
t.Run(test.name, func(t *testing.T) {
for i, check := range test.checks {
msg := fmt.Sprintf("check=%d, user=%v, server=%v, should_have_access=%v",
i, check.login, check.desktop.GetName(), check.hasAccess)
_, err := test.roleSet.checkAccess(check.desktop, "", wrappers.Traits{}, AccessState{}, NewWindowsLoginMatcher(check.login))
if check.hasAccess {
require.NoError(t, err, msg)
} else {
require.Error(t, err, msg)
require.True(t, trace.IsAccessDenied(err), "expected access denied error, got %v", err)
}
}
})
}
}
func TestCheckAccessToUserGroups(t *testing.T) {
userGroupNoLabels := &types.UserGroupV1{
ResourceHeader: types.ResourceHeader{
Kind: types.KindUserGroup,
Metadata: types.Metadata{Name: "no-labels"},
},
}
userGroupLabels := &types.UserGroupV1{
ResourceHeader: types.ResourceHeader{
Kind: types.KindUserGroup,
Metadata: types.Metadata{
Name: "labels",
Labels: map[string]string{
"a": "b",
},
},
},
}
type check struct {
userGroup *types.UserGroupV1
hasAccess bool
}
for _, test := range []struct {
name string
roleSet RoleSet
checks []check
}{
{
name: "no roles, no access",
roleSet: RoleSet{},
checks: []check{
{userGroup: userGroupNoLabels, hasAccess: false},
{userGroup: userGroupLabels, hasAccess: false},
},
},
{
name: "no matching labels, no access",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Deny.Namespaces = []string{apidefaults.Namespace}
r.Spec.Allow.GroupLabels = types.Labels{"a": []string{"c"}}
}),
},
checks: []check{
{userGroup: userGroupNoLabels, hasAccess: false},
{userGroup: userGroupLabels, hasAccess: false},
},
},
{
name: "deny labels, no access",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Deny.Namespaces = []string{apidefaults.Namespace}
r.Spec.Allow.GroupLabels = types.Labels{"a": []string{"b"}}
r.Spec.Deny.GroupLabels = types.Labels{"a": []string{"b"}}
}),
},
checks: []check{
{userGroup: userGroupNoLabels, hasAccess: false},
{userGroup: userGroupLabels, hasAccess: false},
},
},
{
name: "wild card, access",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Deny.Namespaces = []string{apidefaults.Namespace}
r.Spec.Allow.GroupLabels = types.Labels{types.Wildcard: []string{types.Wildcard}}
}),
},
checks: []check{
{userGroup: userGroupNoLabels, hasAccess: true},
{userGroup: userGroupLabels, hasAccess: true},
},
},
{
name: "labels expression, access",
roleSet: RoleSet{
newRole(func(r *types.RoleV6) {
r.Spec.Deny.Namespaces = []string{apidefaults.Namespace}
r.Spec.Allow.GroupLabelsExpression = matchAllExpression
}),
},
checks: []check{
{userGroup: userGroupNoLabels, hasAccess: true},
{userGroup: userGroupLabels, hasAccess: true},
},
},
} {
t.Run(test.name, func(t *testing.T) {
for i, check := range test.checks {
msg := fmt.Sprintf("check=%d, userGroup=%v, should_have_access=%v",
i, check.userGroup.GetName(), check.hasAccess)
_, err := test.roleSet.checkAccess(check.userGroup, "", wrappers.Traits{}, AccessState{})
if check.hasAccess {
require.NoError(t, err, msg)
} else {
require.Error(t, err, msg)
require.True(t, trace.IsAccessDenied(err), "expected access denied error, got %v", err)
}
}
})
}
}
// BenchmarkCheckConditionalAccessToServer tests how long it takes to run
// CheckConditionalAccess for servers across 4,000 nodes for 5 roles each with 5 logins each.
//
// To run benchmark:
//
// go test -bench=.
//
// To run benchmark and obtain CPU and memory profiling:
//
// go test -bench=. -cpuprofile=cpu.prof -memprofile=mem.prof
//
// To use the command line tool to read the profile:
//
// go tool pprof cpu.prof
// go tool pprof cpu.prof
//
// To generate a graph:
//
// go tool pprof --pdf cpu.prof > cpu.pdf
// go tool pprof --pdf mem.prof > mem.pdf
func BenchmarkCheckConditionalAccessToServer(b *testing.B) {
if testing.Short() {
b.Skip("skipping heavy benchmark")
}
servers := make([]*types.ServerV2, 0, 4000)
// Create 4,000 servers with random IDs.
for range 4000 {
hostname := uuid.New().String()
servers = append(servers, &types.ServerV2{
Kind: types.KindNode,
Version: types.V2,
Metadata: types.Metadata{
Name: hostname,
Namespace: apidefaults.Namespace,
},
Spec: types.ServerSpecV2{
Addr: "127.0.0.1:3022",
Hostname: hostname,
},
})
}
// Create RoleSet with four generic roles that have five logins
// each and only have access to the a:b label.
var set RoleSet
for i := range 4 {
set = append(set, &types.RoleV6{
Kind: types.KindRole,
Version: types.V3,
Metadata: types.Metadata{
Name: strconv.Itoa(i),
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Logins: []string{"admin", "one", "two", "three", "four"},
NodeLabels: types.Labels{"a": []string{"b"}},
},
},
})
}
userTraits := wrappers.Traits{}
// Build a map of all allowed logins.
allowLogins := map[string]bool{}
for _, role := range set {
for _, login := range role.GetLogins(types.Allow) {
allowLogins[login] = true
}
}
// Check access to all 4,000 nodes.
for b.Loop() {
for i := range 4000 {
for login := range allowLogins {
// note: we don't check the error here because this benchmark
// is testing the performance of failed RBAC checks
_, _ = set.checkAccess(
servers[i],
"",
userTraits,
AccessState{},
NewLoginMatcher(login),
)
}
}
}
}
// userGetter is used in tests to return a user with the specified roles and
// traits.
type userGetter struct {
roles []string
traits map[string][]string
}
func (f *userGetter) GetUser(ctx context.Context, name string, _ bool) (types.User, error) {
user, err := types.NewUser(name)
if err != nil {
return nil, trace.Wrap(err)
}
user.SetRoles(f.roles)
user.SetTraits(f.traits)
return user, nil
}
func TestRoleSetLockingMode(t *testing.T) {
t.Parallel()
t.Run("empty RoleSet gives default LockingMode", func(t *testing.T) {
t.Parallel()
set := RoleSet{}
for _, defaultMode := range []constants.LockingMode{constants.LockingModeBestEffort, constants.LockingModeStrict} {
require.Equal(t, defaultMode, set.LockingMode(defaultMode))
}
})
missingMode := constants.LockingMode("")
newRoleWithLockingMode := func(t *testing.T, mode constants.LockingMode) types.Role {
role, err := types.NewRole(uuid.New().String(), types.RoleSpecV6{Options: types.RoleOptions{Lock: mode}})
require.NoError(t, err)
return role
}
t.Run("RoleSet with missing LockingMode gives default LockingMode", func(t *testing.T) {
t.Parallel()
set := RoleSet{newRoleWithLockingMode(t, missingMode), newRoleWithLockingMode(t, missingMode)}
for _, defaultMode := range []constants.LockingMode{constants.LockingModeBestEffort, constants.LockingModeStrict} {
require.Equal(t, defaultMode, set.LockingMode(defaultMode))
}
})
t.Run("RoleSet with a set LockingMode gives the set LockingMode", func(t *testing.T) {
t.Parallel()
role1 := newRoleWithLockingMode(t, missingMode)
for _, mode := range []constants.LockingMode{constants.LockingModeBestEffort, constants.LockingModeStrict} {
role2 := newRoleWithLockingMode(t, mode)
set := RoleSet{role1, role2}
require.Equal(t, mode, set.LockingMode(mode))
}
})
t.Run("RoleSet featuring LockingModeStrict gives LockingModeStrict", func(t *testing.T) {
t.Parallel()
role1 := newRoleWithLockingMode(t, constants.LockingModeBestEffort)
for _, mode := range []constants.LockingMode{constants.LockingModeBestEffort, constants.LockingModeStrict} {
role2 := newRoleWithLockingMode(t, mode)
set := RoleSet{role1, role2}
require.Equal(t, mode, set.LockingMode(mode))
}
})
}
func TestExtractConditionForIdentifier(t *testing.T) {
t.Parallel()
set := RoleSet{}
_, err := set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
allowWhere := func(where string) types.Role {
role, err := types.NewRole(uuid.New().String(), types.RoleSpecV6{Allow: types.RoleConditions{
Rules: []types.Rule{{Resources: []string{types.KindSession}, Verbs: []string{types.VerbList}, Where: where}},
}})
require.NoError(t, err)
return role
}
denyWhere := func(where string) types.Role {
role, err := types.NewRole(uuid.New().String(), types.RoleSpecV6{Deny: types.RoleConditions{
Rules: []types.Rule{{Resources: []string{types.KindSession}, Verbs: []string{types.VerbList}, Where: where}},
}})
require.NoError(t, err)
return role
}
user, err := types.NewUser("test-user")
require.NoError(t, err)
user2, err := types.NewUser("test-user2")
require.NoError(t, err)
user2Meta := user2.GetMetadata()
user2Meta.Labels = map[string]string{"can-audit-guest": "yes"}
user2.SetMetadata(user2Meta)
// Add a role allowing access to guest session recordings if the user has a set label.
role := allowWhere(`contains(session.participants, "guest") && equals(user.metadata.labels["can-audit-guest"], "yes")`)
guestParticipantCond := &types.WhereExpr{Contains: types.WhereExpr2{L: &types.WhereExpr{Field: "participants"}, R: &types.WhereExpr{Literal: "guest"}}}
set = append(set, role)
_, err = set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
_, err = set.ExtractConditionForIdentifier(&Context{User: user}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
cond, err := set.ExtractConditionForIdentifier(&Context{User: user2}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, guestParticipantCond))
// Add a role allowing access to the user's own session recordings.
role = allowWhere(`contains(session.participants, user.metadata.name)`)
userParticipantCond := func(user types.User) *types.WhereExpr {
return &types.WhereExpr{Contains: types.WhereExpr2{L: &types.WhereExpr{Field: "participants"}, R: &types.WhereExpr{Literal: user.GetName()}}}
}
set = append(set, role)
cond, err = set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, userParticipantCond(emptyUser)))
cond, err = set.ExtractConditionForIdentifier(&Context{User: user}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, userParticipantCond(user)))
cond, err = set.ExtractConditionForIdentifier(&Context{User: user2}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, &types.WhereExpr{Or: types.WhereExpr2{L: guestParticipantCond, R: userParticipantCond(user2)}}))
// Add a role denying access to sessions with root login.
role = denyWhere(`equals(session.login, "root")`)
noRootLoginCond := &types.WhereExpr{Not: &types.WhereExpr{Equals: types.WhereExpr2{L: &types.WhereExpr{Field: "login"}, R: &types.WhereExpr{Literal: "root"}}}}
set = append(set, role)
cond, err = set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, &types.WhereExpr{And: types.WhereExpr2{L: noRootLoginCond, R: userParticipantCond(emptyUser)}}))
cond, err = set.ExtractConditionForIdentifier(&Context{User: user}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, &types.WhereExpr{And: types.WhereExpr2{L: noRootLoginCond, R: userParticipantCond(user)}}))
cond, err = set.ExtractConditionForIdentifier(&Context{User: user2}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, &types.WhereExpr{And: types.WhereExpr2{L: noRootLoginCond, R: &types.WhereExpr{Or: types.WhereExpr2{L: guestParticipantCond, R: userParticipantCond(user2)}}}}))
// Add a role denying access for user2.
role = denyWhere(fmt.Sprintf(`equals(user.metadata.name, "%s")`, user2.GetName()))
set = append(set, role)
cond, err = set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, &types.WhereExpr{And: types.WhereExpr2{L: noRootLoginCond, R: userParticipantCond(emptyUser)}}))
cond, err = set.ExtractConditionForIdentifier(&Context{User: user}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, &types.WhereExpr{And: types.WhereExpr2{L: noRootLoginCond, R: userParticipantCond(user)}}))
_, err = set.ExtractConditionForIdentifier(&Context{User: user2}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
// Add a role allowing access to all sessions.
// This should cause all the other allow rules' conditions to be dropped.
role = allowWhere(``)
set = append(set, role)
cond, err = set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, noRootLoginCond))
cond, err = set.ExtractConditionForIdentifier(&Context{User: user}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.NoError(t, err)
require.Empty(t, gocmp.Diff(cond, noRootLoginCond))
_, err = set.ExtractConditionForIdentifier(&Context{User: user2}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
// Add a role denying access to all sessions.
// This should make all calls return an AccessDenied.
role = denyWhere(``)
set = append(set, role)
_, err = set.ExtractConditionForIdentifier(&Context{}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
_, err = set.ExtractConditionForIdentifier(&Context{User: user}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
_, err = set.ExtractConditionForIdentifier(&Context{User: user2}, apidefaults.Namespace, types.KindSession, types.VerbList, SessionIdentifier)
require.True(t, trace.IsAccessDenied(err))
}
func TestCheckKubeGroupsAndUsers(t *testing.T) {
roleA := &types.RoleV6{
Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
KubernetesLabelsExpression: `labels["env"] == "dev"`,
},
},
}
roleB := &types.RoleV6{
Metadata: types.Metadata{Name: "roleB", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubeGroups: []string{"limited"},
KubeUsers: []string{"limited-user"},
KubernetesLabels: map[string]apiutils.Strings{
"env": []string{"prod"},
},
},
},
}
roleC := &types.RoleV6{
Metadata: types.Metadata{Name: "roleC", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubeGroups: []string{"system:masters", "groupB"},
KubeUsers: []string{"user"},
KubernetesLabels: map[string]apiutils.Strings{
"env": []string{"dev", "prod"},
},
},
},
}
testCases := []struct {
name string
kubeResLabels map[string]string
roles RoleSet
wantUsers []string
wantGroups []string
errorFunc func(t *testing.T, err error)
}{
{
name: "empty kube labels should returns all kube users/groups",
roles: RoleSet{
&types.RoleV6{
Metadata: types.Metadata{Name: "role-dev", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubeUsers: []string{"dev-user"},
KubeGroups: []string{"system:masters"},
KubernetesLabels: map[string]apiutils.Strings{
"*": []string{"*"},
},
},
},
},
&types.RoleV6{
Metadata: types.Metadata{Name: "role-prod", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubeUsers: []string{"limited-user"},
KubeGroups: []string{"limited"},
KubernetesLabels: map[string]apiutils.Strings{
"*": []string{"*"},
},
},
},
},
},
wantUsers: []string{"dev-user", "limited-user"},
wantGroups: []string{"limited", "system:masters"},
},
{
name: "dev accesses should allow system:masters roles",
roles: RoleSet{roleA, roleB},
kubeResLabels: map[string]string{
"env": "dev",
},
wantUsers: []string{"dev-user"},
wantGroups: []string{"system:masters"},
},
{
name: "prod limited access",
roles: RoleSet{roleA, roleB},
kubeResLabels: map[string]string{
"env": "prod",
},
wantUsers: []string{"limited-user"},
wantGroups: []string{"limited"},
},
{
name: "combine kube users/groups for different roles",
roles: RoleSet{roleA, roleB, roleC},
kubeResLabels: map[string]string{
"env": "prod",
},
wantUsers: []string{"limited-user", "user"},
wantGroups: []string{"groupB", "limited", "system:masters"},
},
{
name: "all prod group",
roles: RoleSet{roleC},
kubeResLabels: map[string]string{
"env": "prod",
},
wantUsers: []string{"user"},
wantGroups: []string{"system:masters", "groupB"},
},
{
name: "deny system:masters prod kube group",
roles: RoleSet{
roleC,
&types.RoleV6{
Metadata: types.Metadata{Name: "roleD", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
KubeGroups: []string{"system:masters"},
KubernetesLabels: map[string]apiutils.Strings{
"env": []string{"prod", "dev"},
},
},
},
},
},
kubeResLabels: map[string]string{
"env": "prod",
},
wantUsers: []string{"user"},
wantGroups: []string{"groupB"},
},
{
name: "deny access with system:masters kube group",
kubeResLabels: map[string]string{
"env": "prod",
"release": "test",
},
roles: RoleSet{
&types.RoleV6{
Metadata: types.Metadata{Name: "roleA", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubeGroups: []string{"system:masters", "groupA"},
KubeUsers: []string{"dev-user"},
KubernetesLabels: map[string]apiutils.Strings{
"env": []string{"prod"},
"release": []string{"test"},
},
},
},
},
&types.RoleV6{
Metadata: types.Metadata{Name: "deny", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
KubeGroups: []string{"system:masters"},
KubernetesLabels: map[string]apiutils.Strings{
"env": []string{"prod"},
},
},
},
},
},
wantUsers: []string{"dev-user"},
wantGroups: []string{"groupA"},
},
{
name: "v5 role empty deny.kubernetes_labels",
kubeResLabels: nil,
roles: RoleSet{
&types.RoleV6{
Version: types.V5,
Metadata: types.Metadata{Name: "roleV5A", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
Deny: types.RoleConditions{
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
},
},
},
errorFunc: func(t *testing.T, err error) {
require.ErrorAs(t, err, new(*trace.NotFoundError))
},
},
{
name: "v5 role with wildcard deny.kubernetes_labels",
kubeResLabels: nil,
roles: RoleSet{
&types.RoleV6{
Version: types.V5,
Metadata: types.Metadata{Name: "roleV5A", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
Deny: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
},
},
},
errorFunc: func(t *testing.T, err error) {
require.ErrorAs(t, err, new(*trace.NotFoundError))
},
},
{
name: "v3 role with empty deny.kubernetes_labels",
kubeResLabels: nil,
roles: RoleSet{
&types.RoleV6{
Version: types.V3,
Metadata: types.Metadata{Name: "roleV3A", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
Deny: types.RoleConditions{
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
},
},
},
errorFunc: func(t *testing.T, err error) {
require.ErrorAs(t, err, new(*trace.NotFoundError))
},
},
{
name: "v3 with wildcard deny.kubernetes_labels",
kubeResLabels: nil,
roles: RoleSet{
&types.RoleV6{
Version: types.V3,
Metadata: types.Metadata{Name: "roleV3A", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
Deny: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
},
},
},
errorFunc: func(t *testing.T, err error) {
require.ErrorAs(t, err, new(*trace.NotFoundError))
},
},
{
name: "v3 role with custom deny.kubernetes_labels",
kubeResLabels: nil,
roles: RoleSet{
&types.RoleV6{
Version: types.V3,
Metadata: types.Metadata{Name: "roleV3A", Namespace: apidefaults.Namespace},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
Deny: types.RoleConditions{
KubernetesLabels: map[string]apiutils.Strings{"env": []string{"env"}},
KubeGroups: []string{"system:masters"},
KubeUsers: []string{"dev-user"},
},
},
},
},
wantUsers: []string{"dev-user"},
wantGroups: []string{"system:masters"},
},
}
var userTraits wrappers.Traits
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
matcher := NewKubernetesClusterLabelMatcher(tc.kubeResLabels, "alice", userTraits)
gotGroups, gotUsers, err := tc.roles.CheckKubeGroupsAndUsers(time.Hour, true, matcher)
if tc.errorFunc == nil {
require.NoError(t, err)
} else {
tc.errorFunc(t, err)
}
require.ElementsMatch(t, tc.wantUsers, gotUsers)
require.ElementsMatch(t, tc.wantGroups, gotGroups)
})
}
}
func TestWindowsDesktopGroups(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
desc string
roles []types.Role
desktopLabels map[string]string
expectDenied bool
expectGroups []string
}{
{
desc: "allow labels",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"env": {"prod"}}
r.Spec.Allow.DesktopGroups = []string{"a", "b", "c"}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(true)
}),
},
desktopLabels: map[string]string{"env": "prod"},
expectGroups: []string{"a", "b", "c"},
},
{
desc: "allow expression",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = nil
r.Spec.Allow.WindowsDesktopLabelsExpression = `labels["env"] == "prod"`
r.Spec.Allow.DesktopGroups = []string{"a", "b", "c"}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(true)
}),
},
desktopLabels: map[string]string{"env": "prod"},
expectGroups: []string{"a", "b", "c"},
},
{
desc: "option denied",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"*": {"*"}}
r.Spec.Allow.DesktopGroups = []string{"a", "b", "c"}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(true)
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"env": {"prod"}}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(false)
}),
},
desktopLabels: map[string]string{"env": "prod"},
expectDenied: true,
},
{
desc: "irrelevant deny",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"*": {"*"}}
r.Spec.Allow.DesktopGroups = []string{"a", "b", "c"}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(true)
}),
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"env": {"prod"}}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(false)
}),
},
desktopLabels: map[string]string{"env": "staging"},
expectGroups: []string{"a", "b", "c"},
},
{
desc: "one group denied",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.WindowsDesktopLabels = types.Labels{"env": {"prod"}}
r.Spec.Allow.DesktopGroups = []string{"a", "b", "c"}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(true)
}),
newRole(func(r *types.RoleV6) {
r.Spec.Deny.WindowsDesktopLabels = nil
r.Spec.Deny.WindowsDesktopLabelsExpression = matchAllExpression
r.Spec.Deny.DesktopGroups = []string{"b"}
r.Spec.Options.CreateDesktopUser = types.NewBoolOption(true)
}),
},
desktopLabels: map[string]string{"env": "prod"},
expectGroups: []string{"a", "c"},
},
} {
t.Run(tc.desc, func(t *testing.T) {
desktop := mustMakeTestWindowsDesktop(tc.desktopLabels)
set := NewRoleSet(tc.roles...)
accessChecker := makeAccessCheckerWithRoleSet(set)
groups, err := accessChecker.DesktopGroups(desktop)
if tc.expectDenied {
require.True(t, trace.IsAccessDenied(err), "expected access denied error, got %v", err)
return
}
require.NoError(t, err, trace.DebugReport(err))
require.ElementsMatch(t, tc.expectGroups, groups)
})
}
}
func TestGetKubeResources(t *testing.T) {
t.Parallel()
podA := types.KubernetesResource{
Kind: "pods",
Namespace: "test",
Name: "podA",
}
podB := types.KubernetesResource{
Kind: "pods",
Namespace: "test",
Name: "podB",
}
for _, tc := range []struct {
desc string
roles []types.Role
clusterLabels map[string]string
expectAllowed, expectDenied []types.KubernetesResource
}{
{
desc: "labels allow",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.KubernetesLabels = types.Labels{"env": {"prod"}}
r.Spec.Allow.KubernetesResources = []types.KubernetesResource{podA, podB}
}),
},
clusterLabels: map[string]string{"env": "prod"},
expectAllowed: []types.KubernetesResource{podA, podB},
},
{
desc: "labels expression allow",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.KubernetesLabelsExpression = `labels["env"] == "prod"`
r.Spec.Allow.KubernetesResources = []types.KubernetesResource{podA, podB}
}),
},
clusterLabels: map[string]string{"env": "prod"},
expectAllowed: []types.KubernetesResource{podA, podB},
},
{
desc: "labels expression matches username",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.KubernetesLabelsExpression = `labels["owner"] == user.metadata.name`
r.Spec.Allow.KubernetesResources = []types.KubernetesResource{podA, podB}
}),
},
clusterLabels: map[string]string{"owner": "alice"},
expectAllowed: []types.KubernetesResource{podA, podB},
},
{
desc: "one denied",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.KubernetesLabelsExpression = `labels["env"] == "prod"`
r.Spec.Allow.KubernetesResources = []types.KubernetesResource{podA, podB}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Deny.KubernetesLabels = types.Labels{"env": {"prod"}}
r.Spec.Deny.KubernetesResources = []types.KubernetesResource{podA}
}),
},
clusterLabels: map[string]string{"env": "prod"},
expectAllowed: []types.KubernetesResource{podA, podB},
expectDenied: []types.KubernetesResource{podA},
},
{
desc: "irrelevant deny",
roles: []types.Role{
newRole(func(r *types.RoleV6) {
r.Spec.Allow.KubernetesLabelsExpression = `labels["env"] == "staging"`
r.Spec.Allow.KubernetesResources = []types.KubernetesResource{podA, podB}
}),
newRole(func(r *types.RoleV6) {
r.Spec.Deny.KubernetesLabels = types.Labels{"env": {"prod"}}
r.Spec.Deny.KubernetesResources = []types.KubernetesResource{podA}
}),
},
clusterLabels: map[string]string{"env": "staging"},
expectAllowed: []types.KubernetesResource{podA, podB},
expectDenied: []types.KubernetesResource{podA},
},
} {
t.Run(tc.desc, func(t *testing.T) {
cluster, err := types.NewKubernetesClusterV3(types.Metadata{
Name: "testcluster",
Labels: tc.clusterLabels,
}, types.KubernetesClusterSpecV3{})
require.NoError(t, err)
set := NewRoleSet(tc.roles...)
accessChecker := makeAccessCheckerWithRoleSet(set)
allowed, denied := accessChecker.GetKubeResources(cluster)
tc.expectAllowed = append(tc.expectAllowed, types.KubernetesResourceSelfSubjectAccessReview)
require.ElementsMatch(t, tc.expectAllowed, allowed, "allow list mismatch")
require.ElementsMatch(t, tc.expectDenied, denied, "deny list mismatch")
})
}
}
func TestSessionRecordingMode(t *testing.T) {
tests := map[string]struct {
service constants.SessionRecordingService
expectedMode constants.SessionRecordingMode
rolesOptions []types.RoleOptions
}{
"service-specific option": {
expectedMode: constants.SessionRecordingModeBestEffort,
service: constants.SessionRecordingServiceSSH,
rolesOptions: []types.RoleOptions{
{RecordSession: &types.RecordSession{SSH: constants.SessionRecordingModeBestEffort}},
},
},
"service-specific multiple roles": {
expectedMode: constants.SessionRecordingModeBestEffort,
service: constants.SessionRecordingServiceSSH,
rolesOptions: []types.RoleOptions{
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeStrict}},
{RecordSession: &types.RecordSession{SSH: constants.SessionRecordingModeBestEffort}},
},
},
"strict service-specific multiple roles": {
expectedMode: constants.SessionRecordingModeStrict,
service: constants.SessionRecordingServiceSSH,
rolesOptions: []types.RoleOptions{
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeStrict}},
{RecordSession: &types.RecordSession{SSH: constants.SessionRecordingModeBestEffort}},
{RecordSession: &types.RecordSession{SSH: constants.SessionRecordingModeStrict}},
},
},
"strict default multiple roles": {
expectedMode: constants.SessionRecordingModeStrict,
service: constants.SessionRecordingServiceSSH,
rolesOptions: []types.RoleOptions{
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeBestEffort}},
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeBestEffort}},
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeStrict}},
},
},
"default multiple roles": {
expectedMode: constants.SessionRecordingModeBestEffort,
service: constants.SessionRecordingServiceSSH,
rolesOptions: []types.RoleOptions{
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeBestEffort}},
{RecordSession: &types.RecordSession{Default: constants.SessionRecordingModeBestEffort}},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
roles := make([]types.Role, len(test.rolesOptions))
for i := range roles {
roles[i] = &types.RoleV6{
Spec: types.RoleSpecV6{Options: test.rolesOptions[i]},
}
}
roleSet := RoleSet(roles)
require.Equal(t, test.expectedMode, roleSet.SessionRecordingMode(test.service))
})
}
}
func TestHostUsers_getGroups(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
test string
groups []string
roles RoleSet
server types.Server
}{
{
test: "test exact match, one group, one role",
groups: []string{"group"},
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostGroups: []string{"group"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "test deny on group entry",
groups: []string{"group"},
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostGroups: []string{"group", "groupdel"},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Deny: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostGroups: []string{"groupdel"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "multiple roles, one no match",
groups: []string{"group1", "group2"},
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostGroups: []string{"group1"},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
HostGroups: []string{"group2"},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"fail": []string{"abc"}},
HostGroups: []string{"notpresentgroup"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
} {
t.Run(tc.test, func(t *testing.T) {
accessChecker := makeAccessCheckerWithRoleSet(tc.roles)
hu, err := accessChecker.HostUsers(tc.server)
require.NoError(t, err)
require.ElementsMatch(t, tc.groups, hu.Info.GetGroups())
})
}
}
func TestHostUsers_HostSudoers(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
test string
sudoers []string
roles RoleSet
server types.Server
}{
{
test: "test exact match, one sudoer entry, one role",
sudoers: []string{"%sudo ALL=(ALL) ALL"},
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"%sudo ALL=(ALL) ALL"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "multiple roles, one not matching",
sudoers: []string{"sudoers entry 1", "sudoers entry 2"},
roles: NewRoleSet(&types.RoleV6{
Metadata: types.Metadata{
Name: "a",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"sudoers entry 1"},
},
},
}, &types.RoleV6{
Metadata: types.Metadata{
Name: "b",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
HostSudoers: []string{"sudoers entry 2"},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"fail": []string{"abc"}},
HostSudoers: []string{"not present sudoers entry"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "glob deny",
sudoers: nil,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"%sudo ALL=(ALL) ALL"},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"*"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "line deny",
sudoers: []string{"%sudo ALL=(ALL) ALL"},
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{
"%sudo ALL=(ALL) ALL",
"removed entry",
},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"removed entry"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "multiple roles, order preserved by role name",
sudoers: []string{"sudoers entry 1", "sudoers entry 2", "sudoers entry 3", "sudoers entry 4"},
roles: NewRoleSet(&types.RoleV6{
Metadata: types.Metadata{
Name: "a",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"sudoers entry 1"},
},
},
}, &types.RoleV6{
Metadata: types.Metadata{
Name: "c",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
HostSudoers: []string{"sudoers entry 4", "sudoers entry 1"},
},
},
}, &types.RoleV6{
Metadata: types.Metadata{
Name: "b",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
HostSudoers: []string{"sudoers entry 2", "sudoers entry 3"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "duplication handled",
sudoers: []string{"sudoers entry 2"},
roles: NewRoleSet(&types.RoleV6{
Metadata: types.Metadata{
Name: "a",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"sudoers entry 1"},
},
},
}, &types.RoleV6{ // DENY sudoers entry 1
Metadata: types.Metadata{
Name: "d",
},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
HostSudoers: []string{"sudoers entry 1"},
},
},
}, &types.RoleV6{ // duplicate sudoers entry 1 case also gets removed
Metadata: types.Metadata{
Name: "c",
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}},
HostSudoers: []string{"sudoers entry 1", "sudoers entry 2"},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
} {
t.Run(tc.test, func(t *testing.T) {
accessChecker := makeAccessCheckerWithRoleSet(tc.roles)
info, err := accessChecker.HostSudoers(tc.server)
require.NoError(t, err)
require.Equal(t, tc.sudoers, info)
})
}
}
func TestHostUsers_CanCreateHostUser(t *testing.T) {
t.Parallel()
type testCase struct {
test string
canCreate bool
roles RoleSet
server types.Server
expectedMode types.CreateHostUserMode
}
createDefaultTCWithMode := func(name string, canCreate bool, mode types.CreateHostUserMode) testCase {
return testCase{
test: name,
canCreate: canCreate,
expectedMode: mode,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: mode,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
}
}
for _, tc := range []testCase{
{
test: "test exact match, one role, can create",
canCreate: true,
expectedMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "test two roles, 1 exact match, one can create",
canCreate: false,
expectedMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(false),
},
Allow: types.RoleConditions{
NodeLabelsExpression: `labels["success"] == "abc"`,
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "test three roles, 2 exact match, both can create",
canCreate: true,
expectedMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(true),
},
Allow: types.RoleConditions{
NodeLabelsExpression: `labels["success"] == "abc"`,
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: types.NewBoolOption(false),
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"unmatched": []string{"abc"}},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "test cant create when create host user is nil",
canCreate: false,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUser: nil,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
createDefaultTCWithMode(
"test cant create when create host user mode is off",
false,
types.CreateHostUserMode_HOST_USER_MODE_OFF,
),
createDefaultTCWithMode(
"test can create when create host user mode is insecure-drop",
true,
types.CreateHostUserMode_HOST_USER_MODE_INSECURE_DROP,
),
createDefaultTCWithMode(
"test can create when create host user mode is keep",
true,
types.CreateHostUserMode_HOST_USER_MODE_KEEP,
),
{
test: "test three roles, 3 exact match, one off",
canCreate: false,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_OFF,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_INSECURE_DROP,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
{
test: "test three roles, 3 exact match, mode defaults to keep",
canCreate: true,
expectedMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
roles: NewRoleSet(&types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}, &types.RoleV6{
Spec: types.RoleSpecV6{
Options: types.RoleOptions{
CreateHostUserMode: types.CreateHostUserMode_HOST_USER_MODE_KEEP,
},
Allow: types.RoleConditions{
NodeLabels: types.Labels{"success": []string{"abc"}},
},
},
}),
server: &types.ServerV2{
Kind: types.KindNode,
Metadata: types.Metadata{
Labels: map[string]string{
"success": "abc",
},
},
},
},
} {
t.Run(tc.test, func(t *testing.T) {
accessChecker := makeAccessCheckerWithRoleSet(tc.roles)
hu, err := accessChecker.HostUsers(tc.server)
require.NoError(t, err)
if tc.canCreate {
require.NotEmpty(t, hu.AllowedBy)
require.Empty(t, hu.DeniedBy)
require.Equal(t, convertHostUserMode(tc.expectedMode), hu.Info.GetMode())
} else {
require.NotEmpty(t, hu.DeniedBy)
require.Nil(t, hu.Info)
}
})
}
}
type mockCurrentUserRoleGetter struct {
getCurrentUserError error
currentUser types.User
nameToRole map[string]types.Role
}
func (m mockCurrentUserRoleGetter) GetCurrentUser(ctx context.Context) (types.User, error) {
if m.getCurrentUserError != nil {
return nil, trace.Wrap(m.getCurrentUserError)
}
if m.currentUser != nil {
return m.currentUser, nil
}
return nil, trace.NotFound("currentUser not set")
}
func (m mockCurrentUserRoleGetter) GetCurrentUserRoles(ctx context.Context) ([]types.Role, error) {
var roles []types.Role
for _, role := range m.nameToRole {
roles = append(roles, role)
}
return roles, nil
}
func (m mockCurrentUserRoleGetter) GetRole(ctx context.Context, name string) (types.Role, error) {
if role, ok := m.nameToRole[name]; ok {
return role, nil
}
return nil, trace.NotFound("role not found: %v", name)
}
type mockCurrentUser struct {
types.User
roles []string
traits wrappers.Traits
}
func (u mockCurrentUser) GetRoles() []string {
return u.roles
}
func (u mockCurrentUser) GetTraits() map[string][]string {
return u.traits
}
func (u mockCurrentUser) GetName() string {
return "mockCurrentUser"
}
func (u mockCurrentUser) toRemoteUserFromCluster(localClusterName string) types.User {
return mockRemoteUser{
mockCurrentUser: u,
localClusterName: localClusterName,
}
}
type mockRemoteUser struct {
mockCurrentUser
localClusterName string
}
// GetName returns the username from the remote cluster's view.
func (u mockRemoteUser) GetName() string {
return UsernameForRemoteCluster(u.mockCurrentUser.GetName(), u.localClusterName)
}
func TestNewAccessCheckerForRemoteCluster(t *testing.T) {
user := mockCurrentUser{
roles: []string{"dev", "admin"},
traits: map[string][]string{
"logins": {"currentUserTraitLogin"},
},
}
devRole := newRole(func(r *types.RoleV6) {
r.Metadata.Name = "dev"
r.Spec.Allow.Logins = []string{"{{internal.logins}}"}
})
adminRole := newRole(func(r *types.RoleV6) {
r.Metadata.Name = "admin"
})
currentUserRoleGetter := mockCurrentUserRoleGetter{
nameToRole: map[string]types.Role{
"dev": devRole,
"admin": adminRole,
},
currentUser: user.toRemoteUserFromCluster("localCluster"),
}
localAccessInfo := AccessInfoFromUserState(user)
require.Equal(t, "mockCurrentUser", localAccessInfo.Username)
accessChecker, err := NewAccessCheckerForRemoteCluster(context.Background(), localAccessInfo, "remoteCluster", currentUserRoleGetter)
require.NoError(t, err)
// After sort: "admin","default-implicit-role","dev"
roles := accessChecker.Roles()
slices.SortFunc(roles, func(a, b types.Role) int {
return cmp.Compare(a.GetName(), b.GetName())
})
require.Len(t, roles, 3)
require.Contains(t, roles, devRole, "devRole not found in roleSet")
require.Contains(t, roles, adminRole, "adminRole not found in roleSet")
require.Equal(t, []string{"currentUserTraitLogin"}, roles[2].GetLogins(types.Allow))
mustHaveUsername(t, accessChecker, "remote-mockCurrentUser-localCluster")
}
func mustHaveUsername(t *testing.T, access AccessChecker, wantUsername string) {
t.Helper()
accessImpl, ok := access.(*accessChecker)
require.True(t, ok)
require.Equal(t, wantUsername, accessImpl.info.Username)
}
func TestRoleSet_GetAccessState(t *testing.T) {
testCases := []struct {
name string
roleMFARequireTypes []types.RequireMFAType
authPrefMFARequireType types.RequireMFAType
expectState AccessState
}{
{
name: "empty role set and auth pref requirement",
expectState: AccessState{
MFARequired: MFARequiredNever,
},
},
{
name: "no roles require mfa, auth pref doesn't require mfa",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_OFF,
types.RequireMFAType_OFF,
},
authPrefMFARequireType: types.RequireMFAType_OFF,
expectState: AccessState{
MFARequired: MFARequiredNever,
},
},
{
name: "no roles require mfa, auth pref requires mfa",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_OFF,
types.RequireMFAType_OFF,
},
authPrefMFARequireType: types.RequireMFAType_SESSION,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "some roles require mfa, auth pref doesn't require mfa",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_OFF,
types.RequireMFAType_SESSION,
},
authPrefMFARequireType: types.RequireMFAType_OFF,
expectState: AccessState{
MFARequired: MFARequiredPerRole,
},
},
{
name: "some roles require mfa, auth pref requires mfa",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_OFF,
types.RequireMFAType_SESSION,
},
authPrefMFARequireType: types.RequireMFAType_SESSION,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "all roles require mfa, auth pref requires mfa",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_SESSION,
types.RequireMFAType_SESSION,
},
authPrefMFARequireType: types.RequireMFAType_SESSION,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "all roles require mfa, auth pref doesn't require mfa",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_SESSION,
types.RequireMFAType_SESSION,
},
authPrefMFARequireType: types.RequireMFAType_OFF,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "auth pref requires hardware key",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_OFF,
},
authPrefMFARequireType: types.RequireMFAType_SESSION_AND_HARDWARE_KEY,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "auth pref requires hardware key touch",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_OFF,
},
authPrefMFARequireType: types.RequireMFAType_HARDWARE_KEY_TOUCH,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "role requires hardware key",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_SESSION_AND_HARDWARE_KEY,
},
authPrefMFARequireType: types.RequireMFAType_OFF,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
{
name: "role requires hardware key touch",
roleMFARequireTypes: []types.RequireMFAType{
types.RequireMFAType_HARDWARE_KEY_TOUCH,
},
authPrefMFARequireType: types.RequireMFAType_OFF,
expectState: AccessState{
MFARequired: MFARequiredAlways,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
authPref, err := types.NewAuthPreference(types.AuthPreferenceSpecV2{
RequireMFAType: tc.authPrefMFARequireType,
})
require.NoError(t, err, "NewAuthPreference failed")
var set RoleSet
for _, roleRequirement := range tc.roleMFARequireTypes {
set = append(set, newRole(func(r *types.RoleV6) {
r.Spec.Options.RequireMFAType = roleRequirement
}))
}
require.Equal(t, tc.expectState, set.GetAccessState(authPref))
})
}
}
func TestAzureIdentityMatcher_Match(t *testing.T) {
tests := []struct {
name string
identities []string
role types.Role
matchType types.RoleConditionType
wantMatched []string
}{
{
name: "allow ignores wildcard",
identities: []string{"foo", "BAR", "baz"},
role: newRole(func(r *types.RoleV6) {
r.Spec.Allow.AzureIdentities = []string{"*", "bar", "baz"}
}),
matchType: types.Allow,
wantMatched: []string{"BAR", "baz"},
},
{
name: "deny matches wildcard",
identities: []string{"FoO", "BAr", "baz"},
role: newRole(func(r *types.RoleV6) {
r.Spec.Deny.AzureIdentities = []string{"*", "bar", "baz"}
}),
matchType: types.Deny,
wantMatched: []string{"FoO", "BAr", "baz"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var matched []string
for _, identity := range tt.identities {
m := &AzureIdentityMatcher{Identity: identity}
if ok, _ := m.Match(tt.role, tt.matchType); ok {
matched = append(matched, identity)
}
}
require.Equal(t, tt.wantMatched, matched)
})
}
}
func TestMatchAzureIdentity(t *testing.T) {
tests := []struct {
name string
identities []string
identity string
matchWildcard bool
wantMatch bool
wantMatchType string
}{
{
name: "allow exact match",
identities: []string{"foo", "bar", "baz"},
identity: "bar",
matchWildcard: false,
wantMatch: true,
wantMatchType: "element matched",
},
{
name: "allow case insensitive match",
identities: []string{"foo", "bar", "baz"},
identity: "BAR",
matchWildcard: false,
wantMatch: true,
wantMatchType: "element matched",
},
{
name: "allow wildcard mismatch",
identities: []string{"foo", "bar", "*"},
identity: "baz",
matchWildcard: false,
wantMatch: false,
wantMatchType: "no match, role selectors [foo bar *], identity: baz",
},
{
name: "deny exact match",
identities: []string{"foo", "bar", "baz"},
identity: "bar",
matchWildcard: true,
wantMatch: true,
wantMatchType: "element matched",
},
{
name: "deny case insensitive match",
identities: []string{"foo", "bar", "baz"},
identity: "BAZ",
matchWildcard: true,
wantMatch: true,
wantMatchType: "element matched",
},
{
name: "deny wildcard match",
identities: []string{"foo", "bar", "*"},
identity: "baz",
matchWildcard: true,
wantMatch: true,
wantMatchType: "wildcard matched",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotMatch, gotMatchType := MatchAzureIdentity(tt.identities, tt.identity, tt.matchWildcard)
require.Equal(t, tt.wantMatch, gotMatch)
require.Equal(t, tt.wantMatchType, gotMatchType)
})
}
}
func TestMatchValidAzureIdentity(t *testing.T) {
tests := []struct {
name string
identity string
valid bool
ignoreParseResourceID bool
}{
{
name: "wildcard",
identity: "*",
valid: true,
ignoreParseResourceID: true,
},
{
name: "correct format",
identity: "/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
valid: true,
},
{
name: "correct format with underscore",
identity: "/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/az-cli-access_group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure_under",
valid: true,
},
{
name: "correct format, case insensitive match",
identity: "/SUBscriptions/0000000000000-0000-CAFE-A0B0C0D0E0F0/RESOURCEGroups/EXAMPLE-resource-group/provIders/microsoft.managedidentity/userassignedidentities/Tele10329azure",
valid: true,
},
{
name: "invalid format # 1",
identity: "/subscriptions/0000000000000-XXXX-XXXX-000000000000/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
valid: false,
},
{
name: "invalid format # 2",
identity: "/subscriptions/0000000000000-0000-0000-000000000000/resourceGroups/example resource group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
valid: false,
},
{
name: "invalid format # 3",
identity: "/subscriptions/0000000000000-0000-0000-000000000000/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport azure",
valid: false,
},
{
name: "invalid format # 4",
identity: "/subscriptions/0000000000000-0000-0000-000000000000/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/",
valid: false,
},
{
name: "invalid format # 5",
identity: "/subscriptions/0000000000000-0000-0000-000000000000/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities",
valid: false,
},
{
name: "invalid format # 6",
identity: "whatever /subscriptions/0000000000000-0000-0000-000000000000/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/foo",
valid: false,
},
{
name: "invalid format # 7",
identity: "///subscriptions///1020304050607-cafe-8090-a0b0c0d0e0f0///resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/teleport-azure",
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.valid, MatchValidAzureIdentity(tt.identity))
if tt.ignoreParseResourceID == false {
// if it ParseResourceID returns an error, we expect MatchValidAzureIdentity to do the same.
_, err := arm.ParseResourceID(tt.identity)
if err != nil {
require.False(t, MatchValidAzureIdentity(tt.identity))
}
}
})
}
}
func TestGCPServiceAccountMatcher_Match(t *testing.T) {
tests := []struct {
name string
accounts []string
role types.Role
matchType types.RoleConditionType
wantMatched []string
}{
{
name: "allow ignores wildcard",
accounts: []string{"foo", "bar", "baz"},
role: newRole(func(r *types.RoleV6) {
r.Spec.Allow.GCPServiceAccounts = []string{"*", "bar", "baz"}
}),
matchType: types.Allow,
wantMatched: []string{"bar", "baz"},
},
{
name: "deny matches wildcard",
accounts: []string{"FoO", "BAr", "baz"},
role: newRole(func(r *types.RoleV6) {
r.Spec.Deny.GCPServiceAccounts = []string{"*"}
}),
matchType: types.Deny,
wantMatched: []string{"FoO", "BAr", "baz"},
},
{
name: "non-wildcard deny matches",
accounts: []string{"foo", "bar", "baz"},
role: newRole(func(r *types.RoleV6) {
r.Spec.Deny.GCPServiceAccounts = []string{"foo", "bar", "admin"}
}),
matchType: types.Deny,
wantMatched: []string{"foo", "bar"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var matched []string
for _, account := range tt.accounts {
m := &GCPServiceAccountMatcher{ServiceAccount: account}
if ok, _ := m.Match(tt.role, tt.matchType); ok {
matched = append(matched, account)
}
}
require.Equal(t, tt.wantMatched, matched)
})
}
}
func TestMatchGCPServiceAccount(t *testing.T) {
tests := []struct {
name string
accounts []string
account string
matchWildcard bool
wantMatch bool
wantMatchType string
}{
{
name: "allow exact match",
accounts: []string{"foo", "bar", "baz"},
account: "bar",
matchWildcard: false,
wantMatch: true,
wantMatchType: "element matched",
},
{
name: "wildcard in allow doesn't work",
accounts: []string{"foo", "bar", "*"},
account: "baz",
matchWildcard: false,
wantMatch: false,
wantMatchType: "no match, role selectors [foo bar *], identity: baz",
},
{
name: "deny exact match",
accounts: []string{"foo", "bar", "baz"},
account: "bar",
matchWildcard: true,
wantMatch: true,
wantMatchType: "element matched",
},
{
name: "wildcard in deny works",
accounts: []string{"foo", "bar", "*"},
account: "baz",
matchWildcard: true,
wantMatch: true,
wantMatchType: "wildcard matched",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotMatch, gotMatchType := MatchGCPServiceAccount(tt.accounts, tt.account, tt.matchWildcard)
require.Equal(t, tt.wantMatch, gotMatch)
require.Equal(t, tt.wantMatchType, gotMatchType)
})
}
}
func TestKubeResourcesMatcher(t *testing.T) {
defaultRole, err := types.NewRole("kube",
types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "dev",
Name: types.Wildcard,
APIGroup: types.Wildcard,
},
{
Kind: "pods",
Namespace: "default",
Name: "nginx-*",
APIGroup: types.Wildcard,
},
},
},
Deny: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "default",
Name: "restricted",
APIGroup: types.Wildcard,
},
},
},
},
)
require.NoError(t, err)
prodRole, err := types.NewRole("kube2",
types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "prod",
Name: "pod",
APIGroup: types.Wildcard,
},
},
},
},
)
require.NoError(t, err)
invalidRole, err := types.NewRole("kube3",
types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: `^[($`,
Name: `^[($`,
APIGroup: types.Wildcard,
},
},
},
},
)
require.NoError(t, err)
type args struct {
resources []types.KubernetesResource
roles []types.Role
cond types.RoleConditionType
}
tests := []struct {
name string
args args
wantMatch []bool
assertErr require.ErrorAssertionFunc
unmatchedResources []string
}{
{
name: "user requests a valid subset of pods for defaultRole",
args: args{
roles: []types.Role{defaultRole},
cond: types.Allow,
resources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "dev",
Name: "pod",
},
{
Kind: "pods",
Namespace: "default",
Name: "nginx-*",
},
},
},
wantMatch: boolsToSlice(true),
assertErr: require.NoError,
unmatchedResources: []string{},
},
{
name: "user requests a valid and invalid pod for role defaultRole",
args: args{
roles: []types.Role{defaultRole},
cond: types.Allow,
resources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "dev",
Name: "pod",
},
{
Kind: "pods",
Namespace: "default",
Name: "nginx*",
},
},
},
// returns true because the first resource matched but the request will fail
// because unmatchedResources is not empty.
wantMatch: boolsToSlice(true),
assertErr: require.NoError,
unmatchedResources: []string{"pods/default/nginx*"},
},
{
name: "user requests a valid subset of pods but distributed across two roles",
args: args{
roles: []types.Role{defaultRole, prodRole},
cond: types.Allow,
resources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "dev",
Name: "pod",
},
{
Kind: "pods",
Namespace: "prod",
Name: "pod",
},
},
},
wantMatch: boolsToSlice(true, true),
assertErr: require.NoError,
unmatchedResources: []string{},
},
{
name: "user requests a pod that does not match any role",
args: args{
roles: []types.Role{defaultRole, prodRole},
cond: types.Allow,
resources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "default",
Name: "pod",
},
},
},
wantMatch: boolsToSlice(false, false),
assertErr: require.NoError,
unmatchedResources: []string{"pods/default/pod"},
},
{
name: "user requests a denied pod",
args: args{
roles: []types.Role{defaultRole},
cond: types.Deny,
resources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "default",
Name: "restricted",
},
},
},
wantMatch: boolsToSlice(true),
assertErr: require.NoError,
unmatchedResources: []string{},
},
{
name: "user requests a role with wrong regex",
args: args{
roles: []types.Role{invalidRole},
cond: types.Allow,
resources: []types.KubernetesResource{
{
Kind: "pods",
Namespace: "default",
Name: "restricted",
},
},
},
wantMatch: boolsToSlice(false),
assertErr: require.Error,
unmatchedResources: []string{"pods/default/restricted"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matcher := NewKubeResourcesMatcher(tt.args.resources)
// Verify each role independently. If a resource matches the role, matched
// is true. Later, after analyzing all roles we verify the resources that
// missed the match.
for i, role := range tt.args.roles {
matched, err := matcher.Match(role, tt.args.cond)
require.Equal(t, tt.wantMatch[i], matched)
tt.assertErr(t, err)
}
unmatched := matcher.Unmatched()
sort.Strings(unmatched)
require.Equal(t, tt.unmatchedResources, unmatched)
})
}
}
func boolsToSlice(v ...bool) []bool {
return v
}
func TestCheckAccessWithLabelExpressions(t *testing.T) {
t.Parallel()
resources := []types.ResourceWithLabels{
&types.ServerV2{Kind: types.KindNode},
&types.KubernetesClusterV3{Kind: types.KindKubernetesCluster},
&types.AppV3{Kind: types.KindApp},
&types.DatabaseV3{Kind: types.KindDatabase},
&types.DatabaseServiceV1{ResourceHeader: types.ResourceHeader{Kind: types.KindDatabaseService}},
&types.WindowsDesktopV3{ResourceHeader: types.ResourceHeader{Kind: types.KindWindowsDesktop}},
&types.WindowsDesktopServiceV3{ResourceHeader: types.ResourceHeader{Kind: types.KindWindowsDesktopService}},
&types.UserGroupV1{ResourceHeader: types.ResourceHeader{Kind: types.KindUserGroup}},
types.Resource153ToResourceWithLabels(beamsv1.Beam_builder{
Kind: types.KindBeam,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Labels: map[string]string{},
}.Build(),
}.Build()),
types.Resource153ToResourceWithLabels(linuxdesktopv1.LinuxDesktop_builder{
Kind: types.KindLinuxDesktop,
Version: types.V1,
Metadata: headerv1.Metadata_builder{
Labels: map[string]string{},
}.Build(),
}.Build()),
}
for _, r := range resources {
r.SetStaticLabels(map[string]string{"env": "prod"})
}
// remoteCluster doesn't implement ResourceWithLabels and access is checked
// with CheckAccessToRemoteCluster instead of checkAccess
remoteCluster := &types.RemoteClusterV3{
Kind: types.KindRemoteCluster,
Metadata: types.Metadata{
Labels: map[string]string{"env": "prod"},
},
}
type option string
const (
unset option = "unset"
match option = "match"
nomatch option = "nomatch"
)
allOptions := []option{unset, match, nomatch}
type testcase struct {
allowLabels, allowLabelsExpression, denyLabels, denyLabelsExpression option
}
var testcases []testcase
for _, al := range allOptions {
for _, ae := range allOptions {
for _, dl := range allOptions {
for _, de := range allOptions {
testcases = append(testcases, testcase{al, ae, dl, de})
}
}
}
}
matchLabels := types.Labels{"env": {"prod"}}
noMatchLabels := types.Labels{"env": {"staging"}}
matchExpression := `contains(user.spec.traits["allow-env"], labels["env"])`
noMatchExpression := `!contains(user.spec.traits["allow-env"], labels["env"])`
labelsForOption := func(o option) types.Labels {
switch o {
case match:
return matchLabels
case nomatch:
return noMatchLabels
}
return nil
}
expressionForOption := func(o option) string {
switch o {
case match:
return matchExpression
case nomatch:
return noMatchExpression
}
return ""
}
makeRole := func(tc testcase, kind string) types.Role {
role, err := types.NewRole("rolename", types.RoleSpecV6{})
require.NoError(t, err)
require.NoError(t, role.SetLabelMatchers(types.Allow, kind, types.LabelMatchers{
Labels: labelsForOption(tc.allowLabels),
Expression: expressionForOption(tc.allowLabelsExpression),
}))
require.NoError(t, role.SetLabelMatchers(types.Deny, kind, types.LabelMatchers{
Labels: labelsForOption(tc.denyLabels),
Expression: expressionForOption(tc.denyLabelsExpression),
}))
return role
}
expectDenied := func(tc testcase) bool {
return tc.denyLabels == match ||
tc.denyLabelsExpression == match ||
tc.allowLabels == nomatch ||
tc.allowLabelsExpression == nomatch ||
(tc.allowLabels == unset && tc.allowLabelsExpression == unset)
}
for _, resource := range resources {
t.Run(resource.GetKind(), func(t *testing.T) {
t.Parallel()
for _, tc := range testcases {
t.Run(fmt.Sprint(tc), func(t *testing.T) {
role := makeRole(tc, resource.GetKind())
rs := NewRoleSet(role)
accessInfo := &AccessInfo{
Roles: []string{role.GetName()},
Traits: wrappers.Traits{
"allow-env": {"prod"},
},
}
accessChecker := NewAccessCheckerWithRoleSet(accessInfo, "testcluster", rs)
err := accessChecker.CheckAccess(resource, AccessState{})
if expectDenied(tc) {
require.True(t, trace.IsAccessDenied(err),
"expected AccessDenied error, got: %v", err)
return
}
require.NoError(t, err, trace.DebugReport(err))
})
}
})
}
t.Run("remote cluster", func(t *testing.T) {
t.Parallel()
for _, tc := range testcases {
t.Run(fmt.Sprint(tc), func(t *testing.T) {
role := makeRole(tc, types.KindRemoteCluster)
rs := NewRoleSet(role)
accessInfo := &AccessInfo{
Roles: []string{role.GetName()},
Traits: wrappers.Traits{
"allow-env": {"prod"},
},
}
accessChecker := NewAccessCheckerWithRoleSet(accessInfo, "testcluster", rs)
err := accessChecker.CheckAccessToRemoteCluster(remoteCluster)
if expectDenied(tc) {
require.True(t, trace.IsAccessDenied(err),
"expected AccessDenied error, got: %v", err)
return
}
require.NoError(t, err, trace.DebugReport(err))
})
}
})
}
func TestCheckAccess_WithLabelExpressionUsername(t *testing.T) {
t.Parallel()
role := newRole(func(r *types.RoleV6) {
r.Spec.Allow.NodeLabelsExpression = `labels["owner"] == user.metadata.name`
})
server := &types.ServerV2{Kind: types.KindNode}
server.SetStaticLabels(map[string]string{"owner": "alice"})
accessChecker := makeAccessCheckerWithRoleSet(NewRoleSet(role))
err := accessChecker.CheckAccess(server, AccessState{})
require.NoError(t, err)
}
func TestCheckSPIFFESVID(t *testing.T) {
t.Parallel()
makeRole := func(allow []*types.SPIFFERoleCondition, deny []*types.SPIFFERoleCondition) types.Role {
role, err := types.NewRole(uuid.NewString(), types.RoleSpecV6{
Allow: types.RoleConditions{
SPIFFE: allow,
},
Deny: types.RoleConditions{
SPIFFE: deny,
},
})
require.NoError(t, err)
return role
}
tests := []struct {
name string
roles []types.Role
spiffeIDPath string
dnsSANs []string
ipSANs []net.IP
requireErr require.ErrorAssertionFunc
}{
{
name: "simple success",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{
"foo.example.com",
"foo.example.net",
},
ipSANs: []net.IP{
{10, 0, 0, 32},
},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
// Non-matching condition.
Path: "/bar/boo",
DNSSANs: []string{},
IPSANs: []string{},
},
{
Path: "/foo/*",
DNSSANs: []string{
"foo.example.com",
"*.example.net",
},
IPSANs: []string{
"10.0.0.1/8",
},
},
}, []*types.SPIFFERoleCondition{}),
},
requireErr: require.NoError,
},
{
name: "deny path matches, but dns and ip sans do not",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{
"foo.example.com",
},
ipSANs: []net.IP{
{10, 0, 0, 32},
},
roles: []types.Role{
makeRole(
[]*types.SPIFFERoleCondition{{
Path: "/foo/*",
DNSSANs: []string{"*"},
IPSANs: []string{"0.0.0.0/0"},
}},
[]*types.SPIFFERoleCondition{{
Path: "/foo/bar",
DNSSANs: []string{"never.example.com"},
IPSANs: []string{"127.0.0.1/32"},
}},
),
},
requireErr: require.NoError,
},
{
name: "regex success",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{
"foo.example.com",
"foo.example.net",
},
ipSANs: []net.IP{
{10, 0, 0, 32},
},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
// Non-matching condition.
Path: "/bar/boo",
DNSSANs: []string{},
IPSANs: []string{},
},
{
Path: `^\/foo\/.*$`,
DNSSANs: []string{
"foo.example.com",
"*.example.net",
},
IPSANs: []string{
"10.0.0.1/8",
},
},
}, []*types.SPIFFERoleCondition{}),
},
requireErr: require.NoError,
},
{
name: "explicit deny - id path",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/foo/*",
DNSSANs: []string{},
IPSANs: []string{},
},
}, []*types.SPIFFERoleCondition{
{
Path: "/foo/bar",
},
}),
},
requireErr: requireAccessDenied,
},
{
name: "explicit deny - id path regex",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/foo/*",
DNSSANs: []string{},
IPSANs: []string{},
},
}, []*types.SPIFFERoleCondition{
{
Path: `^\/foo\/bar$`,
},
}),
},
requireErr: requireAccessDenied,
},
{
name: "explicit deny - ip san",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{
{10, 0, 0, 42},
},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/foo/*",
DNSSANs: []string{},
IPSANs: []string{"10.0.0.1/8"},
},
}, []*types.SPIFFERoleCondition{
{
Path: "/*",
IPSANs: []string{
"10.0.0.42/32",
},
},
}),
},
requireErr: requireAccessDenied,
},
{
name: "explicit deny - multiple ip sans",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{
{10, 0, 0, 42},
{8, 8, 8, 8},
},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/foo/*",
DNSSANs: []string{},
IPSANs: []string{"10.0.0.1/8"},
},
}, []*types.SPIFFERoleCondition{
{
Path: "/*",
IPSANs: []string{
"10.0.0.42/32",
},
},
}),
},
requireErr: requireAccessDenied,
},
{
name: "explicit deny - dns san",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{
"foo.example.com",
},
ipSANs: []net.IP{},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/foo/*",
DNSSANs: []string{
"*",
},
IPSANs: []string{},
},
}, []*types.SPIFFERoleCondition{
{
Path: "/*",
DNSSANs: []string{
"foo.example.com",
},
},
}),
},
requireErr: requireAccessDenied,
},
{
name: "explicit deny - multiple dns sans",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{
"foo.example.com",
"bar.example.com",
},
ipSANs: []net.IP{},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/foo/*",
DNSSANs: []string{
"*",
},
IPSANs: []string{},
},
}, []*types.SPIFFERoleCondition{
{
Path: "/*",
DNSSANs: []string{
"foo.example.com",
},
},
}),
},
requireErr: requireAccessDenied,
},
{
name: "implicit deny - no match",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: "/bar/*",
},
}, []*types.SPIFFERoleCondition{}),
},
requireErr: requireAccessDenied,
},
{
name: "implicit deny - no match regex",
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{},
roles: []types.Role{
makeRole([]*types.SPIFFERoleCondition{
{
Path: `^\/bar\/.*$`,
},
}, []*types.SPIFFERoleCondition{}),
},
requireErr: requireAccessDenied,
},
{
name: "no roles",
roles: []types.Role{},
spiffeIDPath: "/foo/bar",
dnsSANs: []string{},
ipSANs: []net.IP{},
requireErr: requireAccessDenied,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
accessChecker := makeAccessCheckerWithRoleSet(tt.roles)
err := accessChecker.CheckSPIFFESVID(tt.spiffeIDPath, tt.dnsSANs, tt.ipSANs)
tt.requireErr(t, err)
})
}
}
func TestCheckAccessToGitServer(t *testing.T) {
githubOrgServer, err := types.NewGitHubServer(types.GitHubServerMetadata{
Integration: "my-org",
Organization: "my-org",
})
require.NoError(t, err)
makeRole := func(t *testing.T, allowOrg, denyOrg string) types.Role {
spec := types.RoleSpecV6{}
if allowOrg != "" {
spec.Allow.GitHubPermissions = append(spec.Allow.GitHubPermissions, types.GitHubPermission{
Organizations: []string{allowOrg},
})
}
if denyOrg != "" {
spec.Deny.GitHubPermissions = append(spec.Deny.GitHubPermissions, types.GitHubPermission{
Organizations: []string{denyOrg},
})
}
role, err := types.NewRole(uuid.NewString(), spec)
require.NoError(t, err)
return role
}
tests := []struct {
name string
roles []types.Role
requireErr require.ErrorAssertionFunc
}{
{
name: "no roles",
requireErr: requireAccessDenied,
},
{
name: "explicit allow",
roles: []types.Role{
makeRole(t, "my-org", ""),
},
requireErr: require.NoError,
},
{
name: "wildcard allow",
roles: []types.Role{
makeRole(t, "*", "some-other-org"),
},
requireErr: require.NoError,
},
{
name: "explicit deny",
roles: []types.Role{
makeRole(t, "*", "my-org"),
},
requireErr: requireAccessDenied,
},
{
name: "wildcard deny",
roles: []types.Role{
makeRole(t, "my-org", "*"),
},
requireErr: requireAccessDenied,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
accessChecker := makeAccessCheckerWithRoleSet(test.roles)
err := accessChecker.CheckAccess(githubOrgServer, AccessState{})
test.requireErr(t, err)
})
}
}
func TestMatchRequestKubernetesResource(t *testing.T) {
tests := []struct {
input gk
reference types.RequestKubernetesResource
cond types.RoleConditionType
expected bool
}{
// Deny not match.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false},
{gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false},
// Same checks with allow.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false},
{gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "batch"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false},
// Deny match.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
// Same checks with allow.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "deployments", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "ap*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
// Allow not match. Some dups from earlier, but keeping them for logic, before it was the counter-part of the deny, here it is the main check.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "a*"}, types.Allow, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, false},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, false},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, false},
// Same checks with deny.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Deny, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "jobs", APIGroup: "*"}, types.Deny, false},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "a*"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "batch"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "batch"}, types.Deny, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ba*"}, types.Deny, false},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
// Allow match. Some dups from earlier, but keeping them for logic, before it was the counter-part of the deny, here it is the main check.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Allow, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Allow, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Allow, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Allow, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Allow, true},
// Same checks with deny.
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "deployments", APIGroup: "*"}, types.Deny, true},
{gk{kind: "deployments", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "apps"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
{gk{kind: "*", group: "apps"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "ap*"}, types.Deny, true},
{gk{kind: "*", group: "*"}, types.RequestKubernetesResource{Kind: "*", APIGroup: "*"}, types.Deny, true},
}
for i, test := range tests {
t.Run(fmt.Sprint(i), func(t *testing.T) {
if result := matchRequestKubernetesResources(test.input, test.reference, test.cond); result != test.expected {
checkType := "allow"
if test.cond == types.Deny {
checkType = "deny"
}
t.Fatalf("Checking '%s.%s' %s by '%s.%s', expected %t, got %t",
test.input.kind, test.input.group, checkType, test.reference.Kind, test.reference.APIGroup, test.expected, result)
}
})
}
}
func TestMCPToolMatcher(t *testing.T) {
roleWithSpecificAllow := &types.RoleV6{
Kind: types.KindRole,
Version: types.V8,
Metadata: types.Metadata{
Name: "ai_user",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
MCP: &types.MCPPermissions{
Tools: []string{
"allow_literal",
"^allow(_regex)+$",
"allow_glob*",
},
},
},
},
}
roleWithWildcardAllow := &types.RoleV6{
Kind: types.KindRole,
Version: types.V8,
Metadata: types.Metadata{
Name: "ai_user",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
MCP: &types.MCPPermissions{
Tools: []string{"*"},
},
},
Deny: types.RoleConditions{
MCP: &types.MCPPermissions{
Tools: []string{"deny_literal"},
},
},
},
}
tests := []struct {
testToolName string
testRole types.Role
testCondition types.RoleConditionType
requireMatch require.BoolAssertionFunc
}{
{
testToolName: "deny_empty",
testRole: roleWithSpecificAllow,
testCondition: types.Deny,
requireMatch: require.False,
},
{
testToolName: "allow_literal",
testRole: roleWithSpecificAllow,
testCondition: types.Allow,
requireMatch: require.True,
},
{
testToolName: "allow_regex_regex",
testRole: roleWithSpecificAllow,
testCondition: types.Allow,
requireMatch: require.True,
},
{
testToolName: "allow_globbb",
testRole: roleWithSpecificAllow,
testCondition: types.Allow,
requireMatch: require.True,
},
{
testToolName: "allow_wildcard",
testRole: roleWithWildcardAllow,
testCondition: types.Allow,
requireMatch: require.True,
},
{
testToolName: "deny_literal",
testRole: roleWithWildcardAllow,
testCondition: types.Deny,
requireMatch: require.True,
},
}
for _, tt := range tests {
t.Run(tt.testToolName, func(t *testing.T) {
matcher := MCPToolMatcher{
Name: tt.testToolName,
}
match, err := matcher.Match(tt.testRole, tt.testCondition)
require.NoError(t, err)
tt.requireMatch(t, match)
})
}
}
func TestNewEnumerationResultFromEntities(t *testing.T) {
tests := []struct {
name string
inputAllowed []string
inputDenied []string
wantAllowed []string
wantDenied []string
}{
{
name: "empty",
},
{
name: "wildcard denied",
inputAllowed: []string{"allow_entry"},
inputDenied: []string{"deny_entry", "*"},
wantDenied: []string{"*"},
},
{
name: "wildcard allowed",
inputAllowed: []string{"allow_entry", "*", "deny_overwrite"},
inputDenied: []string{"deny_overwrite"},
wantAllowed: []string{"*", "allow_entry"},
wantDenied: []string{"deny_overwrite"},
},
{
name: "no wildcard",
inputAllowed: []string{"allow_entry_1", "deny_overwrite", "allow_entry_2"},
inputDenied: []string{"deny_overwrite", "deny_entry"},
wantAllowed: []string{"allow_entry_1", "allow_entry_2"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := NewEnumerationResultFromEntities(test.inputAllowed, test.inputDenied)
actualAllowed, actualDenied := result.ToEntities()
require.Equal(t, test.wantAllowed, actualAllowed)
require.Equal(t, test.wantDenied, actualDenied)
})
}
}
func TestSessionRecordingRBAC(t *testing.T) {
newUser := func(name string, traits map[string][]string, roles []string) types.User {
user, err := types.NewUser(name)
require.NoError(t, err)
user.SetTraits(traits)
user.SetRoles(roles)
return user
}
newRole := func(allow, deny *types.Rule) *types.RoleV6 {
var denyRules []types.Rule
var denyNamespaces []string
if deny != nil {
denyRules = []types.Rule{*deny}
denyNamespaces = []string{apidefaults.Namespace}
}
var allowRules []types.Rule
if allow != nil {
allowRules = []types.Rule{*allow}
}
return &types.RoleV6{
Metadata: types.Metadata{
Name: "test-role",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
Namespaces: []string{apidefaults.Namespace},
NodeLabels: types.Labels{
"env": []string{"prod"},
},
Rules: allowRules,
},
Deny: types.RoleConditions{
Rules: denyRules,
Namespaces: denyNamespaces,
},
},
}
}
newServer := func(name string, labels map[string]string) types.Server {
server, err := types.NewServer(name, types.KindNode, types.ServerSpecV2{})
require.NoError(t, err)
server.SetStaticLabels(labels)
return server
}
serverWithAccess := newServer("server-with-access", map[string]string{
"env": "prod",
})
serverWithoutAccess := newServer("server-without-access", map[string]string{
"env": "dev",
})
alice := newUser(
"alice",
map[string][]string{
"group": {"prod"},
"team": {"engineering"},
}, []string{"role1"})
bob := newUser(
"bob",
map[string][]string{
"group": {"prod"},
"team": {"engineering"},
}, []string{"role1"})
john := newUser(
"john",
map[string][]string{
"group": {"sre"},
"team": {"sre"},
}, []string{"role2"})
testCases := []struct {
name string
roles RoleSet
accessRequestResourceIds []types.ResourceID
context Context
errCheck require.ErrorAssertionFunc
}{
{
name: "0 - empty role set has access to nothing",
roles: RoleSet{},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "1 - user can read session",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "2 - user can read session with trait match",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], "prod")`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "3 - user can not read session with missing trait match",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], "missing")`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "4 - alice user can read session if he is a participant",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(session.participants, user.metadata.name)`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "5 - bob user can read session if he is a participant",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(session.participants, user.metadata.name)`,
}, nil),
},
context: Context{
User: bob,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "6 - john user can not read session because he is a participant",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(session.participants, user.metadata.name)`,
}, nil),
},
context: Context{
User: john,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "7 - alice can read session if server label matches",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], session.server_labels["env"])`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "8 - john can not read session because server label doesn't match",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], session.server_labels["env"])`,
}, nil),
},
context: Context{
User: john,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "9 - bob can read session if he shares any role with the session user",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains_any(user.spec.roles, session.user_roles)`,
}, nil),
},
context: Context{
User: bob,
Session: newSessionEndEvent(),
},
errCheck: require.NoError,
},
{
name: "10 - john can not read session because he shares no roles with the session user",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains_any(user.spec.roles, session.user_roles)`,
}, nil),
},
context: Context{
User: john,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "11 - bob can read session if he belongs to the same team as the session user",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains_all(user.spec.traits["team"], session.user_traits["team"])`,
}, nil),
},
context: Context{
User: bob,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "12 - john can not read session because he belongs to a different team",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains_all(user.spec.traits["team"], session.user_traits["team"])`,
}, nil),
},
context: Context{
User: john,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "13 - bob can read session if he belongs to the same team as the session user",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains_all(user.spec.traits["team"], session.user_traits["team"])`,
}, nil),
},
context: Context{
User: bob,
Session: newWindowsSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "14 - john can not read session because he belongs to a different team",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains_all(user.spec.traits["team"], session.user_traits["team"])`,
}, nil),
},
context: Context{
User: john,
Session: newWindowsSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "15 - alice can't read session because of deny rule",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
}, nil),
newRole(nil, &types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], "prod")`,
}),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "15 - alice can read session because of deny rule does not match",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
}, nil),
newRole(nil, &types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], "missing")`,
}),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "16 - alice can read session because she can access the server",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "17 - alice can not read session because she can't access the server",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
Resource: serverWithoutAccess,
},
errCheck: requireAccessDenied,
},
{
name: "18 - alice can not read session because resource doesn't exist",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
context: Context{
User: alice,
Session: newSessionEndEvent(),
},
errCheck: requireAccessDenied,
},
{
name: "19 - bob can read session because he has access and belongs to the same team as the session user",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view() && contains_all(user.spec.traits["team"], session.user_traits["team"])`,
}, nil),
},
context: Context{
User: bob,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "20 - john can not read session because he has access but belongs to a different team",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view() && contains_all(user.spec.traits["team"], session.user_traits["team"])`,
}, nil),
},
context: Context{
User: john,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "21 - bob can read database session because his group matches with env label for database",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], session.db_labels["env"])`,
}, nil),
},
context: Context{
User: bob,
Session: newDatabaseSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "21 - john can not read database session because his group does not match with env label for database",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `contains(user.spec.traits["group"], session.db_labels["env"])`,
}, nil),
},
context: Context{
User: john,
Session: newDatabaseSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "20 - bob can read session because he has access request for the server",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
accessRequestResourceIds: []types.ResourceID{
{
ClusterName: "local",
Kind: types.KindNode,
Name: serverWithAccess.GetName(),
},
},
context: Context{
User: bob,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
{
name: "21 - bob can't read session because he has access request for a different server",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `can_view()`,
}, nil),
},
accessRequestResourceIds: []types.ResourceID{
{
ClusterName: "local",
Kind: types.KindNode,
Name: serverWithoutAccess.GetName(),
},
},
context: Context{
User: bob,
Session: newSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "22 - bob can't read windows session because his role references session.server_labels",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `session.server_labels["env"]=="prod"`,
}, nil),
},
context: Context{
User: bob,
Session: newWindowsSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: requireAccessDenied,
},
{
name: "23 - bob can read windows session one of his roles allow it",
roles: RoleSet{
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
Where: `session.server_labels["env"]=="prod"`,
}, nil),
newRole(&types.Rule{
Resources: []string{types.KindSession},
Verbs: []string{types.VerbRead},
}, nil),
},
context: Context{
User: bob,
Session: newWindowsSessionEndEvent(),
Resource: serverWithAccess,
},
errCheck: require.NoError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.context.AccessChecker = NewAccessCheckerWithRoleSet(&AccessInfo{AllowedResourceAccessIDs: types.ResourceIDsToResourceAccessIDs(tc.accessRequestResourceIds)}, "local", tc.roles)
result := tc.roles.CheckAccessToRule(&tc.context, apidefaults.Namespace, types.KindSession, types.VerbRead)
tc.errCheck(t, result)
})
}
}
func newSessionEndEvent() *apievents.SessionEnd {
startTime := time.Date(2020, 3, 30, 15, 58, 54, 561*int(time.Millisecond), time.UTC)
endTime := startTime.Add(time.Minute)
return &apievents.SessionEnd{
Metadata: apievents.Metadata{
Index: 20,
Type: "session.end",
ID: "da455e0f-c27d-459f-a218-4e83b3db9426",
Code: "T2004I",
Time: endTime,
},
ServerMetadata: apievents.ServerMetadata{
ServerVersion: teleport.Version,
ServerID: "6a7c593d-345a-431f-9e21-4049be982fa5",
ServerNamespace: "default",
ServerLabels: map[string]string{"env": "prod"},
},
SessionMetadata: apievents.SessionMetadata{
SessionID: "cb116fb0-9227-4889-9392-aedd13a914a6",
},
UserMetadata: apievents.UserMetadata{
User: "alice",
UserRoles: []string{"role1"},
UserTraits: map[string][]string{
"group": {"prod"},
"team": {"engineering"},
},
},
EnhancedRecording: true,
Interactive: true,
Participants: []string{"alice", "bob"},
StartTime: startTime,
EndTime: endTime,
}
}
func newWindowsSessionEndEvent() *apievents.WindowsDesktopSessionEnd {
startTime := time.Date(2020, 3, 30, 15,
58, 54, 561*int(time.Millisecond), time.UTC)
endTime := startTime.Add(time.Minute)
return &apievents.WindowsDesktopSessionEnd{
Metadata: apievents.Metadata{
Index: 20,
Type: "session.end",
ID: "da455e0f-c27d-459f-a218-4e83b3db9426",
Code: "T2004I",
Time: endTime,
},
DesktopLabels: map[string]string{"env": "prod"},
UserMetadata: apievents.UserMetadata{
User: "alice",
UserRoles: []string{"role1"},
UserTraits: map[string][]string{
"group": {"prod"},
"team": {"engineering"},
},
},
Participants: []string{"alice", "bob"},
StartTime: startTime,
EndTime: endTime,
}
}
func newDatabaseSessionEndEvent() *apievents.DatabaseSessionEnd {
startTime := time.Date(2020, 3, 30, 15,
58, 54, 561*int(time.Millisecond), time.UTC)
endTime := startTime.Add(time.Minute)
return &apievents.DatabaseSessionEnd{
Metadata: apievents.Metadata{
Index: 20,
Type: "session.end",
ID: "da455e0f-c27d-459f-a218-4e83b3db9426",
Code: "T2004I",
Time: endTime,
},
DatabaseMetadata: apievents.DatabaseMetadata{
DatabaseLabels: map[string]string{"env": "prod"},
},
UserMetadata: apievents.UserMetadata{
User: "alice",
UserRoles: []string{"role1"},
UserTraits: map[string][]string{
"group": {"prod"},
"team": {"engineering"},
},
},
StartTime: startTime,
EndTime: endTime,
}
}
func TestCheckSubmitForUser(t *testing.T) {
t.Parallel()
aliceUser, err := types.NewUser("alice")
require.NoError(t, err)
reviewer, err := types.NewUser("reviewer")
require.NoError(t, err)
roleWildcardAllow := &types.RoleV6{
Metadata: types.Metadata{
Name: "wildcard-allow",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
ReviewRequests: &types.AccessReviewConditions{
SubmitForUsers: []string{"*"},
},
},
},
}
roleWildcardDeny := &types.RoleV6{
Metadata: types.Metadata{
Name: "wildcard-deny",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
ReviewRequests: &types.AccessReviewConditions{
SubmitForUsers: []string{"*"},
},
},
},
}
roleAliceAllow := &types.RoleV6{
Metadata: types.Metadata{
Name: "alice-allow",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Allow: types.RoleConditions{
ReviewRequests: &types.AccessReviewConditions{
SubmitForUsers: []string{"alice"},
},
},
},
}
roleAliceDeny := &types.RoleV6{
Metadata: types.Metadata{
Name: "alice-deny",
Namespace: apidefaults.Namespace,
},
Spec: types.RoleSpecV6{
Deny: types.RoleConditions{
ReviewRequests: &types.AccessReviewConditions{
SubmitForUsers: []string{"alice"},
},
},
},
}
tests := []struct {
name string
roles RoleSet
wantErr bool
}{
{
name: "allow empty",
roles: RoleSet{},
wantErr: true,
},
{
name: "allow wildcard",
roles: RoleSet{roleWildcardAllow},
},
{
name: "deny wildcard",
roles: RoleSet{roleWildcardDeny},
wantErr: true,
},
{
name: "allow alice",
roles: RoleSet{roleAliceAllow},
},
{
name: "deny alice",
roles: RoleSet{roleAliceDeny},
wantErr: true,
},
{
name: "allow wildcard, deny alice",
roles: RoleSet{roleWildcardAllow, roleAliceDeny},
wantErr: true,
},
{
name: "deny wildcard, allow alice",
roles: RoleSet{roleWildcardDeny, roleAliceAllow},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err = tt.roles.CheckSubmitForUser(reviewer, aliceUser)
if tt.wantErr {
require.True(t, trace.IsAccessDenied(err))
return
}
require.NoError(t, err)
})
}
}
func TestCheckImpersonateRoles(t *testing.T) {
t.Parallel()
u, err := types.NewUser("myuser")
require.NoError(t, err)
newRole := func(t *testing.T, name string, allow, deny *types.ImpersonateConditions) types.Role {
role, err := types.NewRole(name, types.RoleSpecV6{
Allow: types.RoleConditions{
Impersonate: allow,
},
Deny: types.RoleConditions{
Impersonate: deny,
},
})
require.NoError(t, err)
return role
}
acceptTests := []struct {
name string
roleSet RoleSet
impersonateRoles []types.Role
}{
{
name: "allow rule",
roleSet: []types.Role{newRole(t, "myrole", &types.ImpersonateConditions{
Roles: []string{"foo"},
}, nil)},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
{
name: "matching where clause",
roleSet: []types.Role{newRole(t, "myrole", &types.ImpersonateConditions{
Roles: []string{"*"},
Where: `equals(impersonate_role.metadata.name, "foo")`,
}, nil)},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
}
for _, tc := range acceptTests {
t.Run("accept "+tc.name, func(t *testing.T) {
require.NoError(t, tc.roleSet.CheckImpersonateRoles(u, tc.impersonateRoles))
})
}
rejectTests := []struct {
name string
roleSet RoleSet
impersonateRoles []types.Role
}{
{
name: "no allow rules",
roleSet: []types.Role{newRole(t, "foo", nil, nil)},
impersonateRoles: []types.Role{newRole(t, "bar", nil, nil)},
},
{
name: "deny rule",
roleSet: []types.Role{newRole(t, "myrole", &types.ImpersonateConditions{
Roles: []string{"*"},
}, &types.ImpersonateConditions{
Roles: []string{"foo"},
})},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
{
name: "users in allow rule",
roleSet: []types.Role{newRole(t, "myrole", &types.ImpersonateConditions{
Users: []string{"*"},
Roles: []string{"foo"},
}, nil)},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
{
name: "users bypassing role check",
roleSet: []types.Role{newRole(t, "myrole", &types.ImpersonateConditions{
Roles: []string{"*"},
}, &types.ImpersonateConditions{
Roles: []string{"foo"},
Users: []string{"*"},
})},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
{
name: "users in deny rule",
roleSet: []types.Role{newRole(t, "myrole", &types.ImpersonateConditions{
Roles: []string{"*"},
}, &types.ImpersonateConditions{
Roles: []string{"bar"},
Users: []string{"*"},
})},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
{
name: "no matching where clause",
roleSet: []types.Role{newRole(t, "myRole", &types.ImpersonateConditions{
Roles: []string{"*"},
Where: `equals(impersonate_role.metadata.name, "wrongname")`,
}, nil)},
impersonateRoles: []types.Role{newRole(t, "foo", nil, nil)},
},
}
for _, tc := range rejectTests {
t.Run("reject "+tc.name, func(t *testing.T) {
err := tc.roleSet.CheckImpersonateRoles(u, tc.impersonateRoles)
require.True(t, trace.IsAccessDenied(err), "unexpected error: %v", err)
})
}
}
// TestDefaultImplicitRulesHaveNoWildcardVerbs verifies the assumption newScopedImplicitRole relies on,
// that the default implicit rules contain no wildcards (if they did, our secret-inclusive -> secret-exclusive
// conversion would break).
func TestDefaultImplicitRulesHaveNoWildcardVerbs(t *testing.T) {
t.Parallel()
for _, rule := range DefaultImplicitRules {
require.NotContains(t, rule.Verbs, types.Wildcard, "resources %v", rule.Resources)
}
}