diff --git a/docs/guides/setting_up_encrypted_volumes_for_ske.md b/docs/guides/setting_up_encrypted_volumes_for_ske.md new file mode 100644 index 000000000..0be33e510 --- /dev/null +++ b/docs/guides/setting_up_encrypted_volumes_for_ske.md @@ -0,0 +1,187 @@ +--- +page_title: "Setting up Encrypted Volumes for STACKIT Kubernetes Engine (SKE)" +--- + +# Setting up Encrypted Volumes for STACKIT Kubernetes Engine (SKE) + +~> This guide assumes that your project or organization has been enabled for a preview version of the STACKIT CSI Driver. If you wish to use encrypted volumes, please contact your account manager. + +## Overview + +This guide demonstrates how to roll out an encrypted storage class for SKE using the STACKIT Key Management Service (KMS). To achieve this, we use a **Service Account Impersonation** (Act-As) pattern. This allows the internal SKE service account to perform encryption and decryption tasks on behalf of a user-managed service account that has been granted access to your KMS keys. + +## Steps + +### 1. Configure the SKE Cluster + +Create a standard SKE cluster. We also generate a kubeconfig dynamically to allow the `kubernetes` provider to interact with the cluster within the same Terraform execution. + +```hcl +resource "stackit_ske_cluster" "default" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "ske-enc-vol" + kubernetes_version_min = "1.33" + + node_pools = [{ + name = "standard" + machine_type = "c2i.4" + minimum = 1 + maximum = 3 + availability_zones = ["eu01-1"] + os_name = "flatcar" + volume_size = 32 + }] +} + +resource "stackit_ske_kubeconfig" "default" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + cluster_name = stackit_ske_cluster.default.name + refresh = true +} +``` + + +### 2. Identify the Internal SKE Service Account + +Each STACKIT project with a SKE Cluster deployed has a dedicated, internal service account used by SKE. We need to look this up to grant it permissions in a later step. + +```hcl +data "stackit_service_accounts" "ske_internal" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@ske.sa.stackit.cloud" + + depends_on = [stackit_ske_cluster.default] +} +``` + +### 3. Setup KMS Infrastructure + +Define the Keyring and the specific Key that will be used to encrypt the block storage volumes. + +```hcl +resource "stackit_kms_keyring" "encryption" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "ske-volume-keyring" +} + +resource "stackit_kms_key" "volume_key" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + keyring_id = stackit_kms_keyring.encryption.keyring_id + display_name = "volume-encryption-key" + protection = "software" + algorithm = "aes_256_gcm" + purpose = "symmetric_encrypt_decrypt" +} +``` + +### 4. Configure Identity and Permissions (Act-As) + +This is the most critical part of the setup. We create a **manager** service account that holds the KMS permissions, and then authorize the SKE internal service account to **Act-As** (impersonate) that manager. + +```hcl +# Create the service account that 'owns' the KMS access +resource "stackit_service_account" "kms_manager" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "volume-encryptor" +} + +# Grant the 'kms.admin' role to the manager service-account +resource "stackit_authorization_project_role_assignment" "kms_user" { + // in this case the STACKIT project_id + resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + role = "kms.admin" + subject = stackit_service_account.kms_manager.email +} + +# Authorize the internal SKE account to impersonate the kms manager service-account (Act-As) +resource "stackit_authorization_service_account_role_assignment" "ske_impersonation" { + resource_id = stackit_service_account.kms_manager.service_account_id + role = "user" + subject = data.stackit_service_accounts.ske_internal.items[0].email +} +``` + +### 5. Create the Encrypted Storage Class in Kubernetes + +Define the `kubernetes_storage_class`. We pass the IDs of the KMS resources and the email of our manager service account into the parameters. + +```hcl +resource "kubernetes_storage_class_v1" "encrypted_premium" { + metadata { + name = "stackit-encrypted-premium" + } + + storage_provisioner = "block-storage.csi.stackit.cloud" + reclaim_policy = "Delete" + allow_volume_expansion = true + volume_binding_mode = "WaitForFirstConsumer" + + parameters = { + type = "storage_premium_perf6" + encrypted = "true" + kmsKeyID = stackit_kms_key.volume_key.key_id + kmsKeyringID = stackit_kms_keyring.encryption.keyring_id + kmsProjectID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + kmsKeyVersion = "1" + kmsServiceAccount = stackit_service_account.kms_manager.email + } + + depends_on = [ + stackit_authorization_service_account_role_assignment.ske_impersonation, + stackit_authorization_project_role_assignment.kms_user + ] +} +``` + +### 6. Verify with a Persistent Volume Claim (PVC) + +You can now create a PVC using the new storage class. When a pod claims this volume, the STACKIT CSI driver will automatically use the KMS key to provide an encrypted volume. + +```hcl +resource "kubernetes_persistent_volume_claim_v1" "test_pvc" { + metadata { + name = "test-encryption-pvc" + } + + spec { + access_modes = ["ReadWriteOnce"] + + resources { + requests = { + storage = "10Gi" + } + } + + storage_class_name = kubernetes_storage_class_v1.encrypted_premium.metadata[0].name + } +} +``` + +### 7. Create a Pod to Consume the Volume + +```hcl +resource "kubernetes_pod_v1" "test_app" { + metadata { + name = "encrypted-volume-test" + } + + spec { + container { + image = "nginx:latest" + name = "web-server" + + volume_mount { + mount_path = "/usr/share/nginx/html" + name = "data-volume" + } + } + + volume { + name = "data-volume" + persistent_volume_claim { + claim_name = "test-encryption-pvc" + } + } + } +} +``` \ No newline at end of file diff --git a/docs/resources/authorization_folder_role_assignment.md b/docs/resources/authorization_folder_role_assignment.md index a1495e865..fb479a6c2 100644 --- a/docs/resources/authorization_folder_role_assignment.md +++ b/docs/resources/authorization_folder_role_assignment.md @@ -41,7 +41,7 @@ import { ### Required -- `resource_id` (String) folder Resource to assign the role to. +- `resource_id` (String) Folder Resource to assign the role to. - `role` (String) Role to be assigned. Available roles can be queried using stackit-cli: `stackit curl https://authorization.api.stackit.cloud/v2/permissions` - `subject` (String) Identifier of user, service account or client. Usually email address or name in case of clients. All letters must be lowercased. diff --git a/docs/resources/authorization_organization_role_assignment.md b/docs/resources/authorization_organization_role_assignment.md index 46174f1cc..5cc52d80b 100644 --- a/docs/resources/authorization_organization_role_assignment.md +++ b/docs/resources/authorization_organization_role_assignment.md @@ -34,7 +34,7 @@ import { ### Required -- `resource_id` (String) organization Resource to assign the role to. +- `resource_id` (String) Organization Resource to assign the role to. - `role` (String) Role to be assigned. Available roles can be queried using stackit-cli: `stackit curl https://authorization.api.stackit.cloud/v2/permissions` - `subject` (String) Identifier of user, service account or client. Usually email address or name in case of clients. All letters must be lowercased. diff --git a/docs/resources/authorization_project_role_assignment.md b/docs/resources/authorization_project_role_assignment.md index 6e7bd0c1c..7073fd5ec 100644 --- a/docs/resources/authorization_project_role_assignment.md +++ b/docs/resources/authorization_project_role_assignment.md @@ -41,7 +41,7 @@ import { ### Required -- `resource_id` (String) project Resource to assign the role to. +- `resource_id` (String) Project Resource to assign the role to. - `role` (String) Role to be assigned. Available roles can be queried using stackit-cli: `stackit curl https://authorization.api.stackit.cloud/v2/permissions` - `subject` (String) Identifier of user, service account or client. Usually email address or name in case of clients. All letters must be lowercased. diff --git a/docs/resources/authorization_service_account_role_assignment.md b/docs/resources/authorization_service_account_role_assignment.md new file mode 100644 index 000000000..d084e04a3 --- /dev/null +++ b/docs/resources/authorization_service_account_role_assignment.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_authorization_service_account_role_assignment Resource - stackit" +subcategory: "" +description: |- + Service-account Role Assignment resource schema. + ~> Important: Use this resource to grant 'Act-As' permissions. This allows a service-account (the subject) to impersonate the target Service Account. A common example is authorizing the SKE Service Account to act as a project-specific Service Account to access APIs. + ~> This resource is part of the iam experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion. +--- + +# stackit_authorization_service_account_role_assignment (Resource) + +Service-account Role Assignment resource schema. + +~> **Important:** Use this resource to grant 'Act-As' permissions. This allows a service-account (the `subject`) to impersonate the target Service Account. A common example is authorizing the SKE Service Account to act as a project-specific Service Account to access APIs. + +~> This resource is part of the iam experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion. + +## Example Usage + +```terraform +data "stackit_service_accounts" "ske_sa_suffix" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@ske.sa.stackit.cloud" +} + +resource "stackit_service_account" "iam" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "kms" +} + +resource "stackit_authorization_project_role_assignment" "pr_sa" { + resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + role = "kms.admin" + subject = stackit_service_account.iam.email +} + +// Assign the Act-As permissions to the previously created ServiceAccount. +// The SKE ServiceAccount is now authorized to access KMS upon the behalf of stackit_service_account.iam +resource "stackit_authorization_service_account_role_assignment" "sa" { + resource_id = stackit_service_account.iam.service_account_id + role = "user" + subject = data.stackit_service_accounts.ske_sa_suffix.items.0.email +} + +# Only use the import statement, if you want to import an existing service account assignment +import { + to = stackit_authorization_service_account_assignment.sa + id = "${var.resource_id},${var.service_account_assignment_role},${var.service_account_assignment_subject}" +} +``` + + +## Schema + +### Required + +- `resource_id` (String) Service-account Resource to assign the role to. +- `role` (String) Role to be assigned. Available roles can be queried using stackit-cli: `stackit curl https://authorization.api.stackit.cloud/v2/permissions` +- `subject` (String) Identifier of user, service account or client. Usually email address or name in case of clients + +### Read-Only + +- `id` (String) Terraform's internal resource identifier. It is structured as "`resource_id`,`role`,`subject`". diff --git a/examples/resources/stackit_authorization_service_account_role_assignment/resource.tf b/examples/resources/stackit_authorization_service_account_role_assignment/resource.tf new file mode 100644 index 000000000..7fbc95577 --- /dev/null +++ b/examples/resources/stackit_authorization_service_account_role_assignment/resource.tf @@ -0,0 +1,29 @@ +data "stackit_service_accounts" "ske_sa_suffix" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@ske.sa.stackit.cloud" +} + +resource "stackit_service_account" "iam" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "kms" +} + +resource "stackit_authorization_project_role_assignment" "pr_sa" { + resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + role = "kms.admin" + subject = stackit_service_account.iam.email +} + +// Assign the Act-As permissions to the previously created ServiceAccount. +// The SKE ServiceAccount is now authorized to access KMS upon the behalf of stackit_service_account.iam +resource "stackit_authorization_service_account_role_assignment" "sa" { + resource_id = stackit_service_account.iam.service_account_id + role = "user" + subject = data.stackit_service_accounts.ske_sa_suffix.items.0.email +} + +# Only use the import statement, if you want to import an existing service account assignment +import { + to = stackit_authorization_service_account_assignment.sa + id = "${var.resource_id},${var.service_account_assignment_role},${var.service_account_assignment_subject}" +} diff --git a/stackit/internal/services/authorization/authorization_acc_test.go b/stackit/internal/services/authorization/authorization_acc_test.go index b5a7718b5..274d7f0d6 100644 --- a/stackit/internal/services/authorization/authorization_acc_test.go +++ b/stackit/internal/services/authorization/authorization_acc_test.go @@ -2,10 +2,12 @@ package authorization_test import ( "context" + "errors" "fmt" "maps" "regexp" "strings" + "sync" "testing" _ "embed" @@ -41,8 +43,14 @@ var ( //go:embed testdata/resource-org-role-assignment-duplicate.tf resourceOrgRoleAssignmentDuplicate string - //go:embed testdata/custom-role.tf - customRole string + //go:embed testdata/resource-custom-role.tf + resourceCustomRole string + + //go:embed testdata/resource-service-account-role-assignment.tf + resourceServiceAccountRoleAssignment string + + //go:embed testdata/resource-service-account-role-assignment-duplicate.tf + resourceServiceAccountRoleAssignmentDuplicate string ) var ( @@ -90,6 +98,13 @@ var testConfigVarsCustomRoleUpdated = config.Variables{ "role_permissions_0": config.StringVariable("iam.role.edit"), } +var testConfigVarsServiceAccountRoleAssignment = config.Variables{ + "project_id": config.StringVariable(testutil.ProjectId), + "name": config.StringVariable(fmt.Sprintf("sa-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum))), + "act_as_name": config.StringVariable(fmt.Sprintf("act-%s", acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum))), + "role": config.StringVariable("user"), +} + func testConfigVarsProjectRoleAssignmentUpdated() config.Variables { tempConfig := make(config.Variables, len(testConfigVarsProjectRoleAssignment)) maps.Copy(tempConfig, testConfigVarsProjectRoleAssignment) @@ -114,12 +129,20 @@ func testConfigVarsOrgRoleAssignmentUpdated() config.Variables { return tempConfig } +func testConfigVarsServiceAccountRoleAssignmentUpdated() config.Variables { + tempConfig := make(config.Variables, len(testConfigVarsServiceAccountRoleAssignment)) + maps.Copy(tempConfig, testConfigVarsServiceAccountRoleAssignment) + + tempConfig["role"] = config.StringVariable("owner") + return tempConfig +} + func TestAccProjectRoleAssignmentResource(t *testing.T) { t.Log("Testing project role assignment resource") resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, // deleting project will also delete project role assignments - CheckDestroy: testAccCheckResourceManagerProjectsDestroy, + CheckDestroy: testAccCheckDestroy, Steps: []resource.TestStep{ // Creation { @@ -205,7 +228,7 @@ func TestAccFolderRoleAssignmentResource(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, // deleting folder will also delete project role assignments - CheckDestroy: testAccCheckResourceManagerFoldersDestroy, + CheckDestroy: testAccCheckDestroy, Steps: []resource.TestStep{ // Creation { @@ -290,7 +313,7 @@ func TestAccOrgRoleAssignmentResource(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, // only deleting the role assignment of org level - CheckDestroy: testAccCheckOrganizationRoleAssignmentDestroy, + CheckDestroy: testAccCheckDestroy, Steps: []resource.TestStep{ // Creation { @@ -352,6 +375,182 @@ func TestAccOrgRoleAssignmentResource(t *testing.T) { }) } +func TestAccServiceAccountRoleAssignmentResource(t *testing.T) { + t.Log("Testing service-account (act-as) role assignment resource") + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDestroy, + Steps: []resource.TestStep{ + // Creation + { + ConfigVariables: testConfigVarsServiceAccountRoleAssignment, + Config: testutil.AuthorizationProviderConfig() + "\n" + resourceServiceAccountRoleAssignment, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("stackit_authorization_service_account_role_assignment.sa", "resource_id"), + resource.TestCheckResourceAttrSet("stackit_authorization_service_account_role_assignment.sa", "id"), + resource.TestCheckResourceAttr("stackit_authorization_service_account_role_assignment.sa", "role", testutil.ConvertConfigVariable(testConfigVarsServiceAccountRoleAssignment["role"])), + resource.TestCheckResourceAttrPair( + "stackit_service_account.act_as", "email", + "stackit_authorization_service_account_role_assignment.sa", "subject", + ), + ), + }, + // Import + { + ConfigVariables: testConfigVarsServiceAccountRoleAssignment, + ResourceName: "stackit_authorization_service_account_role_assignment.sa", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_authorization_service_account_role_assignment.sa"] + if !ok { + return "", fmt.Errorf("couldn't find resource") + } + return fmt.Sprintf("%s,%s,%s", r.Primary.Attributes["resource_id"], r.Primary.Attributes["role"], r.Primary.Attributes["subject"]), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testConfigVarsServiceAccountRoleAssignmentUpdated(), + Config: testutil.AuthorizationProviderConfig() + "\n" + resourceServiceAccountRoleAssignment, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("stackit_authorization_service_account_role_assignment.sa", "resource_id"), + resource.TestCheckResourceAttrSet("stackit_authorization_service_account_role_assignment.sa", "id"), + resource.TestCheckResourceAttr("stackit_authorization_service_account_role_assignment.sa", "role", testutil.ConvertConfigVariable(testConfigVarsServiceAccountRoleAssignmentUpdated()["role"])), + resource.TestCheckResourceAttrPair( + "stackit_service_account.act_as", "email", + "stackit_authorization_service_account_role_assignment.sa", "subject", + ), + ), + }, + // Duplicate assignment should fail + { + ConfigVariables: testConfigVarsServiceAccountRoleAssignmentUpdated(), + Config: testutil.AuthorizationProviderConfig() + "\n" + resourceServiceAccountRoleAssignmentDuplicate, + ExpectError: regexp.MustCompile(`Error while checking for duplicate role assignments`), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func TestAccProjectCustomRoleResource(t *testing.T) { + t.Log("Testing org role assignment resource") + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + ConfigVariables: testConfigVarsCustomRole, + Config: testutil.AuthorizationProviderConfig() + resourceCustomRole, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "resource_id", testutil.ConvertConfigVariable(testConfigVarsCustomRole["project_id"])), + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "name", testutil.ConvertConfigVariable(testConfigVarsCustomRole["role_name"])), + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "description", testutil.ConvertConfigVariable(testConfigVarsCustomRole["role_description"])), + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "permissions.#", "1"), + resource.TestCheckTypeSetElemAttr("stackit_authorization_project_custom_role.custom_role", "permissions.*", testutil.ConvertConfigVariable(testConfigVarsCustomRole["role_permissions_0"])), + resource.TestCheckResourceAttrSet("stackit_authorization_project_custom_role.custom_role", "role_id"), + ), + }, + // Data source + { + ConfigVariables: testConfigVarsCustomRole, + Config: fmt.Sprintf(` + %s + + data "stackit_authorization_project_custom_role" "custom_role" { + resource_id = stackit_authorization_project_custom_role.custom_role.resource_id + role_id = stackit_authorization_project_custom_role.custom_role.role_id + } + `, + testutil.AuthorizationProviderConfig()+resourceCustomRole, + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("data.stackit_authorization_project_custom_role.custom_role", "resource_id", testutil.ConvertConfigVariable(testConfigVarsCustomRole["project_id"])), + resource.TestCheckResourceAttrPair( + "stackit_authorization_project_custom_role.custom_role", "resource_id", + "data.stackit_authorization_project_custom_role.custom_role", "resource_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_authorization_project_custom_role.custom_role", "role_id", + "data.stackit_authorization_project_custom_role.custom_role", "role_id", + ), + resource.TestCheckResourceAttrPair( + "stackit_authorization_project_custom_role.custom_role", "name", + "data.stackit_authorization_project_custom_role.custom_role", "name", + ), + resource.TestCheckResourceAttrPair( + "stackit_authorization_project_custom_role.custom_role", "description", + "data.stackit_authorization_project_custom_role.custom_role", "description", + ), + resource.TestCheckResourceAttrPair( + "stackit_authorization_project_custom_role.custom_role", "permissions", + "data.stackit_authorization_project_custom_role.custom_role", "permissions", + ), + ), + }, + // Import + { + ConfigVariables: testConfigVarsCustomRole, + ResourceName: "stackit_authorization_project_custom_role.custom_role", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + r, ok := s.RootModule().Resources["stackit_authorization_project_custom_role.custom_role"] + if !ok { + return "", fmt.Errorf("couldn't find resource stackit_authorization_project_custom_role.custom_role") + } + roleId, ok := r.Primary.Attributes["role_id"] + if !ok { + return "", fmt.Errorf("couldn't find attribute role_id") + } + + return fmt.Sprintf("%s,%s", testutil.ProjectId, roleId), nil + }, + ImportState: true, + ImportStateVerify: true, + }, + // Update + { + ConfigVariables: testConfigVarsCustomRoleUpdated, + Config: testutil.AuthorizationProviderConfig() + resourceCustomRole, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "resource_id", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["project_id"])), + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "name", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["role_name"])), + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "description", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["role_description"])), + resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom_role", "permissions.#", "1"), + resource.TestCheckTypeSetElemAttr("stackit_authorization_project_custom_role.custom_role", "permissions.*", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["role_permissions_0"])), + resource.TestCheckResourceAttrSet("stackit_authorization_project_custom_role.custom_role", "role_id"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckDestroy(s *terraform.State) error { + checkFunctions := []func(s *terraform.State) error{ + testAccCheckServiceAccountRoleAssignmentDestroy, + testAccCheckResourceManagerProjectsDestroy, + testAccCheckResourceManagerFoldersDestroy, + testAccCheckOrganizationRoleAssignmentDestroy, + } + + var errs []error + + wg := sync.WaitGroup{} + wg.Add(len(checkFunctions)) + + for _, f := range checkFunctions { + go func() { + err := f(s) + if err != nil { + errs = append(errs, err) + } + wg.Done() + }() + } + wg.Wait() + return errors.Join(errs...) +} + func testAccCheckResourceManagerProjectsDestroy(s *terraform.State) error { ctx := context.Background() var client *resourcemanager.APIClient @@ -504,93 +703,46 @@ func testAccCheckOrganizationRoleAssignmentDestroy(s *terraform.State) error { return nil } -func TestAccProjectCustomRoleResource(t *testing.T) { - t.Log("Testing org role assignment resource") - resource.Test(t, resource.TestCase{ - ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, - Steps: []resource.TestStep{ - { - ConfigVariables: testConfigVarsCustomRole, - Config: testutil.AuthorizationProviderConfig() + customRole, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "resource_id", testutil.ConvertConfigVariable(testConfigVarsCustomRole["project_id"])), - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "name", testutil.ConvertConfigVariable(testConfigVarsCustomRole["role_name"])), - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "description", testutil.ConvertConfigVariable(testConfigVarsCustomRole["role_description"])), - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "permissions.#", "1"), - resource.TestCheckTypeSetElemAttr("stackit_authorization_project_custom_role.custom-role", "permissions.*", testutil.ConvertConfigVariable(testConfigVarsCustomRole["role_permissions_0"])), - resource.TestCheckResourceAttrSet("stackit_authorization_project_custom_role.custom-role", "role_id"), - ), - }, - // Data source - { - ConfigVariables: testConfigVarsCustomRole, - Config: fmt.Sprintf(` - %s +func testAccCheckServiceAccountRoleAssignmentDestroy(s *terraform.State) error { + ctx := context.Background() + var client *authorization.APIClient + var err error + if testutil.AuthorizationCustomEndpoint == "" { + client, err = authorization.NewAPIClient() + } else { + client, err = authorization.NewAPIClient( + stackitSdkConfig.WithEndpoint(testutil.AuthorizationCustomEndpoint), + ) + } + if err != nil { + return fmt.Errorf("creating client: %w", err) + } - data "stackit_authorization_project_custom_role" "custom-role" { - resource_id = stackit_authorization_project_custom_role.custom-role.resource_id - role_id = stackit_authorization_project_custom_role.custom-role.role_id - } - `, - testutil.AuthorizationProviderConfig()+customRole, - ), - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("data.stackit_authorization_project_custom_role.custom-role", "resource_id", testutil.ConvertConfigVariable(testConfigVarsCustomRole["project_id"])), - resource.TestCheckResourceAttrPair( - "stackit_authorization_project_custom_role.custom-role", "resource_id", - "data.stackit_authorization_project_custom_role.custom-role", "resource_id", - ), - resource.TestCheckResourceAttrPair( - "stackit_authorization_project_custom_role.custom-role", "role_id", - "data.stackit_authorization_project_custom_role.custom-role", "role_id", - ), - resource.TestCheckResourceAttrPair( - "stackit_authorization_project_custom_role.custom-role", "name", - "data.stackit_authorization_project_custom_role.custom-role", "name", - ), - resource.TestCheckResourceAttrPair( - "stackit_authorization_project_custom_role.custom-role", "description", - "data.stackit_authorization_project_custom_role.custom-role", "description", - ), - resource.TestCheckResourceAttrPair( - "stackit_authorization_project_custom_role.custom-role", "permissions", - "data.stackit_authorization_project_custom_role.custom-role", "permissions", - ), - ), - }, - // Import - { - ConfigVariables: testConfigVarsCustomRole, - ResourceName: "stackit_authorization_project_custom_role.custom-role", - ImportStateIdFunc: func(s *terraform.State) (string, error) { - r, ok := s.RootModule().Resources["stackit_authorization_project_custom_role.custom-role"] - if !ok { - return "", fmt.Errorf("couldn't find resource stackit_authorization_project_custom_role.custom-role") - } - roleId, ok := r.Primary.Attributes["role_id"] - if !ok { - return "", fmt.Errorf("couldn't find attribute role_id") - } + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_authorization_service_account_role_assignment" { + continue + } - return fmt.Sprintf("%s,%s", testutil.ProjectId, roleId), nil + terraformId := strings.Split(rs.Primary.ID, ",") + if len(terraformId) != 3 { + continue + } + + resourceId := terraformId[0] + payload := authorization.RemoveMembersPayload{ + ResourceType: utils.Ptr("service-account"), + Members: &[]authorization.Member{ + { + Role: utils.Ptr(terraformId[1]), + Subject: utils.Ptr(terraformId[2]), }, - ImportState: true, - ImportStateVerify: true, - }, - // Update - { - ConfigVariables: testConfigVarsCustomRoleUpdated, - Config: testutil.AuthorizationProviderConfig() + customRole, - Check: resource.ComposeAggregateTestCheckFunc( - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "resource_id", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["project_id"])), - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "name", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["role_name"])), - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "description", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["role_description"])), - resource.TestCheckResourceAttr("stackit_authorization_project_custom_role.custom-role", "permissions.#", "1"), - resource.TestCheckTypeSetElemAttr("stackit_authorization_project_custom_role.custom-role", "permissions.*", testutil.ConvertConfigVariable(testConfigVarsCustomRoleUpdated["role_permissions_0"])), - resource.TestCheckResourceAttrSet("stackit_authorization_project_custom_role.custom-role", "role_id"), - ), }, - // Deletion is done by the framework implicitly - }, - }) + } + + _, err = client.RemoveMembers(ctx, resourceId).RemoveMembersPayload(payload).Execute() + if err != nil && !strings.Contains(err.Error(), "400") { + return fmt.Errorf("destroying assignment %s: %w", rs.Primary.ID, err) + } + } + return nil } diff --git a/stackit/internal/services/authorization/roleassignments/resource.go b/stackit/internal/services/authorization/roleassignments/resource.go index 0925d17c9..46259617f 100644 --- a/stackit/internal/services/authorization/roleassignments/resource.go +++ b/stackit/internal/services/authorization/roleassignments/resource.go @@ -29,6 +29,7 @@ var roleTargets = []string{ "project", "organization", "folder", + "service-account", } // Ensure the implementation satisfies the expected interfaces. @@ -69,7 +70,7 @@ type roleAssignmentResource struct { // Metadata returns the resource type name. func (r *roleAssignmentResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { - resp.TypeName = fmt.Sprintf("%s_authorization_%s_role_assignment", req.ProviderTypeName, r.apiName) + resp.TypeName = fmt.Sprintf("%s_authorization_%s_role_assignment", req.ProviderTypeName, strings.Replace(r.apiName, "-", "_", -1)) } // Configure adds the provider configured client to the resource. @@ -94,10 +95,25 @@ func (r *roleAssignmentResource) Configure(ctx context.Context, req resource.Con // Schema defines the schema for the resource. func (r *roleAssignmentResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + // Capitalize the first letter for display (e.g. "Project", "Service-account") + resourceTitle := fmt.Sprintf("%s%s", strings.ToUpper(r.apiName[:1]), strings.ToLower(r.apiName[1:])) + + descriptionText := fmt.Sprintf("%s Role Assignment resource schema.", resourceTitle) + + // Append specific use case note for service-account + if r.apiName == "service-account" { + descriptionText = fmt.Sprintf( + "%s\n\n~> **Important:** Use this resource to grant 'Act-As' permissions. "+ + "This allows a service-account (the `subject`) to impersonate the target Service Account. "+ + "A common example is authorizing the SKE Service Account to act as a project-specific Service Account to access APIs.", + descriptionText, + ) + } + descriptions := map[string]string{ - "main": features.AddExperimentDescription(fmt.Sprintf("%s Role Assignment resource schema.", fmt.Sprintf("%s%s", strings.ToUpper(r.apiName[:1]), strings.ToLower(r.apiName[1:]))), features.IamExperiment, core.Resource), + "main": features.AddExperimentDescription(descriptionText, features.IamExperiment, core.Resource), "id": "Terraform's internal resource identifier. It is structured as \"`resource_id`,`role`,`subject`\".", - "resource_id": fmt.Sprintf("%s Resource to assign the role to.", r.apiName), + "resource_id": fmt.Sprintf("%s Resource to assign the role to.", resourceTitle), "role": "Role to be assigned. Available roles can be queried using stackit-cli: `stackit curl https://authorization.api.stackit.cloud/v2/permissions`", "subject": "Identifier of user, service account or client. Usually email address or name in case of clients. All letters must be lowercased.", } diff --git a/stackit/internal/services/authorization/testdata/custom-role.tf b/stackit/internal/services/authorization/testdata/resource-custom-role.tf similarity index 80% rename from stackit/internal/services/authorization/testdata/custom-role.tf rename to stackit/internal/services/authorization/testdata/resource-custom-role.tf index 33fc59e84..b84f3ce1f 100644 --- a/stackit/internal/services/authorization/testdata/custom-role.tf +++ b/stackit/internal/services/authorization/testdata/resource-custom-role.tf @@ -4,7 +4,7 @@ variable "role_name" {} variable "role_description" {} variable "role_permissions_0" {} -resource "stackit_authorization_project_custom_role" "custom-role" { +resource "stackit_authorization_project_custom_role" "custom_role" { resource_id = var.project_id name = var.role_name description = var.role_description diff --git a/stackit/internal/services/authorization/testdata/resource-service-account-role-assignment-duplicate.tf b/stackit/internal/services/authorization/testdata/resource-service-account-role-assignment-duplicate.tf new file mode 100644 index 000000000..364f6dab4 --- /dev/null +++ b/stackit/internal/services/authorization/testdata/resource-service-account-role-assignment-duplicate.tf @@ -0,0 +1,33 @@ +variable "name" {} +variable "act_as_name" {} +variable "project_id" {} +variable "role" {} + +resource "stackit_service_account" "iam" { + project_id = var.project_id + name = var.name +} + +resource "stackit_authorization_project_role_assignment" "pr_sa" { + resource_id = var.project_id + role = "editor" + subject = stackit_service_account.iam.email +} + +resource "stackit_service_account" "act_as" { + project_id = var.project_id + name = var.act_as_name +} + +resource "stackit_authorization_service_account_role_assignment" "sa" { + resource_id = stackit_service_account.iam.service_account_id + role = var.role + subject = stackit_service_account.act_as.email +} + +# Duplicate resource to trigger the validation error +resource "stackit_authorization_service_account_role_assignment" "sa_dup" { + resource_id = stackit_service_account.iam.service_account_id + role = var.role + subject = stackit_service_account.act_as.email +} diff --git a/stackit/internal/services/authorization/testdata/resource-service-account-role-assignment.tf b/stackit/internal/services/authorization/testdata/resource-service-account-role-assignment.tf new file mode 100644 index 000000000..76b14bb07 --- /dev/null +++ b/stackit/internal/services/authorization/testdata/resource-service-account-role-assignment.tf @@ -0,0 +1,26 @@ +variable "name" {} +variable "act_as_name" {} +variable "project_id" {} +variable "role" {} + +resource "stackit_service_account" "iam" { + project_id = var.project_id + name = var.name +} + +resource "stackit_authorization_project_role_assignment" "pr_sa" { + resource_id = var.project_id + role = "editor" + subject = stackit_service_account.iam.email +} + +resource "stackit_service_account" "act_as" { + project_id = var.project_id + name = var.act_as_name +} + +resource "stackit_authorization_service_account_role_assignment" "sa" { + resource_id = stackit_service_account.iam.service_account_id + role = var.role + subject = stackit_service_account.act_as.email +} \ No newline at end of file diff --git a/templates/guides/import_resources.md b/templates/guides/import_resources.md.tmpl similarity index 100% rename from templates/guides/import_resources.md rename to templates/guides/import_resources.md.tmpl diff --git a/templates/guides/setting_up_encrypted_volumes_for_ske.md.tmpl b/templates/guides/setting_up_encrypted_volumes_for_ske.md.tmpl new file mode 100644 index 000000000..0be33e510 --- /dev/null +++ b/templates/guides/setting_up_encrypted_volumes_for_ske.md.tmpl @@ -0,0 +1,187 @@ +--- +page_title: "Setting up Encrypted Volumes for STACKIT Kubernetes Engine (SKE)" +--- + +# Setting up Encrypted Volumes for STACKIT Kubernetes Engine (SKE) + +~> This guide assumes that your project or organization has been enabled for a preview version of the STACKIT CSI Driver. If you wish to use encrypted volumes, please contact your account manager. + +## Overview + +This guide demonstrates how to roll out an encrypted storage class for SKE using the STACKIT Key Management Service (KMS). To achieve this, we use a **Service Account Impersonation** (Act-As) pattern. This allows the internal SKE service account to perform encryption and decryption tasks on behalf of a user-managed service account that has been granted access to your KMS keys. + +## Steps + +### 1. Configure the SKE Cluster + +Create a standard SKE cluster. We also generate a kubeconfig dynamically to allow the `kubernetes` provider to interact with the cluster within the same Terraform execution. + +```hcl +resource "stackit_ske_cluster" "default" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "ske-enc-vol" + kubernetes_version_min = "1.33" + + node_pools = [{ + name = "standard" + machine_type = "c2i.4" + minimum = 1 + maximum = 3 + availability_zones = ["eu01-1"] + os_name = "flatcar" + volume_size = 32 + }] +} + +resource "stackit_ske_kubeconfig" "default" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + cluster_name = stackit_ske_cluster.default.name + refresh = true +} +``` + + +### 2. Identify the Internal SKE Service Account + +Each STACKIT project with a SKE Cluster deployed has a dedicated, internal service account used by SKE. We need to look this up to grant it permissions in a later step. + +```hcl +data "stackit_service_accounts" "ske_internal" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + email_suffix = "@ske.sa.stackit.cloud" + + depends_on = [stackit_ske_cluster.default] +} +``` + +### 3. Setup KMS Infrastructure + +Define the Keyring and the specific Key that will be used to encrypt the block storage volumes. + +```hcl +resource "stackit_kms_keyring" "encryption" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + display_name = "ske-volume-keyring" +} + +resource "stackit_kms_key" "volume_key" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + keyring_id = stackit_kms_keyring.encryption.keyring_id + display_name = "volume-encryption-key" + protection = "software" + algorithm = "aes_256_gcm" + purpose = "symmetric_encrypt_decrypt" +} +``` + +### 4. Configure Identity and Permissions (Act-As) + +This is the most critical part of the setup. We create a **manager** service account that holds the KMS permissions, and then authorize the SKE internal service account to **Act-As** (impersonate) that manager. + +```hcl +# Create the service account that 'owns' the KMS access +resource "stackit_service_account" "kms_manager" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "volume-encryptor" +} + +# Grant the 'kms.admin' role to the manager service-account +resource "stackit_authorization_project_role_assignment" "kms_user" { + // in this case the STACKIT project_id + resource_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + role = "kms.admin" + subject = stackit_service_account.kms_manager.email +} + +# Authorize the internal SKE account to impersonate the kms manager service-account (Act-As) +resource "stackit_authorization_service_account_role_assignment" "ske_impersonation" { + resource_id = stackit_service_account.kms_manager.service_account_id + role = "user" + subject = data.stackit_service_accounts.ske_internal.items[0].email +} +``` + +### 5. Create the Encrypted Storage Class in Kubernetes + +Define the `kubernetes_storage_class`. We pass the IDs of the KMS resources and the email of our manager service account into the parameters. + +```hcl +resource "kubernetes_storage_class_v1" "encrypted_premium" { + metadata { + name = "stackit-encrypted-premium" + } + + storage_provisioner = "block-storage.csi.stackit.cloud" + reclaim_policy = "Delete" + allow_volume_expansion = true + volume_binding_mode = "WaitForFirstConsumer" + + parameters = { + type = "storage_premium_perf6" + encrypted = "true" + kmsKeyID = stackit_kms_key.volume_key.key_id + kmsKeyringID = stackit_kms_keyring.encryption.keyring_id + kmsProjectID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + kmsKeyVersion = "1" + kmsServiceAccount = stackit_service_account.kms_manager.email + } + + depends_on = [ + stackit_authorization_service_account_role_assignment.ske_impersonation, + stackit_authorization_project_role_assignment.kms_user + ] +} +``` + +### 6. Verify with a Persistent Volume Claim (PVC) + +You can now create a PVC using the new storage class. When a pod claims this volume, the STACKIT CSI driver will automatically use the KMS key to provide an encrypted volume. + +```hcl +resource "kubernetes_persistent_volume_claim_v1" "test_pvc" { + metadata { + name = "test-encryption-pvc" + } + + spec { + access_modes = ["ReadWriteOnce"] + + resources { + requests = { + storage = "10Gi" + } + } + + storage_class_name = kubernetes_storage_class_v1.encrypted_premium.metadata[0].name + } +} +``` + +### 7. Create a Pod to Consume the Volume + +```hcl +resource "kubernetes_pod_v1" "test_app" { + metadata { + name = "encrypted-volume-test" + } + + spec { + container { + image = "nginx:latest" + name = "web-server" + + volume_mount { + mount_path = "/usr/share/nginx/html" + name = "data-volume" + } + } + + volume { + name = "data-volume" + persistent_volume_claim { + claim_name = "test-encryption-pvc" + } + } + } +} +``` \ No newline at end of file