mirror of https://github.com/kubernetes/kops.git
399 lines
12 KiB
Go
399 lines
12 KiB
Go
/*
|
|
Copyright 2016 The Kubernetes Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package iam
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/golang/glog"
|
|
"k8s.io/apimachinery/pkg/util/sets"
|
|
api "k8s.io/kops/pkg/apis/kops"
|
|
"k8s.io/kops/pkg/util/stringorslice"
|
|
"k8s.io/kops/upup/pkg/fi"
|
|
"k8s.io/kops/upup/pkg/fi/cloudup/awstasks"
|
|
"k8s.io/kops/util/pkg/vfs"
|
|
)
|
|
|
|
const IAMPolicyDefaultVersion = "2012-10-17"
|
|
|
|
type IAMPolicy struct {
|
|
Version string
|
|
Statement []*IAMStatement
|
|
}
|
|
|
|
func (p *IAMPolicy) AsJSON() (string, error) {
|
|
j, err := json.MarshalIndent(p, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("error marshaling policy to JSON: %v", err)
|
|
}
|
|
return string(j), nil
|
|
}
|
|
|
|
type IAMStatementEffect string
|
|
|
|
const IAMStatementEffectAllow IAMStatementEffect = "Allow"
|
|
const IAMStatementEffectDeny IAMStatementEffect = "Deny"
|
|
|
|
type IAMStatement struct {
|
|
Effect IAMStatementEffect
|
|
Action stringorslice.StringOrSlice
|
|
Resource stringorslice.StringOrSlice
|
|
}
|
|
|
|
func (l *IAMStatement) Equal(r *IAMStatement) bool {
|
|
if l.Effect != r.Effect {
|
|
return false
|
|
}
|
|
if !l.Action.Equal(r.Action) {
|
|
return false
|
|
}
|
|
if !l.Resource.Equal(r.Resource) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
type IAMPolicyBuilder struct {
|
|
Cluster *api.Cluster
|
|
Role api.InstanceGroupRole
|
|
Region string
|
|
HostedZoneID string
|
|
}
|
|
|
|
// BuildAWSIAMPolicy builds a set of IAM policy statements based on the
|
|
// instance group type and IAM Strict setting within the Cluster Spec
|
|
func (b *IAMPolicyBuilder) BuildAWSIAMPolicy() (*IAMPolicy, error) {
|
|
wildcard := stringorslice.Slice([]string{"*"})
|
|
iamPrefix := b.IAMPrefix()
|
|
|
|
// The Legacy IAM setting deploys an open policy (prior to the hardening PRs)
|
|
legacyIAM := false
|
|
if b.Cluster.Spec.IAM != nil {
|
|
legacyIAM = b.Cluster.Spec.IAM.Legacy
|
|
}
|
|
|
|
p := &IAMPolicy{
|
|
Version: IAMPolicyDefaultVersion,
|
|
}
|
|
|
|
// Don't give bastions any permissions (yet)
|
|
if b.Role == api.InstanceGroupRoleBastion {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
// We grant a trivial (?) permission (DescribeRegions), because empty policies are not allowed
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"ec2:DescribeRegions"}),
|
|
Resource: wildcard,
|
|
})
|
|
|
|
return p, nil
|
|
}
|
|
|
|
if b.Role == api.InstanceGroupRoleNode {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"ec2:Describe*"}),
|
|
Resource: wildcard,
|
|
})
|
|
|
|
}
|
|
|
|
{
|
|
// We provide ECR access on the nodes (naturally), but we also provide access on the master.
|
|
// We shouldn't be running lots of pods on the master, but it is perfectly reasonable to run
|
|
// a private logging pod or similar.
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Of(
|
|
"ecr:GetAuthorizationToken",
|
|
"ecr:BatchCheckLayerAvailability",
|
|
"ecr:GetDownloadUrlForLayer",
|
|
"ecr:GetRepositoryPolicy",
|
|
"ecr:DescribeRepositories",
|
|
"ecr:ListImages",
|
|
"ecr:BatchGetImage",
|
|
),
|
|
Resource: wildcard,
|
|
})
|
|
}
|
|
|
|
if b.Role == api.InstanceGroupRoleMaster {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"ec2:*"}),
|
|
Resource: wildcard,
|
|
})
|
|
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"elasticloadbalancing:*"}),
|
|
Resource: wildcard,
|
|
})
|
|
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Of(
|
|
"autoscaling:DescribeAutoScalingGroups",
|
|
"autoscaling:DescribeAutoScalingInstances",
|
|
"autoscaling:SetDesiredCapacity",
|
|
"autoscaling:TerminateInstanceInAutoScalingGroup",
|
|
),
|
|
Resource: wildcard,
|
|
})
|
|
|
|
// Restrict the KMS permissions to only the keys that are being used
|
|
kmsKeyIDs := sets.NewString()
|
|
for _, e := range b.Cluster.Spec.EtcdClusters {
|
|
for _, m := range e.Members {
|
|
if m.KmsKeyId != nil {
|
|
kmsKeyIDs.Insert(*m.KmsKeyId)
|
|
}
|
|
}
|
|
}
|
|
|
|
if kmsKeyIDs.Len() > 0 {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Of(
|
|
"kms:Encrypt",
|
|
"kms:Decrypt",
|
|
"kms:ReEncrypt*",
|
|
"kms:GenerateDataKey*",
|
|
"kms:DescribeKey",
|
|
"kms:CreateGrant",
|
|
"kms:ListGrants",
|
|
"kms:RevokeGrant",
|
|
),
|
|
Resource: stringorslice.Slice(kmsKeyIDs.List()),
|
|
})
|
|
}
|
|
}
|
|
|
|
if b.HostedZoneID != "" {
|
|
addRoute53Permissions(p, b.HostedZoneID)
|
|
}
|
|
// dns-controller currently assumes it can list the hosted zones, even when using gossip
|
|
addRoute53ListHostedZonesPermission(p)
|
|
|
|
// For S3 IAM permissions, we grant permissions to subtrees. So find the parents;
|
|
// we don't need to grant mypath and mypath/child.
|
|
var roots []string
|
|
{
|
|
var locations []string
|
|
|
|
for _, p := range []string{
|
|
b.Cluster.Spec.KeyStore,
|
|
b.Cluster.Spec.SecretStore,
|
|
b.Cluster.Spec.ConfigStore,
|
|
} {
|
|
if p == "" {
|
|
continue
|
|
}
|
|
|
|
if !strings.HasSuffix(p, "/") {
|
|
p = p + "/"
|
|
}
|
|
locations = append(locations, p)
|
|
}
|
|
|
|
for i, l := range locations {
|
|
isTopLevel := true
|
|
for j := range locations {
|
|
if i == j {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(l, locations[j]) {
|
|
glog.V(4).Infof("Ignoring location %q because found parent %q", l, locations[j])
|
|
isTopLevel = false
|
|
}
|
|
}
|
|
if isTopLevel {
|
|
glog.V(4).Infof("Found root location %q", l)
|
|
roots = append(roots, l)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, root := range roots {
|
|
vfsPath, err := vfs.Context.BuildVfsPath(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse VFS path %q: %v", root, err)
|
|
}
|
|
|
|
if s3Path, ok := vfsPath.(*vfs.S3Path); ok {
|
|
addS3Permissions(p, iamPrefix, s3Path, b.Role, legacyIAM)
|
|
} else if _, ok := vfsPath.(*vfs.MemFSPath); ok {
|
|
// Tests -ignore - nothing we can do in terms of IAM policy
|
|
glog.Warningf("ignoring memfs path %q for IAM policy builder", vfsPath)
|
|
} else {
|
|
// We could implement this approach, but it seems better to get all clouds using cluster-readable storage
|
|
return nil, fmt.Errorf("path is not cluster readable: %v", root)
|
|
}
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
func addRoute53Permissions(p *IAMPolicy, hostedZoneID string) {
|
|
// Remove /hostedzone/ prefix (if present)
|
|
hostedZoneID = strings.TrimPrefix(hostedZoneID, "/")
|
|
hostedZoneID = strings.TrimPrefix(hostedZoneID, "hostedzone/")
|
|
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Of("route53:ChangeResourceRecordSets",
|
|
"route53:ListResourceRecordSets",
|
|
"route53:GetHostedZone"),
|
|
Resource: stringorslice.Slice([]string{"arn:aws:route53:::hostedzone/" + hostedZoneID}),
|
|
})
|
|
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"route53:GetChange"}),
|
|
Resource: stringorslice.Slice([]string{"arn:aws:route53:::change/*"}),
|
|
})
|
|
}
|
|
|
|
func addRoute53ListHostedZonesPermission(p *IAMPolicy) {
|
|
wildcard := stringorslice.Slice([]string{"*"})
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"route53:ListHostedZones"}),
|
|
Resource: wildcard,
|
|
})
|
|
}
|
|
|
|
// addS3Permissions updates the IAM Policy with statements granting tailored
|
|
// access to S3 assets, depending on the instance role
|
|
func addS3Permissions(p *IAMPolicy, iamPrefix string, s3Path *vfs.S3Path, role api.InstanceGroupRole, legacyIAM bool) {
|
|
// Note that the config store may itself be a subdirectory of a bucket
|
|
iamS3Path := s3Path.Bucket() + "/" + s3Path.Key()
|
|
iamS3Path = strings.TrimSuffix(iamS3Path, "/")
|
|
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Of("s3:GetBucketLocation", "s3:ListBucket"),
|
|
Resource: stringorslice.Slice([]string{
|
|
strings.Join([]string{iamPrefix, ":s3:::", s3Path.Bucket()}, ""),
|
|
}),
|
|
})
|
|
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"s3:List*"}),
|
|
Resource: stringorslice.Of(
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/*"}, ""),
|
|
),
|
|
})
|
|
|
|
if legacyIAM {
|
|
if role == api.InstanceGroupRoleMaster || role == api.InstanceGroupRoleNode {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"s3:*"}),
|
|
Resource: stringorslice.Of(
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/*"}, ""),
|
|
),
|
|
})
|
|
}
|
|
} else {
|
|
if role == api.InstanceGroupRoleMaster {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"s3:Get*"}),
|
|
Resource: stringorslice.Of(
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/*"}, ""),
|
|
),
|
|
})
|
|
} else if role == api.InstanceGroupRoleNode {
|
|
p.Statement = append(p.Statement, &IAMStatement{
|
|
Effect: IAMStatementEffectAllow,
|
|
Action: stringorslice.Slice([]string{"s3:Get*"}),
|
|
Resource: stringorslice.Of(
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/addons/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/instancegroup/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/pki/issued/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/pki/ssh/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/pki/private/kube-proxy/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/pki/private/kubelet/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/secrets/*"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/cluster.spec"}, ""),
|
|
strings.Join([]string{iamPrefix, ":s3:::", iamS3Path, "/config"}, ""),
|
|
),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// IAMPrefix returns the prefix for AWS ARNs in the current region, for use with IAM
|
|
// it is arn:aws everywhere but in cn-north and us-gov-west-1
|
|
func (b *IAMPolicyBuilder) IAMPrefix() string {
|
|
switch b.Region {
|
|
case "cn-north-1":
|
|
return "arn:aws-cn"
|
|
case "us-gov-west-1":
|
|
return "arn:aws-us-gov"
|
|
default:
|
|
return "arn:aws"
|
|
}
|
|
}
|
|
|
|
type IAMPolicyResource struct {
|
|
Builder *IAMPolicyBuilder
|
|
DNSZone *awstasks.DNSZone
|
|
}
|
|
|
|
var _ fi.Resource = &IAMPolicyResource{}
|
|
var _ fi.HasDependencies = &IAMPolicyResource{}
|
|
|
|
func (b *IAMPolicyResource) GetDependencies(tasks map[string]fi.Task) []fi.Task {
|
|
var deps []fi.Task
|
|
if b.DNSZone != nil {
|
|
deps = append(deps, b.DNSZone)
|
|
}
|
|
return deps
|
|
}
|
|
|
|
// Open produces the AWS IAM policy for the given role
|
|
func (b *IAMPolicyResource) Open() (io.Reader, error) {
|
|
// Defensive copy before mutation
|
|
pb := *b.Builder
|
|
|
|
if b.DNSZone != nil {
|
|
hostedZoneID := fi.StringValue(b.DNSZone.ZoneID)
|
|
if hostedZoneID == "" {
|
|
// Dependency analysis failure?
|
|
return nil, fmt.Errorf("DNS ZoneID not set")
|
|
}
|
|
pb.HostedZoneID = hostedZoneID
|
|
}
|
|
|
|
policy, err := pb.BuildAWSIAMPolicy()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error building IAM policy: %v", err)
|
|
}
|
|
json, err := policy.AsJSON()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error building IAM policy: %v", err)
|
|
}
|
|
return bytes.NewReader([]byte(json)), nil
|
|
}
|