From 40ba580befad18ce743fc3be76169bc7e1f41e52 Mon Sep 17 00:00:00 2001 From: Volodymyr Manilo Date: Tue, 9 Apr 2024 21:20:39 +0200 Subject: [PATCH] added support for usage based access --- docs/resources/resource.md | 2 + .../resources/twingate_resource/resource.tf | 1 + golangci.yml | 1 + twingate/internal/attr/resource.go | 35 ++++----- .../internal/client/query/resource-read.go | 10 +-- twingate/internal/client/resource.go | 5 +- twingate/internal/model/resource.go | 25 +++++-- twingate/internal/provider/resource/helper.go | 2 +- .../internal/provider/resource/resource.go | 72 +++++++++++++++++-- twingate/internal/test/acctests/helper.go | 64 +++++++++++++++++ .../test/acctests/resource/resource_test.go | 55 ++++++++++++++ 11 files changed, 238 insertions(+), 34 deletions(-) diff --git a/docs/resources/resource.md b/docs/resources/resource.md index 1fddf121..75a650e5 100644 --- a/docs/resources/resource.md +++ b/docs/resources/resource.md @@ -57,6 +57,7 @@ resource "twingate_resource" "resource" { content { group_id = access_group.value security_policy_id = data.twingate_security_policy.test_policy.id + usage_based_autolock_duration_days = 30 } } @@ -103,6 +104,7 @@ Optional: - `group_id` (String) Group ID that will have permission to access the Resource. - `security_policy_id` (String) The ID of a `twingate_security_policy` to use as the access policy for the group IDs in the access block. +- `usage_based_autolock_duration_days` (Number) The usage-based auto-lock duration configured on the edge (in days). diff --git a/examples/resources/twingate_resource/resource.tf b/examples/resources/twingate_resource/resource.tf index 41a3f10b..afea537a 100644 --- a/examples/resources/twingate_resource/resource.tf +++ b/examples/resources/twingate_resource/resource.tf @@ -42,6 +42,7 @@ resource "twingate_resource" "resource" { content { group_id = access_group.value security_policy_id = data.twingate_security_policy.test_policy.id + usage_based_autolock_duration_days = 30 } } diff --git a/golangci.yml b/golangci.yml index 1a3dac05..a7071184 100644 --- a/golangci.yml +++ b/golangci.yml @@ -23,6 +23,7 @@ linters-settings: - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.Set - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.Bool - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.String + - github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier.Int64 - github.com/hashicorp/terraform-plugin-testing/plancheck.PlanCheck errcheck: check-type-assertions: false diff --git a/twingate/internal/attr/resource.go b/twingate/internal/attr/resource.go index 87681305..7cd1b7c1 100644 --- a/twingate/internal/attr/resource.go +++ b/twingate/internal/attr/resource.go @@ -1,21 +1,22 @@ package attr const ( - Access = "access" - AccessGroup = "access_group" - AccessService = "access_service" - GroupIDs = "group_ids" - GroupID = "group_id" - ServiceAccountIDs = "service_account_ids" - IsAuthoritative = "is_authoritative" - Policy = "policy" - Ports = "ports" - Address = "address" - Protocols = "protocols" - AllowIcmp = "allow_icmp" - TCP = "tcp" - UDP = "udp" - IsVisible = "is_visible" - IsBrowserShortcutEnabled = "is_browser_shortcut_enabled" - Resources = "resources" + Access = "access" + AccessGroup = "access_group" + UsageBasedAutolockDurationDays = "usage_based_autolock_duration_days" + AccessService = "access_service" + GroupIDs = "group_ids" + GroupID = "group_id" + ServiceAccountIDs = "service_account_ids" + IsAuthoritative = "is_authoritative" + Policy = "policy" + Ports = "ports" + Address = "address" + Protocols = "protocols" + AllowIcmp = "allow_icmp" + TCP = "tcp" + UDP = "udp" + IsVisible = "is_visible" + IsBrowserShortcutEnabled = "is_browser_shortcut_enabled" + Resources = "resources" ) diff --git a/twingate/internal/client/query/resource-read.go b/twingate/internal/client/query/resource-read.go index 44bb0bad..a96fbe7c 100644 --- a/twingate/internal/client/query/resource-read.go +++ b/twingate/internal/client/query/resource-read.go @@ -31,8 +31,9 @@ type Access struct { } type AccessEdge struct { - Node Principal - SecurityPolicy *gqlSecurityPolicy + Node Principal + SecurityPolicy *gqlSecurityPolicy + UsageBasedAutolockDurationDays *int64 } type Principal struct { @@ -88,8 +89,9 @@ func (r gqlResource) ToModel() *model.Resource { switch access.Node.Type { case AccessGroup: resource.GroupsAccess = append(resource.GroupsAccess, model.AccessGroup{ - GroupID: string(access.Node.ID), - SecurityPolicyID: securityPolicyID, + GroupID: string(access.Node.ID), + SecurityPolicyID: securityPolicyID, + UsageBasedDuration: access.UsageBasedAutolockDurationDays, }) case AccessServiceAccount: resource.ServiceAccounts = append(resource.ServiceAccounts, string(access.Node.ID)) diff --git a/twingate/internal/client/resource.go b/twingate/internal/client/resource.go index 2816ab9b..6cdc0ebf 100644 --- a/twingate/internal/client/resource.go +++ b/twingate/internal/client/resource.go @@ -298,8 +298,9 @@ func (client *Client) RemoveResourceAccess(ctx context.Context, resourceID strin } type AccessInput struct { - PrincipalID string `json:"principalId"` - SecurityPolicyID *string `json:"securityPolicyId"` + PrincipalID string `json:"principalId"` + SecurityPolicyID *string `json:"securityPolicyId"` + UsageBasedAutolockDurationDays *int64 `json:"usageBasedAutolockDurationDays"` } func (client *Client) AddResourceAccess(ctx context.Context, resourceID string, access []AccessInput) error { diff --git a/twingate/internal/model/resource.go b/twingate/internal/model/resource.go index 3ae4e4ea..0ea0d05f 100644 --- a/twingate/internal/model/resource.go +++ b/twingate/internal/model/resource.go @@ -16,16 +16,33 @@ const ( PolicyRestricted = "RESTRICTED" PolicyAllowAll = "ALLOW_ALL" PolicyDenyAll = "DENY_ALL" - - NullSecurityPolicy = "none" ) //nolint:gochecknoglobals var Policies = []string{PolicyRestricted, PolicyAllowAll, PolicyDenyAll} type AccessGroup struct { - GroupID string - SecurityPolicyID *string + GroupID string + SecurityPolicyID *string + UsageBasedDuration *int64 +} + +func (g AccessGroup) Equals(another AccessGroup) bool { + if g.GroupID == another.GroupID && + equalsOptionalString(g.SecurityPolicyID, another.SecurityPolicyID) && + equalsOptionalInt64(g.UsageBasedDuration, another.UsageBasedDuration) { + return true + } + + return false +} + +func equalsOptionalString(s1, s2 *string) bool { + return s1 == nil && s2 == nil || s1 != nil && s2 != nil && strings.EqualFold(*s1, *s2) +} + +func equalsOptionalInt64(i1, i2 *int64) bool { + return i1 == nil && i2 == nil || i1 != nil && i2 != nil && *i1 == *i2 } type Resource struct { diff --git a/twingate/internal/provider/resource/helper.go b/twingate/internal/provider/resource/helper.go index 8599953e..304ffa9d 100644 --- a/twingate/internal/provider/resource/helper.go +++ b/twingate/internal/provider/resource/helper.go @@ -91,7 +91,7 @@ func setDifferenceGroupAccess(inputA, inputB []model.AccessGroup) []model.Access result := make([]model.AccessGroup, 0, len(setA)) for key, valA := range setA { - if valB, exist := setB[key]; !exist || valA.SecurityPolicyID != valB.SecurityPolicyID { + if valB, exist := setB[key]; !exist || !valA.Equals(valB) { result = append(result, valA) } } diff --git a/twingate/internal/provider/resource/resource.go b/twingate/internal/provider/resource/resource.go index c3757ab4..e616ed36 100644 --- a/twingate/internal/provider/resource/resource.go +++ b/twingate/internal/provider/resource/resource.go @@ -13,6 +13,7 @@ import ( "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/client" "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/model" "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/utils" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" tfattr "github.com/hashicorp/terraform-plugin-framework/attr" @@ -289,6 +290,17 @@ func groupAccessBlock() schema.SetNestedBlock { UseNullPolicyForGroupAccessWhenValueOmitted(), }, }, + attr.UsageBasedAutolockDurationDays: schema.Int64Attribute{ + Optional: true, + Computed: true, + Description: "The usage-based auto-lock duration configured on the edge (in days).", + Validators: []validator.Int64{ + int64validator.AlsoRequires(path.MatchRelative().AtParent().AtName(attr.GroupID)), + }, + PlanModifiers: []planmodifier.Int64{ + UseNullIntWhenValueOmitted(), + }, + }, }, }, } @@ -450,7 +462,11 @@ func convertResourceAccess(serviceAccounts []string, groupsAccess []model.Access } for _, group := range groupsAccess { - access = append(access, client.AccessInput{PrincipalID: group.GroupID, SecurityPolicyID: group.SecurityPolicyID}) + access = append(access, client.AccessInput{ + PrincipalID: group.GroupID, + SecurityPolicyID: group.SecurityPolicyID, + UsageBasedAutolockDurationDays: group.UsageBasedDuration, + }) } return access @@ -474,6 +490,7 @@ func getAccessAttribute(list types.List, attribute string) []string { return convertIDs(val.(types.Set)) } +//nolint:cyclop func getGroupAccessAttribute(list types.Set) []model.AccessGroup { if list.IsNull() || list.IsUnknown() || len(list.Elements()) == 0 { return nil @@ -497,6 +514,11 @@ func getGroupAccessAttribute(list types.Set) []model.AccessGroup { accessGroup.SecurityPolicyID = securityPolicyVal.(types.String).ValueStringPointer() } + usageBasedDuration := obj.Attributes()[attr.UsageBasedAutolockDurationDays] + if usageBasedDuration != nil && !usageBasedDuration.IsNull() && !usageBasedDuration.IsUnknown() { + accessGroup.UsageBasedDuration = usageBasedDuration.(types.Int64).ValueInt64Pointer() + } + access = append(access, accessGroup) } @@ -538,7 +560,7 @@ func convertResource(plan *resourceModel) (*model.Resource, error) { serviceAccountIDs := getServiceAccountAccessAttribute(plan.ServiceAccess) for _, access := range accessGroups { - if access.SecurityPolicyID == nil && len(strings.TrimSpace(access.GroupID)) == 0 { + if access.SecurityPolicyID == nil && access.UsageBasedDuration == nil && len(strings.TrimSpace(access.GroupID)) == 0 { return nil, ErrInvalidAttributeCombination } @@ -1219,8 +1241,9 @@ func convertGroupsAccessToTerraform(ctx context.Context, groupAccess []model.Acc for _, access := range groupAccess { attributes := map[string]tfattr.Value{ - attr.GroupID: types.StringValue(access.GroupID), - attr.SecurityPolicyID: types.StringPointerValue(access.SecurityPolicyID), + attr.GroupID: types.StringValue(access.GroupID), + attr.SecurityPolicyID: types.StringPointerValue(access.SecurityPolicyID), + attr.UsageBasedAutolockDurationDays: types.Int64PointerValue(access.UsageBasedDuration), } obj, diags := types.ObjectValue(accessGroupAttributeTypes(), attributes) @@ -1329,8 +1352,9 @@ func (m useDefaultPolicyForUnknownModifier) PlanModifyString(ctx context.Context func accessGroupAttributeTypes() map[string]tfattr.Type { return map[string]tfattr.Type{ - attr.GroupID: types.StringType, - attr.SecurityPolicyID: types.StringType, + attr.GroupID: types.StringType, + attr.SecurityPolicyID: types.StringType, + attr.UsageBasedAutolockDurationDays: types.Int64Type, } } @@ -1375,3 +1399,39 @@ func (m useNullPolicyForGroupAccessWhenValueOmitted) PlanModifyString(ctx contex resp.PlanValue = types.StringNull() } } + +func UseNullIntWhenValueOmitted() planmodifier.Int64 { + return useNullIntWhenValueOmitted{} +} + +type useNullIntWhenValueOmitted struct{} + +func (m useNullIntWhenValueOmitted) Description(_ context.Context) string { + return "" +} + +func (m useNullIntWhenValueOmitted) MarkdownDescription(_ context.Context) string { + return "" +} + +func (m useNullIntWhenValueOmitted) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + if req.StateValue.IsNull() && req.ConfigValue.IsNull() { + resp.PlanValue = types.Int64Null() + + return + } + + // Do nothing if there is no state value. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + if req.ConfigValue.IsNull() && !req.PlanValue.IsNull() { + resp.PlanValue = types.Int64Null() + } +} diff --git a/twingate/internal/test/acctests/helper.go b/twingate/internal/test/acctests/helper.go index bf71fb9a..db8a51a9 100644 --- a/twingate/internal/test/acctests/helper.go +++ b/twingate/internal/test/acctests/helper.go @@ -36,7 +36,9 @@ var ( ErrSecurityPoliciesNotFound = errors.New("security policies not found") ErrInvalidPath = errors.New("invalid path: the path value cannot be asserted as string") ErrNotNullSecurityPolicy = errors.New("expected null security policy in GroupAccess, got non null") + ErrNotNullUsageBased = errors.New("expected null usage based duration in GroupAccess, got non null") ErrNullSecurityPolicy = errors.New("expected non null security policy in GroupAccess, got null") + ErrNullUsageBased = errors.New("expected non null usage based duration in GroupAccess, got null") ErrEmptyGroupAccess = errors.New("expected at least one group in GroupAccess") ) @@ -346,6 +348,39 @@ func CheckTwingateResourceSecurityPolicyOnGroupAccess(resourceName string, expec } } +func CheckTwingateResourceUsageBasedOnGroupAccess(resourceName string, expectedUsageBased int64) sdk.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[resourceName] + + if !ok { + return fmt.Errorf("%w: %s", ErrResourceNotFound, resourceName) + } + + if resourceState.Primary.ID == "" { + return ErrResourceIDNotSet + } + + res, err := providerClient.ReadResource(context.Background(), resourceState.Primary.ID) + if err != nil { + return fmt.Errorf("failed to read resource: %w", err) + } + + if len(res.GroupsAccess) == 0 { + return ErrEmptyGroupAccess + } + + if res.GroupsAccess[0].UsageBasedDuration == nil { + return ErrNullUsageBased + } + + if *res.GroupsAccess[0].UsageBasedDuration != expectedUsageBased { + return fmt.Errorf("expected usage based duration %v, got %v", expectedUsageBased, *res.GroupsAccess[0].UsageBasedDuration) //nolint:goerr113 + } + + return nil + } +} + func CheckTwingateResourceSecurityPolicyIsNullOnGroupAccess(resourceName string) sdk.TestCheckFunc { return func(s *terraform.State) error { resourceState, ok := s.RootModule().Resources[resourceName] @@ -375,6 +410,35 @@ func CheckTwingateResourceSecurityPolicyIsNullOnGroupAccess(resourceName string) } } +func CheckTwingateResourceUsageBasedIsNullOnGroupAccess(resourceName string) sdk.TestCheckFunc { + return func(s *terraform.State) error { + resourceState, ok := s.RootModule().Resources[resourceName] + + if !ok { + return fmt.Errorf("%w: %s", ErrResourceNotFound, resourceName) + } + + if resourceState.Primary.ID == "" { + return ErrResourceIDNotSet + } + + res, err := providerClient.ReadResource(context.Background(), resourceState.Primary.ID) + if err != nil { + return fmt.Errorf("failed to read resource: %w", err) + } + + if len(res.GroupsAccess) == 0 { + return ErrEmptyGroupAccess + } + + if res.GroupsAccess[0].UsageBasedDuration != nil { + return ErrNotNullUsageBased + } + + return nil + } +} + func CheckTwingateResourceActiveState(resourceName string, expectedActiveState bool) sdk.TestCheckFunc { return func(s *terraform.State) error { resourceState, ok := s.RootModule().Resources[resourceName] diff --git a/twingate/internal/test/acctests/resource/resource_test.go b/twingate/internal/test/acctests/resource/resource_test.go index d85985a9..5b54adbc 100644 --- a/twingate/internal/test/acctests/resource/resource_test.go +++ b/twingate/internal/test/acctests/resource/resource_test.go @@ -3297,3 +3297,58 @@ func TestAccTwingateResourceUnsetSecurityPolicyOnGroupAccess(t *testing.T) { }, }) } + +func TestAccTwingateResourceWithUsageBasedOnGroupAccess(t *testing.T) { + t.Parallel() + + resourceName := test.RandomResourceName() + theResource := acctests.TerraformResource(resourceName) + remoteNetworkName := test.RandomName() + groupName := test.RandomGroupName() + + var usageBasedDuration int64 = 2 + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: createResourceWithUsageBasedOnGroupAccess(remoteNetworkName, resourceName, groupName, usageBasedDuration), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + acctests.CheckTwingateResourceUsageBasedOnGroupAccess(theResource, usageBasedDuration), + ), + }, + { + Config: createResourceWithNullSecurityPolicyOnGroupAccess(remoteNetworkName, resourceName, groupName), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceUsageBasedIsNullOnGroupAccess(theResource), + ), + }, + }, + }) +} + +func createResourceWithUsageBasedOnGroupAccess(remoteNetwork, resource, groupName string, daysDuration int64) string { + return fmt.Sprintf(` + resource "twingate_group" "g21" { + name = "%[3]s" + } + + resource "twingate_remote_network" "%[1]s" { + name = "%[1]s" + } + + resource "twingate_resource" "%[2]s" { + name = "%[2]s" + address = "acc-test-address.com" + remote_network_id = twingate_remote_network.%[1]s.id + + access_group { + group_id = twingate_group.g21.id + usage_based_autolock_duration_days = %[4]v + } + } + `, remoteNetwork, resource, groupName, daysDuration) +}