Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion authz/providers/azure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ If the operation is absent:
unregistered action, but `Writer` matches one whenever the parent resource is among
its 24 wildcards.

Status of the actions used by the mapping, measured on 2026-08-18:
Status of the actions used by the mapping, measured on 2026-08-26:

| Action | Registered |
| ------------------------------------------------------------------------------------- | ---------- |
Expand Down Expand Up @@ -280,6 +280,12 @@ Two traps make this easy to miss:
node and controller identities stop there and never reach the mapping. Only a cluster
whose kubelet uses an Entra ID identity sends these requests to CheckAccess.

The `--azure.enforce-csr-nodeclient-data-action` rollout flag controls the
`certificatesigningrequests/nodeclient` mapping. It defaults to `false`, which preserves
the legacy `certificatesigningrequests/write` action. Set it to `true` only after the
dedicated operation is published and affected custom roles have migrated. Turning it
back off restores the legacy mapping without rolling back the Guard image.

### Two different subresource mechanisms

Guard handles subresources in two separate places. They use separate lists and serve
Expand Down Expand Up @@ -615,6 +621,7 @@ AKS_AUTHZ_TOKEN_URL="https://${FQDN}:443/authz/token"
| `--azure.discover-resources-frequency` | Discovery refresh interval | 5m |
| `--azure.allow-custom-resource-type-check` | Enable CRD support | false |
| `--azure.allow-subresource-type-check` | Enable subresource perms | false |
| `--azure.enforce-csr-nodeclient-data-action` | Enforce CSR nodeclient action | false |
| `--azure.audit-sar` | Log all SAR requests | false |

### Prometheus Metrics
Expand Down
11 changes: 11 additions & 0 deletions authz/providers/azure/options/fleet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,20 @@ package options
import (
"testing"

"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
)

func TestEnforceCSRNodeClientDataActionFlag(t *testing.T) {
opts := NewOptions()
assert.False(t, opts.EnforceCSRNodeClientDataAction)

flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
opts.AddFlags(flags)
assert.NoError(t, flags.Parse([]string{"--azure.enforce-csr-nodeclient-data-action=true"}))
assert.True(t, opts.EnforceCSRNodeClientDataAction)
}

