diff --git a/azure-test/tests/azure_key_vault_certificate/test-get-expected.json b/azure-test/tests/azure_key_vault_certificate/test-get-expected.json new file mode 100644 index 00000000..f2d090cd --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-get-expected.json @@ -0,0 +1,6 @@ +[ + { + "id": "{{ output.resource_id.value }}", + "name": "{{ resourceName }}" + } +] \ No newline at end of file diff --git a/azure-test/tests/azure_key_vault_certificate/test-get-query.sql b/azure-test/tests/azure_key_vault_certificate/test-get-query.sql new file mode 100644 index 00000000..e5cb892f --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-get-query.sql @@ -0,0 +1,3 @@ +select name, id +from azure.azure_key_vault_certificate +where name = '{{resourceName}}' and vault_name = '{{resourceName}}' diff --git a/azure-test/tests/azure_key_vault_certificate/test-hydrate-expected.json b/azure-test/tests/azure_key_vault_certificate/test-hydrate-expected.json new file mode 100644 index 00000000..15a69a3c --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-hydrate-expected.json @@ -0,0 +1,7 @@ +[ + { + "id": "{{ output.resource_id.value }}", + "name": "{{resourceName}}", + "vault_name": "{{resourceName}}" + } +] \ No newline at end of file diff --git a/azure-test/tests/azure_key_vault_certificate/test-hydrate-query.sql b/azure-test/tests/azure_key_vault_certificate/test-hydrate-query.sql new file mode 100644 index 00000000..13ee222d --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-hydrate-query.sql @@ -0,0 +1,3 @@ +select name, vault_name, id +from azure.azure_key_vault_certificate +where name = '{{resourceName}}' and title = '{{resourceName}}' diff --git a/azure-test/tests/azure_key_vault_certificate/test-list-expected.json b/azure-test/tests/azure_key_vault_certificate/test-list-expected.json new file mode 100644 index 00000000..db8cea80 --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-list-expected.json @@ -0,0 +1,6 @@ +[ + { + "id": "{{ output.resource_id.value }}", + "name": "{{resourceName}}" + } +] \ No newline at end of file diff --git a/azure-test/tests/azure_key_vault_certificate/test-list-query.sql b/azure-test/tests/azure_key_vault_certificate/test-list-query.sql new file mode 100644 index 00000000..af7c63ac --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-list-query.sql @@ -0,0 +1,3 @@ +select id, name +from azure.azure_key_vault_certificate +where name = '{{resourceName}}' diff --git a/azure-test/tests/azure_key_vault_certificate/test-not-found-expected.json b/azure-test/tests/azure_key_vault_certificate/test-not-found-expected.json new file mode 100644 index 00000000..ec747fa4 --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-not-found-expected.json @@ -0,0 +1 @@ +null \ No newline at end of file diff --git a/azure-test/tests/azure_key_vault_certificate/test-not-found-query.sql b/azure-test/tests/azure_key_vault_certificate/test-not-found-query.sql new file mode 100644 index 00000000..bf4b5445 --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/test-not-found-query.sql @@ -0,0 +1,3 @@ +select name, akas, tags, title +from azure.azure_key_vault_certificate +where name = 'dummy-{{resourceName}}' and vault_name = '{{resourceName}}' diff --git a/azure-test/tests/azure_key_vault_certificate/variables.tf b/azure-test/tests/azure_key_vault_certificate/variables.tf new file mode 100644 index 00000000..ae8ec3b8 --- /dev/null +++ b/azure-test/tests/azure_key_vault_certificate/variables.tf @@ -0,0 +1,171 @@ +variable "resource_name" { + type = string + default = "turbot-test-20200125-create-update" + description = "Name of the resource used throughout the test." +} + +variable "azure_environment" { + type = string + default = "public" + description = "Azure environment used for the test." +} + +variable "azure_subscription" { + type = string + default = "3510ae4d-530b-497d-8f30-53c0616fc6c1" + description = "Azure subscription used for the test." +} + +provider "azurerm" { + environment = var.azure_environment + subscription_id = var.azure_subscription + features {} +} + +data "azurerm_client_config" "current" {} + +data "null_data_source" "resource" { + inputs = { + scope = "azure:///subscriptions/${data.azurerm_client_config.current.subscription_id}" + } +} + +resource "azurerm_resource_group" "named_test_resource" { + name = var.resource_name + location = "West US" +} + +resource "azurerm_key_vault" "example" { + name = var.resource_name + location = azurerm_resource_group.named_test_resource.location + resource_group_name = azurerm_resource_group.named_test_resource.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + soft_delete_retention_days = 7 + + access_policy { + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + certificate_permissions = [ + "Create", + "Delete", + "DeleteIssuers", + "Get", + "GetIssuers", + "Import", + "List", + "ListIssuers", + "ManageContacts", + "ManageIssuers", + "Purge", + "SetIssuers", + "Update", + ] + + key_permissions = [ + "Backup", + "Create", + "Decrypt", + "Delete", + "Encrypt", + "Get", + "Import", + "List", + "Purge", + "Recover", + "Restore", + "Sign", + "UnwrapKey", + "Update", + "Verify", + "WrapKey", + ] + + secret_permissions = [ + "Backup", + "Delete", + "Get", + "List", + "Purge", + "Recover", + "Restore", + "Set", + ] + } +} + +resource "azurerm_key_vault_certificate" "example" { + depends_on = [azurerm_key_vault.example] + name = var.resource_name + key_vault_id = azurerm_key_vault.example.id + + certificate_policy { + issuer_parameters { + name = "Self" + } + + key_properties { + exportable = true + key_size = 2048 + key_type = "RSA" + reuse_key = true + } + + lifetime_action { + action { + action_type = "AutoRenew" + } + + trigger { + days_before_expiry = 30 + } + } + + secret_properties { + content_type = "application/x-pkcs12" + } + + x509_certificate_properties { + # Server Authentication = 1.3.6.1.5.5.7.3.1 + # Client Authentication = 1.3.6.1.5.5.7.3.2 + extended_key_usage = ["1.3.6.1.5.5.7.3.1"] + + key_usage = [ + "cRLSign", + "dataEncipherment", + "digitalSignature", + "keyAgreement", + "keyCertSign", + "keyEncipherment", + ] + + subject_alternative_names { + dns_names = ["internal.contoso.com", "domain.hello.world"] + } + + subject = "CN=hello-world" + validity_in_months = 12 + } + } +} + +output "resource_aka" { + value = "azure://${azurerm_key_vault_certificate.example.id}" +} + +output "resource_aka_lower" { + value = "azure://${lower(azurerm_key_vault_certificate.example.id)}" +} + +output "resource_id" { + value = azurerm_key_vault_certificate.example.id +} + +output "subscription_id" { + value = var.azure_subscription +} + +output "resource_name" { + value = var.resource_name +} \ No newline at end of file diff --git a/azure/plugin.go b/azure/plugin.go index d83f2fdb..72216dd2 100644 --- a/azure/plugin.go +++ b/azure/plugin.go @@ -96,6 +96,7 @@ func Plugin(ctx context.Context) *plugin.Plugin { "azure_iothub": tableAzureIotHub(ctx), "azure_iothub_dps": tableAzureIotHubDps(ctx), "azure_key_vault": tableAzureKeyVault(ctx), + "azure_key_vault_certificate": tableAzureKeyVaultCertificate(ctx), "azure_key_vault_deleted_vault": tableAzureKeyVaultDeletedVault(ctx), "azure_key_vault_key": tableAzureKeyVaultKey(ctx), "azure_key_vault_key_version": tableAzureKeyVaultKeyVersion(ctx), diff --git a/azure/table_azure_key_vault.go b/azure/table_azure_key_vault.go index b39ad9fa..8920493d 100644 --- a/azure/table_azure_key_vault.go +++ b/azure/table_azure_key_vault.go @@ -251,6 +251,8 @@ func listKeyVaults(ctx context.Context, d *plugin.QueryData, _ *plugin.HydrateDa } + plugin.Logger(ctx).Error("List Key Vaults ====>>> ", "OK") + return nil, err } diff --git a/azure/table_azure_key_vault_certificate.go b/azure/table_azure_key_vault_certificate.go new file mode 100644 index 00000000..d01db774 --- /dev/null +++ b/azure/table_azure_key_vault_certificate.go @@ -0,0 +1,352 @@ +package azure + +import ( + "context" + "strings" + + "github.com/Azure/azure-sdk-for-go/services/keyvault/2016-10-01/keyvault" + keyVaultp1 "github.com/Azure/azure-sdk-for-go/services/keyvault/mgmt/2019-09-01/keyvault" + "github.com/turbot/steampipe-plugin-sdk/v5/grpc/proto" + "github.com/turbot/steampipe-plugin-sdk/v5/plugin/transform" + + "github.com/turbot/steampipe-plugin-sdk/v5/plugin" +) + +//// TABLE DEFINITION + +func tableAzureKeyVaultCertificate(_ context.Context) *plugin.Table { + return &plugin.Table{ + Name: "azure_key_vault_certificate", + Description: "Azure Key Vault Certificate", + Get: &plugin.GetConfig{ + KeyColumns: plugin.AllColumns([]string{"vault_name", "name"}), + Hydrate: getKeyVaultCertificate, + IgnoreConfig: &plugin.IgnoreConfig{ + ShouldIgnoreErrorFunc: isNotFoundError([]string{"ResourceNotFound", "404", "SecretDisabled"}), + }, + }, + List: &plugin.ListConfig{ + Hydrate: listKeyVaultCertificates, + ParentHydrate: listKeyVaults, + KeyColumns: plugin.KeyColumnSlice{ + { + Name: "vault_name", Require: plugin.Optional, + }, + }, + IgnoreConfig: &plugin.IgnoreConfig{ + ShouldIgnoreErrorFunc: isNotFoundError([]string{"ResourceNotFound", "ResourceGroupNotFound", "404"}), + }, + }, + Columns: azureColumns([]*plugin.Column{ + { + Name: "name", + Description: "Name of the certificate.", + Type: proto.ColumnType_STRING, + Transform: transform.FromP(getCertificateNameAndVaultName, "Name"), + }, + { + Name: "vault_name", + Description: "The name of the vault.", + Type: proto.ColumnType_STRING, + Transform: transform.FromP(getCertificateNameAndVaultName, "VaultName"), + }, + // We are getting the ID value from Get API call correctly not from List API call + // Get Response: https://turbottest94388.vault.azure.net/certificates/turbottest94388/beaf55112a214cd88aa500fcee10b0f4 + // List Response: https://turbottest94388.vault.azure.net/certificates/turbottest94388 + { + Name: "id", + Description: "Certificate identifier.", + Type: proto.ColumnType_STRING, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromGo(), + }, + { + Name: "x509_thumbprint", + Description: "Thumbprint of the certificate. A URL-encoded base64 string.", + Type: proto.ColumnType_STRING, + Transform: transform.FromField("X509Thumbprint"), + }, + { + Name: "recovery_level", + Description: "Reflects the deletion recovery level currently in effect for certificates in the current vault. If it contains 'Purgeable', the certificate can be permanently deleted by a privileged user; otherwise, only the system can purge the certificate, at the end of the retention interval. Possible values include: 'Purgeable', 'RecoverablePurgeable', 'Recoverable', 'RecoverableProtectedSubscription'.", + Type: proto.ColumnType_STRING, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Attributes.RecoveryLevel"), + }, + { + Name: "enabled", + Description: "Determines whether the object is enabled.", + Type: proto.ColumnType_BOOL, + Transform: transform.FromField("Attributes.Enabled"), + }, + { + Name: "not_before", + Description: "Not before date in UTC.", + Type: proto.ColumnType_TIMESTAMP, + Transform: transform.FromP(convertPointerUnixTimestampToTimestamp, "NotBefore").Transform(transform.UnixMsToTimestamp), + }, + { + Name: "expires", + Description: "Expiry date in UTC.", + Type: proto.ColumnType_TIMESTAMP, + Transform: transform.FromP(convertPointerUnixTimestampToTimestamp, "Expires").Transform(transform.UnixMsToTimestamp), + }, + { + Name: "created", + Description: "Creation time in UTC.", + Type: proto.ColumnType_TIMESTAMP, + Transform: transform.FromP(convertPointerUnixTimestampToTimestamp, "Created").Transform(transform.UnixMsToTimestamp), + }, + { + Name: "updated", + Description: "Last updated time in UTC.", + Type: proto.ColumnType_TIMESTAMP, + Transform: transform.FromP(convertPointerUnixTimestampToTimestamp, "Updated").Transform(transform.UnixMsToTimestamp), + }, + { + Name: "key_id.", + Description: "The key id.", + Type: proto.ColumnType_STRING, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Kid"), + }, + { + Name: "secret_id.", + Description: "The secret id.", + Type: proto.ColumnType_STRING, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Sid"), + }, + { + Name: "content_type", + Description: "The content type of the secret.", + Type: proto.ColumnType_STRING, + Hydrate: getKeyVaultCertificate, + }, + { + Name: "cer", + Description: "CER contents of x509 certificate.", + Type: proto.ColumnType_JSON, + Hydrate: getKeyVaultCertificate, + }, + { + Name: "key_properties", + Description: "Properties of the key backing a certificate.", + Type: proto.ColumnType_JSON, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Policy.KeyProperties"), + }, + { + Name: "secret_properties", + Description: "Properties of the secret backing a certificate.", + Type: proto.ColumnType_JSON, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Policy.SecretProperties"), + }, + { + Name: "x509_certificate_properties", + Description: "Properties of the X509 component of a certificate.", + Type: proto.ColumnType_JSON, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Policy.X509CertificateProperties"), + }, + { + Name: "lifetime_actions", + Description: "Actions that will be performed by Key Vault over the lifetime of a certificate.", + Type: proto.ColumnType_JSON, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Policy.LifetimeActions"), + }, + { + Name: "issuer_parameters", + Description: "Parameters for the issuer of the X509 component of a certificate.", + Type: proto.ColumnType_JSON, + Hydrate: getKeyVaultCertificate, + Transform: transform.FromField("Policy.IssuerParameters"), + }, + + // Steampipe standard columns + { + Name: "title", + Description: ColumnDescriptionTitle, + Type: proto.ColumnType_STRING, + Transform: transform.FromP(getCertificateNameAndVaultName, "Name"), + }, + { + Name: "tags", + Description: ColumnDescriptionTags, + Type: proto.ColumnType_JSON, + }, + { + Name: "akas", + Description: ColumnDescriptionAkas, + Hydrate: getKeyVaultCertificate, + Type: proto.ColumnType_JSON, + Transform: transform.FromField("ID").Transform(idToAkas), + }, + + // We will not get the location and resource groups for the certificate because they are based on vault. + // Azure standard columns + }), + } +} + +//// LIST FUNCTION + +func listKeyVaultCertificates(ctx context.Context, d *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + // Get the details of vault + vault := h.Item.(keyVaultp1.Resource) + + // Create session + session, err := GetNewSession(ctx, d, "VAULT") + if err != nil { + plugin.Logger(ctx).Error("azure_key_vault_certificate.listKeyVaultCertificates", "session_error", err) + return nil, err + } + vaultURI := "https://" + *vault.Name + ".vault.azure.net/" + + client := keyvault.New() + client.Authorizer = session.Authorizer + + maxresult := int32(25) + + result, err := client.GetCertificates(ctx, vaultURI, &maxresult) + if err != nil { + plugin.Logger(ctx).Error("azure_key_vault_certificate.listKeyVaultCertificates", "api_error", err) + return nil, err + } + for _, cert := range result.Values() { + d.StreamListItem(ctx, cert) + + // Check if context has been cancelled or if the limit has been hit (if specified) + // if there is a limit, it will return the number of rows required to reach this limit + if d.RowsRemaining(ctx) == 0 { + return nil, nil + } + } + + for result.NotDone() { + err = result.NextWithContext(ctx) + if err != nil { + return nil, err + } + + for _, cert := range result.Values() { + d.StreamListItem(ctx, cert) + + // Check if context has been cancelled or if the limit has been hit (if specified) + // if there is a limit, it will return the number of rows required to reach this limit + if d.RowsRemaining(ctx) == 0 { + return nil, nil + } + } + } + + return nil, err +} + +//// HYDRATE FUNCTIONS + +func getKeyVaultCertificate(ctx context.Context, d *plugin.QueryData, h *plugin.HydrateData) (interface{}, error) { + plugin.Logger(ctx).Trace("getKeyVaultCertificate") + + var vaultName, name string + if h.Item != nil { + data := h.Item.(keyvault.CertificateItem) + splitID := strings.Split(*data.ID, "/") + vaultName = strings.Split(splitID[2], ".")[0] + name = splitID[4] + + // TODO need to check this condition + // Operation get is not allowed on a disabled certificate + if !*data.Attributes.Enabled { + return nil, nil + } + } else { + vaultName = d.EqualsQuals["vault_name"].GetStringValue() + name = d.EqualsQuals["name"].GetStringValue() + } + + // Create session + session, err := GetNewSession(ctx, d, "VAULT") + if err != nil { + return nil, err + } + + client := keyvault.New() + client.Authorizer = session.Authorizer + + vaultURI := "https://" + vaultName + ".vault.azure.net/" + + op, err := client.GetCertificate(ctx, vaultURI, name, "") + if err != nil { + return nil, err + } + + // In some cases resource does not give any notFound error + // instead of notFound error, it returns empty data + if op.ID != nil { + return op, nil + } + + return nil, nil +} + +//// TRANSFORM FUNCTION + +func convertPointerUnixTimestampToTimestamp(_ context.Context, d *transform.TransformData) (interface{}, error) { + param := d.Param.(string) + result := make(map[string]interface{}, 0) + switch item := d.HydrateItem.(type) { + case keyvault.CertificateItem: + a := item.Attributes + if a != nil { + if a.Created != nil { + result["Created"] = a.Created.Duration() + } + if a.Expires != nil { + result["Expires"] = a.Expires.Duration() + } + if a.NotBefore != nil { + result["NotBefore"] = a.NotBefore.Duration() + } + if a.Updated != nil { + result["Updated"] = a.Updated.Duration() + } + } + case keyvault.CertificateBundle: + a := item.Attributes + if a != nil { + if a.Created != nil { + result["Created"] = a.Created.Duration() + } + if a.Expires != nil { + result["Expires"] = a.Expires.Duration() + } + if a.NotBefore != nil { + result["NotBefore"] = a.NotBefore.Duration() + } + if a.Updated != nil { + result["Updated"] = a.Updated.Duration() + } + } + } + + return result[param], nil +} + +func getCertificateNameAndVaultName(_ context.Context, d *transform.TransformData) (interface{}, error) { + param := d.Param.(string) + result := make(map[string]interface{}, 0) + if d.HydrateItem != nil { + switch item := d.HydrateItem.(type) { + case keyvault.CertificateItem: + result["Name"] = strings.Split(*item.ID, "/")[4] + result["VaultName"] = strings.Split(result["Name"].(string), ".")[0] + case keyvault.CertificateBundle: + result["Name"] = strings.Split(*item.ID, "/")[4] + result["VaultName"] = strings.Split(result["Name"].(string), ".")[0] + } + } + return result[param], nil +}