diff --git a/.github/workflows/tmp.yaml b/.github/workflows/tmp.yaml new file mode 100644 index 000000000..295320354 --- /dev/null +++ b/.github/workflows/tmp.yaml @@ -0,0 +1,103 @@ +# Ignore this file, it is a temporary workflow for testing purposes. + +name: Build and Validate Provider + +on: + push: + branches: + - '**' + workflow_dispatch: + +jobs: + build-and-test: + name: Build Provider & Test Plan + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: Build Provider + run: | + go build -o terraform-provider-stackit + chmod +x terraform-provider-stackit + echo "BINARY_PATH=${GITHUB_WORKSPACE}" >> $GITHUB_ENV + + - name: Configure Terraform Dev Overrides + run: | + cat < ~/.terraformrc + provider_installation { + dev_overrides { + "stackitcloud/stackit" = "${{ env.BINARY_PATH }}" + } + # Para otros providers (random, null, etc), usa el registro normal + direct {} + } + EOF + + echo "Terraform RC content:" + cat ~/.terraformrc + + - 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" + enable_beta_resources = true + service_account_custom_endpoint = "https://service-account.api.qa.stackit.cloud" + token_custom_endpoint = "https://accounts.qa.stackit.cloud/oauth/v2/token" + service_account_email = "test-idp-njf5pc1@sa.stackit.cloud" + } + + resource "stackit_service_account" "sa" { + project_id = "62675838-7758-4b99-a4eb-84b20ac8626b" + name = "terraform-wif-ci" + } + EOF + + - name: Terraform Plan + run: | + # Inicializamos sin backend para pruebas rĂ¡pidas + terraform init -backend=false + + # Ejecutamos plan. Si el provider carga y la API responde, esto pasarĂ¡. + terraform plan -out=tfplan + env: + STACKIT_USE_OIDC: "1" + + - name: Terraform Apply + run: terraform apply -auto-approve tfplan + env: + STACKIT_USE_OIDC: "1" + + - name: Terraform Destroy + if: always() + run: | + if [ -f terraform.tfstate ]; then + terraform destroy -auto-approve + else + echo "No state file found, skipping destroy." + fi + env: + STACKIT_USE_OIDC: "1" diff --git a/docs/index.md b/docs/index.md index 095ddaeb7..a85f49462 100644 --- a/docs/index.md +++ b/docs/index.md @@ -170,6 +170,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. @@ -183,7 +185,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. +- `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. @@ -192,3 +196,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/go.mod b/go.mod index 4be8fd6f9..aaf4680c8 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,8 @@ require ( golang.org/x/mod v0.31.0 ) +replace github.com/stackitcloud/stackit-sdk-go/core => github.com/JorTurFer/stackit-sdk-go/core v0.0.0-20260107172957-5e41dc32d226 + require ( github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/go.sum b/go.sum index c872fd30a..be2186660 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/JorTurFer/stackit-sdk-go/core v0.0.0-20260107172957-5e41dc32d226 h1:AgqbJ2DrS+Be2Qrgzu2cQYdCFpktG6oo3cFK6XHpBrA= +github.com/JorTurFer/stackit-sdk-go/core v0.0.0-20260107172957-5e41dc32d226/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= @@ -149,8 +151,6 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/stackitcloud/stackit-sdk-go/core v0.20.1 h1:odiuhhRXmxvEvnVTeZSN9u98edvw2Cd3DcnkepncP3M= -github.com/stackitcloud/stackit-sdk-go/core v0.20.1/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 h1:7ZKd3b+E/R4TEVShLTXxx5FrsuDuJBOyuVOuKTMa4mo= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0/go.mod h1:/FoXa6hF77Gv8brrvLBCKa5ie1Xy9xn39yfHwaln9Tw= github.com/stackitcloud/stackit-sdk-go/services/cdn v1.6.0 h1:Q+qIdejeMsYMkbtVoI9BpGlKGdSVFRBhH/zj44SP8TM= 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 688335ec0..2f5899d40 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 d93b2b3e1..a411038dc 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -2,7 +2,12 @@ package stackit import ( "context" + "encoding/json" "fmt" + "io" + "net/http" + "net/url" + "os" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" @@ -17,6 +22,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/utils" "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" @@ -129,12 +135,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"` @@ -168,6 +178,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"` @@ -176,45 +188,50 @@ 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", - "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", - "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.", + "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", + "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", + "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{ @@ -224,9 +241,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, @@ -251,6 +267,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"], @@ -426,10 +462,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 }) @@ -474,6 +512,39 @@ 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() (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 = func() (string, error) { + return utils.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 = func() (string, error) { + return githubAssertion(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)) @@ -489,11 +560,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 providerData.Version = p.version @@ -662,3 +728,81 @@ func (p *Provider) EphemeralResources(_ context.Context) []func() ephemeral.Ephe access_token.NewAccessTokenEphemeralResource, } } + +func githubAssertion(oidc_request_url, oidc_request_token string) (string, error) { + req, err := http.NewRequest(http.MethodGet, oidc_request_url, http.NoBody) + if err != nil { + return "", fmt.Errorf("githubAssertion: failed to build request: %w", err) + } + + query, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + return "", fmt.Errorf("githubAssertion: cannot parse URL query") + } + + if query.Get("audience") == "" { + query.Set("audience", "sts.accounts.stackit.cloud") + req.URL.RawQuery = query.Encode() + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oidc_request_token)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("githubAssertion: cannot request token: %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("githubAssertion: cannot parse response: %w", err) + } + + if c := resp.StatusCode; c < 200 || c > 299 { + return "", fmt.Errorf("githubAssertion: received HTTP status %d with response: %s", resp.StatusCode, body) + } + + var tokenRes struct { + Value string `json:"value"` + } + if err := json.Unmarshal(body, &tokenRes); err != nil { + return "", fmt.Errorf("githubAssertion: cannot unmarshal response: %w", err) + } + + return tokenRes.Value, nil +} + +// 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() +}