mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: project EKS access policies into Kubernetes RBAC (#1049)
AssociateAccessPolicy recorded a policy association but never granted it in the cluster. Bake static ClusterRoleBindings for the mulga: groups into the eks-node image, project a principal's cluster-scoped associated policies onto those groups in the TokenReview webhook, and reject namespace scope at association time since there is no namespace-scoped RBAC writer.
This commit is contained in:
@@ -23,6 +23,8 @@ KONN_AGENT_MANIFEST_DST=${KONN_AGENT_MANIFEST_DST:-/var/lib/rancher/k3s/server/m
|
||||
COREDNS_SRC=${COREDNS_SRC:-/usr/share/spinifex-eks/coredns/coredns-mulga.yaml}
|
||||
COREDNS_SKIP=${COREDNS_SKIP:-/var/lib/rancher/k3s/server/manifests/coredns.yaml.skip}
|
||||
COREDNS_DST=${COREDNS_DST:-/var/lib/rancher/k3s/server/manifests/coredns-mulga.yaml}
|
||||
ACCESS_POLICY_BINDINGS_SRC=${ACCESS_POLICY_BINDINGS_SRC:-/usr/share/spinifex-eks/rbac/eks-access-policy-bindings.yaml}
|
||||
ACCESS_POLICY_BINDINGS_DST=${ACCESS_POLICY_BINDINGS_DST:-/var/lib/rancher/k3s/server/manifests/eks-access-policy-bindings.yaml}
|
||||
TOKEN_WEBHOOK_KUBECONFIG=${TOKEN_WEBHOOK_KUBECONFIG:-/etc/spinifex-eks/token-webhook.kubeconfig}
|
||||
WAIT_SECS=${WAIT_SECS:-30}
|
||||
|
||||
@@ -91,6 +93,14 @@ if [ -f "${COREDNS_SRC}" ]; then
|
||||
cp "${COREDNS_SRC}" "${COREDNS_DST}"
|
||||
fi
|
||||
|
||||
# Static ClusterRoleBindings for the EKS access-policy groups (mulga:eks-admin/
|
||||
# edit/view). No templating, so a plain idempotent copy; guarded on the baked
|
||||
# manifest so an older image without it just leaves the associations unbound.
|
||||
if [ -f "${ACCESS_POLICY_BINDINGS_SRC}" ]; then
|
||||
mkdir -p "$(dirname "${ACCESS_POLICY_BINDINGS_DST}")"
|
||||
cp "${ACCESS_POLICY_BINDINGS_SRC}" "${ACCESS_POLICY_BINDINGS_DST}"
|
||||
fi
|
||||
|
||||
# The apiserver is configured with --authentication-token-webhook-config-file
|
||||
# pointing at the kubeconfig eks-token-webhook writes at startup. `before k3s`
|
||||
# orders the webhook's start but it backgrounds, so wait (bounded) for the
|
||||
|
||||
@@ -32,12 +32,16 @@ reset_env() {
|
||||
COREDNS_SRC="${CASE}/coredns-mulga.yaml.src"
|
||||
COREDNS_SKIP="${CASE}/manifests/coredns.yaml.skip"
|
||||
COREDNS_DST="${CASE}/manifests/coredns-mulga.yaml"
|
||||
ACCESS_POLICY_BINDINGS_SRC="${CASE}/eks-access-policy-bindings.yaml.src"
|
||||
ACCESS_POLICY_BINDINGS_DST="${CASE}/manifests/eks-access-policy-bindings.yaml"
|
||||
TOKEN_WEBHOOK_KUBECONFIG="${CASE}/token-webhook.kubeconfig"
|
||||
WAIT_SECS=0
|
||||
STDERR="${CASE}/stderr"
|
||||
export BLOCK_MARKER K3S_CONFIG K3S_CONFIG_SKEL FIRST_BOOT_ENVFILE \
|
||||
KONN_AGENT_MANIFEST_SRC KONN_AGENT_MANIFEST_DST \
|
||||
COREDNS_SRC COREDNS_SKIP COREDNS_DST TOKEN_WEBHOOK_KUBECONFIG WAIT_SECS
|
||||
COREDNS_SRC COREDNS_SKIP COREDNS_DST \
|
||||
ACCESS_POLICY_BINDINGS_SRC ACCESS_POLICY_BINDINGS_DST \
|
||||
TOKEN_WEBHOOK_KUBECONFIG WAIT_SECS
|
||||
: > "${K3S_CONFIG}"
|
||||
printf 'server-ca\n' > "${TOKEN_WEBHOOK_KUBECONFIG}"
|
||||
}
|
||||
@@ -122,6 +126,14 @@ invoke
|
||||
[ "$(cat "${COREDNS_DST}" 2>/dev/null)" = "kind: DaemonSet" ] \
|
||||
&& pass "coredns: DaemonSet staged" || fail "coredns: DaemonSet not staged"
|
||||
|
||||
# --- Case 6b: EKS access-policy ClusterRoleBindings staged when bundle present ---
|
||||
reset_env access_policy_bindings
|
||||
printf 'kind: ClusterRoleBinding\n' > "${ACCESS_POLICY_BINDINGS_SRC}"
|
||||
invoke
|
||||
[ "${rc}" -eq 0 ] && pass "access-policy-bindings: exit 0" || fail "access-policy-bindings: expected exit 0 (rc=${rc})"
|
||||
[ "$(cat "${ACCESS_POLICY_BINDINGS_DST}" 2>/dev/null)" = "kind: ClusterRoleBinding" ] \
|
||||
&& pass "access-policy-bindings: manifest staged" || fail "access-policy-bindings: manifest not staged"
|
||||
|
||||
# --- Case 7: token-webhook kubeconfig absent -> bounded wait, warns, exit 0 ---
|
||||
reset_env webhook_wait
|
||||
rm -f "${TOKEN_WEBHOOK_KUBECONFIG}"
|
||||
|
||||
@@ -66,6 +66,7 @@ scripts/images/eks-node/addons/aws-ebs-csi-driver/1.40.1/20-node.yaml:/usr/share
|
||||
scripts/images/eks-node/addons/aws-ebs-csi-driver/1.40.1/30-storageclass.yaml:/usr/share/spinifex-eks/addons/aws-ebs-csi-driver/1.40.1/30-storageclass.yaml \
|
||||
scripts/images/eks-node/addons/nvidia-device-plugin/0.17.4/10-daemonset.yaml:/usr/share/spinifex-eks/addons/nvidia-device-plugin/0.17.4/10-daemonset.yaml \
|
||||
scripts/images/eks-node/coredns/coredns-mulga.yaml:/usr/share/spinifex-eks/coredns/coredns-mulga.yaml \
|
||||
scripts/images/eks-node/rbac/eks-access-policy-bindings.yaml:/usr/share/spinifex-eks/rbac/eks-access-policy-bindings.yaml \
|
||||
scripts/images/eks-node/cloud-init-ec2.cfg:/etc/cloud/cloud.cfg.d/90-spinifex-ec2-imds.cfg \
|
||||
scripts/images/eks-node/mulga-eks-etcd-snapshot.sh:/etc/periodic/daily/mulga-eks-etcd-snapshot \
|
||||
scripts/images/eks-node/mulga-eks-etcd-snapshot.sh:/etc/periodic/15min/mulga-eks-etcd-snapshot"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Static ClusterRoleBindings for the three AWS-managed EKS access policies
|
||||
# that have no default Kubernetes binding. AmazonEKSClusterAdminPolicy needs
|
||||
# no entry here: it maps to system:masters, which Kubernetes already binds to
|
||||
# cluster-admin. These exist from first boot, independent of any cluster or
|
||||
# access-entry association, so AssociateAccessPolicy's group projection has
|
||||
# something to bind to as soon as it names one of these groups.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: mulga-eks-admin
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: spinifex-eks
|
||||
subjects:
|
||||
- apiGroup: rbac.authorization.k8s.io
|
||||
kind: Group
|
||||
name: "mulga:eks-admin"
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: admin
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: mulga-eks-edit
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: spinifex-eks
|
||||
subjects:
|
||||
- apiGroup: rbac.authorization.k8s.io
|
||||
kind: Group
|
||||
name: "mulga:eks-edit"
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: edit
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: mulga-eks-view
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: spinifex-eks
|
||||
subjects:
|
||||
- apiGroup: rbac.authorization.k8s.io
|
||||
kind: Group
|
||||
name: "mulga:eks-view"
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: view
|
||||
@@ -384,6 +384,10 @@ var (
|
||||
ErrorResourceNotFound = "ResourceNotFound"
|
||||
ErrorEKSResourceInUse = "ResourceInUseException"
|
||||
ErrorEKSResourceNotFound = "ResourceNotFoundException"
|
||||
// ErrorEKSInvalidParameter aliases ErrorECSInvalidParameter: real EKS and
|
||||
// real ECS both use the restJson1 wire code "InvalidParameterException",
|
||||
// so this gives EKS call sites their own name for the same registered code.
|
||||
ErrorEKSInvalidParameter = ErrorECSInvalidParameter
|
||||
// ErrorACMResourceInUse aliases ErrorEKSResourceInUse: real ACM's
|
||||
// DeleteCertificate and real EKS's CreateCluster both legitimately use the
|
||||
// wire code "ResourceInUseException", so this is a distinct, self-documenting
|
||||
|
||||
@@ -34,6 +34,16 @@ var supportedAccessPolicies = map[string]string{
|
||||
"arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy": "view",
|
||||
}
|
||||
|
||||
// accessPolicyGroups maps each supported policy ARN to the Kubernetes group
|
||||
// its cluster-scope association projects: system:masters for the cluster-admin
|
||||
// policy, a synthetic mulga: group bound by a static manifest for the rest.
|
||||
var accessPolicyGroups = map[string]string{
|
||||
"arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy": "system:masters",
|
||||
"arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy": "mulga:eks-admin",
|
||||
"arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy": "mulga:eks-edit",
|
||||
"arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy": "mulga:eks-view",
|
||||
}
|
||||
|
||||
// ErrAccessEntryNotFound is returned when no entry exists for the principal.
|
||||
// Callers translate it to ResourceNotFoundException at the service boundary.
|
||||
var ErrAccessEntryNotFound = errors.New("eks: access entry not found")
|
||||
|
||||
@@ -1691,6 +1691,10 @@ func (s *EKSServiceImpl) AssociateAccessPolicy(ctx context.Context, input *eks.A
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if scope.Type == accessScopeNamespace {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorEKSInvalidParameter,
|
||||
"accessScope.type: namespace-scoped access policies are not supported, only cluster is supported")
|
||||
}
|
||||
acctKV, err := s.acctKVForCluster(ctx, accountID, cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -370,6 +370,66 @@ func TestAccessPolicy_AssociateRejectsUnsupportedPolicyAndScope(t *testing.T) {
|
||||
require.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// Namespace scope has no RBAC writer (Change 3): it must fail at association
|
||||
// time with a message naming accessScope.type, not read back as a durable
|
||||
// grant that never actually authorizes anything.
|
||||
func TestAccessPolicy_AssociateRejectsNamespaceScope(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
seedTestCluster(t, svc, "c1")
|
||||
_, err := svc.CreateAccessEntry(context.Background(), &eks.CreateAccessEntryInput{
|
||||
ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.AssociateAccessPolicy(context.Background(), &eks.AssociateAccessPolicyInput{
|
||||
ClusterName: aws.String("c1"),
|
||||
PrincipalArn: aws.String(testPrincipalARN),
|
||||
PolicyArn: aws.String("arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"),
|
||||
AccessScope: &eks.AccessScope{Type: aws.String("namespace"), Namespaces: aws.StringSlice([]string{"team-a"})},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorEKSInvalidParameter, code)
|
||||
assert.Contains(t, message, "accessScope.type")
|
||||
}
|
||||
|
||||
// DisassociateAccessPolicy already removes the association from the record;
|
||||
// this confirms the projection built on top of it reflects the removal too.
|
||||
func TestAccessPolicy_DisassociateRemovesProjectedGroup(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
seedTestCluster(t, svc, "c1")
|
||||
_, err := svc.CreateAccessEntry(context.Background(), &eks.CreateAccessEntryInput{
|
||||
ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
const viewPolicy = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
|
||||
_, err = svc.AssociateAccessPolicy(context.Background(), &eks.AssociateAccessPolicyInput{
|
||||
ClusterName: aws.String("c1"),
|
||||
PrincipalArn: aws.String(testPrincipalARN),
|
||||
PolicyArn: aws.String(viewPolicy),
|
||||
AccessScope: &eks.AccessScope{Type: aws.String("cluster")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
js := testutil.NewJetStream(t, svc.deps.NATSConn)
|
||||
kv, err := GetOrCreateAccountBucket(t.Context(), js, testAccountID, 1)
|
||||
require.NoError(t, err)
|
||||
rec, err := GetAccessEntryRecord(t.Context(), kv, "c1", testPrincipalARN)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"mulga:eks-view"}, effectiveGroups(rec))
|
||||
|
||||
_, err = svc.DisassociateAccessPolicy(context.Background(), &eks.DisassociateAccessPolicyInput{
|
||||
ClusterName: aws.String("c1"), PrincipalArn: aws.String(testPrincipalARN), PolicyArn: aws.String(viewPolicy),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
rec, err = GetAccessEntryRecord(t.Context(), kv, "c1", testPrincipalARN)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, effectiveGroups(rec))
|
||||
}
|
||||
|
||||
func TestListAccessPolicies_ReturnsSupportedCatalogue(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
out, err := svc.ListAccessPolicies(context.Background(), &eks.ListAccessPoliciesInput{}, testAccountID)
|
||||
|
||||
@@ -57,10 +57,44 @@ func Authenticate(
|
||||
Authenticated: true,
|
||||
Username: rec.KubernetesUsername,
|
||||
UID: uid,
|
||||
Groups: rec.KubernetesGroups,
|
||||
Groups: effectiveGroups(rec),
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveGroups returns rec's static groups plus the Kubernetes group each
|
||||
// cluster-scoped associated policy projects, deduplicated in first-seen order.
|
||||
// A namespace-scoped or unrecognized association contributes nothing, so an
|
||||
// older or wider record degrades safely instead of widening scope or panicking.
|
||||
func effectiveGroups(rec *AccessEntryRecord) []string {
|
||||
seen := make(map[string]struct{}, len(rec.KubernetesGroups))
|
||||
groups := make([]string, 0, len(rec.KubernetesGroups))
|
||||
add := func(g string) {
|
||||
if g == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[g]; ok {
|
||||
return
|
||||
}
|
||||
seen[g] = struct{}{}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
for _, g := range rec.KubernetesGroups {
|
||||
add(g)
|
||||
}
|
||||
for _, p := range rec.AssociatedPolicies {
|
||||
if p.AccessScope.Type != accessScopeCluster {
|
||||
continue
|
||||
}
|
||||
g, ok := accessPolicyGroups[p.PolicyARN]
|
||||
if !ok {
|
||||
slog.Debug("access policy projection: unrecognized policy ARN", "policy_arn", p.PolicyARN)
|
||||
continue
|
||||
}
|
||||
add(g)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// ResolveTokenReview runs the TokenReview decision host-side, wiring the real
|
||||
// STS verify (NATS request to the awsgw-hosted verify subject) and AccessEntry
|
||||
// KV read into Authenticate. The eks-token-webhook reaches this through the
|
||||
|
||||
@@ -107,6 +107,89 @@ func TestAuthenticate_FallsBackUIDToARN(t *testing.T) {
|
||||
assert.Equal(t, testARN, res.UID)
|
||||
}
|
||||
|
||||
func TestEffectiveGroups_EachPolicyProducesItsGroup(t *testing.T) {
|
||||
cases := []struct {
|
||||
policyARN string
|
||||
want string
|
||||
}{
|
||||
{"arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy", "system:masters"},
|
||||
{"arn:aws:eks::aws:cluster-access-policy/AmazonEKSAdminPolicy", "mulga:eks-admin"},
|
||||
{"arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy", "mulga:eks-edit"},
|
||||
{"arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", "mulga:eks-view"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
rec := &AccessEntryRecord{
|
||||
AssociatedPolicies: []AssociatedAccessPolicy{
|
||||
{PolicyARN: tc.policyARN, AccessScope: AccessScope{Type: accessScopeCluster}},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, []string{tc.want}, effectiveGroups(rec))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveGroups_TwoAssociationsProduceBothGroups(t *testing.T) {
|
||||
rec := &AccessEntryRecord{
|
||||
AssociatedPolicies: []AssociatedAccessPolicy{
|
||||
{PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy", AccessScope: AccessScope{Type: accessScopeCluster}},
|
||||
{PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", AccessScope: AccessScope{Type: accessScopeCluster}},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, []string{"mulga:eks-edit", "mulga:eks-view"}, effectiveGroups(rec))
|
||||
}
|
||||
|
||||
// A creator seeded system:masters directly (CreateCluster bootstrap) who is
|
||||
// also associated to AmazonEKSClusterAdminPolicy must not see the group twice.
|
||||
func TestEffectiveGroups_DedupesSeededAndAssociatedClusterAdmin(t *testing.T) {
|
||||
rec := &AccessEntryRecord{
|
||||
KubernetesGroups: []string{"system:masters"},
|
||||
AssociatedPolicies: []AssociatedAccessPolicy{
|
||||
{PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy", AccessScope: AccessScope{Type: accessScopeCluster}},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, []string{"system:masters"}, effectiveGroups(rec))
|
||||
}
|
||||
|
||||
// Records written before namespace scope was rejected at association time may
|
||||
// still carry one; it must not silently widen to a cluster-scope grant.
|
||||
func TestEffectiveGroups_NamespaceScopeContributesNothing(t *testing.T) {
|
||||
rec := &AccessEntryRecord{
|
||||
AssociatedPolicies: []AssociatedAccessPolicy{
|
||||
{PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", AccessScope: AccessScope{Type: accessScopeNamespace, Namespaces: []string{"team-a"}}},
|
||||
},
|
||||
}
|
||||
assert.Empty(t, effectiveGroups(rec))
|
||||
}
|
||||
|
||||
func TestEffectiveGroups_UnrecognizedPolicyDoesNotPanic(t *testing.T) {
|
||||
rec := &AccessEntryRecord{
|
||||
AssociatedPolicies: []AssociatedAccessPolicy{
|
||||
{PolicyARN: "arn:aws:eks::aws:cluster-access-policy/MadeUpPolicy", AccessScope: AccessScope{Type: accessScopeCluster}},
|
||||
},
|
||||
}
|
||||
assert.NotPanics(t, func() { effectiveGroups(rec) })
|
||||
assert.Empty(t, effectiveGroups(rec))
|
||||
}
|
||||
|
||||
// End-to-end through Authenticate: an associated policy's group must reach the
|
||||
// TokenReview result, not just the internal helper.
|
||||
func TestAuthenticate_ProjectsAssociatedPolicyGroup(t *testing.T) {
|
||||
lookup := func(arn string) (*AccessEntryRecord, error) {
|
||||
return &AccessEntryRecord{
|
||||
KubernetesUsername: testARN,
|
||||
AssociatedPolicies: []AssociatedAccessPolicy{
|
||||
{PolicyARN: "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", AccessScope: AccessScope{Type: accessScopeCluster}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
res := Authenticate(validToken("https://sts.amazonaws.com/?Action=GetCallerIdentity"), okVerify, lookup)
|
||||
|
||||
require.True(t, res.Authenticated)
|
||||
assert.Equal(t, []string{"mulga:eks-view"}, res.Groups)
|
||||
}
|
||||
|
||||
func TestResolveTokenReview_NilConn(t *testing.T) {
|
||||
_, err := ResolveTokenReview(context.Background(), nil, "111122223333", "alpha", "tok", time.Second)
|
||||
require.Error(t, err)
|
||||
|
||||
Reference in New Issue
Block a user