Make k8s namespaces addressable individually in RBAC. (#54938)

This commit is contained in:
Guillaume J. Charmes
2025-05-27 01:52:25 -04:00
committed by GitHub
parent 86c7e4619b
commit 93fcdccaf2
22 changed files with 804 additions and 243 deletions
+2 -3
View File
@@ -1450,12 +1450,11 @@ var KubernetesResourcesV7KindGroups = map[string]string{
// to their kubernetes name.
// Used to upgrade roles <=v7 as well as to support existing access request
// format.
// TODO(@creack): Remove this, find a better way to handle the mapping.
// NOTE: Namespace having a different behavior between versions, it is omitted from this map.
var KubernetesResourcesKindsPlurals = map[string]string{
KindKubePod: "pods",
KindKubeSecret: "secrets",
KindKubeConfigmap: "configmaps",
KindKubeNamespace: "namespaces",
KindKubeService: "services",
KindKubeServiceAccount: "serviceaccounts",
KindKubeNode: "nodes",
@@ -1521,7 +1520,7 @@ var KubernetesVerbs = []string{
// KubernetesClusterWideResourceKinds is the list of supported Kubernetes cluster resource kinds
// that are not namespaced.
// TODO(@creack): Remove in favor of proper lookup.
// Needed to maintain backward compatibility.
var KubernetesClusterWideResourceKinds = []string{
KindKubeNamespace,
KindKubeNode,
+66 -26
View File
@@ -302,13 +302,16 @@ type Role interface {
Clone() Role
}
// DefaultRoleVersion for NewRole() and test helpers.
// When incrementing the role version, make sure to update the
// role version in the asset file used by the UI.
// See: web/packages/teleport/src/Roles/templates/role.yaml
const DefaultRoleVersion = V8
// NewRole constructs new standard V8 role.
// This creates a V8 role with V4+ RBAC semantics.
func NewRole(name string, spec RoleSpecV6) (Role, error) {
// When incrementing the role version, make sure to update the
// role version in the asset file used by the UI.
// See: web/packages/teleport/src/Roles/templates/role.yaml
role, err := NewRoleWithVersion(name, V8, spec)
role, err := NewRoleWithVersion(name, DefaultRoleVersion, spec)
return role, trace.Wrap(err)
}
@@ -463,39 +466,83 @@ func (r *RoleV6) GetKubeResources(rct RoleConditionType) []KubernetesResource {
if rct == Allow {
return r.convertAllowKubernetesResourcesBetweenRoleVersions(r.Spec.Allow.KubernetesResources)
}
return r.convertDenyKubernetesResourcesBetweenRoleVersions(r.Spec.Deny.KubernetesResources)
return r.convertKubernetesResourcesBetweenRoleVersions(r.Spec.Deny.KubernetesResources)
}
// convertDenyKubernetesResourcesBetweenRoleVersions converts Kubernetes resources between role versions.
// convertKubernetesResourcesBetweenRoleVersions converts Kubernetes resources between role versions.
// This is required to keep compatibility between role versions to avoid breaking changes
// when using an older role version.
//
// For roles v8, it returns the list as it is.
//
// For roles <=v7, it maps the legacy teleport Kinds to k8s plurals and sets the APIGroup to wildcard.
func (r *RoleV6) convertDenyKubernetesResourcesBetweenRoleVersions(resources []KubernetesResource) []KubernetesResource {
func (r *RoleV6) convertKubernetesResourcesBetweenRoleVersions(resources []KubernetesResource) []KubernetesResource {
switch r.Version {
case V8:
return resources
default:
v7resources := slices.Clone(resources)
var extraResources []KubernetesResource
for i, r := range v7resources {
// "namespace" kind used to mean "namespaces" and all resources in the namespace.
// It is now represented by 'namespaces' for the resource itself and wildcard for
// all resources in the namespace.
if r.Kind == KindKubeNamespace {
r.Kind = Wildcard
if r.Name == Wildcard {
r.Namespace = "^" + Wildcard + "$"
} else {
r.Namespace = r.Name
}
r.Name = Wildcard
r.APIGroup = Wildcard
v7resources[i] = r
extraResources = append(extraResources, KubernetesResource{
Kind: "namespaces",
Name: r.Namespace,
Verbs: r.Verbs,
})
continue
}
// The namespace field was ignored in v7 for global resources.
if r.Namespace != "" && slices.Contains(KubernetesClusterWideResourceKinds, r.Kind) {
r.Namespace = ""
}
if k, ok := KubernetesResourcesKindsPlurals[r.Kind]; ok { // Can be empty if the kind is a wildcard.
r.Kind = k
}
r.APIGroup = Wildcard
v7resources[i] = r
if r.Kind == Wildcard { // If we have a wildcard, inject the clusterwide resources.
for _, elem := range KubernetesClusterWideResourceKinds {
if elem == KindKubeNamespace { // Namespace is handled separately.
continue
}
extraResources = append(extraResources, KubernetesResource{
Kind: KubernetesResourcesKindsPlurals[elem],
Name: r.Name,
Verbs: r.Verbs,
APIGroup: Wildcard,
})
}
}
}
return v7resources
return append(v7resources, extraResources...)
}
}
// convertKubeResourcesBetweenRoleVersions converts Kubernetes resources between role versions.
// convertAllowKubeResourcesBetweenRoleVersions converts Kubernetes resources between role versions.
// This is required to keep compatibility between role versions to avoid breaking changes
// when using an older role version.
//
// For roles v8, it returns the list as it is.
//
// For roles v7, if we have a Wildcard kind, add the v7 cluster-wide resources to maintain
// the existing behavior as in Teleport <=v17, those resources ignored the namespace value
// of the rbac entry. Earlier roles didn't support wildcard so it is not a concern.
//
// For roles v7, if we have a "namespace" kind, map it to a wildcard + namespaces kind.
//
// For roles <=v7, it sets the APIGroup to wildcard for all resources and maps the legacy
// teleport Kinds to k8s plurals.
//
@@ -507,18 +554,9 @@ func (r *RoleV6) convertDenyKubernetesResourcesBetweenRoleVersions(resources []K
// and append the other supported resources - KubernetesResourcesKinds - for Role v8.
func (r *RoleV6) convertAllowKubernetesResourcesBetweenRoleVersions(resources []KubernetesResource) []KubernetesResource {
switch r.Version {
case V8:
return resources
case V7:
v7resources := slices.Clone(resources)
for i, r := range v7resources {
if k, ok := KubernetesResourcesKindsPlurals[r.Kind]; ok { // Can be empty if the kind is a wildcard.
r.Kind = k
}
r.APIGroup = Wildcard
v7resources[i] = r
}
return v7resources
case V7, V8:
// V7 and v8 uses the same logic for allow and deny.
return r.convertKubernetesResourcesBetweenRoleVersions(resources)
// Teleport does not support role versions < v3.
case V6, V5, V4, V3:
switch {
@@ -1232,9 +1270,10 @@ func (r *RoleV6) CheckAndSetDefaults() error {
return trace.Wrap(err)
}
case V7:
// Kubernetes resources default to {kind:*, name:*, namespace:*} for v7 and v8 roles.
// Kubernetes resources default to {kind:*, name:*, namespace:*, verbs:[*]} for v7 roles.
if len(r.Spec.Allow.KubernetesResources) == 0 && r.HasLabelMatchers(Allow, KindKubernetesCluster) {
r.Spec.Allow.KubernetesResources = []KubernetesResource{
// Full access to everything.
{
Kind: Wildcard,
Namespace: Wildcard,
@@ -1247,9 +1286,10 @@ func (r *RoleV6) CheckAndSetDefaults() error {
return trace.Wrap(err)
}
case V8:
// Kubernetes resources default to {kind:*, name:*, namespace:*, group:*} for v7 and v8 roles.
// Kubernetes resources default to {kind:*, name:*, namespace:*, api_group:*, verbs:[*]} for v8 roles.
if len(r.Spec.Allow.KubernetesResources) == 0 && r.HasLabelMatchers(Allow, KindKubernetesCluster) {
r.Spec.Allow.KubernetesResources = []KubernetesResource{
// Full access to everything.
{
Kind: Wildcard,
Namespace: Wildcard,
@@ -1956,17 +1996,17 @@ func validateKubeResources(roleVersion string, kubeResources []KubernetesResourc
fallthrough
case V7:
if kubeResource.APIGroup != "" {
return trace.BadParameter("Group %q is not supported in role version %q. Upgrade the role version to %q", kubeResource.APIGroup, roleVersion, V8)
return trace.BadParameter("API Group %q is not supported in role version %q. Upgrade the role version to %q", kubeResource.APIGroup, roleVersion, V8)
}
if kubeResource.Kind != Wildcard && !slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) {
return trace.BadParameter("KubernetesResource kind %q is invalid or unsupported; Supported: %v", kubeResource.Kind, append([]string{Wildcard}, KubernetesResourcesKinds...))
}
if kubeResource.Namespace == "" && !slices.Contains(KubernetesClusterWideResourceKinds, kubeResource.Kind) {
return trace.BadParameter("KubernetesResource must include Namespace")
return trace.BadParameter("KubernetesResource kind %q must include Namespace", kubeResource.Kind)
}
case V8:
if kubeResource.Kind == "" {
return trace.BadParameter("KubernetesResource kind is required in role version %q", roleVersion)
return trace.BadParameter("KubernetesResource kind %q is required in role version %q", kubeResource.Kind, roleVersion)
}
// If we have a kind that match a role v7 one, check the api group.
if slices.Contains(KubernetesResourcesKinds, kubeResource.Kind) {
+176
View File
@@ -357,6 +357,182 @@ func TestRole_GetKubeResources(t *testing.T) {
},
},
},
{
name: "v7 with allow wildcard kind",
args: args{
version: V7,
labels: kubeLabels,
resources: []KubernetesResource{
{
// rolev7 ignored the namespace field for global resources.
Kind: Wildcard,
Namespace: "default",
Name: Wildcard,
Verbs: []string{Wildcard},
},
},
},
assertErrorCreation: require.NoError,
wantAllow: []KubernetesResource{
// Expect the main resource to match namespaced resources.
{
Kind: Wildcard,
Namespace: "default",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
// Expect injected global resources to maintain v7 behavior.
{
Kind: "nodes",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "persistentvolumes",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "clusterroles",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "clusterrolebindings",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "certificatesigningrequests",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
},
},
{
name: "v7 with deny wildcard kind",
args: args{
version: V7,
labels: kubeLabels,
resources: []KubernetesResource{
{
// rolev7 ignored the namespace field for global resources.
Kind: Wildcard,
Namespace: "default",
Name: Wildcard,
Verbs: []string{Wildcard},
},
},
},
assertErrorCreation: require.NoError,
wantDeny: []KubernetesResource{
// Expect the main resource to match namespaced resources.
{
Kind: Wildcard,
Namespace: "default",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
// Expect injected global resources to maintain v7 behavior.
{
Kind: "nodes",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "persistentvolumes",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "clusterroles",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "clusterrolebindings",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "certificatesigningrequests",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
},
},
{
name: "v7 with allow namespace",
args: args{
version: V7,
labels: kubeLabels,
resources: []KubernetesResource{
{
Kind: KindKubeNamespace,
Name: "default",
Verbs: []string{Wildcard},
},
},
},
assertErrorCreation: require.NoError,
wantAllow: []KubernetesResource{
{
Kind: Wildcard,
Namespace: "default",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "namespaces",
Name: "default",
Verbs: []string{Wildcard},
},
},
},
{
name: "v7 with deny namespace",
args: args{
version: V7,
labels: kubeLabels,
resources: []KubernetesResource{
{
Kind: KindKubeNamespace,
Name: "default",
Verbs: []string{Wildcard},
},
},
},
assertErrorCreation: require.NoError,
wantDeny: []KubernetesResource{
{
Kind: Wildcard,
Namespace: "default",
Name: Wildcard,
Verbs: []string{Wildcard},
APIGroup: Wildcard,
},
{
Kind: "namespaces",
Name: "default",
Verbs: []string{Wildcard},
},
},
},
{
name: "v6 without wildcard; labels expression",
args: args{
+6 -9
View File
@@ -2149,12 +2149,8 @@ func maybeDowngradeRoleK8sAPIGroupToV7(role *types.RoleV6) *types.RoleV6 {
if elem.APIGroup == types.Wildcard {
elem.APIGroup = ""
}
// If we have a wildcard kind, keep it.
if elem.Kind == types.Wildcard && elem.APIGroup == "" {
out = append(out, elem)
continue
}
// If Kind is known in v7 and group is known, remove it.
// If Kind is known in v7 and group is known, remove it the api group and keep the resource.
if v, ok := defaultRBACResources[allowedResourcesKey{elem.APIGroup, elem.Kind}]; ok {
elem.APIGroup = ""
elem.Kind = v
@@ -2162,8 +2158,8 @@ func maybeDowngradeRoleK8sAPIGroupToV7(role *types.RoleV6) *types.RoleV6 {
continue
}
// If we reach this point, we are dealing with a resource we don't know about.
// As <=v17 granted too much access for unknown resource, we deny everything.
// If we reach this point, we are dealing with a resource we don't know about or a wildcard
// As the scope of permissions granted differs, deny everything.
role.Spec.Allow.KubernetesResources = nil
role.Spec.Deny.KubernetesLabels = types.Labels{
types.Wildcard: {types.Wildcard},
@@ -2200,11 +2196,12 @@ type allowedResourcesKey struct {
// TODO(@creack): Delete in v19.0.0.
// Only used in the maybeDowngradeRoleVersionToV7 function above.
// Must be synced with the defaultRBACResources map in lib/kube/proxy/url.go.
// NOTE: 'namespaces' is not included as the v8 behavior is different from v7.
// A 'namespaces' resource in v8 would result in wildcard deny in older versions.
var defaultRBACResources = map[allowedResourcesKey]string{
{apiGroup: "", resourceKind: "pods"}: types.KindKubePod,
{apiGroup: "", resourceKind: "secrets"}: types.KindKubeSecret,
{apiGroup: "", resourceKind: "configmaps"}: types.KindKubeConfigmap,
{apiGroup: "", resourceKind: "namespaces"}: types.KindKubeNamespace,
{apiGroup: "", resourceKind: "services"}: types.KindKubeService,
{apiGroup: "", resourceKind: "endpoints"}: types.KindKubeService,
{apiGroup: "", resourceKind: "serviceaccounts"}: types.KindKubeServiceAccount,
+139 -26
View File
@@ -5372,13 +5372,6 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
{
Kind: "ingresses",
Name: types.Wildcard,
@@ -5404,13 +5397,6 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
{
Kind: "ingresses",
Name: types.Wildcard,
@@ -5459,6 +5445,43 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
},
},
})
downgradev7incompatibleNamespace := newRole("downgrade_v7_incompatible_namespace", types.V8, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: "namespaces",
Name: "foo",
Verbs: []string{types.Wildcard},
},
},
},
})
downgradev7incompatibleNamespacedWildcard := newRole("downgrade_v7_incompatible_namespaced_wildcard", types.V8, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: types.Wildcard,
Name: "foo",
Namespace: "bar",
Verbs: []string{"get"},
APIGroup: types.Wildcard,
},
},
},
})
downgradev7incompatibleClusterWideWildcard := newRole("downgrade_v7_incompatible_cluster_wide_wildcard", types.V8, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
{
Kind: types.Wildcard,
Name: "bar",
Namespace: "", // Cluster wide.
Verbs: []string{"get"},
APIGroup: types.Wildcard,
},
},
},
})
downgradev7mixedK8sResourcesRole := newRole("downgrade_v7_mixed_k8s_resources2", types.V8, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: []types.KubernetesResource{
@@ -5515,6 +5538,9 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
testRole1,
downgradev7comptibleK8sResourcesRole,
downgradev7incompatibleK8sResourcesRole,
downgradev7incompatibleNamespace,
downgradev7incompatibleNamespacedWildcard,
downgradev7incompatibleClusterWideWildcard,
downgradev7mixedK8sResourcesRole,
)
require.NoError(t, err)
@@ -5552,12 +5578,6 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
{
Kind: types.KindKubeIngress,
Name: types.Wildcard,
@@ -5580,12 +5600,6 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
{
Kind: types.KindKubeIngress,
Name: types.Wildcard,
@@ -5643,6 +5657,105 @@ func TestRoleVersionV8ToV7Downgrade(t *testing.T) {
}),
expectDowngraded: true,
},
{
desc: "downgrade v7 incompatible k8s namespaces kind",
clientVersions: []string{
"17.2.7",
},
inputRole: downgradev7incompatibleNamespace,
expectedRole: newRole(downgradev7incompatibleNamespace.GetName(), types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: nil,
},
Deny: types.RoleConditions{
KubernetesLabels: types.Labels{
types.Wildcard: {types.Wildcard},
},
KubernetesResources: []types.KubernetesResource{
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
},
Options: types.RoleOptions{
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(false),
},
},
},
}),
expectDowngraded: true,
},
{
desc: "downgrade v7 incompatible k8s namespaced wildcard kind",
clientVersions: []string{
"17.2.7",
},
inputRole: downgradev7incompatibleNamespacedWildcard,
expectedRole: newRole(downgradev7incompatibleNamespacedWildcard.GetName(), types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: nil,
},
Deny: types.RoleConditions{
KubernetesLabels: types.Labels{
types.Wildcard: {types.Wildcard},
},
KubernetesResources: []types.KubernetesResource{
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
},
Options: types.RoleOptions{
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(false),
},
},
},
}),
expectDowngraded: true,
},
{
desc: "downgrade v7 incompatible k8s cluster wide wildcard kind",
clientVersions: []string{
"17.2.7",
},
inputRole: downgradev7incompatibleClusterWideWildcard,
expectedRole: newRole(downgradev7incompatibleClusterWideWildcard.GetName(), types.V7, types.RoleSpecV6{
Allow: types.RoleConditions{
KubernetesResources: nil,
},
Deny: types.RoleConditions{
KubernetesLabels: types.Labels{
types.Wildcard: {types.Wildcard},
},
KubernetesResources: []types.KubernetesResource{
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
},
Options: types.RoleOptions{
IDP: &types.IdPOptions{
SAML: &types.IdPSAMLOptions{
Enabled: types.NewBoolOption(false),
},
},
},
}),
expectDowngraded: true,
},
{
desc: "downgrade v7 mixed k8s resources",
clientVersions: []string{
+12 -2
View File
@@ -1395,11 +1395,19 @@ func CreateUser(ctx context.Context, clt clt, username string, roles ...types.Ro
type createUserAndRoleOptions struct {
mutateUser []func(user types.User)
mutateRole []func(role types.Role)
version string
}
// CreateUserAndRoleOption is a functional option for CreateUserAndRole
type CreateUserAndRoleOption func(*createUserAndRoleOptions)
// WithRoleVersion sets the version of the role to be created.
func WithRoleVersion(version string) CreateUserAndRoleOption {
return func(o *createUserAndRoleOptions) {
o.version = version
}
}
// WithUserMutator sets a function that will be called to mutate the user before it is created
func WithUserMutator(mutate ...func(user types.User)) CreateUserAndRoleOption {
return func(o *createUserAndRoleOptions) {
@@ -1419,7 +1427,9 @@ func WithRoleMutator(mutate ...func(role types.Role)) CreateUserAndRoleOption {
// If allowRules is not-nil, then the rules associated with the role will be
// replaced with those specified.
func CreateUserAndRole(clt clt, username string, allowedLogins []string, allowRules []types.Rule, opts ...CreateUserAndRoleOption) (types.User, types.Role, error) {
o := createUserAndRoleOptions{}
o := createUserAndRoleOptions{
version: types.DefaultRoleVersion,
}
for _, opt := range opts {
opt(&o)
}
@@ -1429,7 +1439,7 @@ func CreateUserAndRole(clt clt, username string, allowedLogins []string, allowRu
return nil, nil, trace.Wrap(err)
}
role := services.RoleForUser(user)
role := services.RoleWithVersionForUser(user, o.version)
role.SetLogins(types.Allow, allowedLogins)
if allowRules != nil {
role.SetRules(types.Allow, allowRules)
+12 -4
View File
@@ -90,7 +90,15 @@ func TestListKubernetesResources(t *testing.T) {
// override the role to allow access to all kube resources.
r.SetKubeResources(
types.Allow,
[]types.KubernetesResource{{Kind: types.Wildcard, Name: types.Wildcard, Namespace: types.Wildcard, Verbs: []string{types.Wildcard}, APIGroup: types.Wildcard}},
[]types.KubernetesResource{
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
},
)
},
},
@@ -555,7 +563,7 @@ func TestListKubernetesResources(t *testing.T) {
Kind: types.KindKubeClusterRole,
Version: "v1",
Metadata: types.Metadata{
Name: "nginx-1",
Name: "cr-nginx-1",
Namespace: "default",
},
Spec: types.KubernetesResourceSpecV1{},
@@ -564,7 +572,7 @@ func TestListKubernetesResources(t *testing.T) {
Kind: types.KindKubeClusterRole,
Version: "v1",
Metadata: types.Metadata{
Name: "nginx-2",
Name: "cr-nginx-2",
Namespace: "default",
},
Spec: types.KubernetesResourceSpecV1{},
@@ -573,7 +581,7 @@ func TestListKubernetesResources(t *testing.T) {
Kind: types.KindKubeClusterRole,
Version: "v1",
Metadata: types.Metadata{
Name: "test",
Name: "cr-test",
Namespace: "default",
},
Spec: types.KubernetesResourceSpecV1{},
+2
View File
@@ -75,6 +75,7 @@ func newResourceFilterer(kind, group, verb string, isClusterWideResource bool, c
// wildcardFilter is a filter that matches all pods.
var wildcardFilter = types.KubernetesResource{
Kind: types.Wildcard,
APIGroup: types.Wildcard,
Namespace: types.Wildcard,
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
@@ -84,6 +85,7 @@ var wildcardFilter = types.KubernetesResource{
func containsWildcard(resources []types.KubernetesResource) bool {
for _, r := range resources {
if r.Kind == wildcardFilter.Kind &&
r.APIGroup == wildcardFilter.APIGroup &&
r.Name == wildcardFilter.Name &&
r.Namespace == wildcardFilter.Namespace &&
len(r.Verbs) == 1 && r.Verbs[0] == wildcardFilter.Verbs[0] {
+49 -20
View File
@@ -1223,10 +1223,12 @@ func TestDeleteCRDCollectionRBAC(t *testing.T) {
}
func TestListClusterRoleRBAC(t *testing.T) {
t.Parallel()
const (
usernameWithFullAccess = "full_user"
usernameWithLimitedAccess = "limited_user"
testPodName = "test"
testClusterRoleName = "cr-test"
)
// kubeMock is a Kubernetes API mock for the session tests.
// Once a new session is created, this mock will write to
@@ -1286,7 +1288,7 @@ func TestListClusterRoleRBAC(t *testing.T) {
[]types.KubernetesResource{
{
Kind: "clusterroles",
Name: "nginx-*",
Name: "cr-nginx-*",
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
@@ -1317,9 +1319,9 @@ func TestListClusterRoleRBAC(t *testing.T) {
},
want: want{
listClusterRolesResult: []string{
"nginx-1",
"nginx-2",
"test",
"cr-nginx-1",
"cr-nginx-2",
"cr-test",
},
},
},
@@ -1330,13 +1332,13 @@ func TestListClusterRoleRBAC(t *testing.T) {
},
want: want{
listClusterRolesResult: []string{
"nginx-1",
"nginx-2",
"cr-nginx-1",
"cr-nginx-2",
},
getTestResult: &kubeerrors.StatusError{
ErrStatus: metav1.Status{
Status: "Failure",
Message: "clusterroles \"test\" is forbidden: User \"limited_user\" cannot get resource \"clusterroles\" in API group \"rbac.authorization.k8s.io\"",
Message: "clusterroles \"cr-test\" is forbidden: User \"limited_user\" cannot get resource \"clusterroles\" in API group \"rbac.authorization.k8s.io\"",
Code: 403,
Reason: metav1.StatusReasonForbidden,
},
@@ -1354,7 +1356,7 @@ func TestListClusterRoleRBAC(t *testing.T) {
ClusterName: testCtx.ClusterName,
Kind: types.KindKubePod,
Name: kubeCluster,
SubResourceName: fmt.Sprintf("%s/%s", metav1.NamespaceDefault, testPodName),
SubResourceName: fmt.Sprintf("%s/%s", metav1.NamespaceDefault, testClusterRoleName),
},
),
},
@@ -1372,7 +1374,7 @@ func TestListClusterRoleRBAC(t *testing.T) {
getTestResult: &kubeerrors.StatusError{
ErrStatus: metav1.Status{
Status: "Failure",
Message: "clusterroles \"test\" is forbidden: User \"limited_user\" cannot get resource \"clusterroles\" in API group \"rbac.authorization.k8s.io\"",
Message: "clusterroles \"cr-test\" is forbidden: User \"limited_user\" cannot get resource \"clusterroles\" in API group \"rbac.authorization.k8s.io\"",
Code: 403,
Reason: metav1.StatusReasonForbidden,
},
@@ -1414,7 +1416,7 @@ func TestListClusterRoleRBAC(t *testing.T) {
_, err = client.RbacV1().ClusterRoles().Get(
testCtx.Context,
testPodName,
testClusterRoleName,
metav1.GetOptions{},
)
@@ -1429,6 +1431,8 @@ func TestListClusterRoleRBAC(t *testing.T) {
}
func TestGenericCustomResourcesRBAC(t *testing.T) {
t.Parallel()
const (
usernameWithFullAccess = "full_user"
usernameWithLimitedAccess = "limited_user"
@@ -1453,10 +1457,11 @@ func TestGenericCustomResourcesRBAC(t *testing.T) {
SetupRoleFunc: func(r types.Role) {
r.SetKubeResources(types.Allow, []types.KubernetesResource{
{
Kind: "namespaces",
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
})
},
@@ -1476,10 +1481,16 @@ func TestGenericCustomResourcesRBAC(t *testing.T) {
r.SetKubeResources(types.Allow,
[]types.KubernetesResource{
{
Kind: "namespaces",
Name: "dev",
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "dev",
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
{
Kind: "namespaces",
Name: "dev",
Verbs: []string{types.Wildcard},
},
},
)
@@ -2022,7 +2033,7 @@ func TestSpecificCustomResourcesRBAC(t *testing.T) {
{
Kind: clusterswagv0.GetKindPlural(),
Name: "clusterswag-*",
Namespace: types.Wildcard,
Namespace: "",
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
@@ -2041,6 +2052,24 @@ func TestSpecificCustomResourcesRBAC(t *testing.T) {
},
{
name: "cluster wide crd no access",
args: args{
user: newUser("cluster_crd_ko", []types.KubernetesResource{
{
Kind: telerolev8.GetKindPlural(),
Name: types.Wildcard,
Namespace: "",
Verbs: []string{types.Wildcard},
APIGroup: types.Wildcard,
},
}),
crds: []*tkm.CRD{clusterswagv0},
},
want: want{
wantListErr: []bool{true},
},
},
{
name: "cluster wide crd no acces wildcard",
args: args{
user: newUser("cluster_crd_ko", []types.KubernetesResource{
{
+13 -6
View File
@@ -144,14 +144,14 @@ func (f *Forwarder) validateSelfSubjectAccessReview(sess *clusterSession, w http
// Append a matcher that validates if the Kubernetes resource is allowed
// by the roles that satisfy the Kubernetes Cluster.
&kubernetesResourceMatcher{
types.KubernetesResource{
resource: types.KubernetesResource{
Kind: resource.Name,
Name: name,
Namespace: namespace,
Verbs: []string{accessReview.Spec.ResourceAttributes.Verb},
APIGroup: accessReview.Spec.ResourceAttributes.Group,
},
!resource.Namespaced,
isClusterWideResource: !resource.Namespaced,
},
}...); {
case errors.Is(err, services.ErrTrustedDeviceRequired):
@@ -309,12 +309,20 @@ func (m *kubernetesResourceMatcher) Match(role types.Role, condition types.RoleC
if len(m.resource.Verbs) == 1 && !isVerbAllowed(resource.Verbs, m.resource.Verbs[0]) {
continue
}
switch ok, err := utils.SliceMatchesRegex(m.resource.APIGroup, []string{resource.APIGroup}); {
case err != nil:
return false, trace.Wrap(err)
case !ok:
continue
}
// If the resource name and namespace are empty, it means that the
// user wants to match all resources of the specified kind.
// We can return true immediately because the user is allowed to get resources
// of the specified kind but might not be able to see any if the matchers do not
// match with any resource.
if name == "" && namespace == "" {
if (resource.Namespace == "" || resource.Namespace == types.Wildcard) && name == "" && namespace == "" {
return true, nil
}
// If the resource name isn't empty but the resource kind is a namespace scope
@@ -334,11 +342,10 @@ func (m *kubernetesResourceMatcher) Match(role types.Role, condition types.RoleC
return ok, trace.Wrap(err)
}
} else {
if ok, err := utils.SliceMatchesRegex(namespace, []string{resource.Namespace}); err != nil || ok || namespace == "" {
return ok || namespace == "", trace.Wrap(err)
if ok, err := utils.SliceMatchesRegex(namespace, []string{resource.Namespace}); err != nil || ok {
return ok, trace.Wrap(err)
}
}
}
return false, nil
@@ -39,9 +39,9 @@ var clusterRoleList = authv1.ClusterRoleList{
ResourceVersion: "1231415",
},
Items: []authv1.ClusterRole{
newClusterRole("nginx-1"),
newClusterRole("nginx-2"),
newClusterRole("test"),
newClusterRole("cr-nginx-1"),
newClusterRole("cr-nginx-2"),
newClusterRole("cr-test"),
},
}
+49 -31
View File
@@ -29,6 +29,7 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"sync/atomic"
@@ -168,7 +169,6 @@ type KubeUpgradeRequests struct {
}
type KubeMockServer struct {
router *httprouter.Router
log *slog.Logger
server *httptest.Server
TLS *tls.Config
@@ -197,7 +197,6 @@ type KubeMockServer struct {
// TODO(tigrato): add support for other endpoints
func NewKubeAPIMock(opts ...Option) (*KubeMockServer, error) {
s := &KubeMockServer{
router: httprouter.New(),
log: slog.Default(),
deletedResources: make(map[deletedResource][]string),
version: &apimachineryversion.Info{
@@ -230,46 +229,53 @@ func NewKubeAPIMock(opts ...Option) (*KubeMockServer, error) {
}
func (s *KubeMockServer) setup() {
s.router.UseRawPath = true
s.router.POST("/api/:ver/namespaces/:namespace/pods/:name/exec", s.withWriter(s.exec))
s.router.GET("/api/:ver/namespaces/:namespace/pods/:name/exec", s.withWriter(s.exec))
s.router.GET("/api/:ver/namespaces/:namespace/pods/:name/portforward", s.withWriter(s.portforward))
s.router.POST("/api/:ver/namespaces/:namespace/pods/:name/portforward", s.withWriter(s.portforward))
// NOTE: We use stdlib because the gravitational/httplib package doesn't support k8s patterns,
// it panics due to overlapping routes.
router := http.NewServeMux()
s.router.GET("/apis/rbac.authorization.k8s.io/:ver/clusterroles", s.withWriter(s.listClusterRoles))
s.router.GET("/apis/rbac.authorization.k8s.io/:ver/clusterroles/:name", s.withWriter(s.getClusterRole))
s.router.DELETE("/apis/rbac.authorization.k8s.io/:ver/clusterroles/:name", s.withWriter(s.deleteClusterRole))
s.router.GET("/apis/rbac.authorization.k8s.io/:ver", s.withWriter(s.discoveryEndpoint))
router.Handle("POST /api/{ver}/namespaces/{namespace}/pods/{name}/exec", s.withWriter(s.exec))
router.Handle("GET /api/{ver}/namespaces/{namespace}/pods/{name}/exec", s.withWriter(s.exec))
router.Handle("GET /api/{ver}/namespaces/{namespace}/pods/{name}/portforward", s.withWriter(s.portforward))
router.Handle("POST /api/{ver}/namespaces/{namespace}/pods/{name}/portforward", s.withWriter(s.portforward))
s.router.GET("/api/:ver/namespaces/:namespace/pods", s.withWriter(s.listPods))
s.router.GET("/api/:ver/pods", s.withWriter(s.listPods))
s.router.GET("/api/:ver/namespaces/:namespace/pods/:name", s.withWriter(s.getPod))
s.router.DELETE("/api/:ver/namespaces/:namespace/pods/:name", s.withWriter(s.deletePod))
router.Handle("GET /apis/rbac.authorization.k8s.io/{ver}/clusterroles", s.withWriter(s.listClusterRoles))
router.Handle("GET /apis/rbac.authorization.k8s.io/{ver}/clusterroles/{name}", s.withWriter(s.getClusterRole))
router.Handle("DELETE /apis/rbac.authorization.k8s.io/{ver}/clusterroles/{name}", s.withWriter(s.deleteClusterRole))
router.Handle("GET /apis/rbac.authorization.k8s.io/{ver}", s.withWriter(s.discoveryEndpoint))
s.router.GET("/api/:ver/namespaces/:namespace/secrets", s.withWriter(s.listSecrets))
s.router.GET("/api/:ver/secrets", s.withWriter(s.listSecrets))
s.router.GET("/api/:ver/namespaces/:namespace/secrets/:name", s.withWriter(s.getSecret))
s.router.DELETE("/api/:ver/namespaces/:namespace/secrets/:name", s.withWriter(s.deleteSecret))
router.Handle("GET /api/{ver}/namespaces/{namespace}/pods", s.withWriter(s.listPods))
router.Handle("GET /api/{ver}/pods", s.withWriter(s.listPods))
router.Handle("GET /api/{ver}/namespaces/{namespace}/pods/{name}", s.withWriter(s.getPod))
router.Handle("DELETE /api/{ver}/namespaces/{namespace}/pods/{name}", s.withWriter(s.deletePod))
s.router.POST("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", s.withWriter(s.selfSubjectAccessReviews))
router.Handle("GET /api/{ver}/namespaces", s.withWriter(s.listNamespaces))
router.Handle("GET /api/{ver}/namespaces/{name}", s.withWriter(s.getNamespace))
router.Handle("DELETE /api/v1/namespaces/{name}", s.withWriter(s.deleteNamespace))
router.Handle("GET /api/{ver}/namespaces/{namespace}/secrets", s.withWriter(s.listSecrets))
router.Handle("GET /api/{ver}/secrets", s.withWriter(s.listSecrets))
router.Handle("GET /api/{ver}/namespaces/{namespace}/secrets/{name}", s.withWriter(s.getSecret))
router.Handle("DELETE /api/{ver}/namespaces/{namespace}/secrets/{name}", s.withWriter(s.deleteSecret))
router.Handle("POST /apis/authorization.k8s.io/v1/selfsubjectaccessreviews", s.withWriter(s.selfSubjectAccessReviews))
for k, crd := range s.crds {
s.router.GET("/apis/"+k.group+"/"+k.version+"/namespaces/:namespace/"+k.plural, s.withWriter(s.listCRDs(crd)))
s.router.GET("/apis/"+k.group+"/"+k.version+"/"+k.plural, s.withWriter(s.listCRDs(crd)))
s.router.GET("/apis/"+k.group+"/"+k.version+"/namespaces/:namespace/"+k.plural+"/:name", s.withWriter(s.getCRD(crd)))
s.router.DELETE("/apis/"+k.group+"/"+k.version+"/namespaces/:namespace/"+k.plural+"/:name", s.withWriter(s.deleteCRD(crd)))
router.Handle("GET /apis/"+k.group+"/"+k.version+"/namespaces/{namespace}/"+k.plural, s.withWriter(s.listCRDs(crd)))
router.Handle("GET /apis/"+k.group+"/"+k.version+"/"+k.plural, s.withWriter(s.listCRDs(crd)))
router.Handle("GET /apis/"+k.group+"/"+k.version+"/namespaces/{namespace}/"+k.plural+"/{name}", s.withWriter(s.getCRD(crd)))
router.Handle("DELETE /apis/"+k.group+"/"+k.version+"/namespaces/{namespace}/"+k.plural+"/{name}", s.withWriter(s.deleteCRD(crd)))
}
s.router.GET("/version", s.withWriter(s.versionEndpoint))
router.Handle("GET /version", s.withWriter(s.versionEndpoint))
for _, endpoint := range []string{"/api", "/api/:ver", "/apis"} {
s.router.GET(endpoint, s.withWriter(s.discoveryEndpoint))
for _, endpoint := range []string{"/api", "/api/{ver}", "/apis"} {
router.Handle("GET "+endpoint, s.withWriter(s.discoveryEndpoint))
}
for k, v := range s.crds {
s.router.GET("/apis/"+k.group+"/"+k.version, s.withWriter(crdDiscovery(v)))
router.Handle("GET /apis/"+k.group+"/"+k.version, s.withWriter(crdDiscovery(v)))
}
s.server = httptest.NewUnstartedServer(s.router)
s.server = httptest.NewUnstartedServer(router)
s.server.EnableHTTP2 = true
}
@@ -301,8 +307,20 @@ func (s *KubeMockServer) Close() error {
return nil
}
func (s *KubeMockServer) withWriter(handler httplib.HandlerFunc) httprouter.Handle {
return httplib.MakeHandlerWithErrorWriter(handler, s.formatResponseError)
var routerRe = regexp.MustCompile(`\{([^}]+)\}`)
// withWriter handles the glue to support stdlib handler.
func (s *KubeMockServer) withWriter(handler httplib.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
matches := routerRe.FindAllStringSubmatch(r.Pattern, -1)
p := httprouter.Params{}
for _, elem := range matches {
p = append(p, httprouter.Param{Key: elem[1], Value: r.PathValue(elem[1])})
}
httplib.MakeHandlerWithErrorWriter(handler, s.formatResponseError)(w, r, p)
}
}
func (s *KubeMockServer) formatResponseError(rw http.ResponseWriter, respErr error) {
@@ -0,0 +1,113 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package kubeserver
import (
"net/http"
"sort"
"github.com/gravitational/trace"
"github.com/julienschmidt/httprouter"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/gravitational/teleport/api/types"
)
var namespaceList = corev1.NamespaceList{
TypeMeta: metav1.TypeMeta{
Kind: "NamespaceList",
APIVersion: "v1",
},
ListMeta: metav1.ListMeta{
ResourceVersion: "1231415",
},
Items: []corev1.Namespace{
newNamespace("default"),
newNamespace("test"),
newNamespace("dev"),
newNamespace("prod"),
},
}
func newNamespace(name string) corev1.Namespace {
return corev1.Namespace{
TypeMeta: metav1.TypeMeta{
Kind: "Namespace",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
}
func (s *KubeMockServer) listNamespaces(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) {
list := namespaceList.DeepCopy()
return list, nil
}
func (s *KubeMockServer) getNamespace(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) {
name := p.ByName("name")
filter := func(role corev1.Namespace) bool {
return role.Name == name
}
for _, role := range namespaceList.Items {
if filter(role) {
return role, nil
}
}
return nil, trace.NotFound("namespace %q not found", name)
}
func (s *KubeMockServer) deleteNamespace(w http.ResponseWriter, req *http.Request, p httprouter.Params) (any, error) {
name := p.ByName("name")
deleteOpts, err := parseDeleteCollectionBody(req)
if err != nil {
return nil, trace.Wrap(err)
}
reqID := ""
if deleteOpts.Preconditions != nil && deleteOpts.Preconditions.UID != nil {
reqID = string(*deleteOpts.Preconditions.UID)
}
filter := func(role corev1.Namespace) bool {
return role.Name == name
}
for _, role := range namespaceList.Items {
if filter(role) {
s.mu.Lock()
key := deletedResource{reqID, types.KindKubeNamespace}
s.deletedResources[key] = append(s.deletedResources[key], name)
s.mu.Unlock()
return role, nil
}
}
return nil, trace.NotFound("namespace %q not found", name)
}
func (s *KubeMockServer) Deletednamespaces(reqID string) []string {
s.mu.Lock()
key := deletedResource{reqID, types.KindKubeNamespace}
deleted := make([]string, len(s.deletedResources[key]))
copy(deleted, s.deletedResources[key])
s.mu.Unlock()
sort.Strings(deleted)
return deleted
}
+4 -2
View File
@@ -40,13 +40,14 @@ type metaResource struct {
resourceDefinition *metav1.APIResource // Resource definition data from the schema.
requestedResource apiResource // User input, based on URL.
verb string // Verb of the user request.
isClusterWide bool // TODO(@creack): Remove this in favor of resourceDefinition.Namespaced.
}
func (mr *metaResource) isClusterWideResource() bool {
if mr == nil || mr.resourceDefinition == nil {
if mr == nil {
return false
}
return !mr.resourceDefinition.Namespaced
return mr.isClusterWide || (mr.resourceDefinition != nil && !mr.resourceDefinition.Namespaced)
}
func (mr *metaResource) rbacResource() *types.KubernetesResource {
@@ -236,6 +237,7 @@ func getResourceFromRequest(req *http.Request, kubeDetails *kubeDetails) (metaRe
// If the resource is not supported, return nil.
return out, nil
}
out.isClusterWide = !resource.Namespaced
if apiResource.resourceName == "" && out.verb != types.KubeVerbCreate {
// if the resource is supported but the resource name is not present and not a create request,
+11 -1
View File
@@ -451,6 +451,10 @@ type RoleSpec struct {
// CreateUserWithTraitsAndRole creates Teleport user and role with specified names
func (c *TestContext) CreateUserWithTraitsAndRole(ctx context.Context, t *testing.T, username string, userTraits map[string][]string, roleSpec RoleSpec) (types.User, types.Role) {
return c.CreateUserWithTraitsAndRoleVersion(ctx, t, username, userTraits, types.DefaultRoleVersion, roleSpec)
}
func (c *TestContext) CreateUserWithTraitsAndRoleVersion(ctx context.Context, t *testing.T, username string, userTraits map[string][]string, roleVersion string, roleSpec RoleSpec) (types.User, types.Role) {
user, role, err := auth.CreateUserAndRole(
c.TLSServer.Auth(),
username,
@@ -459,6 +463,7 @@ func (c *TestContext) CreateUserWithTraitsAndRole(ctx context.Context, t *testin
auth.WithUserMutator(func(user types.User) {
user.SetTraits(userTraits)
}),
auth.WithRoleVersion(roleVersion),
)
require.NoError(t, err)
role.SetKubeUsers(types.Allow, roleSpec.KubeUsers)
@@ -478,7 +483,12 @@ func (c *TestContext) CreateUserWithTraitsAndRole(ctx context.Context, t *testin
// CreateUserAndRole creates Teleport user and role with specified names
func (c *TestContext) CreateUserAndRole(ctx context.Context, t *testing.T, username string, roleSpec RoleSpec) (types.User, types.Role) {
return c.CreateUserWithTraitsAndRole(ctx, t, username, nil, roleSpec)
return c.CreateUserAndRoleVersion(ctx, t, username, types.DefaultRoleVersion, roleSpec)
}
// CreateUserAndRoleVersion creates Teleport user and role with specified names and role version.
func (c *TestContext) CreateUserAndRoleVersion(ctx context.Context, t *testing.T, username, roleVersion string, roleSpec RoleSpec) (types.User, types.Role) {
return c.CreateUserWithTraitsAndRoleVersion(ctx, t, username, nil, roleVersion, roleSpec)
}
func newKubeConfigFile(t *testing.T, clusters ...KubeClusterConfig) string {
+27 -1
View File
@@ -508,11 +508,26 @@ func (a *accessChecker) GetKubeResources(cluster types.KubeCluster) (allowed, de
}
var err error
rolesAllowed, rolesDenied := a.RoleSet.GetKubeResources(cluster, a.info.Traits)
// If we have a legacy 'namespace' in the allowedResourceIDs, we need to add the new 'namespaces' one.
// The old one will get mapped to wildcard later.
allowedResourceIDs := slices.Clone(a.info.AllowedResourceIDs)
for _, elem := range a.info.AllowedResourceIDs {
if elem.Kind == types.KindKubeNamespace {
allowedResourceIDs = append(allowedResourceIDs, types.ResourceID{
ClusterName: elem.ClusterName,
Kind: "namespaces",
SubResourceName: elem.SubResourceName,
Name: elem.Name,
})
}
}
// Allways append the denied resources from the roles. This is because
// the denied resources from the roles take precedence over the allowed
// resources from the certificate.
denied = rolesDenied
for _, r := range a.info.AllowedResourceIDs {
for _, r := range allowedResourceIDs {
if r.Name != cluster.GetName() || r.ClusterName != a.localCluster {
continue
}
@@ -520,6 +535,7 @@ func (a *accessChecker) GetKubeResources(cluster types.KubeCluster) (allowed, de
case slices.Contains(types.KubernetesResourcesKinds, r.Kind):
namespace := ""
name := ""
// TODO(@creack): Make sure this gets handled in the AccessRequest PR.
if slices.Contains(types.KubernetesClusterWideResourceKinds, r.Kind) {
// Cluster wide resources do not have a namespace.
name = r.SubResourceName
@@ -540,6 +556,16 @@ func (a *accessChecker) GetKubeResources(cluster types.KubeCluster) (allowed, de
if kind == "" {
kind = r.Kind
}
// NOTE: The 'namespace' behavior changed, to maintain backwards compatibility,
// map the legacy value to wildcard.
if r.Kind == types.KindKubeNamespace {
kind = types.Wildcard
namespace = name
if namespace == types.Wildcard {
namespace = "^" + types.Wildcard + "$"
}
name = types.Wildcard
}
r := types.KubernetesResource{
Kind: kind,
Namespace: namespace,
+1
View File
@@ -2391,6 +2391,7 @@ func getKubeResourcesFromResourceIDs(resourceIDs []types.ResourceID, clusterName
kind = resourceID.Kind
}
switch {
// TODO(@creack): Make sure this is handled in the AccessRequest PR.
case slices.Contains(types.KubernetesClusterWideResourceKinds, resourceID.Kind):
kubernetesResources = append(kubernetesResources, types.KubernetesResource{
Kind: kind,
+1 -1
View File
@@ -2948,7 +2948,7 @@ func TestValidate_WithAllowRequestKubernetesResources(t *testing.T) {
"*": {"*"},
},
KubernetesResources: []types.KubernetesResource{
{Kind: "namespaces", Namespace: "*", Name: "*", Verbs: []string{"*"}, APIGroup: "*"},
{Kind: "*", Namespace: "*", Name: "*", Verbs: []string{"*"}, APIGroup: "*"},
},
},
},
+8 -1
View File
@@ -151,7 +151,14 @@ func NewImplicitRole() types.Role {
//
// Used in tests only.
func RoleForUser(u types.User) types.Role {
role, _ := types.NewRole(RoleNameForUser(u.GetName()), types.RoleSpecV6{
return RoleWithVersionForUser(u, types.DefaultRoleVersion)
}
// RoleWithVersionForUser creates an admin role for a services.User.
//
// Used in tests only.
func RoleWithVersionForUser(u types.User, v string) types.Role {
role, _ := types.NewRoleWithVersion(RoleNameForUser(u.GetName()), v, types.RoleSpecV6{
Options: types.RoleOptions{
CertificateFormat: constants.CertificateFormatStandard,
MaxSessionTTL: types.NewDuration(defaults.MaxCertDuration),
+1 -1
View File
@@ -187,7 +187,7 @@ func TestRoleParse(t *testing.T) {
}
}`,
error: trace.BadParameter(""),
matchMessage: "KubernetesResource must include Namespace",
matchMessage: "KubernetesResource kind \"pod\" must include Namespace",
},
{
name: "validation error, invalid kubernetes_resources kind",
+10 -34
View File
@@ -201,19 +201,6 @@ func KubeResourceMatchesRegex(input types.KubernetesResource, isClusterWideResou
continue
}
switch {
// If the user has access to a specific namespace, they should be able to
// access all resources in that namespace.
case resource.Kind == "namespaces" && input.Namespace != "":
// Access to custom resources is determined by the access level of the
// namespace resource where the custom resource is defined.
// This is a special case because custom resources are not defined in the
// user's resources list.
// Access to namspaced resources is determined by the access level of the
// namespace resource where the resource is defined or by the access level
// of the resource if supported.
if ok, err := MatchString(input.Namespace, resource.Name); err != nil || ok {
return ok, trace.Wrap(err)
}
case targetsReadOnlyNamespace && cond == types.Allow && resource.Kind != "namespaces" && resource.Namespace != "":
// If the user requests a read-only namespace get/list/watch, they should
// be able to see the list of namespaces they have resources defined in.
@@ -223,6 +210,10 @@ func KubeResourceMatchesRegex(input types.KubernetesResource, isClusterWideResou
if ok, err := MatchString(input.Name, resource.Namespace); err != nil || ok {
return ok, trace.Wrap(err)
}
case targetsReadOnlyNamespace && cond == types.Allow && resource.Kind == "namespaces" && resource.Name != "":
if ok, err := MatchString(input.Name, resource.Name); err != nil || ok {
return ok, trace.Wrap(err)
}
default:
if input.Kind != resource.Kind && resource.Kind != types.Wildcard {
continue
@@ -237,16 +228,16 @@ func KubeResourceMatchesRegex(input types.KubernetesResource, isClusterWideResou
} else if !ok {
continue
}
if input.Namespace == "" && isClusterWideResource {
return true, nil
}
if input.Namespace == "" && resource.Namespace != "" && resource.Namespace != types.Wildcard {
continue
}
// At this point everything else matched. If we match the namespace as well, we have a match.
if ok, err := MatchString(input.Namespace, resource.Namespace); err != nil || ok {
return ok, trace.Wrap(err)
}
}
}
return false, nil
}
@@ -286,23 +277,6 @@ func KubeResourceCouldMatchRules(input types.KubernetesResource, isClusterWideRe
continue
}
switch {
// If the user has access to a specific namespace, they should be able to
// access all resources in that namespace.
case resource.Kind == "namespaces":
isAllowOrFullDeny := !isDeny || resource.Name == types.Wildcard
if input.Namespace == "" && isAllowOrFullDeny {
return isAllowOrFullDeny, nil
}
// Access to custom resources is determined by the access level of the
// namespace resource where the custom resource is defined.
// This is a special case because custom resources are not defined in the
// user's resources list.
// Access to namespaced resources is determined by the access level of the
// namespace resource where the resource is defined or by the access level
// of the resource if supported.
if ok, err := MatchString(input.Namespace, resource.Name); err != nil || ok && isAllowOrFullDeny {
return isAllowOrFullDeny || isDeny, trace.Wrap(err)
}
case targetsReadOnlyNamespace && !isDeny && resource.Kind != "namespaces" && resource.Namespace != "":
// If the user requests a read-only namespace get/list/watch, they should
// be able to see the list of namespaces they have resources defined in.
@@ -336,11 +310,13 @@ func KubeResourceCouldMatchRules(input types.KubernetesResource, isClusterWideRe
if input.Namespace == "" && isAllowOrFullDeny {
return isAllowOrFullDeny, nil
}
if ok, err := MatchString(input.Namespace, resource.Namespace); err != nil {
return false, trace.Wrap(err)
} else if !ok {
continue
}
if !isDeny || isDeny && resource.Name == types.Wildcard {
return !isDeny || isDeny && resource.Name == types.Wildcard, nil
}
+99 -72
View File
@@ -233,10 +233,18 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
resources: []types.KubernetesResource{
{
Kind: types.Wildcard,
APIGroup: types.Wildcard,
Namespace: types.Wildcard,
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
},
{
Kind: types.Wildcard,
APIGroup: types.Wildcard,
Namespace: "",
Name: types.Wildcard,
Verbs: []string{types.Wildcard},
},
},
assert: require.NoError,
action: types.Deny,
@@ -472,16 +480,18 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
{
name: "list clusterrole with resource",
input: types.KubernetesResource{
Kind: "clusterroles",
Name: "clusterrole",
Verbs: []string{types.KubeVerbGet},
Kind: "clusterroles",
APIGroup: "rbac.authorization.k8s.io",
Name: "clusterrole",
Verbs: []string{types.KubeVerbGet},
},
isClusterWide: true,
resources: []types.KubernetesResource{
{
Kind: "clusterroles",
Name: "clusterrole",
Verbs: []string{types.Wildcard},
Kind: "clusterroles",
APIGroup: "rbac.authorization.k8s.io",
Name: "clusterrole",
Verbs: []string{types.Wildcard},
},
},
assert: require.NoError,
@@ -491,16 +501,18 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
{
name: "list clusterrole with wildcard",
input: types.KubernetesResource{
Kind: "clusterroles",
Name: "clusterrole",
Verbs: []string{types.KubeVerbGet},
Kind: "clusterroles",
APIGroup: "rbac.authorization.k8s.io",
Name: "clusterrole",
Verbs: []string{types.KubeVerbGet},
},
isClusterWide: true,
resources: []types.KubernetesResource{
{
Kind: types.Wildcard,
APIGroup: types.Wildcard,
Name: types.Wildcard,
Namespace: types.Wildcard,
Namespace: "",
Verbs: []string{types.Wildcard},
},
},
@@ -571,7 +583,7 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
},
{
name: "namespace granting read access to pod",
name: "jailed namespace granting read access to pod",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "default",
@@ -584,13 +596,20 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
Name: "default",
Verbs: []string{types.KubeVerbGet},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
action: types.Allow,
matches: true,
},
{
name: "namespace denying update access to pod",
name: "jailed namespace denying update access to pod",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "default",
@@ -603,13 +622,20 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
Name: "default",
Verbs: []string{types.KubeVerbGet},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
action: types.Allow,
matches: false,
},
{
name: "namespace granting read access to custom resource",
name: "jailed namespace granting read access to custom resource",
input: types.KubernetesResource{
Kind: "mycustomresources",
Namespace: "default",
@@ -619,10 +645,16 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
},
resources: []types.KubernetesResource{
{
Kind: "namespaces",
Name: "default",
Verbs: []string{types.KubeVerbGet},
APIGroup: "*",
Kind: "namespaces",
Name: "default",
Verbs: []string{types.KubeVerbGet},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
@@ -640,10 +672,9 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
},
resources: []types.KubernetesResource{
{
Kind: "namespaces",
Name: "default",
Verbs: []string{types.KubeVerbGet},
APIGroup: "*",
Kind: "namespaces",
Name: "default",
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
@@ -665,13 +696,11 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
Namespace: "default",
Name: "name",
Verbs: []string{types.KubeVerbGet},
APIGroup: "*",
},
{
Kind: "namespaces",
Name: "diffnamespace",
Verbs: []string{types.KubeVerbGet},
APIGroup: "*",
Kind: "namespaces",
Name: "diffnamespace",
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
@@ -699,7 +728,7 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
matches: true,
},
{
name: "global custom resource with namespaced wildcard",
name: "global custom resource with jailed namespaced wildcard",
input: types.KubernetesResource{
Kind: "mycustomresources",
Name: "name",
@@ -708,10 +737,16 @@ func TestKubeResourceMatchesRegex(t *testing.T) {
},
resources: []types.KubernetesResource{
{
Kind: "namespaces",
Name: "*",
Verbs: []string{types.KubeVerbGet},
APIGroup: "*",
Kind: "namespaces",
Name: types.Wildcard,
Verbs: []string{types.KubeVerbGet},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
@@ -1269,7 +1304,7 @@ func TestKubeResourceCouldMatchRules(t *testing.T) {
},
{
name: "namespace granting read access to pod",
name: "jailed namespace granting read access to pod",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "default",
@@ -1281,31 +1316,20 @@ func TestKubeResourceCouldMatchRules(t *testing.T) {
Name: "default",
Verbs: []string{types.KubeVerbGet},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbGet},
},
},
assert: require.NoError,
matches: true,
action: types.Allow,
},
{
name: "namespace denying update access to pod",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "default",
Verbs: []string{types.KubeVerbList},
},
resources: []types.KubernetesResource{
{
Kind: "namespaces",
Name: "default",
Verbs: []string{types.KubeVerbList},
},
},
assert: require.NoError,
matches: false,
action: types.Deny,
},
{
name: "namespace denying list access to pod with different namespace",
name: "jailed namespace denying list access to pod with different namespace",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "default2",
@@ -1313,17 +1337,24 @@ func TestKubeResourceCouldMatchRules(t *testing.T) {
},
resources: []types.KubernetesResource{
{
Kind: "namespaces",
Kind: "names",
Name: "default",
Verbs: []string{types.KubeVerbList},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbList},
},
},
assert: require.NoError,
matches: false,
action: types.Deny,
},
{
name: "namespace denying list access to pod in all namespaces doesnt match deny",
name: "jailed namespace denying list access to pod in all namespaces doesnt match deny",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "",
@@ -1335,13 +1366,20 @@ func TestKubeResourceCouldMatchRules(t *testing.T) {
Name: "default",
Verbs: []string{types.KubeVerbList},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbList},
},
},
assert: require.NoError,
matches: false,
action: types.Deny,
},
{
name: "namespace denying update access to pod in all namespaces matches allow",
name: "jailed namespace denying update access to pod in all namespaces matches allow",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "",
@@ -1353,29 +1391,18 @@ func TestKubeResourceCouldMatchRules(t *testing.T) {
Name: "default",
Verbs: []string{types.KubeVerbList},
},
{
Kind: types.Wildcard,
Name: types.Wildcard,
Namespace: "default",
APIGroup: types.Wildcard,
Verbs: []string{types.KubeVerbList},
},
},
assert: require.NoError,
matches: true,
action: types.Allow,
},
{
name: "namespace denying update access to pod deny matches all namespaces",
input: types.KubernetesResource{
Kind: "pods",
Namespace: "",
Verbs: []string{types.KubeVerbList},
},
resources: []types.KubernetesResource{
{
Kind: "namespaces",
Name: types.Wildcard,
Verbs: []string{types.KubeVerbList},
},
},
assert: require.NoError,
matches: true,
action: types.Deny,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {