From 16633ec8c2906ce0ebd046ac3a7898139d3699d7 Mon Sep 17 00:00:00 2001 From: Tal Hazi Date: Wed, 23 Aug 2023 11:15:05 +0300 Subject: [PATCH] LH-69472: Require that the API token provided is for a user with ADMIN or SUPER_ADMIN privileges (#17) * verify only ADMIN and SUPER_ADMIN can perform actions * add tests --- .../examples/resources/ios/ios_example.tf | 21 ++++ provider/go.mod | 1 + provider/go.sum | 2 + provider/internal/device/asa/data_source.go | 4 +- provider/internal/device/asa/resource.go | 3 +- provider/internal/provider/provider.go | 6 +- provider/validators/cdo_role.go | 108 ++++++++++++++++++ .../{one_of_test.go => cdo_role_test.go} | 64 ++++------- provider/validators/one_of.go | 61 ---------- 9 files changed, 165 insertions(+), 105 deletions(-) create mode 100644 provider/examples/resources/ios/ios_example.tf create mode 100644 provider/validators/cdo_role.go rename provider/validators/{one_of_test.go => cdo_role_test.go} (51%) delete mode 100644 provider/validators/one_of.go diff --git a/provider/examples/resources/ios/ios_example.tf b/provider/examples/resources/ios/ios_example.tf new file mode 100644 index 00000000..43546e16 --- /dev/null +++ b/provider/examples/resources/ios/ios_example.tf @@ -0,0 +1,21 @@ +terraform { + required_providers { + cdo = { + source = "hashicorp.com/CiscoDevnet/cdo" + } + } +} + +provider "cdo" { + base_url = "" + api_token = "" +} + +resource "cdo_ios_device" "my_ios" { + name = "" + connector_name = "" + socket_address = ":" + username = "" + password = "" + ignore_certificate = true +} \ No newline at end of file diff --git a/provider/go.mod b/provider/go.mod index 71e29643..c83f9c59 100644 --- a/provider/go.mod +++ b/provider/go.mod @@ -50,6 +50,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.18.27 github.com/cloudflare/circl v1.3.3 // indirect github.com/fatih/color v1.15.0 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/provider/go.sum b/provider/go.sum index cfde10ce..a9378c1d 100644 --- a/provider/go.sum +++ b/provider/go.sum @@ -63,6 +63,8 @@ github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4u github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4= github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= diff --git a/provider/internal/device/asa/data_source.go b/provider/internal/device/asa/data_source.go index 6b78fa24..ef2bf590 100644 --- a/provider/internal/device/asa/data_source.go +++ b/provider/internal/device/asa/data_source.go @@ -10,7 +10,7 @@ import ( cdoClient "github.com/CiscoDevnet/terraform-provider-cdo/go-client" "github.com/CiscoDevnet/terraform-provider-cdo/go-client/device" - "github.com/CiscoDevnet/terraform-provider-cdo/validators" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -74,7 +74,7 @@ func (d *AsaDataSource) Schema(ctx context.Context, req datasource.SchemaRequest MarkdownDescription: "The type of the connector that is used to communicate with the device. CDO can communicate with your device using either a Cloud Connector (CDG) or a Secure Device Connector (SDC); see [the CDO documentation](https://docs.defenseorchestrator.com/c-connect-cisco-defense-orchestratortor-the-secure-device-connector.html) to learn mor (Valid values: [CDG, SDC]).", Computed: true, Validators: []validator.String{ - validators.OneOf("CDG", "SDC"), + stringvalidator.OneOf("CDG", "SDC"), }, }, "socket_address": schema.StringAttribute{ diff --git a/provider/internal/device/asa/resource.go b/provider/internal/device/asa/resource.go index 43649a0a..9bd2e1ec 100644 --- a/provider/internal/device/asa/resource.go +++ b/provider/internal/device/asa/resource.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/CiscoDevnet/terraform-provider-cdo/validators" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -80,7 +81,7 @@ func (r *AsaDeviceResource) Schema(ctx context.Context, req resource.SchemaReque MarkdownDescription: "The type of the connector that will be used to communicate with the device. CDO can communicate with your device using either a Cloud Connector (CDG) or a Secure Device Connector (SDC); see [the CDO documentation](https://docs.defenseorchestrator.com/c-connect-cisco-defense-orchestratortor-the-secure-device-connector.html) to learn mor (Valid values: [CDG, SDC]).", Required: true, Validators: []validator.String{ - validators.OneOf("CDG", "SDC"), + stringvalidator.OneOf("CDG", "SDC"), }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), diff --git a/provider/internal/provider/provider.go b/provider/internal/provider/provider.go index f65bcb92..7c0ce4ac 100644 --- a/provider/internal/provider/provider.go +++ b/provider/internal/provider/provider.go @@ -13,6 +13,7 @@ import ( cdoClient "github.com/CiscoDevnet/terraform-provider-cdo/go-client" "github.com/CiscoDevnet/terraform-provider-cdo/validators" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -52,12 +53,15 @@ func (p *CdoProvider) Schema(ctx context.Context, req provider.SchemaRequest, re MarkdownDescription: "The API token used to authenticate with CDO. [See here](https://docs.defenseorchestrator.com/c_api-tokens.html#!t-generatean-api-token.html) to learn how to generate an API token.", Optional: true, Sensitive: true, + Validators: []validator.String{ + validators.OneOfRoles("ROLE_SUPER_ADMIN", "ROLE_ADMIN"), + }, }, "base_url": schema.StringAttribute{ MarkdownDescription: "The base CDO URL. This is the URL you enter when logging into your CDO account.", Required: true, Validators: []validator.String{ - validators.OneOf("https://www.defenseorchestrator.com", "https://www.defenseorchestrator.eu", "https://apj.cdo.cisco.com", "https://staging.dev.lockhart.io", "https://ci.dev.lockhart.io", "https://scale.dev.lockhart.io"), + stringvalidator.OneOf("https://www.defenseorchestrator.com", "https://www.defenseorchestrator.eu", "https://apj.cdo.cisco.com", "https://staging.dev.lockhart.io", "https://ci.dev.lockhart.io", "https://scale.dev.lockhart.io"), }, }, }, diff --git a/provider/validators/cdo_role.go b/provider/validators/cdo_role.go new file mode 100644 index 00000000..88d760a4 --- /dev/null +++ b/provider/validators/cdo_role.go @@ -0,0 +1,108 @@ +package validators + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + ErrInvalidTokenFormat = "invalid JWT token format" + ErrDecodeFailed = "failed to decode token payload" + ErrNoRolesFound = "no roles found in the JWT token" +) + +var _ validator.String = oneOfRolesValidator{} + +// oneOfRolesValidator validates that the value matches one of expected roles. +type oneOfRolesValidator struct { + expectedRoles []types.String +} + +func (v oneOfRolesValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v oneOfRolesValidator) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("The user must be assigned one of the following CDO roles: %q", v.expectedRoles) +} + +func (v oneOfRolesValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + token := request.ConfigValue.String() + + role, err := extractRoleFromToken(token) + + if err != nil { + fmt.Println("Error:", err) + } + + for _, expectedRole := range v.expectedRoles { + if role == expectedRole.ValueString() { + return + } + } + + response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( + request.Path, + v.Description(ctx), + role, + )) +} + +func extractRoleFromToken(tokenString string) (string, error) { + if tokenString == "" { + return "", fmt.Errorf("tokenString is nil or empty") + } + + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return "", fmt.Errorf(ErrInvalidTokenFormat) + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf(ErrDecodeFailed) + } + + var claims jwt.MapClaims + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return "", fmt.Errorf("failed to decode token payload: %v", err) + } + + if rolesClaim, exists := claims["roles"]; exists { // check if "roles" claim exists + if roles, ok := rolesClaim.([]interface{}); ok { // convert to a slice of interfaces + if len(roles) > 0 { + if role, ok := roles[0].(string); ok { + return role, nil + } + } + } + } + + return "", fmt.Errorf(ErrNoRolesFound) +} + +// OneOfRoles checks that the JWT token roles String held in the attribute +// is one of the given `roles`. +func OneOfRoles(roles ...string) validator.String { + frameworkValues := make([]types.String, 0, len(roles)) + + for _, value := range roles { + frameworkValues = append(frameworkValues, types.StringValue(value)) + } + + return oneOfRolesValidator{ + expectedRoles: frameworkValues, + } +} diff --git a/provider/validators/one_of_test.go b/provider/validators/cdo_role_test.go similarity index 51% rename from provider/validators/one_of_test.go rename to provider/validators/cdo_role_test.go index b4df55b6..25e562dd 100644 --- a/provider/validators/one_of_test.go +++ b/provider/validators/cdo_role_test.go @@ -1,21 +1,22 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - package validators_test import ( "context" "testing" + "github.com/CiscoDevnet/terraform-provider-cdo/validators" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" - - "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" ) -func TestOneOfValidator(t *testing.T) { +func TestOneOfRolesValidator(t *testing.T) { t.Parallel() + SuperAdminRoleToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJST0xFX1NVUEVSX0FETUlOIl19.FBEPzCpXPiYX6esud26WMrJvU3reLDVFdLociGrilVw" + AdminRoleToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJST0xFX0FETUlOIl19.Xlk9JQJV9butj8ERu7mZFvRczY66KvmnTklQVnHwoy0" + BlaRoleToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJST0xFX0JMQSJdfQ.nx10m1Cb8ZkUbxWKyfyJzsD1Y8rdTtkRbGEC2A4yV2M" + NoRolesToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6W119.0Dd6yUeJ4UbCr8WyXOiK3BhqVVwJFk5c53ipJBWenmc" + type testCase struct { in types.String validator validator.String @@ -23,50 +24,33 @@ func TestOneOfValidator(t *testing.T) { } testCases := map[string]testCase{ - "simple-match": { - in: types.StringValue("foo"), - validator: stringvalidator.OneOf( - "foo", - "bar", - "baz", + "super-admin-role-match": { + in: types.StringValue(SuperAdminRoleToken), + validator: validators.OneOfRoles( + "ROLE_SUPER_ADMIN", "ROLE_ADMIN", ), expErrors: 0, }, - "simple-mismatch-case-insensitive": { - in: types.StringValue("foo"), - validator: stringvalidator.OneOf( - "FOO", - "bar", - "baz", + "admin-role-match": { + in: types.StringValue(AdminRoleToken), + validator: validators.OneOfRoles( + "ROLE_SUPER_ADMIN", "ROLE_ADMIN", ), - expErrors: 1, + expErrors: 0, }, "simple-mismatch": { - in: types.StringValue("foz"), - validator: stringvalidator.OneOf( - "foo", - "bar", - "baz", + in: types.StringValue(BlaRoleToken), + validator: validators.OneOfRoles( + "ROLE_SUPER_ADMIN", "ROLE_ADMIN", ), expErrors: 1, }, - "skip-validation-on-null": { - in: types.StringNull(), - validator: stringvalidator.OneOf( - "foo", - "bar", - "baz", + "no-roles": { + in: types.StringValue(NoRolesToken), + validator: validators.OneOfRoles( + "ROLE_SUPER_ADMIN", "ROLE_ADMIN", ), - expErrors: 0, - }, - "skip-validation-on-unknown": { - in: types.StringUnknown(), - validator: stringvalidator.OneOf( - "foo", - "bar", - "baz", - ), - expErrors: 0, + expErrors: 1, }, } diff --git a/provider/validators/one_of.go b/provider/validators/one_of.go deleted file mode 100644 index 729e7276..00000000 --- a/provider/validators/one_of.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 - -package validators - -import ( - "context" - "fmt" - "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -var _ validator.String = oneOfValidator{} - -// oneOfValidator validates that the value matches one of expected values. -type oneOfValidator struct { - values []types.String -} - -func (v oneOfValidator) Description(ctx context.Context) string { - return v.MarkdownDescription(ctx) -} - -func (v oneOfValidator) MarkdownDescription(_ context.Context) string { - return fmt.Sprintf("value must be one of: %q", v.values) -} - -func (v oneOfValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { - if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { - return - } - - value := request.ConfigValue - - for _, validValue := range v.values { - if value.Equal(validValue) { - return - } - } - - response.Diagnostics.Append(validatordiag.InvalidAttributeValueMatchDiagnostic( - request.Path, - v.Description(ctx), - value.String(), - )) -} - -// OneOf checks that the String held in the attribute -// is one of the given `values`. -func OneOf(values ...string) validator.String { - frameworkValues := make([]types.String, 0, len(values)) - - for _, value := range values { - frameworkValues = append(frameworkValues, types.StringValue(value)) - } - - return oneOfValidator{ - values: frameworkValues, - } -}