diff --git a/docs/guides/workload_identity_federation.md b/docs/guides/workload_identity_federation.md new file mode 100644 index 000000000..30d82f558 --- /dev/null +++ b/docs/guides/workload_identity_federation.md @@ -0,0 +1,122 @@ +--- +page_title: "Workload Identity Federation with GitHub Actions" +--- + +# Workload Identity Federation with GitHub Actions + +Workload Identity Federation (WIF) allows you to authenticate the STACKIT Terraform provider without using long-lived Service Account keys. +This is particularly useful in CI/CD environments like **GitHub Actions**, **GitLab CI**, or **Azure DevOps**, where you can use short-lived +OIDC tokens. This guide focuses on using WIF with GitHub Actions, but the principles may apply to other CI/CD platforms that support OIDC. + +## Prerequisites + +Before using Workload Identity Federation flow, you need to: +1. [Create](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-accounts/) a **Service Account** on STACKIT. + +## Setup Workload Identity Federation + +WIF can be configured to trust any public OIDC provider following the [docs page](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-federations/#create-a-federated-identity-provider) +but for the purpose of this guide we will focus on GitHub Actions as OIDC provider. GitHub Actions supports OIDC authentication using +the public issuer "https://token.actions.githubusercontent.com" (for GH Enterprise you should check your issuer URL) and setting repository and action information +as part of the OIDC token claims. [More info here](https://docs.github.com/es/actions/concepts/security/openid-connect). + +Using this provider [repository](https://github.com/stackitcloud/terraform-provider-stackit) (stackitcloud/terraform-provider-stackit) as example and assuming that we want to +execute terraform on the main branch, we will configure the service account "Federated identity Provider" with the following configuration: +- **Provider Name**: GitHub # This is just an example, you can choose any name you want +- **Issuer URL**: https://token.actions.githubusercontent.com # This is the public issuer for GitHub Actions OIDC tokens +- **Assertions**: + - **sub**->equals->repo:stackitcloud/terraform-provider-stackit:ref:refs/heads/main # This is the repository and branch where the action will run + - **aud**->equals->sts.accounts.stackit.cloud # Mandatory value + +> Note: You can use more fine-grained assertions just adding them. More info about OIDC token claims in [GitHub](https://docs.github.com/en/actions/reference/security/oidc) + +## Provider Configuration + +To use WIF, you must set an `use_oidc` flag to `true` as well as provide an OIDC token for the exchange. While you can provide the token directly in the configuration +through `service_account_federated_token`, this is not recommended for GitHub Actions as the provider will automatically fetch the token from the GitHub OIDC. + +In addition to this, you need to set the `service_account_email` to specify which service account you want to use. This is mandatory as the provider needs to know which service account to exchange the token for. + +```hcl +provider "stackit" { + service_account_email = "terraform-example@sa.stackit.cloud" + use_oidc = true + ... # Other provider configuration +} +``` + +### Using Environment Variables (Recommended) + +In most CI/CD scenarios, the cleanest way is to set the `STACKIT_SERVICE_ACCOUNT_EMAIL` environment variable as well as `STACKIT_USE_OIDC="1"` to enable the WIF flow. This way you don't need to +change your provider configuration and the provider will automatically fetch the OIDC token and exchange it for a short-lived access token. + +## Example GitHub Actions Workflow + +> Note: To request OIDC tokens, you need to [grant this permission to the GitHub Actions workflow](https://docs.github.com/en/actions/reference/security/oidc#required-permission). + +```yaml +name: Workload Identity Federation with STACKIT + +on: + push: + branches: + - '**' + +jobs: + demo-job: + name: Workload Identity Federation with STACKIT + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Create Test Configuration + run: | + cat < main.tf + terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + } + } + } + + provider "stackit" { + default_region = "eu01" + } + + resource "stackit_service_account" "sa" { + project_id = "e1925fbf-5272-497a-8298-1586760670de" + name = "terraform-example-ci" + } + EOF + + - name: Terraform Init + run: | + terraform init + env: + STACKIT_USE_OIDC: "1" + STACKIT_SERVICE_ACCOUNT_EMAIL: "terraform-example@sa.stackit.cloud" + + - name: Terraform Plan + run: | + terraform plan -out=tfplan + env: + STACKIT_USE_OIDC: "1" + STACKIT_SERVICE_ACCOUNT_EMAIL: "terraform-example@sa.stackit.cloud" + + - name: Terraform Apply + run: terraform apply -auto-approve tfplan + env: + STACKIT_USE_OIDC: "1" + STACKIT_SERVICE_ACCOUNT_EMAIL: "terraform-example@sa.stackit.cloud" +``` diff --git a/docs/index.md b/docs/index.md index 8c93ec933..a689cdca0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,10 +11,20 @@ provider "stackit" { # Authentication -# Token flow (scheduled for deprecation and will be removed on December 17, 2025) +# Workload Identity Federation flow provider "stackit" { - default_region = "eu01" - service_account_token = var.service_account_token + default_region = "eu01" + service_account_email = var.service_account_email + service_account_federated_token = var.service_account_federated_token + use_oidc = true +} + +# Workload Identity Federation flow (using path) +provider "stackit" { + default_region = "eu01" + service_account_email = var.service_account_email + service_account_federated_token_path = var.service_account_federated_token_path + use_oidc = true } # Key flow @@ -36,13 +46,13 @@ provider "stackit" { To authenticate, you will need a [service account](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/). Create it in the [STACKIT Portal](https://portal.stackit.cloud/) and assign the necessary permissions to it, e.g. `project.owner`. There are multiple ways to authenticate: -- Key flow (recommended) -- Token flow (is scheduled for deprecation and will be removed on December 17, 2025) +- Workload Identity Federation (Recommended) +- Key flow -When setting up authentication, the provider will always try to use the key flow first and search for credentials in several locations, following a specific order: +When setting up authentication, the provider will always try to use the workload identity federation flow first and search for credentials in several locations, following a specific order: -1. Explicit configuration, e.g. by setting the field `service_account_key_path` in the provider block (see example below) -2. Environment variable, e.g. by setting `STACKIT_SERVICE_ACCOUNT_KEY_PATH` +1. Explicit configuration, e.g. by setting the field `use_oidc` in the provider block (see example below) +2. Environment variable, e.g. by setting `STACKIT_USE_OIDC` 3. Credentials file The provider will check the credentials file located in the path defined by the `STACKIT_CREDENTIALS_PATH` env var, if specified, @@ -51,12 +61,23 @@ When setting up authentication, the provider will always try to use the key flow ```json { - "STACKIT_SERVICE_ACCOUNT_TOKEN": "foo_token", "STACKIT_SERVICE_ACCOUNT_KEY_PATH": "path/to/sa_key.json", "STACKIT_PRIVATE_KEY_PATH": "path/to/private_key.pem" } ``` +### Workload Identity Federation (Recommended) + + The following instructions assume that you have created a service account and assigned the necessary permissions to it, e.g. `project.owner`. + +When using Workload Identity Federation (WIF), you don't need a static service account secret or key. Instead, the provider exchanges a short-lived OIDC token (from GitHub Actions, GitLab CI, etc.) for a STACKIT access token. This is the most secure way to authenticate in CI/CD environments as it eliminates the need for long-lived secrets. + +WIF can be configured to trust any public OIDC provider following the [official documentation](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-federations/#create-a-federated-identity-provider). + +To use WIF, set the `use_oidc` flag to `true` and provide an OIDC token for the exchange. While you can provide the token directly via `service_account_federated_token`, this is **not recommended for GitHub Actions**, as the provider will automatically fetch the token from the environment. For a complete setup, see our [Workload Identity Federation guide](./guides/workload_identity_federation.md). + +In addition to this, you must set the `service_account_email` to specify which service account to impersonate. + ### Key flow The following instructions assume that you have created a service account and assigned the necessary permissions to it, e.g. `project.owner`. @@ -67,7 +88,7 @@ When creating the service account key, a new pair can be created automatically, **Optionally**, you can provide your own private key when creating the service account key, which will then require you to also provide it explicitly to the [STACKIT Terraform Provider](https://github.com/stackitcloud/terraform-provider-stackit), additionally to the service account key. Check the STACKIT Docs for an [example of how to create your own key-pair](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-keys/). -To configure the key flow, follow this steps: +To configure the key flow, follow these steps: 1. Create a service account key: @@ -109,17 +130,6 @@ To configure the key flow, follow this steps: > - setting the environment variable: `STACKIT_PRIVATE_KEY_PATH` > - setting `STACKIT_PRIVATE_KEY_PATH` in the credentials file (see above) - -### Token flow - -> Is scheduled for deprecation and will be removed on December 17, 2025. - -Using this flow is less secure since the token is long-lived. You can provide the token in several ways: - -1. Setting the field `service_account_token` in the provider -2. Setting the environment variable `STACKIT_SERVICE_ACCOUNT_TOKEN` -3. Setting it in the credentials file (see above) - # Backend configuration To keep track of your terraform state, you can configure an [S3 backend](https://developer.hashicorp.com/terraform/language/settings/backends/s3) using [STACKIT Object Storage](https://docs.stackit.cloud/products/storage/object-storage). @@ -172,6 +182,8 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `mongodbflex_custom_endpoint` (String) Custom endpoint for the MongoDB Flex service - `objectstorage_custom_endpoint` (String) Custom endpoint for the Object Storage service - `observability_custom_endpoint` (String) Custom endpoint for the Observability service +- `oidc_request_token` (String) The bearer token for the request to the OIDC provider. For use when authenticating as a Service Account using OpenID Connect. +- `oidc_request_url` (String) The URL for the OIDC provider from which to request an ID token. For use when authenticating as a Service Account using OpenID Connect. - `opensearch_custom_endpoint` (String) Custom endpoint for the OpenSearch service - `postgresflex_custom_endpoint` (String) Custom endpoint for the PostgresFlex service - `private_key` (String) Private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key. @@ -185,7 +197,9 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `server_backup_custom_endpoint` (String) Custom endpoint for the Server Backup service - `server_update_custom_endpoint` (String) Custom endpoint for the Server Update service - `service_account_custom_endpoint` (String) Custom endpoint for the Service Account service -- `service_account_email` (String, Deprecated) Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL. It is required if you want to use the resource manager project resource. +- `service_account_email` (String) Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL. It is required if you want to use the resource manager project resource. This value is required using OpenID Connect authentication. +- `service_account_federated_token` (String) The OIDC ID token for use when authenticating as a Service Account using OpenID Connect. +- `service_account_federated_token_path` (String) Path for workload identity assertion. It can also be set using the environment variable STACKIT_FEDERATED_TOKEN_FILE. - `service_account_key` (String) Service account key used for authentication. If set, the key flow will be used to authenticate all operations. - `service_account_key_path` (String) Path for the service account key used for authentication. If set, the key flow will be used to authenticate all operations. - `service_account_token` (String, Deprecated) Token used for authentication. If set, the token flow will be used to authenticate all operations. @@ -194,3 +208,4 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `ske_custom_endpoint` (String) Custom endpoint for the Kubernetes Engine (SKE) service - `sqlserverflex_custom_endpoint` (String) Custom endpoint for the SQL Server Flex service - `token_custom_endpoint` (String) Custom endpoint for the token API, which is used to request access tokens when using the key flow +- `use_oidc` (Boolean) Should OIDC be used for Authentication? This can also be sourced from the `STACKIT_USE_OIDC` Environment Variable. Defaults to `false`. diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf index 75b5cc766..9854dac6f 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -4,10 +4,20 @@ provider "stackit" { # Authentication -# Token flow (scheduled for deprecation and will be removed on December 17, 2025) +# Workload Identity Federation flow provider "stackit" { - default_region = "eu01" - service_account_token = var.service_account_token + default_region = "eu01" + service_account_email = var.service_account_email + service_account_federated_token = var.service_account_federated_token + use_oidc = true +} + +# Workload Identity Federation flow (using path) +provider "stackit" { + default_region = "eu01" + service_account_email = var.service_account_email + service_account_federated_token_path = var.service_account_federated_token_path + use_oidc = true } # Key flow diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go index 08083abb2..3bfb042c5 100644 --- a/stackit/internal/conversion/conversion_test.go +++ b/stackit/internal/conversion/conversion_test.go @@ -2,6 +2,8 @@ package conversion import ( "context" + "crypto/tls" + "net/http" "reflect" "testing" @@ -306,6 +308,9 @@ func TestParseProviderData(t *testing.T) { } func TestParseEphemeralProviderData(t *testing.T) { + var randomRoundTripper http.RoundTripper = &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}, + } type args struct { providerData any } @@ -354,21 +359,17 @@ func TestParseEphemeralProviderData(t *testing.T) { name: "valid provider data 2", args: args{ providerData: core.EphemeralProviderData{ - PrivateKey: "", - PrivateKeyPath: "/home/dev/foo/private-key.json", - ServiceAccountKey: "", - ServiceAccountKeyPath: "/home/dev/foo/key.json", - TokenCustomEndpoint: "", + ProviderData: core.ProviderData{ + RoundTripper: randomRoundTripper, + }, }, }, want: want{ ok: true, providerData: core.EphemeralProviderData{ - PrivateKey: "", - PrivateKeyPath: "/home/dev/foo/private-key.json", - ServiceAccountKey: "", - ServiceAccountKeyPath: "/home/dev/foo/key.json", - TokenCustomEndpoint: "", + ProviderData: core.ProviderData{ + RoundTripper: randomRoundTripper, + }, }, }, wantErr: false, diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index 9817e6fd5..456ddce40 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -29,17 +29,11 @@ const ( type EphemeralProviderData struct { ProviderData - - PrivateKey string - PrivateKeyPath string - ServiceAccountKey string - ServiceAccountKeyPath string - TokenCustomEndpoint string } type ProviderData struct { RoundTripper http.RoundTripper - ServiceAccountEmail string // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025. + ServiceAccountEmail string // Deprecated: Use DefaultRegion instead Region string DefaultRegion string diff --git a/stackit/internal/services/access_token/ephemeral_resource.go b/stackit/internal/services/access_token/ephemeral_resource.go index 8ae346ba3..28d943448 100644 --- a/stackit/internal/services/access_token/ephemeral_resource.go +++ b/stackit/internal/services/access_token/ephemeral_resource.go @@ -3,13 +3,12 @@ package access_token import ( "context" "fmt" + "net/http" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/stackitcloud/stackit-sdk-go/core/auth" "github.com/stackitcloud/stackit-sdk-go/core/clients" - "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" @@ -25,7 +24,7 @@ func NewAccessTokenEphemeralResource() ephemeral.EphemeralResource { } type accessTokenEphemeralResource struct { - keyAuthConfig config.Configuration + roundTripper http.RoundTripper } func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { @@ -44,13 +43,7 @@ func (e *accessTokenEphemeralResource) Configure(ctx context.Context, req epheme return } - e.keyAuthConfig = config.Configuration{ - ServiceAccountKey: ephemeralProviderData.ServiceAccountKey, - ServiceAccountKeyPath: ephemeralProviderData.ServiceAccountKeyPath, - PrivateKeyPath: ephemeralProviderData.PrivateKey, - PrivateKey: ephemeralProviderData.PrivateKeyPath, - TokenCustomUrl: ephemeralProviderData.TokenCustomEndpoint, - } + e.roundTripper = ephemeralProviderData.RoundTripper } type ephemeralTokenModel struct { @@ -95,7 +88,7 @@ func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.O return } - accessToken, err := getAccessToken(&e.keyAuthConfig) + accessToken, err := getAccessToken(e.roundTripper) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Access token generation failed", err.Error()) return @@ -105,28 +98,17 @@ func (e *accessTokenEphemeralResource) Open(ctx context.Context, req ephemeral.O resp.Diagnostics.Append(resp.Result.Set(ctx, model)...) } -// getAccessToken initializes authentication using the provided config and returns an access token via the KeyFlow mechanism. -func getAccessToken(keyAuthConfig *config.Configuration) (string, error) { - roundTripper, err := auth.KeyAuth(keyAuthConfig) - if err != nil { - return "", fmt.Errorf( - "failed to initialize authentication: %w. "+ - "Make sure service account credentials are configured either in the provider configuration or via environment variables", - err, - ) - } - +// getAccessToken initializes authentication using the provided config +func getAccessToken(roundTripper http.RoundTripper) (string, error) { // Type assert to access token functionality - client, ok := roundTripper.(*clients.KeyFlow) + client, ok := roundTripper.(clients.AuthFlow) if !ok { - return "", fmt.Errorf("internal error: expected *clients.KeyFlow, but received a different implementation of http.RoundTripper") + return "", fmt.Errorf("internal error: expected *clients.AuthFlow, but received a different implementation of http.RoundTripper") } - // Retrieve the access token accessToken, err := client.GetAccessToken() if err != nil { return "", fmt.Errorf("error obtaining access token: %w", err) } - return accessToken, nil } diff --git a/stackit/internal/services/access_token/ephemeral_resource_test.go b/stackit/internal/services/access_token/ephemeral_resource_test.go index 5df2b91ce..a468ad97b 100644 --- a/stackit/internal/services/access_token/ephemeral_resource_test.go +++ b/stackit/internal/services/access_token/ephemeral_resource_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-sdk-go/core/auth" "github.com/stackitcloud/stackit-sdk-go/core/clients" "github.com/stackitcloud/stackit-sdk-go/core/config" ) @@ -23,11 +24,10 @@ var testServiceAccountKey string func startMockTokenServer() *httptest.Server { handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { resp := clients.TokenResponseBody{ - AccessToken: "mock_access_token", - RefreshToken: "mock_refresh_token", - TokenType: "Bearer", - ExpiresIn: int(time.Now().Add(time.Hour).Unix()), - Scope: "mock_scope", + AccessToken: "mock_access_token", + TokenType: "Bearer", + ExpiresIn: int(time.Now().Add(time.Hour).Unix()), + Scope: "mock_scope", } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) @@ -235,7 +235,13 @@ func TestGetAccessToken(t *testing.T) { cfg := tt.cfgFactory() - token, err := getAccessToken(cfg) + roundTripper, err := auth.SetupAuth(cfg) + if tt.expectError { + if err == nil { + t.Errorf("expected error generating round tripper for test case '%s'", tt.description) + } + } + token, err := getAccessToken(roundTripper) if tt.expectError { if err == nil { t.Errorf("expected error but got none for test case '%s'", tt.description) diff --git a/stackit/provider.go b/stackit/provider.go index aff434518..1e4043291 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -3,6 +3,7 @@ package stackit import ( "context" "fmt" + "os" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -17,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" sdkauth "github.com/stackitcloud/stackit-sdk-go/core/auth" "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oidcadapters" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features" "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/access_token" @@ -139,12 +141,16 @@ func (p *Provider) Metadata(_ context.Context, _ provider.MetadataRequest, resp type providerModel struct { CredentialsFilePath types.String `tfsdk:"credentials_path"` - ServiceAccountEmail types.String `tfsdk:"service_account_email"` // Deprecated: ServiceAccountEmail is not required and will be removed after 12th June 2025 + ServiceAccountEmail types.String `tfsdk:"service_account_email"` ServiceAccountKey types.String `tfsdk:"service_account_key"` ServiceAccountKeyPath types.String `tfsdk:"service_account_key_path"` PrivateKey types.String `tfsdk:"private_key"` PrivateKeyPath types.String `tfsdk:"private_key_path"` Token types.String `tfsdk:"service_account_token"` + WifFederatedTokenPath types.String `tfsdk:"service_account_federated_token_path"` + WifFederatedToken types.String `tfsdk:"service_account_federated_token"` + UseOIDC types.Bool `tfsdk:"use_oidc"` + // Deprecated: Use DefaultRegion instead Region types.String `tfsdk:"region"` DefaultRegion types.String `tfsdk:"default_region"` @@ -180,6 +186,8 @@ type providerModel struct { SkeCustomEndpoint types.String `tfsdk:"ske_custom_endpoint"` SqlServerFlexCustomEndpoint types.String `tfsdk:"sqlserverflex_custom_endpoint"` TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"` + OIDCTokenRequestURL types.String `tfsdk:"oidc_request_url"` + OIDCTokenRequestToken types.String `tfsdk:"oidc_request_token"` EnableBetaResources types.Bool `tfsdk:"enable_beta_resources"` Experiments types.List `tfsdk:"experiments"` @@ -188,49 +196,53 @@ type providerModel struct { // Schema defines the provider-level schema for configuration data. func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) { descriptions := map[string]string{ - "credentials_path": "Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`.", - "service_account_token": "Token used for authentication. If set, the token flow will be used to authenticate all operations.", - "service_account_key_path": "Path for the service account key used for authentication. If set, the key flow will be used to authenticate all operations.", - "service_account_key": "Service account key used for authentication. If set, the key flow will be used to authenticate all operations.", - "private_key_path": "Path for the private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key.", - "private_key": "Private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key.", - "service_account_email": "Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL. It is required if you want to use the resource manager project resource.", - "region": "Region will be used as the default location for regional services. Not all services require a region, some are global", - "default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global", - "cdn_custom_endpoint": "Custom endpoint for the CDN service", - "dns_custom_endpoint": "Custom endpoint for the DNS service", - "edgecloud_custom_endpoint": "Custom endpoint for the Edge Cloud service", - "git_custom_endpoint": "Custom endpoint for the Git service", - "iaas_custom_endpoint": "Custom endpoint for the IaaS service", - "kms_custom_endpoint": "Custom endpoint for the KMS service", - "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", - "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", - "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", - "logme_custom_endpoint": "Custom endpoint for the LogMe service", - "logs_custom_endpoint": "Custom endpoint for the Logs service", - "rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service", - "mariadb_custom_endpoint": "Custom endpoint for the MariaDB service", - "authorization_custom_endpoint": "Custom endpoint for the Membership service", - "objectstorage_custom_endpoint": "Custom endpoint for the Object Storage service", - "observability_custom_endpoint": "Custom endpoint for the Observability service", - "opensearch_custom_endpoint": "Custom endpoint for the OpenSearch service", - "postgresflex_custom_endpoint": "Custom endpoint for the PostgresFlex service", - "redis_custom_endpoint": "Custom endpoint for the Redis service", - "server_backup_custom_endpoint": "Custom endpoint for the Server Backup service", - "server_update_custom_endpoint": "Custom endpoint for the Server Update service", - "service_account_custom_endpoint": "Custom endpoint for the Service Account service", - "resourcemanager_custom_endpoint": "Custom endpoint for the Resource Manager service", - "scf_custom_endpoint": "Custom endpoint for the Cloud Foundry (SCF) service", - "secretsmanager_custom_endpoint": "Custom endpoint for the Secrets Manager service", - "sqlserverflex_custom_endpoint": "Custom endpoint for the SQL Server Flex service", - "ske_custom_endpoint": "Custom endpoint for the Kubernetes Engine (SKE) service", - "service_enablement_custom_endpoint": "Custom endpoint for the Service Enablement API", - "sfs_custom_endpoint": "Custom endpoint for the Stackit Filestorage API", - "token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow", - "enable_beta_resources": "Enable beta resources. Default is false.", - "experiments": fmt.Sprintf("Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v", strings.Join(features.AvailableExperiments, ", ")), + "credentials_path": "Path of JSON from where the credentials are read. Takes precedence over the env var `STACKIT_CREDENTIALS_PATH`. Default value is `~/.stackit/credentials.json`.", + "service_account_token": "Token used for authentication. If set, the token flow will be used to authenticate all operations.", + "service_account_key_path": "Path for the service account key used for authentication. If set, the key flow will be used to authenticate all operations.", + "service_account_key": "Service account key used for authentication. If set, the key flow will be used to authenticate all operations.", + "private_key_path": "Path for the private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key.", + "private_key": "Private RSA key used for authentication, relevant for the key flow. It takes precedence over the private key that is included in the service account key.", + "service_account_email": "Service account email. It can also be set using the environment variable STACKIT_SERVICE_ACCOUNT_EMAIL. It is required if you want to use the resource manager project resource. This value is required using OpenID Connect authentication.", + "service_account_federated_token_path": "Path for workload identity assertion. It can also be set using the environment variable STACKIT_FEDERATED_TOKEN_FILE.", + "service_account_federated_token": "The OIDC ID token for use when authenticating as a Service Account using OpenID Connect.", + "use_oidc": "Should OIDC be used for Authentication? This can also be sourced from the `STACKIT_USE_OIDC` Environment Variable. Defaults to `false`.", + "oidc_request_url": "The URL for the OIDC provider from which to request an ID token. For use when authenticating as a Service Account using OpenID Connect.", + "oidc_request_token": "The bearer token for the request to the OIDC provider. For use when authenticating as a Service Account using OpenID Connect.", + "region": "Region will be used as the default location for regional services. Not all services require a region, some are global", + "default_region": "Region will be used as the default location for regional services. Not all services require a region, some are global", + "cdn_custom_endpoint": "Custom endpoint for the CDN service", + "dns_custom_endpoint": "Custom endpoint for the DNS service", + "edgecloud_custom_endpoint": "Custom endpoint for the Edge Cloud service", + "git_custom_endpoint": "Custom endpoint for the Git service", + "iaas_custom_endpoint": "Custom endpoint for the IaaS service", + "kms_custom_endpoint": "Custom endpoint for the KMS service", + "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", + "modelserving_custom_endpoint": "Custom endpoint for the AI Model Serving service", + "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", + "logme_custom_endpoint": "Custom endpoint for the LogMe service", + "logs_custom_endpoint": "Custom endpoint for the Logs service", + "rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service", + "mariadb_custom_endpoint": "Custom endpoint for the MariaDB service", + "authorization_custom_endpoint": "Custom endpoint for the Membership service", + "objectstorage_custom_endpoint": "Custom endpoint for the Object Storage service", + "observability_custom_endpoint": "Custom endpoint for the Observability service", + "opensearch_custom_endpoint": "Custom endpoint for the OpenSearch service", + "postgresflex_custom_endpoint": "Custom endpoint for the PostgresFlex service", + "redis_custom_endpoint": "Custom endpoint for the Redis service", + "server_backup_custom_endpoint": "Custom endpoint for the Server Backup service", + "server_update_custom_endpoint": "Custom endpoint for the Server Update service", + "service_account_custom_endpoint": "Custom endpoint for the Service Account service", + "resourcemanager_custom_endpoint": "Custom endpoint for the Resource Manager service", + "scf_custom_endpoint": "Custom endpoint for the Cloud Foundry (SCF) service", + "secretsmanager_custom_endpoint": "Custom endpoint for the Secrets Manager service", + "sqlserverflex_custom_endpoint": "Custom endpoint for the SQL Server Flex service", + "ske_custom_endpoint": "Custom endpoint for the Kubernetes Engine (SKE) service", + "service_enablement_custom_endpoint": "Custom endpoint for the Service Enablement API", + "sfs_custom_endpoint": "Custom endpoint for the Stackit Filestorage API", + "token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow", + "enable_beta_resources": "Enable beta resources. Default is false.", + "experiments": fmt.Sprintf("Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v", strings.Join(features.AvailableExperiments, ", ")), } - resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "credentials_path": schema.StringAttribute{ @@ -238,9 +250,8 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Description: descriptions["credentials_path"], }, "service_account_email": schema.StringAttribute{ - Optional: true, - Description: descriptions["service_account_email"], - DeprecationMessage: "The `service_account_email` field has been deprecated because it is not required. Will be removed after June 12th 2025.", + Optional: true, + Description: descriptions["service_account_email"], }, "service_account_token": schema.StringAttribute{ Optional: true, @@ -265,6 +276,26 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["private_key_path"], }, + "service_account_federated_token_path": schema.StringAttribute{ + Optional: true, + Description: descriptions["service_account_federated_token_path"], + }, + "service_account_federated_token": schema.StringAttribute{ + Optional: true, + Description: descriptions["service_account_federated_token"], + }, + "use_oidc": schema.BoolAttribute{ + Optional: true, + Description: descriptions["use_oidc"], + }, + "oidc_request_token": schema.StringAttribute{ + Optional: true, + Description: descriptions["oidc_request_token"], + }, + "oidc_request_url": schema.StringAttribute{ + Optional: true, + Description: descriptions["oidc_request_url"], + }, "region": schema.StringAttribute{ Optional: true, Description: descriptions["region"], @@ -448,10 +479,12 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, // Configure SDK client setStringField(providerConfig.CredentialsFilePath, func(v string) { sdkConfig.CredentialsFilePath = v }) + setStringField(providerConfig.ServiceAccountEmail, func(v string) { sdkConfig.ServiceAccountEmail = v }) setStringField(providerConfig.ServiceAccountKey, func(v string) { sdkConfig.ServiceAccountKey = v }) setStringField(providerConfig.ServiceAccountKeyPath, func(v string) { sdkConfig.ServiceAccountKeyPath = v }) setStringField(providerConfig.PrivateKey, func(v string) { sdkConfig.PrivateKey = v }) setStringField(providerConfig.PrivateKeyPath, func(v string) { sdkConfig.PrivateKeyPath = v }) + setBoolField(providerConfig.UseOIDC, func(v bool) { sdkConfig.WorkloadIdentityFederation = v }) setStringField(providerConfig.Token, func(v string) { sdkConfig.Token = v }) setStringField(providerConfig.TokenCustomEndpoint, func(v string) { sdkConfig.TokenCustomUrl = v }) @@ -498,6 +531,35 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, providerData.Experiments = experimentValues } + // Workload Identity Federation via provided OIDC Token + oidc_token := "" + setStringField(providerConfig.WifFederatedToken, func(v string) { oidc_token = v }) + if sdkConfig.ServiceAccountFederatedTokenFunc == nil && oidc_token != "" { + sdkConfig.WorkloadIdentityFederation = true + sdkConfig.ServiceAccountFederatedTokenFunc = func(context.Context) (string, error) { + return oidc_token, nil + } + } + + // Workload Identity Federation via OIDC Token from file + oidc_token_path := "" + setStringField(providerConfig.WifFederatedTokenPath, func(v string) { oidc_token_path = v }) + if sdkConfig.ServiceAccountFederatedTokenFunc == nil && oidc_token_path != "" { + sdkConfig.WorkloadIdentityFederation = true + sdkConfig.ServiceAccountFederatedTokenFunc = oidcadapters.ReadJWTFromFileSystem(oidc_token_path) + } + + // Workload Identity Federation via provided OIDC Token from GitHub Actions + if sdkConfig.ServiceAccountFederatedTokenFunc == nil && getEnvBoolIfValueAbsent(providerConfig.UseOIDC, "STACKIT_USE_OIDC") { + sdkConfig.WorkloadIdentityFederation = true + // https://docs.github.com/en/actions/reference/security/oidc#methods-for-requesting-the-oidc-token + oidcReqURL := getEnvStringOrDefault(providerConfig.OIDCTokenRequestURL, "ACTIONS_ID_TOKEN_REQUEST_URL", "") + oidcReqToken := getEnvStringOrDefault(providerConfig.OIDCTokenRequestToken, "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + if oidcReqURL != "" && oidcReqToken != "" { + sdkConfig.ServiceAccountFederatedTokenFunc = oidcadapters.RequestGHOIDCToken(oidcReqURL, oidcReqToken) + } + } + roundTripper, err := sdkauth.SetupAuth(sdkConfig) if err != nil { core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring provider", fmt.Sprintf("Setting up authentication: %v", err)) @@ -516,11 +578,6 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, // Copy service account, private key credentials and custom-token endpoint to support ephemeral access token generation var ephemeralProviderData core.EphemeralProviderData ephemeralProviderData.ProviderData = providerData - setStringField(providerConfig.ServiceAccountKey, func(v string) { ephemeralProviderData.ServiceAccountKey = v }) - setStringField(providerConfig.ServiceAccountKeyPath, func(v string) { ephemeralProviderData.ServiceAccountKeyPath = v }) - setStringField(providerConfig.PrivateKey, func(v string) { ephemeralProviderData.PrivateKey = v }) - setStringField(providerConfig.PrivateKeyPath, func(v string) { ephemeralProviderData.PrivateKeyPath = v }) - setStringField(providerConfig.TokenCustomEndpoint, func(v string) { ephemeralProviderData.TokenCustomEndpoint = v }) resp.EphemeralResourceData = ephemeralProviderData } @@ -702,3 +759,34 @@ func (p *Provider) EphemeralResources(_ context.Context) []func() ephemeral.Ephe access_token.NewAccessTokenEphemeralResource, } } + +// getEnvStringOrDefault takes a Framework StringValue and a corresponding Environment Variable name and returns +// either the string value set in the StringValue if not Null / Unknown _or_ the os.GetEnv() value of the Environment +// Variable provided. If both of these are empty, an empty string defaultValue is returned. +func getEnvStringOrDefault(val types.String, envVar, defaultValue string) string { + if val.IsNull() || val.IsUnknown() { + if v := os.Getenv(envVar); v != "" { + return os.Getenv(envVar) + } + return defaultValue + } + + return val.ValueString() +} + +// getEnvBoolIfValueAbsent takes a Framework BoolValue and a corresponding Environment Variable name and returns +// one of the following in priority order: +// 1 - the Boolean value set in the BoolValue if this is not Null / Unknown. +// 2 - the boolean representation of the os.GetEnv() value of the Environment Variable provided (where anything but +// 'true' or '1' is 'false'). +// 3 - `false` in all other cases. +func getEnvBoolIfValueAbsent(val types.Bool, envVar string) bool { + if val.IsNull() || val.IsUnknown() { + v := os.Getenv(envVar) + if strings.EqualFold(v, "true") || strings.EqualFold(v, "1") { + return true + } + } + + return val.ValueBool() +} diff --git a/templates/guides/workload_identity_federation.md.tmpl b/templates/guides/workload_identity_federation.md.tmpl new file mode 100644 index 000000000..30d82f558 --- /dev/null +++ b/templates/guides/workload_identity_federation.md.tmpl @@ -0,0 +1,122 @@ +--- +page_title: "Workload Identity Federation with GitHub Actions" +--- + +# Workload Identity Federation with GitHub Actions + +Workload Identity Federation (WIF) allows you to authenticate the STACKIT Terraform provider without using long-lived Service Account keys. +This is particularly useful in CI/CD environments like **GitHub Actions**, **GitLab CI**, or **Azure DevOps**, where you can use short-lived +OIDC tokens. This guide focuses on using WIF with GitHub Actions, but the principles may apply to other CI/CD platforms that support OIDC. + +## Prerequisites + +Before using Workload Identity Federation flow, you need to: +1. [Create](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-accounts/) a **Service Account** on STACKIT. + +## Setup Workload Identity Federation + +WIF can be configured to trust any public OIDC provider following the [docs page](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-federations/#create-a-federated-identity-provider) +but for the purpose of this guide we will focus on GitHub Actions as OIDC provider. GitHub Actions supports OIDC authentication using +the public issuer "https://token.actions.githubusercontent.com" (for GH Enterprise you should check your issuer URL) and setting repository and action information +as part of the OIDC token claims. [More info here](https://docs.github.com/es/actions/concepts/security/openid-connect). + +Using this provider [repository](https://github.com/stackitcloud/terraform-provider-stackit) (stackitcloud/terraform-provider-stackit) as example and assuming that we want to +execute terraform on the main branch, we will configure the service account "Federated identity Provider" with the following configuration: +- **Provider Name**: GitHub # This is just an example, you can choose any name you want +- **Issuer URL**: https://token.actions.githubusercontent.com # This is the public issuer for GitHub Actions OIDC tokens +- **Assertions**: + - **sub**->equals->repo:stackitcloud/terraform-provider-stackit:ref:refs/heads/main # This is the repository and branch where the action will run + - **aud**->equals->sts.accounts.stackit.cloud # Mandatory value + +> Note: You can use more fine-grained assertions just adding them. More info about OIDC token claims in [GitHub](https://docs.github.com/en/actions/reference/security/oidc) + +## Provider Configuration + +To use WIF, you must set an `use_oidc` flag to `true` as well as provide an OIDC token for the exchange. While you can provide the token directly in the configuration +through `service_account_federated_token`, this is not recommended for GitHub Actions as the provider will automatically fetch the token from the GitHub OIDC. + +In addition to this, you need to set the `service_account_email` to specify which service account you want to use. This is mandatory as the provider needs to know which service account to exchange the token for. + +```hcl +provider "stackit" { + service_account_email = "terraform-example@sa.stackit.cloud" + use_oidc = true + ... # Other provider configuration +} +``` + +### Using Environment Variables (Recommended) + +In most CI/CD scenarios, the cleanest way is to set the `STACKIT_SERVICE_ACCOUNT_EMAIL` environment variable as well as `STACKIT_USE_OIDC="1"` to enable the WIF flow. This way you don't need to +change your provider configuration and the provider will automatically fetch the OIDC token and exchange it for a short-lived access token. + +## Example GitHub Actions Workflow + +> Note: To request OIDC tokens, you need to [grant this permission to the GitHub Actions workflow](https://docs.github.com/en/actions/reference/security/oidc#required-permission). + +```yaml +name: Workload Identity Federation with STACKIT + +on: + push: + branches: + - '**' + +jobs: + demo-job: + name: Workload Identity Federation with STACKIT + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Create Test Configuration + run: | + cat < main.tf + terraform { + required_providers { + stackit = { + source = "stackitcloud/stackit" + } + } + } + + provider "stackit" { + default_region = "eu01" + } + + resource "stackit_service_account" "sa" { + project_id = "e1925fbf-5272-497a-8298-1586760670de" + name = "terraform-example-ci" + } + EOF + + - name: Terraform Init + run: | + terraform init + env: + STACKIT_USE_OIDC: "1" + STACKIT_SERVICE_ACCOUNT_EMAIL: "terraform-example@sa.stackit.cloud" + + - name: Terraform Plan + run: | + terraform plan -out=tfplan + env: + STACKIT_USE_OIDC: "1" + STACKIT_SERVICE_ACCOUNT_EMAIL: "terraform-example@sa.stackit.cloud" + + - name: Terraform Apply + run: terraform apply -auto-approve tfplan + env: + STACKIT_USE_OIDC: "1" + STACKIT_SERVICE_ACCOUNT_EMAIL: "terraform-example@sa.stackit.cloud" +``` diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index c6a54e1ce..3fcec2a28 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -10,13 +10,13 @@ The STACKIT Terraform provider is the official Terraform provider to integrate a To authenticate, you will need a [service account](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/). Create it in the [STACKIT Portal](https://portal.stackit.cloud/) and assign the necessary permissions to it, e.g. `project.owner`. There are multiple ways to authenticate: -- Key flow (recommended) -- Token flow (is scheduled for deprecation and will be removed on December 17, 2025) +- Workload Identity Federation (Recommended) +- Key flow -When setting up authentication, the provider will always try to use the key flow first and search for credentials in several locations, following a specific order: +When setting up authentication, the provider will always try to use the workload identity federation flow first and search for credentials in several locations, following a specific order: -1. Explicit configuration, e.g. by setting the field `service_account_key_path` in the provider block (see example below) -2. Environment variable, e.g. by setting `STACKIT_SERVICE_ACCOUNT_KEY_PATH` +1. Explicit configuration, e.g. by setting the field `use_oidc` in the provider block (see example below) +2. Environment variable, e.g. by setting `STACKIT_USE_OIDC` 3. Credentials file The provider will check the credentials file located in the path defined by the `STACKIT_CREDENTIALS_PATH` env var, if specified, @@ -25,12 +25,23 @@ When setting up authentication, the provider will always try to use the key flow ```json { - "STACKIT_SERVICE_ACCOUNT_TOKEN": "foo_token", "STACKIT_SERVICE_ACCOUNT_KEY_PATH": "path/to/sa_key.json", "STACKIT_PRIVATE_KEY_PATH": "path/to/private_key.pem" } ``` +### Workload Identity Federation (Recommended) + + The following instructions assume that you have created a service account and assigned the necessary permissions to it, e.g. `project.owner`. + +When using Workload Identity Federation (WIF), you don't need a static service account secret or key. Instead, the provider exchanges a short-lived OIDC token (from GitHub Actions, GitLab CI, etc.) for a STACKIT access token. This is the most secure way to authenticate in CI/CD environments as it eliminates the need for long-lived secrets. + +WIF can be configured to trust any public OIDC provider following the [official documentation](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-federations/#create-a-federated-identity-provider). + +To use WIF, set the `use_oidc` flag to `true` and provide an OIDC token for the exchange. While you can provide the token directly via `service_account_federated_token`, this is **not recommended for GitHub Actions**, as the provider will automatically fetch the token from the environment. For a complete setup, see our [Workload Identity Federation guide](./guides/workload_identity_federation.md). + +In addition to this, you must set the `service_account_email` to specify which service account to impersonate. + ### Key flow The following instructions assume that you have created a service account and assigned the necessary permissions to it, e.g. `project.owner`. @@ -41,7 +52,7 @@ When creating the service account key, a new pair can be created automatically, **Optionally**, you can provide your own private key when creating the service account key, which will then require you to also provide it explicitly to the [STACKIT Terraform Provider](https://github.com/stackitcloud/terraform-provider-stackit), additionally to the service account key. Check the STACKIT Docs for an [example of how to create your own key-pair](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-keys/). -To configure the key flow, follow this steps: +To configure the key flow, follow these steps: 1. Create a service account key: @@ -83,17 +94,6 @@ To configure the key flow, follow this steps: > - setting the environment variable: `STACKIT_PRIVATE_KEY_PATH` > - setting `STACKIT_PRIVATE_KEY_PATH` in the credentials file (see above) - -### Token flow - -> Is scheduled for deprecation and will be removed on December 17, 2025. - -Using this flow is less secure since the token is long-lived. You can provide the token in several ways: - -1. Setting the field `service_account_token` in the provider -2. Setting the environment variable `STACKIT_SERVICE_ACCOUNT_TOKEN` -3. Setting it in the credentials file (see above) - # Backend configuration To keep track of your terraform state, you can configure an [S3 backend](https://developer.hashicorp.com/terraform/language/settings/backends/s3) using [STACKIT Object Storage](https://docs.stackit.cloud/products/storage/object-storage).