mirror of https://github.com/fluxcd/flux2.git
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1430 lines
58 KiB
Markdown
1430 lines
58 KiB
Markdown
# RFC-0010 Multi-Tenant Workload Identity
|
|
|
|
**Status:** implementable
|
|
|
|
<!--
|
|
Status represents the current state of the RFC.
|
|
Must be one of `provisional`, `implementable`, `implemented`, `deferred`, `rejected`, `withdrawn`, or `replaced`.
|
|
-->
|
|
|
|
**Creation date:** 2025-02-22
|
|
|
|
**Last update:** 2025-04-29
|
|
|
|
## Summary
|
|
|
|
In this RFC we aim to add support for multi-tenant workload identity in Flux,
|
|
i.e. the ability to specify at the object-level which set of cloud provider
|
|
permissions must be used for interacting with the respective cloud provider
|
|
on behalf of the reconciliation of the object. In this process, credentials
|
|
must be obtained automatically, i.e. this feature must not involve the use
|
|
of secrets. This would be useful in a number of Flux APIs that need to
|
|
interact with cloud providers, spanning all the Flux controllers.
|
|
|
|
### Multi-Tenancy Model
|
|
|
|
In the context of this RFC, multi-tenancy refers to the ability of a single
|
|
Flux instance running inside a Kubernetes cluster to manage Flux objects
|
|
belonging to all the tenants in the cluster while still ensuring that each
|
|
tenant has access only to their own resources according to the Least Privilege
|
|
Principle. In this scenario a tenant is often a team inside an organization,
|
|
so the reader can consider the
|
|
[multi-team tenancy model](https://kubernetes.io/docs/concepts/security/multi-tenancy/#multiple-teams).
|
|
Each team has their own namespaces, which are not shared with other teams.
|
|
|
|
## Motivation
|
|
|
|
Flux has strong multi-tenancy features. For example, the `Kustomization` and
|
|
`HelmRelease` APIs support the field `spec.serviceAccountName` for specifying
|
|
the Kubernetes `ServiceAccount` to impersonate when interacting with the
|
|
Kubernetes API on behalf of a tenant, e.g. when applying resources. This
|
|
allows tenants to be constrained under the Kubernetes RBAC permissions
|
|
granted to this `ServiceAccount`, and therefore have access only to the
|
|
specific subset of resources they should be allowed to use.
|
|
|
|
Besides the Kubernetes API, Flux also interacts with cloud providers, e.g.
|
|
container registries, object storage, pub/sub services, etc. In these cases,
|
|
Flux currently supports basically two modes of authentication:
|
|
|
|
- *Secret-based multi-tenant authentication*: Objects have the field
|
|
`spec.secretRef` for specifying the Kubernetes `Secret` containing the
|
|
credentials to use when interacting with the cloud provider. This is
|
|
similar to the `spec.serviceAccountName` field, but for cloud providers.
|
|
The problem with this approach is that secrets are a security risk and
|
|
operational burden, as they must be managed and rotated.
|
|
- *Workload-identity-based single-tenant authentication*: Flux offers
|
|
single-tenant workload identity support by configuring the `ServiceAccount`
|
|
of the Flux controllers to impersonate a cloud identity. This eliminates
|
|
the need for secrets, as the credentials are obtained automatically by
|
|
the cloud provider Go libraries used by the Flux controllers when they
|
|
are running inside the respective cloud environment. The problem with
|
|
this approach is that it is single-tenant, i.e. all objects are reconciled
|
|
using the same cloud identity, the one associated with the respective controller.
|
|
|
|
For delivering the high level of security and multi-tenancy support that
|
|
Flux aims for, it is necessary to extend the workload identity support to
|
|
be multi-tenant. This means that each object must be able to specify which
|
|
cloud identity must be impersonated when interacting with the cloud provider
|
|
on behalf of the reconciliation of the object. This would allow tenants to
|
|
be constrained under the cloud provider permissions granted to this identity,
|
|
and therefore have access only to the specific subset of resources they are
|
|
allowed to manage.
|
|
|
|
### Goals
|
|
|
|
Provide multi-tenant workload identity support in Flux, i.e. the ability to
|
|
specify at the object-level which cloud identity must be impersonated to
|
|
interact with the respective cloud provider on behalf of the reconciliation
|
|
of the object, without the need for secrets.
|
|
|
|
### Non-Goals
|
|
|
|
It's not a goal of this RFC to implement an identity provider for Flux.
|
|
Instead, the goal is to leverage Kubernetes' built-in identity provider
|
|
capabilities, i.e. the Kubernetes `ServiceAccount` token issuer, to
|
|
obtain short-lived access tokens for the cloud providers.
|
|
|
|
## Proposal
|
|
|
|
For supporting multi-tenant workload identity at the object-level for the Flux APIs
|
|
we propose associating the Flux objects with Kubernetes `ServiceAccounts`. The
|
|
controller would need to create a token for the `ServiceAccount` associated with
|
|
the object in the Kubernetes API, and then exchange it for a short-lived access
|
|
token for the cloud provider. This would require the controller `ServiceAccount`
|
|
to have RBAC permission to create tokens for any `ServiceAccounts` in the cluster.
|
|
|
|
### User Stories
|
|
|
|
#### Story 1
|
|
|
|
> As a cluster administrator, I want to allow tenant A to pull OCI artifacts
|
|
> from the Amazon ECR repository belonging to tenant A, but only from this
|
|
> repository. At the same time, I want to allow tenant B to pull OCI artifacts
|
|
> from the Amazon ECR repository belonging to tenant B, but only from this
|
|
> repository.
|
|
|
|
For example, I would like to have the following configuration:
|
|
|
|
```yaml
|
|
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
|
kind: OCIRepository
|
|
metadata:
|
|
name: tenant-a-repo
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
provider: aws
|
|
serviceAccountName: tenant-a-ecr-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-a-ecr-sa
|
|
namespace: tenant-a
|
|
annotations:
|
|
eks.amazonaws.com/role-arn: arn:aws:iam::123456789123:role/tenant-a-ecr
|
|
---
|
|
apiVersion: source.toolkit.fluxcd.io/v1beta2
|
|
kind: OCIRepository
|
|
metadata:
|
|
name: tenant-b-repo
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
provider: aws
|
|
serviceAccountName: tenant-b-ecr-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-b-ecr-sa
|
|
namespace: tenant-b
|
|
annotations:
|
|
eks.amazonaws.com/role-arn: arn:aws:iam::123456789123:role/tenant-b-ecr
|
|
```
|
|
|
|
#### Story 2
|
|
|
|
> As a cluster administrator, I want to allow tenant A to pull and push to the Git
|
|
> repository in Azure DevOps belonging to tenant A, but only this repository. At
|
|
> the same time, I want to allow tenant B to pull and push to the Git repository
|
|
> in Azure DevOps belonging to tenant B, but only this repository.
|
|
|
|
For example, I would like to have the following configuration:
|
|
|
|
```yaml
|
|
apiVersion: source.toolkit.fluxcd.io/v1
|
|
kind: GitRepository
|
|
metadata:
|
|
name: tenant-a-repo
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
provider: azure
|
|
serviceAccountName: tenant-a-azure-devops-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-a-azure-devops-sa
|
|
namespace: tenant-a
|
|
annotations:
|
|
azure.workload.identity/client-id: d6e4fc00-c5b2-4a72-9f84-6a92e3f06b08 # client ID for my tenant A
|
|
azure.workload.identity/tenant-id: 72f988bf-86f1-41af-91ab-2d7cd011db47 # azure tenant for the cluster (optional, defaults to the env var AZURE_TENANT_ID set in the controller)
|
|
---
|
|
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
|
kind: ImageUpdateAutomation
|
|
metadata:
|
|
name: tenant-a-image-update
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
sourceRef:
|
|
kind: GitRepository
|
|
name: tenant-a-repo
|
|
---
|
|
apiVersion: source.toolkit.fluxcd.io/v1
|
|
kind: GitRepository
|
|
metadata:
|
|
name: tenant-b-repo
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
provider: azure
|
|
serviceAccountName: tenant-b-azure-devops-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-b-azure-devops-sa
|
|
namespace: tenant-b
|
|
annotations:
|
|
azure.workload.identity/client-id: 4a7272f9-f186-41af-9f84-6a92e32d7cd0 # client ID for my tenant B
|
|
azure.workload.identity/tenant-id: 72f988bf-86f1-41af-91ab-2d7cd011db47 # azure tenant for the cluster (optional, defaults to the env var AZURE_TENANT_ID set in the controller)
|
|
---
|
|
apiVersion: image.toolkit.fluxcd.io/v1beta2
|
|
kind: ImageUpdateAutomation
|
|
metadata:
|
|
name: tenant-b-image-update
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
sourceRef:
|
|
kind: GitRepository
|
|
name: tenant-b-repo
|
|
```
|
|
|
|
#### Story 3
|
|
|
|
> As a cluster administrator, I want to allow tenant A to pull manifests from
|
|
> the GCS bucket belonging to tenant A, but only from this bucket. At the same
|
|
> time, I want to allow tenant B to pull manifests from the GCS bucket
|
|
> belonging to tenant B, but only from this bucket.
|
|
|
|
For example, I would like to have the following configuration:
|
|
|
|
```yaml
|
|
apiVersion: source.toolkit.fluxcd.io/v1
|
|
kind: Bucket
|
|
metadata:
|
|
name: tenant-a-bucket
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
provider: gcp
|
|
serviceAccountName: tenant-a-gcs-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-a-gcs-sa
|
|
namespace: tenant-a
|
|
annotations:
|
|
iam.gke.io/gcp-service-account: tenant-a-bucket@my-org-project.iam.gserviceaccount.com
|
|
---
|
|
apiVersion: source.toolkit.fluxcd.io/v1
|
|
kind: Bucket
|
|
metadata:
|
|
name: tenant-b-bucket
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
provider: gcp
|
|
serviceAccountName: tenant-b-gcs-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-b-gcs-sa
|
|
namespace: tenant-b
|
|
annotations:
|
|
iam.gke.io/gcp-service-account: tenant-b-bucket@my-org-project.iam.gserviceaccount.com
|
|
```
|
|
|
|
#### Story 4
|
|
|
|
> As a cluster administrator, I want to allow tenant A to decrypt secrets using
|
|
> the AWS KMS key belonging to tenant A, but only this key. At the same time,
|
|
> I want to allow tenant B to decrypt secrets using the AWS KMS key belonging
|
|
> to tenant B, but only this key.
|
|
|
|
For example, I would like to have the following configuration:
|
|
|
|
```yaml
|
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
|
kind: Kustomization
|
|
metadata:
|
|
name: tenant-a-aws-kms
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
decryption:
|
|
provider: sops
|
|
serviceAccountName: tenant-a-aws-kms-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-a-aws-kms-sa
|
|
namespace: tenant-a
|
|
annotations:
|
|
eks.amazonaws.com/role-arn: arn:aws:iam::123456789123:role/tenant-a-kms
|
|
---
|
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
|
kind: Kustomization
|
|
metadata:
|
|
name: tenant-b-aws-kms
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
decryption:
|
|
provider: sops
|
|
serviceAccountName: tenant-b-aws-kms-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-b-aws-kms-sa
|
|
namespace: tenant-b
|
|
annotations:
|
|
eks.amazonaws.com/role-arn: arn:aws:iam::123456789123:role/tenant-b-kms
|
|
```
|
|
|
|
#### Story 5
|
|
|
|
> As a cluster administrator, I want to allow tenant A to publish notifications
|
|
> to the `tenant-a` topic in Google Cloud Pub/Sub, but only to this topic. At
|
|
> the same time, I want to allow tenant B to publish notifications to the
|
|
> `tenant-b` topic in Google Cloud Pub/Sub, but only to this topic. I want
|
|
> to do so without creating any GCP IAM Service Accounts.
|
|
|
|
For example, I would like to have the following configuration:
|
|
|
|
```yaml
|
|
apiVersion: notification.toolkit.fluxcd.io/v1beta3
|
|
kind: Provider
|
|
metadata:
|
|
name: tenant-a-google-pubsub
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
type: googlepubsub
|
|
serviceAccountName: tenant-a-google-pubsub-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-a-google-pubsub-sa
|
|
namespace: tenant-a
|
|
---
|
|
apiVersion: notification.toolkit.fluxcd.io/v1beta3
|
|
kind: Provider
|
|
metadata:
|
|
name: tenant-b-google-pubsub
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
type: googlepubsub
|
|
serviceAccountName: tenant-b-google-pubsub-sa
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-b-google-pubsub-sa
|
|
namespace: tenant-b
|
|
```
|
|
|
|
#### Story 6
|
|
|
|
> As a cluster administrator, I want to allow tenant A to use a GCP
|
|
> Service Account to apply resources in a remote GKE cluster with
|
|
> Kubernetes RBAC permissions granted to this GCP Service Account,
|
|
> and tenant B to do the same using a different GCP Service Account.
|
|
|
|
For example, I would like to have the following configuration:
|
|
|
|
```yaml
|
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
|
kind: Kustomization
|
|
metadata:
|
|
name: tenant-a-gke
|
|
namespace: tenant-a
|
|
spec:
|
|
...
|
|
kubeConfig:
|
|
provider: gcp
|
|
serviceAccountName: tenant-a-gke-sa
|
|
cluster: projects/<project-id>/locations/<location>/clusters/<cluster-name>
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-a-gke-sa
|
|
namespace: tenant-a
|
|
---
|
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
|
kind: Kustomization
|
|
metadata:
|
|
name: tenant-b-gke
|
|
namespace: tenant-b
|
|
spec:
|
|
...
|
|
kubeConfig:
|
|
provider: gcp
|
|
serviceAccountName: tenant-b-gke-sa
|
|
cluster: projects/<project-id>/locations/<location>/clusters/<cluster-name>
|
|
---
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: tenant-b-gke-sa
|
|
namespace: tenant-b
|
|
```
|
|
|
|
### Alternatives
|
|
|
|
#### An alternative for identifying Flux resources in cloud providers
|
|
|
|
Instead of issuing `ServiceAccount` tokens in the Kubernetes API we could
|
|
come up with a username naming scheme for Flux resources and issue tokens
|
|
for these usernames instead, e.g. `flux:<resource type>:<namespace>:<name>`.
|
|
This would make each Flux object have its own identity instead of using
|
|
`ServiceAccounts` for this purpose. This choice would then prevent cases
|
|
of other Flux objects from malicious actors in the same namespace from
|
|
abusing the permissions granted to the `ServiceAccount` of the object.
|
|
This choice, however, would provide a worse user experience, as Flux and
|
|
Kubernetes users are already used to the `ServiceAccount` resource being
|
|
the identity for resources in the cluster, not only in the context of plain
|
|
RBAC but also in the context of workload identity.
|
|
This choice would also require the introduction of new APIs for configuring
|
|
the respective cloud identities in the Flux objects, when such APIs already
|
|
exist as defined by the cloud providers themselves as annotations in the
|
|
`ServiceAccount` resources. We therefore choose to stick with the well-known
|
|
pattern of using `ServiceAccounts` for configuring the identities of the
|
|
Flux resources. Furthermore, as mentioned in the
|
|
[Multi-Tenancy Model](#multi-tenancy-model) section, the tenant trust domains
|
|
are namespaces, so a tenant is expected to control and have access to all
|
|
the resources `ServiceAccounts` in their namespaces are allowed to access.
|
|
|
|
#### Alternatives for modifying controller RBAC to create `ServiceAccount` tokens
|
|
|
|
In this section we discuss alternatives for changing the RBAC of controllers for
|
|
creating `ServiceAccount` tokens cluster-wide, as it has a potential impact on
|
|
the security posture of Flux.
|
|
|
|
1. We grant RBAC permissions to the `ServiceAccounts` of the Flux controllers
|
|
(that would implement multi-tenant workload identity) for creating tokens
|
|
for any other `ServiceAccounts` in the cluster.
|
|
2. We require users to grant "self-impersonation" to the `ServiceAccounts` so they
|
|
can create tokens for themselves. The controller would then impersonate the
|
|
`ServiceAccount` when creating a token for it. This operation would then only
|
|
succeed if the `ServiceAccount` has been correctly granted permission to create
|
|
a token for itself.
|
|
|
|
In both alternatives the controller `ServiceAccount` would require some form
|
|
of cluster-wide impersonation permission. Alternative 2 requires impersonation
|
|
permission to be granted directly to the controller `ServiceAccount`, while
|
|
in alternative 1, impersonation permission would be indirectly granted by the
|
|
process of creating a token for another `ServiceAccount`. By creating a token
|
|
for another `ServiceAccount`, the controller `ServiceAccount` effectively has
|
|
the same permissions as the `ServiceAccount` it is creating the token for, as
|
|
it could simply use the token to impersonate the `ServiceAccount`. Therefore
|
|
it is reasonable to affirm that both alternatives are equivalent in terms of
|
|
security.
|
|
|
|
To break the tie between the two alternatives we introduce the fact that
|
|
alternative 1 eliminates operational burden on users. In fact, native
|
|
workload identity for pods does not require users to grant this
|
|
self-impersonation permission to the `ServiceAccounts` of the pods.
|
|
|
|
We therefore choose alternative 1.
|
|
|
|
## Design Details
|
|
|
|
For detailing the proposal we need to first introduce the technical
|
|
background on how workload identity is implemented by the managed
|
|
Kubernetes services from the cloud providers.
|
|
|
|
### Technical Background
|
|
|
|
Workload identity in Kubernetes is based on
|
|
[OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
|
(OIDC).
|
|
The *Kubernetes `ServiceAccount` token issuer*, included as the `iss` JWT claim in the
|
|
issued tokens, and represented by the default URL `https://kubernetes.default.svc.cluster.local`,
|
|
implements the OIDC discovery protocol. Essentially, this means that the Kubernetes API
|
|
will respond requests to the URL
|
|
`https://kubernetes.default.svc.cluster.local/.well-known/openid-configuration`
|
|
with a JSON document similar to the one below:
|
|
|
|
```json
|
|
{
|
|
"issuer": "https://kubernetes.default.svc.cluster.local",
|
|
"jwks_uri": "https://172.18.0.2:6443/openid/v1/jwks",
|
|
"response_types_supported": [
|
|
"id_token"
|
|
],
|
|
"subject_types_supported": [
|
|
"public"
|
|
],
|
|
"id_token_signing_alg_values_supported": [
|
|
"RS256"
|
|
]
|
|
}
|
|
```
|
|
|
|
And to the URL `https://172.18.0.2:6443/openid/v1/jwks`, *discovered* through the field
|
|
`.jwks_uri` in the JSON response above, the Kubernetes API will respond a JSON document
|
|
similar to the following:
|
|
|
|
```json
|
|
{
|
|
"keys": [
|
|
{
|
|
"use": "sig",
|
|
"kty": "RSA",
|
|
"kid": "NWm3YKmazJPVP7tttzkmSxUn0w8LGGp7yS2CanEF-A8",
|
|
"alg": "RS256",
|
|
"n": "lV2tbw9hnz1mseah2kMQNe5sRju4mPLlK0F7np97lLNC49G8yc5TMjyciLF3qsDNFCfWyYmsuGlcRg2BIBBX_jkpIUUjlsktdHhuqO2RnOqyRtNuljlT_b0QJgpgxCqq0DHI31EBc0JALOVd6EjjlhsVvVzZOw_b9KBXVS3D3RENuT0_FWauDq5NYbyYnjlvk-vUXCRMNDQSDNwx6X6bktwsmeDRXtM_bP3DokmnMYc4n0asTEg14L6VKky0ByF88Wi1-y0Pm0BHdobDGt1cIeUDeThk4E79JCHxkT5urAyYHcNwcfU4q-tnD6bTpNkFVsk3cqqK2nF7R_7ac5arSQ",
|
|
"e": "AQAB"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
This JSON document contains the public keys for verifying the signature of the issued tokens.
|
|
|
|
By querying these two URLs in sequence, cloud providers are able to fetch the information
|
|
required for verifying and trusting the tokens issued by the Kubernetes API. Most specifically,
|
|
for trusting the `sub` JWT claim, which contains the Kubernetes `ServiceAccount` reference
|
|
(name and namespace) for which the token was issued for, i.e. the `ServiceAccount` properly
|
|
said.
|
|
|
|
By allowing permissions to be granted to `ServiceAccounts` in the cloud provider,
|
|
the cloud provider is then able to allow Kubernetes `ServiceAccounts` to access its resources.
|
|
This is usually done by a *Security Token Service* (STS) that exchanges the Kubernetes token
|
|
for a short-lived cloud provider access token, which is then used to access the cloud provider
|
|
resources.
|
|
|
|
It's important to mention that the Kubernetes `ServiceAccount` token issuer URL must be
|
|
trusted by the cloud provider, i.e. users must configure this URL as a trusted identity
|
|
provider.
|
|
|
|
This process forms the basis for workload identity in Kubernetes. As long as the issuer
|
|
URL can be reached by the cloud provider, this process can take place successfully.
|
|
|
|
The reachability of the issuer URL by the cloud provider is where the implementation
|
|
of workload identity starts to differ between cloud providers. For example, in GCP
|
|
one can configure the content of the JWKS document directly in the GCP IAM console,
|
|
which eliminates the need for network calls to the Kubernetes API. In AWS, on the
|
|
other hand, this is not possible, the process has to be followed strictly, i.e. the
|
|
issuer URL must be reachable by the AWS STS service.
|
|
|
|
Furthermore, GKE automatically
|
|
creates the necessary trust relationship between the Kubernetes issuer and the GCP
|
|
STS service (i.e. automatically injects the JWKS document of the GKE cluster in the
|
|
STS database), while in EKS this must be done manually by users (an OIDC provider
|
|
must be created for each EKS cluster).
|
|
|
|
Another difference is that the issuer URL remains the default/private one in GKE,
|
|
while in EKS it is automatically set to a public one. This is done through
|
|
the `--service-account-issuer` flag in the `kube-apiserver` command line arguments
|
|
([docs](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-issuer-discovery)). This is a nice feature, as it allows external
|
|
systems to federate access for workloads running in EKS clusters, e.g. EKS workloads
|
|
can have federated access to GCP resources.
|
|
|
|
Yet another difference between cloud providers that sheds light in our proposal is
|
|
how applications running inside pods from the managed Kubernetes services obtain
|
|
the short-lived cloud provider access tokens. In GCP, the GCP libraries used by
|
|
the applications attempt to retrieve tokens from the *metadata server*, which is
|
|
reachable by all pods running in GKE. This server creates a token for the
|
|
`ServiceAccount` of the calling pod in the Kubernetes API, exchanges it for a
|
|
short-lived GCP access token, and returns it to the application. In AKS, on the
|
|
other hand, pods are mutated to include a
|
|
[*token volume projection*](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#serviceaccount-token-volume-projection). The kubelet mounts and automatically
|
|
rotates a volume with a token file inside the pod. The Azure libraries used by
|
|
the applications then read this file periodically to perform the token exchange
|
|
with the Azure STS service.
|
|
|
|
Another aspect of workload identity that is important for this RFC is how the cloud
|
|
identities are associated with the Kubernetes `ServiceAccounts`. In most cases, an
|
|
identity from the IAM service of the cloud provider (e.g. a GCP IAM Service Account,
|
|
or an AWS IAM Role) is associated with a Kubernetes `ServiceAccount` by the process
|
|
of *impersonation*. Permission to impersonate the cloud identity is granted to the
|
|
`ServiceAccount` through a configuration that points to the fully qualified name of
|
|
the Kubernetes `ServiceAccount`, i.e. the name and namespace of the `ServiceAccount`
|
|
and which cluster it belongs to in the name/address system of the cloud provider.
|
|
|
|
Because the cloud provider needs to support this impersonation permission, some
|
|
cloud providers go further and even remove the impersonation requirement, by
|
|
allowing permissions to be granted directly to `ServiceAccounts` (if it needs to
|
|
support granting the impersonation permission, then it can probably also easily
|
|
support granting any other permissions depending on the implementation). GCP for
|
|
example has implemented this feature [recently](https://cloud.google.com/blog/products/identity-security/make-iam-for-gke-easier-to-use-with-workload-identity-federation), a GCP IAM
|
|
Service Account is no longer required for workload identity, i.e. GCP IAM
|
|
permissions can now be granted directly to Kubernetes `ServiceAccounts`. This is
|
|
a significant improvement in the user experience, as it significantly reduces
|
|
the required configuration steps. AWS implemented a similar feature called *EKS
|
|
Pod Identity*, but it still requires an IAM Role to be associated with the
|
|
`ServiceAccount`. The minor improvement from the user experience perspective is
|
|
that this association is implemented entirely in the AWS EKS/IAM APIs, no
|
|
annotations are required in the Kubernetes `ServiceAccount`. Another improvement
|
|
from this EKS feature compared to *IAM Roles for Service Accounts* is that users
|
|
no longer need to create an *OIDC Provider* for the EKS cluster in the IAM API.
|
|
|
|
It should be noted, however, that in the feature proposed here we cannot support
|
|
*EKS Pod Identity* because it requires the `ServiceAccount` token to be bound to
|
|
a pod, and the Kubernetes API will only issue a `ServiceAccount` token bound to a
|
|
pod if that pod uses the respective `ServiceAccount`, which is a requirement we
|
|
simply cannot meet. The only pod guaranteed to exist from the perspective of a
|
|
Flux controller is itself. Therefore, it's impossible to support EKS Pod Identity
|
|
for multi-tenant workload identity. (It is already possible to use it for
|
|
single-tenant workload identity, though.)
|
|
|
|
In sight of the technical background presented above, our proposal becomes simpler.
|
|
The only solution to support multi-tenant workload identity at the object-level for
|
|
the Flux APIs is to associate the Flux objects with Kubernetes `ServiceAccounts`.
|
|
We propose building the `ServiceAccount` token creation and exchange logic into
|
|
the Flux controllers through a library in the `github.com/fluxcd/pkg` repository.
|
|
|
|
### API Changes and Feature Gates
|
|
|
|
For all the Flux APIs interacting with cloud providers (except `Kustomization`,
|
|
see the paragraph below), we propose introducing the field `spec.serviceAccountName`
|
|
(if not already present) for specifying the Kubernetes `ServiceAccount` on the same
|
|
namespace of the object that must be used for getting access to the respective cloud
|
|
resources. This field would be optional, and when not present the original behavior
|
|
would be observed, i.e. the feature only activates when the field is present and a
|
|
cloud provider among `aws`, `azure` or `gcp` is specified in the `spec.provider`
|
|
field. So if only the `spec.provider` field is present and set to a cloud provider,
|
|
then the controller would use single-tenant workload identity as it would prior to
|
|
the implementation of this RFC, i.e. it would use its own identity for the operation.
|
|
|
|
Note that this RFC does not seek to change the behavior when `spec.provider` is set
|
|
to `generic` (or left empty, when it defaults to `generic`), in which case the field
|
|
`spec.secretRef` can be used for specifying the Kubernetes `Secret` containing the
|
|
credentials (or `spec.serviceAccountName` in the case of the APIs dealing with
|
|
container registries, through the `imagePullSecrets` field of the `ServiceAccount`).
|
|
|
|
The `Kustomization` API uses Key Management Services (KMS) for decrypting
|
|
SOPS-encrypted secrets. We propose adding the dedicated optional field
|
|
`spec.decryption.serviceAccountName` for multi-tenant workload identity
|
|
when intercting with the KMS service. We choose having a dedicated field
|
|
for the `Kustomization` API because the field `spec.serviceAccountName`
|
|
already exists and is used for a major part of the functionality which
|
|
is authenticating with the Kubernetes API when applying resources. If
|
|
we used the same field for both purposes users would be forced to use
|
|
multi-tenancy for both cloud and Kubernetes API interactions. Furthermore,
|
|
the cloud provider in the `Kustomization` API is detected by the SOPS SDK
|
|
itself while decrypting the secrets, so we don't need to introduce
|
|
`spec.decryption.provider` for this purpose.
|
|
|
|
The `Kustomization` and `HelmRelease` APIs have the field
|
|
`spec.kubeConfig.secretRef` for specifying a Kubernetes `Secret` containing
|
|
a static kubeconfig for accessing a remote Kubernetes cluster. We propose
|
|
adding `spec.kubeConfig.configMapRef` for specifying a Kubernetes `ConfigMap`
|
|
that is mutually exclusive with `spec.kubeConfig.secretRef` for supporting
|
|
workload identity for both managed Kubernetes services from the cloud
|
|
providers and also a `generic` provider. The fields in the `ConfigMap`
|
|
would be the following:
|
|
- `data.provider`: The provider to use for obtaining the temporary
|
|
`*rest.Config` for the remote cluster. One of `generic`, `aws`, `azure`
|
|
or `gcp`. Required.
|
|
- `data.cluster`: Used only by `aws`, `azure` and `gcp`. The fully qualified
|
|
name of the cluster resource in the respective cloud provider API. Needed
|
|
for obtaining the unspecified fields `data.address` and `data["ca.crt"]`
|
|
(not required if both are specified).
|
|
- `data.address`: The HTTPS address of the API server of the remote cluster.
|
|
Required for `generic`, optional for `aws`, `azure` and `gcp`.
|
|
- `data.serviceAccountName`: The optional Kubernetes `ServiceAccount` to use
|
|
for obtaining access to the remote cluster, implementing object-level
|
|
workload identity. If not specified, the controller identity will be used.
|
|
- `data.audiences`: The audiences Kubernetes `ServiceAccount` tokens must
|
|
be issued for as a list of strings in YAML format. Optional. Defaults to
|
|
`data.address` for `generic`, and has hardcoded default/specific values for
|
|
`aws`, `azure` and `gcp` depending on the provider.
|
|
- `data["ca.crt"]`: The optional PEM-encoded CA certificate of the remote
|
|
cluster.
|
|
|
|
For remote cluster access, the configured identity, be it controller-level
|
|
or object-level, must have the necessary permissions to:
|
|
- Access the cluster resource in the cloud provider API to get the
|
|
cluster CA certificate and the cluster API server address. This is
|
|
only necessary if one of `data.address` or `data["ca.crt"]` is not
|
|
specified in the `ConfigMap`. In other words, at least two of the
|
|
three fields `data.address`, `data["ca.crt"]` and `data.cluster`
|
|
must be specified. If both `data.address` and `data["ca.crt"]`
|
|
are specified, then the `data.cluster` field *must not* be specified,
|
|
the controller will error out if it is. If only `data.cluster` and
|
|
`data.address` are specified, then `data.address` has to match at
|
|
least one of the addresses of the cluster resource in the cloud
|
|
provider API. If only `data.cluster` and `data["ca.crt"]` are
|
|
specified, then the first address of the cluster resource in the
|
|
cloud provider API will be used as the address of the remote cluster
|
|
and the CA returned by the cloud provider API will be ignored.
|
|
If only `data.cluster` is specified, then the first address
|
|
of the cluster resource in the cloud provider API will be used.
|
|
- The relevant permissions for applying and managing the target resources
|
|
in the remote cluster. For cloud providers this means either Kubernetes
|
|
RBAC or the cloud provider API permissions, as managed Kubernetes services
|
|
support authorizing requests through both ways.
|
|
- When used with `spec.serviceAccountName`, the authenticated identity must
|
|
have the necessary permissions to impersonate this `ServiceAccount` in the
|
|
remote cluster (related [bug](https://github.com/fluxcd/pkg/issues/959)).
|
|
|
|
To enable using the new `serviceAccountName` fields, we propose introducing
|
|
a feature gate called `ObjectLevelWorkloadIdentity` in the controllers that
|
|
would support the feature. In the first release we should make it opt-in so
|
|
cluster admins can consciously roll it out. If the feature gate is disabled
|
|
and users set the field a terminal error should be returned.
|
|
|
|
### Workload Identity Library
|
|
|
|
We propose using the Go package `github.com/fluxcd/pkg/auth`
|
|
for implementing a workload identity library that can be
|
|
used by all the Flux controllers that need to interact
|
|
with cloud providers. This library would be responsible
|
|
for creating the `ServiceAccount` tokens in the Kubernetes
|
|
API and exchanging them for short-lived access tokens
|
|
for the cloud provider. The library would also be responsible
|
|
for caching the tokens when configured by users.
|
|
|
|
The library should support both single-tenant and multi-tenant workload
|
|
identity because single-tenant implementations are already supported in
|
|
GA APIs and hence they must remain available for backwards compatibility.
|
|
Furthermore, it would be easier to support both use cases in a single
|
|
library as opposed to mingling a new library into the currently existing
|
|
ones, so this new library becomes the definitive unified solution for
|
|
workload identity in Flux.
|
|
|
|
The library should automatically detect whether the workload identity
|
|
is single-tenant or multi-tenant by checking if a `ServiceAccount` was
|
|
configured for the operation. If a `ServiceAccount` was configured, then
|
|
the operation is multi-tenant, otherwise it is single-tenant and the
|
|
granted access token must represent the identity associated with the
|
|
controller.
|
|
|
|
The directory structure would look like this:
|
|
|
|
```shell
|
|
.
|
|
└── auth
|
|
├── aws
|
|
│ └── aws.go
|
|
├── azure
|
|
│ └── azure.go
|
|
├── gcp
|
|
│ └── gcp.go
|
|
├── access_token.go
|
|
├── options.go
|
|
├── provider.go
|
|
├── registry.go
|
|
├── restconfig.go
|
|
└── token.go
|
|
```
|
|
|
|
The file `auth/token.go` would contain the token abstraction:
|
|
|
|
```go
|
|
package auth
|
|
|
|
// Token is an interface that represents an access token that can be used to
|
|
// authenticate with a cloud provider. The only common method is for getting the
|
|
// duration of the token, because different providers have different ways of
|
|
// representing the token. For example, Azure and GCP use a single string,
|
|
// while AWS uses three strings: access key ID, secret access key and token.
|
|
// Consumers of this interface should know what type to cast it to.
|
|
type Token interface {
|
|
// GetDuration returns the duration for which the token is valid relative to
|
|
// approximately time.Now(). This is used to determine when the token should
|
|
// be refreshed.
|
|
GetDuration() time.Duration
|
|
}
|
|
```
|
|
|
|
The file `auth/access_token.go` would contain the main algorithm for getting access tokens:
|
|
|
|
```go
|
|
package auth
|
|
|
|
// GetAccessToken returns an access token for accessing resources in the given cloud provider.
|
|
func GetAccessToken(ctx context.Context, provider Provider, opts ...Option) (Token, error) {
|
|
// 1. Check if a ServiceAccount is configured and return the controller access token if not (single-tenant WI).
|
|
// 2. Get the provider audience for creating the OIDC token for the ServiceAccount in the Kubernetes API.
|
|
// 3. Get the ServiceAccount using the configured controller-runtime client.
|
|
// 4. Get the provider identity from the ServiceAccount annotations and add it to the options.
|
|
// 5. Build the cache key using the configured options.
|
|
// 6. Get the token from the cache. If present, return it, otherwise continue.
|
|
// 7. Create an OIDC token for the ServiceAccount in the Kubernetes API using the provider audience.
|
|
// 8. Exchange the OIDC token for an access token through the Security Token Service of the provider.
|
|
// 9. Add the final token to the cache and return it.
|
|
}
|
|
```
|
|
|
|
The file `auth/registry.go` would contain the logic for creating artifact registry credentials:
|
|
|
|
```go
|
|
package auth
|
|
|
|
// ArtifactRegistryCredentials is a particular type implementing the Token interface
|
|
// for credentials that can be used to authenticate against an artifact registry
|
|
// from a cloud provider.
|
|
type ArtifactRegistryCredentials struct {
|
|
authn.Authenticator
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func (r *ArtifactRegistryCredentials) GetDuration() time.Duration {
|
|
return time.Until(r.ExpiresAt)
|
|
}
|
|
|
|
// GetArtifactRegistryCredentials retrieves the registry credentials for the
|
|
// specified artifact repository and provider.
|
|
func GetArtifactRegistryCredentials(ctx context.Context, provider Provider,
|
|
artifactRepository string, opts ...Option) (*ArtifactRegistryCredentials, error)
|
|
```
|
|
|
|
The file `auth/restconfig.go` would contain the logic for creating a REST config for the Kubernetes API:
|
|
|
|
```go
|
|
package auth
|
|
|
|
// RESTConfig is a particular type implementing the Token interface
|
|
// for Kubernetes REST configurations.
|
|
type RESTConfig struct {
|
|
Host string
|
|
BearerToken string
|
|
CAData []byte
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// GetDuration implements Token.
|
|
func (r *RESTConfig) GetDuration() time.Duration {
|
|
return time.Until(r.ExpiresAt)
|
|
}
|
|
|
|
// GetRESTConfig retrieves the authentication and connection
|
|
// details to a remote Kubernetes cluster for the given provider,
|
|
// cluster resource name and API server address.
|
|
func GetRESTConfig(ctx context.Context, provider Provider,
|
|
cluster, address string, opts ...Option) (*RESTConfig, error)
|
|
```
|
|
|
|
The file `auth/provider.go` would contain the `Provider` interface:
|
|
|
|
```go
|
|
package auth
|
|
|
|
// Provider contains the logic to retrieve security credentials
|
|
// for accessing resources in a cloud provider.
|
|
type Provider interface {
|
|
// GetName returns the name of the provider.
|
|
GetName() string
|
|
|
|
// NewControllerToken returns a token that can be used to authenticate
|
|
// with the cloud provider retrieved from the default source, i.e. from
|
|
// the environment of the controller pod, e.g. files mounted in the pod,
|
|
// environment variables, local metadata services, etc.
|
|
NewControllerToken(ctx context.Context, opts ...Option) (Token, error)
|
|
|
|
// GetAudience returns the audience the OIDC tokens issued representing
|
|
// ServiceAccounts should have. This is usually a string that represents
|
|
// the cloud provider's STS service, or some entity in the provider for
|
|
// which the OIDC tokens are targeted to.
|
|
GetAudience(ctx context.Context, serviceAccount corev1.ServiceAccount) (string, error)
|
|
|
|
// GetIdentity takes a ServiceAccount and returns the identity which the
|
|
// ServiceAccount wants to impersonate, by looking at annotations.
|
|
GetIdentity(serviceAccount corev1.ServiceAccount) (string, error)
|
|
|
|
// NewToken takes a ServiceAccount and its OIDC token and returns a token
|
|
// that can be used to authenticate with the cloud provider. The OIDC token is
|
|
// the JWT token that was issued for the ServiceAccount by the Kubernetes API.
|
|
// The implementation should exchange this token for a cloud provider access
|
|
// token through the provider's STS service.
|
|
NewTokenForServiceAccount(ctx context.Context, oidcToken string,
|
|
serviceAccount corev1.ServiceAccount, opts ...Option) (Token, error)
|
|
|
|
// GetAccessTokenOptionsForArtifactRepository returns the options that must be
|
|
// passed to the provider to retrieve access tokens for an artifact repository.
|
|
GetAccessTokenOptionsForArtifactRepository(artifactRepository string) ([]Option, error)
|
|
|
|
// ParseArtifactRepository parses the artifact repository to verify
|
|
// it's a valid repository for the provider. As a result, it returns
|
|
// the input required for the provider to issue registry credentials.
|
|
// This input is included in the cache key for the issued credentials.
|
|
ParseArtifactRepository(artifactRepository string) (string, error)
|
|
|
|
// NewArtifactRegistryCredentials takes the registry input extracted by
|
|
// ParseArtifactRepository() and an access token and returns credentials
|
|
// that can be used to authenticate with the registry.
|
|
NewArtifactRegistryCredentials(ctx context.Context, registryInput string,
|
|
accessToken Token, opts ...Option) (*ArtifactRegistryCredentials, error)
|
|
|
|
// GetAccessTokenOptionsForCluster returns the options that must be
|
|
// passed to the provider to retrieve access tokens for a cluster.
|
|
// More than one access token may be required depending on the
|
|
// provider, with different options (e.g. scope). Hence the return
|
|
// type is a slice of slices.
|
|
GetAccessTokenOptionsForCluster(cluster string) ([][]Option, error)
|
|
|
|
// NewRESTConfig returns a RESTConfig that can be used to authenticate
|
|
// with the Kubernetes API server. The access tokens are used for looking
|
|
// up connection details like the API server address and CA certificate
|
|
// data, and for accessing the cluster API server itself via the IAM
|
|
// system of the cloud provider. If it's just a single token or multiple,
|
|
// it depends on the provider.
|
|
NewRESTConfig(ctx context.Context, accessTokens []Token, opts ...Option) (*RESTConfig, error)
|
|
}
|
|
```
|
|
|
|
The file `auth/options.go` would contain the following options:
|
|
|
|
```go
|
|
package auth
|
|
|
|
// Options contains options for configuring the behavior of the provider methods.
|
|
// Not all providers/methods support all options.
|
|
type Options struct {
|
|
Client client.Client
|
|
Cache *cache.TokenCache
|
|
ServiceAccount *client.ObjectKey
|
|
InvolvedObject cache.InvolvedObject
|
|
Audiences []string
|
|
Scopes []string
|
|
STSRegion string
|
|
STSEndpoint string
|
|
ProxyURL *url.URL
|
|
ClusterResource string
|
|
ClusterAddress string
|
|
CAData string
|
|
AllowShellOut bool
|
|
}
|
|
|
|
// WithServiceAccount sets the ServiceAccount reference for the token
|
|
// and a controller-runtime client to fetch the ServiceAccount and
|
|
// create an OIDC token for it in the Kubernetes API.
|
|
func WithServiceAccount(saRef client.ObjectKey, client client.Client) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithCache sets the token cache and the involved object for recording events.
|
|
func WithCache(cache cache.TokenCache, involvedObject cache.InvolvedObject) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithAudiences sets the audiences for the Kubernetes ServiceAccount token.
|
|
func WithAudiences(audiences ...string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithScopes sets the scopes for the token.
|
|
func WithScopes(scopes ...string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithSTSRegion sets the region for the STS service (some cloud providers
|
|
// require a region, e.g. AWS).
|
|
func WithSTSRegion(stsRegion string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithSTSEndpoint sets the endpoint for the STS service.
|
|
func WithSTSEndpoint(stsEndpoint string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithProxyURL sets a *url.URL for an HTTP/S proxy for acquiring the token.
|
|
func WithProxyURL(proxyURL url.URL) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithCAData sets the CA data for credentials that require a CA,
|
|
// e.g. for Kubernetes REST config.
|
|
func WithCAData(caData string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithClusterResource sets the cluster resource for creating a REST config.
|
|
// Must be the fully qualified name of the cluster resource in the cloud
|
|
// provider API.
|
|
func WithClusterResource(clusterResource string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithClusterAddress sets the cluster address for creating a REST config.
|
|
// This address is used to select the correct cluster endpoint and CA data
|
|
// when the provider has a list of endpoints to choose from, or to simply
|
|
// validate the address against the cluster resource when the provider
|
|
// returns a single endpoint. This is optional, providers returning a list
|
|
// of endpoints will select the first one if no address is provided.
|
|
func WithClusterAddress(clusterAddress string) Option {
|
|
// ...
|
|
}
|
|
|
|
// WithAllowShellOut allows the provider to shell out to binary tools
|
|
// for acquiring controller tokens. MUST be used only by the Flux CLI,
|
|
// i.e. in the github.com/fluxcd/flux2 Git repository.
|
|
func WithAllowShellOut() Option {
|
|
// ...
|
|
}
|
|
```
|
|
|
|
The `auth/aws/aws.go`, `auth/azure/azure.go` and
|
|
`auth/gcp/gcp.go` files would contain the implementations for
|
|
the respective cloud providers:
|
|
|
|
```go
|
|
package aws
|
|
|
|
import (
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/sts/types"
|
|
)
|
|
|
|
const ProviderName = "aws"
|
|
|
|
type Provider struct{}
|
|
|
|
type Token struct{ types.Credentials }
|
|
|
|
// GetDuration implements auth.Token.
|
|
func (t *Token) GetDuration() time.Duration {
|
|
return time.Until(*t.Expiration)
|
|
}
|
|
|
|
type credentialsProvider struct {
|
|
opts []auth.Option
|
|
}
|
|
|
|
// NewCredentialsProvider creates an aws.CredentialsProvider for the aws provider.
|
|
func NewCredentialsProvider(opts ...auth.Option) aws.CredentialsProvider {
|
|
return &credentialsProvider{opts}
|
|
}
|
|
|
|
// Retrieve implements aws.CredentialsProvider.
|
|
func (c *credentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) {
|
|
// Use auth.GetToken() to get the token.
|
|
}
|
|
```
|
|
|
|
```go
|
|
package azure
|
|
|
|
import (
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
|
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
|
)
|
|
|
|
const ProviderName = "azure"
|
|
|
|
type Provider struct{}
|
|
|
|
type Token struct{ azcore.AccessToken }
|
|
|
|
// GetDuration implements auth.Token.
|
|
func (t *Token) GetDuration() time.Duration {
|
|
return time.Until(t.ExpiresOn)
|
|
}
|
|
|
|
type tokenCredential struct {
|
|
opts []auth.Option
|
|
}
|
|
|
|
// NewTokenCredential creates an azcore.TokenCredential for the azure provider.
|
|
func NewTokenCredential(opts ...auth.Option) azcore.TokenCredential {
|
|
return &tokenCredential{opts}
|
|
}
|
|
|
|
// GetToken implements azcore.TokenCredential.
|
|
// The options argument is ignored, any options should be
|
|
// specified in the constructor.
|
|
func (t *tokenCredential) GetToken(ctx context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
|
// Use auth.GetToken() to get the token.
|
|
}
|
|
```
|
|
|
|
```go
|
|
package gcp
|
|
|
|
import (
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const ProviderName = "gcp"
|
|
|
|
type Provider struct {}
|
|
|
|
type Token struct{ oauth2.Token }
|
|
|
|
// GetDuration implements auth.Token.
|
|
func (t *Token) GetDuration() time.Duration {
|
|
return time.Until(t.Expiry)
|
|
}
|
|
|
|
type tokenSource struct {
|
|
ctx context.Context
|
|
opts []auth.Option
|
|
}
|
|
|
|
// NewTokenSource creates an oauth2.TokenSource for the gcp provider.
|
|
func NewTokenSource(ctx context.Context, opts ...auth.Option) oauth2.TokenSource {
|
|
return &tokenSource{ctx, opts}
|
|
}
|
|
|
|
// Token implements oauth2.TokenSource.
|
|
func (t *tokenSource) Token() (*oauth2.Token, error) {
|
|
// Use auth.GetToken() to get the token.
|
|
}
|
|
|
|
var gkeMetadata struct {
|
|
projectID string
|
|
location string
|
|
name string
|
|
mu sync.Mutex
|
|
loaded bool
|
|
}
|
|
```
|
|
|
|
As detailed above, each cloud provider implementation defines a simple wrapper
|
|
around the cloud provider access token type. This wrapper implements the
|
|
`auth.Token` interface, which is essentially the method `GetDuration()`
|
|
for the cache library to manage the token lifetime. The wrappers also contain
|
|
a helper function to create a token source for the respective cloud provider
|
|
SDKs. These methods have different names and signatures because the cloud provider
|
|
SDKs are different and have different types, but they all implement the same
|
|
concept of a token source.
|
|
|
|
The `aws` provider needs to read the environment variable `AWS_REGION` for
|
|
configuring the STS client. Even though a specific STS endpoint may be
|
|
configured, the AWS SDKs require the region to be set regardless. This
|
|
variable is usually set automatically in EKS pods, and can be manually set
|
|
by users otherwise (e.g. in Fargate pods).
|
|
|
|
An important detail to take into account in the `azure` provider implementation
|
|
is using our custom implementation of `azidentity.NewDefaultAzureCredential()`
|
|
found in kustomize-controller for SOPS decryption. This custom implementation
|
|
avoids shelling out to the Azure CLI, which is something we strive to avoid in
|
|
the Flux codebase. This is important because today we are doing this in a few
|
|
APIs but not others, so it will be a significant improvement to implement this
|
|
in a single place and use it everywhere.
|
|
|
|
The `gcp` provider needs to load the cluster metadata from the `gke-metadata-server`
|
|
in order to create tokens. This must be done lazily when the first token is
|
|
requested, and there's a very important reason for this: if this was done on
|
|
the controller startup, the controller would crash when running outside GKE and
|
|
enter `CrashLoopBackOff` because the `gke-metadata-server` would never be
|
|
available. This is a very important detail that must be taken into account when
|
|
implementing the `gcp` provider. The cluster metadata doesn't change during the
|
|
lifetime of the controller pod, so we use a `sync.Mutex` and `bool` to load it
|
|
only once into a package variable.
|
|
|
|
When not running in GKE, the `gcp` provider would use the following annotation
|
|
in the `ServiceAccount` to identify the Workload Identity Provider resource
|
|
for use with Workload Identity Federation:
|
|
|
|
```yaml
|
|
apiVersion: v1
|
|
kind: ServiceAccount
|
|
metadata:
|
|
name: my-service-account
|
|
namespace: my-namespace
|
|
annotations:
|
|
gcp.auth.fluxcd.io/workload-identity-provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
|
|
```
|
|
|
|
#### Cache Key
|
|
|
|
The cache key *MUST* include *ALL* the inputs specified for acquiring the
|
|
temporary credentials, as they all obviously influence how the credentials
|
|
are created.
|
|
|
|
##### Format
|
|
|
|
The cache key would be the SHA256 hash of the following multi-line strings:
|
|
|
|
Single-tenant/controller-level access token cache key:
|
|
|
|
```
|
|
provider=<cloud-provider-name>
|
|
scopes=<comma-separated-scopes>
|
|
stsRegion=<sts-region>
|
|
stsEndpoint=<sts-endpoint>
|
|
proxyURL=<proxy-url>
|
|
caData=<ca-data>
|
|
```
|
|
|
|
Multi-tenant/object-level access token cache key:
|
|
|
|
```
|
|
provider=<cloud-provider-name>
|
|
providerIdentity=<cloud-provider-identity>
|
|
serviceAccountName=<service-account-name>
|
|
serviceAccountNamespace=<service-account-namespace>
|
|
serviceAccountTokenAudiences=<comma-separated-audiences>
|
|
scopes=<comma-separated-scopes>
|
|
stsRegion=<sts-region>
|
|
stsEndpoint=<sts-endpoint>
|
|
proxyURL=<proxy-url>
|
|
caData=<ca-data>
|
|
```
|
|
|
|
Artifact registry credentials:
|
|
|
|
```
|
|
accessTokenCacheKey=sha256(<access-token-cache-key>)
|
|
artifactRepositoryCacheKey=<'gcp'-for-gcp|registry-region-for-aws|registry-host-for-azure>
|
|
```
|
|
|
|
REST config:
|
|
|
|
```
|
|
accessToken1CacheKey=sha256(<cache-key-for-access-token-1>)
|
|
...
|
|
accessTokenNCacheKey=sha256(<cache-key-for-access-token-N>)
|
|
cluster=<cluster-resource-name>
|
|
address=<cluster-api-server-address>
|
|
```
|
|
|
|
##### Security Considerations and Controls
|
|
|
|
As mentioned previously, a `ServiceAccount` must have permission to impersonate the
|
|
identity it is configured to impersonate. Once a token for the impersonated identity
|
|
is issued, that token would be valid for a while even if immediately after issuing it
|
|
the `ServiceAccount` loses permission to impersonate that identity. In our cache key
|
|
design, the token would remain available for the `ServiceAccount` to use until it
|
|
expires. If the impersonation permission was revoked to mitigate an attack, the
|
|
attacker could still get a valid token from the cache for a while after the
|
|
revocation, and hence still exercise the permissions they had prior to the revocation.
|
|
|
|
There are a few mitigations for this scenario:
|
|
|
|
* Users that revoke impersonation permissions for a `ServiceAccount` must also
|
|
change the annotations of the `ServiceAccount` to impersonate a different identity,
|
|
or delete the `ServiceAccount` altogether, or restart the Flux controllers so the
|
|
cache is purged. Any of these actions would effectively prevent the attack, but
|
|
they represent an additional step after revoking the impersonation permission.
|
|
|
|
* In the Flux controllers users can specify the `--token-cache-max-duration` flag,
|
|
which can be used to limit the maximum duration for which a token can be cached.
|
|
By reducing the default maximum duration of one hour to a smaller value, users can
|
|
limit the time window during which a token would be available for a `ServiceAccount`
|
|
to use after losing permission to impersonate the identity.
|
|
|
|
* Disable cache entirely by setting the flag `--token-cache-max-size=0`, or removing
|
|
this flag altogether since the default is already zero i.e. no tokens are cached
|
|
in the Flux controller. This mitigation is in case your security requirements are
|
|
extreme and you want to avoid any risk of such an attack. This mitigation is the
|
|
most effective, but it comes with the cost of many API calls to issue tokens in
|
|
the cloud provider, which could result in a performance bottleneck and/or
|
|
throttling/rate-limiting, as tokens would have to be issued for every
|
|
reconciliation.
|
|
|
|
A similar situation could occur in the single-tenant scenario, when the permission
|
|
to impersonate the configured identity is revoked from the controller `ServiceAccount`.
|
|
In this case, the attacker would have access to the cloud provider resources that
|
|
the controller had access to prior to the revocation of the impersonation permission.
|
|
Most of the mitigations mentioned above apply to this scenario as well, except for
|
|
the one that involves changing the annotations of the `ServiceAccount` to impersonate
|
|
a different identity or deleting the `ServiceAccount` altogether, as the controller
|
|
`ServiceAccount` should not be deleted. The best mitigation in this case is to restart
|
|
the Flux controllers so the cache is purged.
|
|
|
|
### Library Integration
|
|
|
|
When reconciling an object, the controller must use the `auth.GetToken()`
|
|
function passing a `controller-runtime` client that has permission to create
|
|
`ServiceAccount` tokens in the Kubernetes API, the desired cloud provider by name,
|
|
and all the remaining options according to the configuration of the controller and
|
|
of the object. The provider names match the ones used for `spec.provider` in the Flux
|
|
APIs, i.e. `aws`, `azure` and `gcp`.
|
|
|
|
Because different cloud providers have different ways of representing their access
|
|
tokens (e.g. Azure and GCP tokens are a single opaque string while AWS has three
|
|
strings: access key ID, secret access key and token), consumers of the
|
|
`auth.Token` interface would need to cast it to `*<provider>.Token`.
|
|
|
|
The following subsections show details of how the integration would look like.
|
|
|
|
#### `GitRepository` and `ImageUpdateAutomation` APIs
|
|
|
|
For these APIs the only provider we have so far that supports workload identity
|
|
is `azure`. In this case we would simply replace `AzureOpts []azure.OptFunc` in
|
|
the `fluxcd/pkg/git.ProviderOptions` struct with `[]fluxcd/pkg/auth.Option`
|
|
and would modify `fluxcd/pkg/git.GetCredentials()` to use `auth.GetToken()`.
|
|
The token interface would be cast to `*azure.Token` and the token string would be
|
|
assigned to `fluxcd/pkg/git.Credentials.BearerToken`. A `GitRepository` object
|
|
configured with the `azure` provider and a `ServiceAccount` would then go through
|
|
this code path.
|
|
|
|
#### `OCIRepository`, `ImageRepository`, `ImagePolicy`, `HelmRepository` and `HelmChart` APIs
|
|
|
|
The `HelmRepository` API only supports a cloud provider for OCI repositories, so
|
|
for all these APIs we would only need to support OCI authentication.
|
|
|
|
All these APIs currently use `*fluxcd/pkg/oci/auth/login.Manager` to get the
|
|
container registry credentials. The new library would replace this library
|
|
entirely, as it mostly handles single-tenant workload identity. The new library
|
|
covers both single-tenant and multi-tenant workload identity, so it would be
|
|
a drop-in replacement for the `login.Manager`.
|
|
|
|
In the case of the source-controller APIs, all of them use the function `OIDCAuth()`
|
|
from the internal package `internal/oci`. We would replace the use of `login.Manager`
|
|
with `auth.GetToken()` in this function. The token interface would
|
|
be cast to `*auth.RegistryCredentials` and then fed to `authn.FromConfig()`
|
|
from the package `github.com/google/go-containerregistry/pkg/authn`.
|
|
|
|
In the case of `ImageRepository` and `ImagePolicy`, we would replace `login.Manager` with
|
|
`auth.GetToken()` in the `setAuthOptions()` method of the
|
|
`ImageRepositoryReconciler`, cast the token to `*auth.RegistryCredentials`
|
|
and then feed it to `authn.FromConfig()`.
|
|
|
|
The beauty of this particular integration is that here we no longer require
|
|
branching code paths for each cloud provider, we would just need to configure
|
|
the options for the `auth.GetToken()` function and the library would take
|
|
care of the rest.
|
|
|
|
#### `Bucket` API
|
|
|
|
##### Provider `aws`
|
|
|
|
A `Bucket` object configured with the `aws` provider and a `ServiceAccount` would
|
|
cause the internal `minio.MinioClient` of source-controller to be created with the
|
|
following new options:
|
|
|
|
* `minio.WithTokenClient(controller-runtime/pkg/client.Client)`
|
|
* `minio.WithTokenCache(*fluxcd/pkg/cache.TokenCache)`
|
|
|
|
The constructor would then use `auth.GetToken()` to get the
|
|
cloud provider access token. When doing so, the `minio.MinioClient` would
|
|
cast the token interface to `*aws.Token` and feed it to `credentials.NewStatic()`
|
|
from the package `github.com/minio/minio-go/v7/pkg/credentials`.
|
|
|
|
##### Provider `azure`
|
|
|
|
A `Bucket` object configured with the `azure` provider and a `ServiceAccount`
|
|
would cause the internal `azure.BlobClient` of source-controller to be created
|
|
with the following new options:
|
|
|
|
* `azure.WithTokenClient(controller-runtime/pkg/client.Client)`
|
|
* `azure.WithTokenCache(*fluxcd/pkg/cache.TokenCache)`
|
|
* `azure.WithServiceAccount(controller-runtime/pkg/client.ObjectKey)`
|
|
* `azure.WithInvolvedObject(*fluxcd/pkg/cache.InvolvedObject)`
|
|
|
|
The constructor would then use `azure.NewTokenCredential()` to feed this
|
|
token credential to `azblob.NewClient()`.
|
|
|
|
##### Provider `gcp`
|
|
|
|
A `Bucket` object configured with the `gcp` provider and a `ServiceAccount`
|
|
would cause the internal `gcp.GCSClient` of source-controller to be created
|
|
with the following new options:
|
|
|
|
* `gcp.WithTokenClient(controller-runtime/pkg/client.Client)`
|
|
* `gcp.WithTokenCache(*fluxcd/pkg/cache.TokenCache)`
|
|
* `gcp.WithServiceAccount(controller-runtime/pkg/client.ObjectKey)`
|
|
* `gcp.WithInvolvedObject(*fluxcd/pkg/cache.InvolvedObject)`
|
|
|
|
The constructor would then use `gcp.NewTokenSource()` to feed this token
|
|
source to the `option.WithTokenSource()` and pass it to
|
|
`cloud.google.com/go/storage.NewClient()`.
|
|
|
|
#### `Kustomization` API (SOPS Decryption)
|
|
|
|
The `Kustomization` API uses Key Management Services (KMS) for decrypting
|
|
SOPS secrets. The internal packages `internal/decryptor` and `internal/sops`
|
|
of kustomize-controller already use interfaces compatible with the new
|
|
library in the case of `aws` and `azure`, i.e. `*awskms.CredentialsProvider`
|
|
and `*azkv.TokenCredential` respectively, so we could easily use the helper
|
|
functions for creating the respective token sources to configure the KMS
|
|
credentials for SOPS. This is thanks to the respective SOPS libraries
|
|
`github.com/getsops/sops/v3/kms` and `github.com/getsops/sops/v3/azkv`.
|
|
For GCP we can introduce the equivalent interface that was recently added
|
|
in [this](https://github.com/getsops/sops/pull/1794/files) pull request.
|
|
This new interface introduced in SOPS upstream can also be used for the
|
|
current JSON credentials method that we use via
|
|
`google.CredentialsFromJSON().TokenSource`. This would allow us to use only
|
|
the respective token source interfaces for all three providers when using
|
|
either workload identity or secrets.
|
|
|
|
#### `Kustomization` and `HelmRelease` APIs (Remote Cluster Access)
|
|
|
|
The kustomize-controller should fetch a `*rest.Config` from the `auth`
|
|
package and feed it to `runtime/client.WithKubeConfig()` for creating
|
|
a `runtime/client.(*Impersonator)` with the configured authentication.
|
|
|
|
The helm-controller should fetch a `*rest.Config` from the `auth`
|
|
package and feed it to the internal `kube.NewMemoryRESTClientGetter()`,
|
|
just like it does for the secret-based alternative.
|
|
|
|
#### `Provider` API
|
|
|
|
The constructor of the internal `notifier.Factory` of notification-controller
|
|
would now accept the following new options:
|
|
|
|
* `notifier.WithTokenClient(controller-runtime/pkg/client.Client)`
|
|
* `notifier.WithTokenCache(*fluxcd/pkg/cache.TokenCache)`
|
|
* `notifier.WithServiceAccount(controller-runtime/pkg/client.ObjectKey)`
|
|
* `notifier.WithInvolvedObject(*fluxcd/pkg/cache.InvolvedObject)`
|
|
|
|
The cloud provider types that support workload identity would then use these
|
|
options. See the following subsections for details.
|
|
|
|
##### Type `azuredevops`
|
|
|
|
The `notifier.NewAzureDevOps()` constructor would use the existing and new
|
|
options to call `auth.GetToken()` and use it to get the cloud
|
|
provider access token. When doing so, the `notifier.AzureDevOps` would cast
|
|
the token interface to `*azure.Token` and feed the token string to
|
|
`NewPatConnection()` from the package
|
|
`github.com/microsoft/azure-devops-go-api/azuredevops/v6`.
|
|
|
|
##### Type `azureeventhub`
|
|
|
|
The `notifier.NewAzureEventHub()` constructor would use the existing and new
|
|
options to call `auth.GetToken()` and use it to get the cloud
|
|
provider access token. When doing so, the `notifier.AzureEventHub` would cast
|
|
the token interface to `*azure.Token` and feed the token string to `newJWTHub()`.
|
|
|
|
##### Type `googlepubsub`
|
|
|
|
The `notifier.NewGooglePubSub()` constructor would use the existing and new
|
|
options to call `gcp.NewTokenSource()` and feed this token source to the
|
|
`option.WithTokenSource()` and pass it to `cloud.google.com/go/pubsub.NewClient()`.
|
|
|
|
## Implementation History
|
|
|
|
* In Flux 2.6 object-level workload identity was introduced for the
|
|
OCI artifact APIs, i.e. `OCIRepository`, `ImageRepository`, `ImagePolicy`,
|
|
`HelmRepository` and `HelmChart`, as well as for SOPS decryption
|
|
in the `Kustomization` API and Azure Event Hubs in the
|
|
`Provider` API.
|
|
|
|
<!--
|
|
Major milestones in the lifecycle of the RFC such as:
|
|
- The first Flux release where an initial version of the RFC was available.
|
|
- The version of Flux where the RFC graduated to general availability.
|
|
- The version of Flux where the RFC was retired or superseded.
|
|
-->
|