func TestValidateFleetID(t *testing.T) {
tests := []struct {
name string
Expand Down
5 changes: 5 additions & 0 deletions authz/providers/azure/options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type Options struct {
AllowNonResDiscoveryPathAccess bool
AllowCustomResourceTypeCheck bool
AllowSubresourceTypeCheck bool
EnforceCSRNodeClientDataAction bool
UseNamespaceResourceScopeFormat bool
DiscoverResources bool
UseManagedNamespaceResourceScopeFormat bool
Expand Down Expand Up @@ -85,6 +86,7 @@ func NewOptions() Options {
AllowNonResDiscoveryPathAccess: true,
AllowCustomResourceTypeCheck: false,
AllowSubresourceTypeCheck: false,
EnforceCSRNodeClientDataAction: false,
UseNamespaceResourceScopeFormat: false,
DiscoverResources: false,
ReconcileDiscoverResourcesFrequency: 5 * time.Minute,
Expand All @@ -109,6 +111,7 @@ func (o *Options) AddFlags(fs *pflag.FlagSet) {
fs.BoolVar(&o.AllowNonResDiscoveryPathAccess, "azure.allow-nonres-discovery-path-access", o.AllowNonResDiscoveryPathAccess, "allow access on Non Resource paths required for discovery, setting it false will require explicit non resource path role assignment for all users in Azure RBAC")
fs.BoolVar(&o.AllowCustomResourceTypeCheck, "azure.allow-custom-resource-type-check", o.AllowCustomResourceTypeCheck, "allow custom resource type checks for authorization")
fs.BoolVar(&o.AllowSubresourceTypeCheck, "azure.allow-subresource-type-check", o.AllowSubresourceTypeCheck, "allow subresource type checks for authorization")
fs.BoolVar(&o.EnforceCSRNodeClientDataAction, "azure.enforce-csr-nodeclient-data-action", o.EnforceCSRNodeClientDataAction, "map CSR nodeclient authorization checks to the dedicated nodeclient DataAction")
fs.BoolVar(&o.UseNamespaceResourceScopeFormat, "azure.use-ns-resource-scope-format", o.UseNamespaceResourceScopeFormat, "use namespace as resource scope format for making rbac checkaccess calls at namespace scope")
fs.StringVar(&o.KubeConfigFile, "azure.kubeconfig-file", "", "path to the kubeconfig of cluster.")
fs.BoolVar(&o.UseManagedNamespaceResourceScopeFormat, "azure.use-managed-namespace-resource-scope-format", o.UseManagedNamespaceResourceScopeFormat, "enable managed namespace RBAC for azure authz mode")
Expand Down Expand Up @@ -218,6 +221,8 @@ func (o Options) Apply(d *apps.Deployment) (extraObjs []runtime.Object, err erro

args = append(args, fmt.Sprintf("--azure.allow-subresource-type-check=%t", o.AllowSubresourceTypeCheck))

args = append(args, fmt.Sprintf("--azure.enforce-csr-nodeclient-data-action=%t", o.EnforceCSRNodeClientDataAction))

d.Spec.Template.Spec.Containers[0].Args = args
return extraObjs, nil
}
Expand Down
8 changes: 4 additions & 4 deletions authz/providers/azure/rbac/checkaccess_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ func (a *AccessInfo) checkAccessV2(ctx context.Context, request *authzv1.Subject
log.V(7).Info("Extracted user identity for v2", "userOid", userOid, "groupsCount", len(groups))

// Prepare actions list from request (same logic as v1 but get action IDs)
actions, err := getDataActionsV2(ctx, request, a.clusterType, a.allowCustomResourceTypeCheck, a.allowSubresourceTypeCheck)
actions, err := getDataActionsV2(ctx, request, a.clusterType, a.allowCustomResourceTypeCheck, a.allowSubresourceTypeCheck, a.enforceCSRNodeClientDataAction)
if err != nil {
return nil, fmt.Errorf("error preparing v2 actions list: %w", err)
}
Expand Down Expand Up @@ -328,7 +328,7 @@ func (a *AccessInfo) checkAccessV2(ctx context.Context, request *authzv1.Subject
// Generate fleet-specific actions using fleetMembers cluster type
// This ensures actions like "Microsoft.ContainerService/fleets/members/pods/read"
// are used instead of "Microsoft.ContainerService/managedClusters/pods/read"
fleetMemberActions, err := getDataActionsV2(ctx, request, fleetMembers, a.allowCustomResourceTypeCheck, a.allowSubresourceTypeCheck)
fleetMemberActions, err := getDataActionsV2(ctx, request, fleetMembers, a.allowCustomResourceTypeCheck, a.allowSubresourceTypeCheck, a.enforceCSRNodeClientDataAction)
if err != nil {
return nil, fmt.Errorf("error preparing fleet actions list: %w", err)
}
Expand Down Expand Up @@ -372,8 +372,8 @@ func (a *AccessInfo) checkAccessV2(ctx context.Context, request *authzv1.Subject
// IsDataAction and any subresource attributes are preserved for the PDP request
// (see buildAuthorizationRequestV2 / toActionInfos). Dropping IsDataAction makes
// PDP evaluate Kubernetes RBAC actions as management actions and deny every check.
func getDataActionsV2(ctx context.Context, request *authzv1.SubjectAccessReviewSpec, clusterType string, allowCustomResourceTypeCheck bool, allowSubresourceTypeCheck bool) ([]azureutils.AuthorizationActionInfo, error) {
return getDataActions(ctx, request, clusterType, allowCustomResourceTypeCheck, allowSubresourceTypeCheck)
func getDataActionsV2(ctx context.Context, request *authzv1.SubjectAccessReviewSpec, clusterType string, allowCustomResourceTypeCheck bool, allowSubresourceTypeCheck bool, enforceCSRNodeClientDataAction bool) ([]azureutils.AuthorizationActionInfo, error) {
return getDataActions(ctx, request, clusterType, allowCustomResourceTypeCheck, allowSubresourceTypeCheck, enforceCSRNodeClientDataAction)
}

// buildResourceIDForV2 constructs and validates a resource ID for CheckAccess v2 API.
Expand Down
21 changes: 20 additions & 1 deletion authz/providers/azure/rbac/checkaccess_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ func TestGetDataActionsV2_Success(t *testing.T) {
},
}

actions, err := getDataActionsV2(ctx, request, managedClusters, false, false)
actions, err := getDataActionsV2(ctx, request, managedClusters, false, false, false)

assert.NoError(t, err)
assert.NotEmpty(t, actions)
Expand All @@ -260,6 +260,25 @@ func TestGetDataActionsV2_Success(t *testing.T) {
assert.True(t, actions[0].IsDataAction)
}

func TestGetDataActionsV2_CSRNodeClientEnforced(t *testing.T) {
request := &authzv1.SubjectAccessReviewSpec{
ResourceAttributes: &authzv1.ResourceAttributes{
Group: "certificates.k8s.io",
Verb: "create",
Resource: "certificatesigningrequests",
Subresource: "nodeclient",
},
}

actions, err := getDataActionsV2(context.Background(), request, managedClusters, false, false, true)

assert.NoError(t, err)
if assert.Len(t, actions, 1) {
assert.Equal(t, "Microsoft.ContainerService/managedClusters/certificates.k8s.io/certificatesigningrequests/nodeclient/action", actions[0].Id)
assert.True(t, actions[0].IsDataAction)
}
}

func TestPerformCheckAccessV2_Success(t *testing.T) {
mockClient := &mockPDPClient{
checkAccessFunc: func(ctx context.Context, authzReq checkaccess.AuthorizationRequest) (*checkaccess.AuthorizationDecisionResponse, error) {
Expand Down
44 changes: 33 additions & 11 deletions authz/providers/azure/rbac/checkaccessreqhelper.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ const (
ServicesResource = "services"
NodesResource = "nodes"
ServiceAccountsResource = "serviceaccounts"
CSRResource = "certificatesigningrequests"
CustomResources = "customresources"
ProxySubresource = "proxy"
AttachSubresource = "attach"
PortForwardSubresource = "portforward"
ExecSubresource = "exec"
TokenSubresource = "token"
NodeClientSubresource = "nodeclient"
ReadVerb = "read"
WriteVerb = "write"
DeleteVerb = "delete"
Expand Down Expand Up @@ -277,6 +279,11 @@ func getActionName(verb string) string {
// object. Collapsing it into serviceaccounts/write would grant token issuance
// to every principal that can create or update ServiceAccount objects, which
// upstream Kubernetes RBAC does not do.
//
// certificatesigningrequests/nodeclient is the authorization gate used by the
// upstream CSR approver before issuing a kubelet client certificate. Collapsing
// it into certificatesigningrequests/write would let general CSR writers pass
// this credential-issuance check.
var securitySensitiveSubresources = map[string]map[string]struct{}{
PodsResource: {
ExecSubresource: {},
Expand All @@ -293,18 +300,33 @@ var securitySensitiveSubresources = map[string]map[string]struct{}{
ServiceAccountsResource: {
TokenSubresource: {},
},
CSRResource: {
NodeClientSubresource: {},
},
}

func getResourceAndAction(resource string, subResource string, verb string) string {
if subs, ok := securitySensitiveSubresources[resource]; ok && subResource != "" {
if _, sensitive := subs[subResource]; sensitive {
return path.Join(resource, subResource, "action")
}
func getResourceAndAction(resource string, subResource string, verb string, enforceCSRNodeClientDataAction bool) string {
if shouldPreserveSubresource(resource, subResource, enforceCSRNodeClientDataAction) {
return path.Join(resource, subResource, "action")
}
return path.Join(resource, getActionName(verb))
}

func getDataActions(ctx context.Context, subRevReq *authzv1.SubjectAccessReviewSpec, clusterType string, allowCustomResourceTypeCheck bool, allowSubresourceTypeCheck bool) ([]azureutils.AuthorizationActionInfo, error) {
func shouldPreserveSubresource(resource string, subResource string, enforceCSRNodeClientDataAction bool) bool {
if resource == CSRResource && subResource == NodeClientSubresource {
return enforceCSRNodeClientDataAction
}

subResources, ok := securitySensitiveSubresources[resource]
if !ok {
return false
}

_, ok = subResources[subResource]
return ok
}

func getDataActions(ctx context.Context, subRevReq *authzv1.SubjectAccessReviewSpec, clusterType string, allowCustomResourceTypeCheck bool, allowSubresourceTypeCheck bool, enforceCSRNodeClientDataAction bool) ([]azureutils.AuthorizationActionInfo, error) {
var authInfoList []azureutils.AuthorizationActionInfo
var err error
log := klog.FromContext(ctx)
Expand Down Expand Up @@ -345,7 +367,7 @@ func getDataActions(ctx context.Context, subRevReq *authzv1.SubjectAccessReviewS
authInfoSingle.AuthorizationEntity.Id = path.Join(authInfoSingle.AuthorizationEntity.Id, subRevReq.ResourceAttributes.Group)
}

action := getResourceAndAction(subRevReq.ResourceAttributes.Resource, subRevReq.ResourceAttributes.Subresource, subRevReq.ResourceAttributes.Verb)
action := getResourceAndAction(subRevReq.ResourceAttributes.Resource, subRevReq.ResourceAttributes.Subresource, subRevReq.ResourceAttributes.Verb, enforceCSRNodeClientDataAction)
authInfoSingle.AuthorizationEntity.Id = path.Join(authInfoSingle.AuthorizationEntity.Id, action)
if allowSubresourceTypeCheck {
err = setAuthInfoSubresourceAttributes(&authInfoSingle, subRevReq)
Expand Down Expand Up @@ -648,13 +670,13 @@ func defaultDir(s string) string {
return "-" // invalid for a namespace
}

func getResultCacheKey(subRevReq *authzv1.SubjectAccessReviewSpec, allowSubresourceTypeCheck bool) string {
func getResultCacheKey(subRevReq *authzv1.SubjectAccessReviewSpec, allowSubresourceTypeCheck bool, enforceCSRNodeClientDataAction bool) string {
cacheKey := subRevReq.User

if subRevReq.ResourceAttributes != nil {
cacheKey = path.Join(cacheKey, defaultDir(subRevReq.ResourceAttributes.Namespace))
cacheKey = path.Join(cacheKey, defaultDir(subRevReq.ResourceAttributes.Group))
action := getResourceAndAction(subRevReq.ResourceAttributes.Resource, subRevReq.ResourceAttributes.Subresource, subRevReq.ResourceAttributes.Verb)
action := getResourceAndAction(subRevReq.ResourceAttributes.Resource, subRevReq.ResourceAttributes.Subresource, subRevReq.ResourceAttributes.Verb, enforceCSRNodeClientDataAction)
cacheKey = path.Join(cacheKey, action)

// Cache results for subresources of interest separately
Expand All @@ -670,7 +692,7 @@ func getResultCacheKey(subRevReq *authzv1.SubjectAccessReviewSpec, allowSubresou
return cacheKey
}

func prepareCheckAccessRequestBody(ctx context.Context, req *authzv1.SubjectAccessReviewSpec, clusterType string, resourceId string, useNamespaceResourceScopeFormat bool, allowCustomResourceTypeCheck bool, allowSubresourceTypeCheck bool) ([]*CheckAccessRequest, error) {
func prepareCheckAccessRequestBody(ctx context.Context, req *authzv1.SubjectAccessReviewSpec, clusterType string, resourceId string, useNamespaceResourceScopeFormat bool, allowCustomResourceTypeCheck bool, allowSubresourceTypeCheck bool, enforceCSRNodeClientDataAction bool) ([]*CheckAccessRequest, error) {
/* This is how sample SubjectAccessReview request will look like
{
"kind": "SubjectAccessReview",
Expand Down Expand Up @@ -733,7 +755,7 @@ func prepareCheckAccessRequestBody(ctx context.Context, req *authzv1.SubjectAcce
return nil, errutils.WithCode(fmt.Errorf("oid info not sent from authentication module"), http.StatusBadRequest)
}
groups := getValidSecurityGroups(req.Groups)
actions, err := getDataActions(ctx, req, clusterType, allowCustomResourceTypeCheck, allowSubresourceTypeCheck)
actions, err := getDataActions(ctx, req, clusterType, allowCustomResourceTypeCheck, allowSubresourceTypeCheck, enforceCSRNodeClientDataAction)
if err != nil {
return nil, errutils.WithCode(fmt.Errorf("Error while creating list of dataactions for check access call: %w", err), http.StatusInternalServerError)
}
Expand Down
Loading
Loading