diff --git a/docs/data-sources/dns_filtering_profile.md b/docs/data-sources/dns_filtering_profile.md new file mode 100644 index 00000000..f22110c0 --- /dev/null +++ b/docs/data-sources/dns_filtering_profile.md @@ -0,0 +1,100 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "twingate_dns_filtering_profile Data Source - terraform-provider-twingate" +subcategory: "" +description: |- + DNS filtering gives you the ability to control what websites your users can access. DNS filtering is only available on certain plans. For more information, see Twingate's documentation https://www.twingate.com/docs/dns-filtering. DNS filtering must be enabled for this data source to work. If DNS filtering isn't enabled, the provider will throw an error. +--- + +# twingate_dns_filtering_profile (Data Source) + +DNS filtering gives you the ability to control what websites your users can access. DNS filtering is only available on certain plans. For more information, see Twingate's [documentation](https://www.twingate.com/docs/dns-filtering). DNS filtering must be enabled for this data source to work. If DNS filtering isn't enabled, the provider will throw an error. + +## Example Usage + +```terraform +provider "twingate" { + api_token = "1234567890abcdef" + network = "mynetwork" +} + +data "twingate_dns_filtering_profile" "example" { + id = "" +} +``` + + +## Schema + +### Required + +- `id` (String) The DNS filtering profile's ID. + +### Read-Only + +- `allowed_domains` (Block, Read-only) A block with the following attributes. (see [below for nested schema](#nestedblock--allowed_domains)) +- `content_categories` (Block, Read-only) A block with the following attributes. (see [below for nested schema](#nestedblock--content_categories)) +- `denied_domains` (Block, Read-only) A block with the following attributes. (see [below for nested schema](#nestedblock--denied_domains)) +- `fallback_method` (String) The DNS filtering profile's fallback method. One of AUTOMATIC or STRICT. +- `groups` (Set of String) A set of group IDs that have this as their DNS filtering profile. Defaults to an empty set. +- `name` (String) The DNS filtering profile's name. +- `priority` (Number) A floating point number representing the profile's priority. +- `privacy_categories` (Block, Read-only) A block with the following attributes. (see [below for nested schema](#nestedblock--privacy_categories)) +- `security_categories` (Block, Read-only) A block with the following attributes. (see [below for nested schema](#nestedblock--security_categories)) + + +### Nested Schema for `allowed_domains` + +Read-Only: + +- `domains` (Set of String) A set of allowed domains. + + + +### Nested Schema for `content_categories` + +Read-Only: + +- `block_adult_content` (Boolean) Whether to block adult content. +- `block_dating` (Boolean) Whether to block dating content. +- `block_gambling` (Boolean) Whether to block gambling content. +- `block_games` (Boolean) Whether to block games. +- `block_piracy` (Boolean) Whether to block piracy sites. +- `block_social_media` (Boolean) Whether to block social media. +- `block_streaming` (Boolean) Whether to block streaming content. +- `enable_safesearch` (Boolean) Whether to force safe search. +- `enable_youtube_restricted_mode` (Boolean) Whether to force YouTube to use restricted mode. + + + +### Nested Schema for `denied_domains` + +Read-Only: + +- `domains` (Set of String) A set of denied domains. + + + +### Nested Schema for `privacy_categories` + +Read-Only: + +- `block_ads_and_trackers` (Boolean) Whether to block ads and trackers. +- `block_affiliate_links` (Boolean) Whether to block affiliate links. +- `block_disguised_trackers` (Boolean) Whether to block disguised third party trackers. + + + +### Nested Schema for `security_categories` + +Read-Only: + +- `block_cryptojacking` (Boolean) Whether to block cryptojacking sites. +- `block_dns_rebinding` (Boolean) Blocks public DNS entries from returning private IP addresses. +- `block_domain_generation_algorithms` (Boolean) Blocks DGA domains. +- `block_idn_homoglyph` (Boolean) Whether to block homoglyph attacks. +- `block_newly_registered_domains` (Boolean) Blocks newly registered domains. +- `block_parked_domains` (Boolean) Block parked domains. +- `block_typosquatting` (Boolean) Blocks typosquatted domains. +- `enable_google_safe_browsing` (Boolean) Whether to use Google Safe browsing lists to block content. +- `enable_threat_intelligence_feeds` (Boolean) Whether to filter content using threat intelligence feeds. diff --git a/docs/resources/dns_filtering_profile.md b/docs/resources/dns_filtering_profile.md new file mode 100644 index 00000000..2775cf9a --- /dev/null +++ b/docs/resources/dns_filtering_profile.md @@ -0,0 +1,151 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "twingate_dns_filtering_profile Resource - terraform-provider-twingate" +subcategory: "" +description: |- + DNS filtering gives you the ability to control what websites your users can access. DNS filtering is only available on certain plans. For more information, see Twingate's documentation https://www.twingate.com/docs/dns-filtering. DNS filtering must be enabled for this resources to work. If DNS filtering isn't enabled, the provider will throw an error. +--- + +# twingate_dns_filtering_profile (Resource) + +DNS filtering gives you the ability to control what websites your users can access. DNS filtering is only available on certain plans. For more information, see Twingate's [documentation](https://www.twingate.com/docs/dns-filtering). DNS filtering must be enabled for this resources to work. If DNS filtering isn't enabled, the provider will throw an error. + +## Example Usage + +```terraform +provider "twingate" { + api_token = "1234567890abcdef" + network = "mynetwork" +} + +resource "twingate_group" "example1" { + name = "example_1" +} + +resource "twingate_group" "example2" { + name = "example_2" +} + +data "twingate_groups" "example" { + name_prefix = "example" + + depends_on = [twingate_group.example1, twingate_group.example2] +} + +resource "twingate_dns_filtering_profile" "example" { + name = "Example DNS Filtering Profile" + priority = 2 + fallback_method = "AUTO" + groups = toset(data.twingate_groups.example.groups[*].id) + + allowed_domains { + is_authoritative = false + domains = [ + "twingate.com", + "zoom.us" + ] + } + + denied_domains { + is_authoritative = true + domains = [ + "evil.example" + ] + } + + content_categories { + block_adult_content = true + } + + security_categories { + block_dns_rebinding = false + block_newly_registered_domains = false + } + + privacy_categories { + block_disguised_trackers = true + } + +} +``` + + +## Schema + +### Required + +- `name` (String) The DNS filtering profile's name. +- `priority` (Number) A floating point number representing the profile's priority. + +### Optional + +- `allowed_domains` (Block, Optional) A block with the following attributes. (see [below for nested schema](#nestedblock--allowed_domains)) +- `content_categories` (Block, Optional) A block with the following attributes. (see [below for nested schema](#nestedblock--content_categories)) +- `denied_domains` (Block, Optional) A block with the following attributes. (see [below for nested schema](#nestedblock--denied_domains)) +- `fallback_method` (String) The DNS filtering profile's fallback method. One of "AUTO" or "STRICT". Defaults to "STRICT". +- `groups` (Set of String) A set of group IDs that have this as their DNS filtering profile. Defaults to an empty set. +- `privacy_categories` (Block, Optional) A block with the following attributes. (see [below for nested schema](#nestedblock--privacy_categories)) +- `security_categories` (Block, Optional) A block with the following attributes. (see [below for nested schema](#nestedblock--security_categories)) + +### Read-Only + +- `id` (String) Autogenerated ID of the DNS filtering profile. + + +### Nested Schema for `allowed_domains` + +Optional: + +- `domains` (Set of String) A set of allowed domains. Defaults to an empty set. +- `is_authoritative` (Boolean) Whether Terraform should override changes made outside of Terraform. Defaults to true. + + + +### Nested Schema for `content_categories` + +Optional: + +- `block_adult_content` (Boolean) Whether to block adult content. Defaults to false. +- `block_dating` (Boolean) Whether to block dating content. Defaults to false. +- `block_gambling` (Boolean) Whether to block gambling content. Defaults to false. +- `block_games` (Boolean) Whether to block games. Defaults to false. +- `block_piracy` (Boolean) Whether to block piracy sites. Defaults to false. +- `block_social_media` (Boolean) Whether to block social media. Defaults to false. +- `block_streaming` (Boolean) Whether to block streaming content. Defaults to false. +- `enable_safesearch` (Boolean) Whether to force safe search. Defaults to false. +- `enable_youtube_restricted_mode` (Boolean) Whether to force YouTube to use restricted mode. Defaults to false. + + + +### Nested Schema for `denied_domains` + +Optional: + +- `domains` (Set of String) A set of denied domains. Defaults to an empty set. +- `is_authoritative` (Boolean) Whether Terraform should override changes made outside of Terraform. Defaults to true. + + + +### Nested Schema for `privacy_categories` + +Optional: + +- `block_ads_and_trackers` (Boolean) Whether to block ads and trackers. Defaults to false. +- `block_affiliate_links` (Boolean) Whether to block affiliate links. Defaults to false. +- `block_disguised_trackers` (Boolean) Whether to block disguised third party trackers. Defaults to false. + + + +### Nested Schema for `security_categories` + +Optional: + +- `block_cryptojacking` (Boolean) Whether to block cryptojacking sites. Defaults to true. +- `block_dns_rebinding` (Boolean) Blocks public DNS entries from returning private IP addresses. Defaults to true. +- `block_domain_generation_algorithms` (Boolean) Blocks DGA domains. Defaults to true. +- `block_idn_homoglyph` (Boolean) Whether to block homoglyph attacks. Defaults to true. +- `block_newly_registered_domains` (Boolean) Blocks newly registered domains. Defaults to true. +- `block_parked_domains` (Boolean) Block parked domains. Defaults to true. +- `block_typosquatting` (Boolean) Blocks typosquatted domains. Defaults to true. +- `enable_google_safe_browsing` (Boolean) Whether to use Google Safe browsing lists to block content. Defaults to true. +- `enable_threat_intelligence_feeds` (Boolean) Whether to filter content using threat intelligence feeds. Defaults to true. diff --git a/examples/data-sources/twingate_dns_filtering_profile/data-source.tf b/examples/data-sources/twingate_dns_filtering_profile/data-source.tf new file mode 100644 index 00000000..52c56773 --- /dev/null +++ b/examples/data-sources/twingate_dns_filtering_profile/data-source.tf @@ -0,0 +1,9 @@ +provider "twingate" { + api_token = "1234567890abcdef" + network = "mynetwork" +} + +data "twingate_dns_filtering_profile" "example" { + id = "" +} + diff --git a/examples/resources/twingate_dns_filtering_profile/resource.tf b/examples/resources/twingate_dns_filtering_profile/resource.tf new file mode 100644 index 00000000..b1206d85 --- /dev/null +++ b/examples/resources/twingate_dns_filtering_profile/resource.tf @@ -0,0 +1,55 @@ +provider "twingate" { + api_token = "1234567890abcdef" + network = "mynetwork" +} + +resource "twingate_group" "example1" { + name = "example_1" +} + +resource "twingate_group" "example2" { + name = "example_2" +} + +data "twingate_groups" "example" { + name_prefix = "example" + + depends_on = [twingate_group.example1, twingate_group.example2] +} + +resource "twingate_dns_filtering_profile" "example" { + name = "Example DNS Filtering Profile" + priority = 2 + fallback_method = "AUTO" + groups = toset(data.twingate_groups.example.groups[*].id) + + allowed_domains { + is_authoritative = false + domains = [ + "twingate.com", + "zoom.us" + ] + } + + denied_domains { + is_authoritative = true + domains = [ + "evil.example" + ] + } + + content_categories { + block_adult_content = true + } + + security_categories { + block_dns_rebinding = false + block_newly_registered_domains = false + } + + privacy_categories { + block_disguised_trackers = true + } + +} + diff --git a/twingate/internal/attr/dns-filtering-profile.go b/twingate/internal/attr/dns-filtering-profile.go new file mode 100644 index 00000000..0d17a21e --- /dev/null +++ b/twingate/internal/attr/dns-filtering-profile.go @@ -0,0 +1,33 @@ +package attr + +const ( + Priority = "priority" + FallbackMethod = "fallback_method" + AllowedDomains = "allowed_domains" + DeniedDomains = "denied_domains" + Domains = "domains" + PrivacyCategories = "privacy_categories" + BlockAffiliateLinks = "block_affiliate_links" + BlockDisguisedTrackers = "block_disguised_trackers" + BlockAdsAndTrackers = "block_ads_and_trackers" + SecurityCategories = "security_categories" + EnableThreatIntelligenceFeeds = "enable_threat_intelligence_feeds" + EnableGoogleSafeBrowsing = "enable_google_safe_browsing" + BlockCryptojacking = "block_cryptojacking" + BlockIdnHomoglyph = "block_idn_homoglyph" + BlockTyposquatting = "block_typosquatting" + BlockDNSRebinding = "block_dns_rebinding" + BlockNewlyRegisteredDomains = "block_newly_registered_domains" + BlockDomainGenerationAlgorithms = "block_domain_generation_algorithms" + BlockParkedDomains = "block_parked_domains" + ContentCategories = "content_categories" + BlockGambling = "block_gambling" + BlockDating = "block_dating" + BlockAdultContent = "block_adult_content" + BlockSocialMedia = "block_social_media" + BlockGames = "block_games" + BlockStreaming = "block_streaming" + BlockPiracy = "block_piracy" + EnableYoutubeRestrictedMode = "enable_youtube_restricted_mode" + EnableSafesearch = "enable_safesearch" +) diff --git a/twingate/internal/client/dns-filtering-profile.go b/twingate/internal/client/dns-filtering-profile.go new file mode 100644 index 00000000..426949d1 --- /dev/null +++ b/twingate/internal/client/dns-filtering-profile.go @@ -0,0 +1,208 @@ +package client + +import ( + "context" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/client/query" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/model" +) + +func (client *Client) ReadShallowDNSFilteringProfiles(ctx context.Context) ([]*model.DNSFilteringProfile, error) { + opr := resourceDNSFilteringProfile.read().withCustomName("readShallowDNSFilteringProfiles") + + response := query.ReadDNSFilteringProfiles{} + if err := client.query(ctx, &response, nil, opr, attr{id: "All"}); err != nil { + return nil, err + } + + return response.ToModel(), nil +} + +func (client *Client) ReadDNSFilteringProfile(ctx context.Context, profileID string) (*model.DNSFilteringProfile, error) { + opr := resourceDNSFilteringProfile.read() + + if profileID == "" { + return nil, opr.apiError(ErrGraphqlIDIsEmpty) + } + + variables := newVars( + gqlID(profileID), + cursor(query.CursorGroups), + pageLimit(defaultPageLimit), + ) + + response := query.ReadDNSFilteringProfile{} + if err := client.query(ctx, &response, variables, opr, attr{id: profileID}); err != nil { + return nil, err + } + + oprCtx := withOperationCtx(ctx, opr) + + if err := response.DNSFilteringProfile.Groups.FetchPages(oprCtx, client.readDNSFilteringProfileGroupsAfter, variables); err != nil { + return nil, err //nolint + } + + return response.ToModel(), nil +} + +func (client *Client) CreateDNSFilteringProfile(ctx context.Context, name string) (*model.DNSFilteringProfile, error) { + opr := resourceDNSFilteringProfile.create() + + if name == "" { + return nil, opr.apiError(ErrGraphqlNameIsEmpty) + } + + variables := newVars( + gqlVar(name, "name"), + cursor(query.CursorGroups), + pageLimit(defaultPageLimit), + ) + + var response query.CreateDNSFilteringProfile + if err := client.mutate(ctx, &response, variables, opr, attr{name: name}); err != nil { + return nil, err + } + + return response.Entity.ToModel(), nil +} + +type PrivacyCategoryConfigInput struct { + BlockAdsAndTrackers bool `json:"blockAdsAndTrackers"` + BlockAffiliate bool `json:"blockAffiliate"` + BlockDisguisedTrackers bool `json:"blockDisguisedTrackers"` +} + +func newPrivacyCategoryConfigInput(input *model.PrivacyCategories) *PrivacyCategoryConfigInput { + if input == nil { + return nil + } + + return &PrivacyCategoryConfigInput{ + BlockAdsAndTrackers: input.BlockAdsAndTrackers, + BlockAffiliate: input.BlockAffiliate, + BlockDisguisedTrackers: input.BlockDisguisedTrackers, + } +} + +type SecurityCategoryConfigInput struct { + BlockCryptojacking bool `json:"blockCryptojacking"` + BlockDNSRebinding bool `json:"blockDnsRebinding"` + BlockDomainGenerationAlgorithms bool `json:"blockDomainGenerationAlgorithms"` + BlockIdnHomographs bool `json:"blockIdnHomographs"` + BlockNewlyRegisteredDomains bool `json:"blockNewlyRegisteredDomains"` + BlockParkedDomains bool `json:"blockParkedDomains"` + BlockTyposquatting bool `json:"blockTyposquatting"` + EnableGoogleSafeBrowsing bool `json:"enableGoogleSafeBrowsing"` + EnableThreatIntelligenceFeeds bool `json:"enableThreatIntelligenceFeeds"` +} + +func newSecurityCategoryConfigInput(input *model.SecurityCategory) *SecurityCategoryConfigInput { + if input == nil { + return nil + } + + return &SecurityCategoryConfigInput{ + BlockCryptojacking: input.BlockCryptojacking, + BlockDNSRebinding: input.BlockDNSRebinding, + BlockDomainGenerationAlgorithms: input.BlockDomainGenerationAlgorithms, + BlockIdnHomographs: input.BlockIdnHomographs, + BlockNewlyRegisteredDomains: input.BlockNewlyRegisteredDomains, + BlockParkedDomains: input.BlockParkedDomains, + BlockTyposquatting: input.BlockTyposquatting, + EnableGoogleSafeBrowsing: input.EnableGoogleSafeBrowsing, + EnableThreatIntelligenceFeeds: input.EnableThreatIntelligenceFeeds, + } +} + +type ContentCategoryConfigInput struct { + BlockAdultContent bool `json:"blockAdultContent"` + BlockDating bool `json:"blockDating"` + BlockGambling bool `json:"blockGambling"` + BlockGames bool `json:"blockGames"` + BlockPiracy bool `json:"blockPiracy"` + BlockSocialMedia bool `json:"blockSocialMedia"` + BlockStreaming bool `json:"blockStreaming"` + EnableSafeSearch bool `json:"enableSafeSearch"` + EnableYoutubeRestrictedMode bool `json:"enableYoutubeRestrictedMode"` +} + +func newContentCategoryConfigInput(input *model.ContentCategory) *ContentCategoryConfigInput { + if input == nil { + return nil + } + + return &ContentCategoryConfigInput{ + BlockAdultContent: input.BlockAdultContent, + BlockDating: input.BlockDating, + BlockGambling: input.BlockGambling, + BlockGames: input.BlockGames, + BlockPiracy: input.BlockPiracy, + BlockSocialMedia: input.BlockSocialMedia, + BlockStreaming: input.BlockStreaming, + EnableSafeSearch: input.EnableSafeSearch, + EnableYoutubeRestrictedMode: input.EnableYoutubeRestrictedMode, + } +} + +type DohFallbackMethod string + +func (client *Client) UpdateDNSFilteringProfile(ctx context.Context, input *model.DNSFilteringProfile) (*model.DNSFilteringProfile, error) { + opr := resourceDNSFilteringProfile.update() + + if input == nil || input.ID == "" { + return nil, opr.apiError(ErrGraphqlIDIsEmpty) + } + + variables := newVars( + gqlID(input.ID, "id"), + gqlNullable(input.Name, "name"), + gqlNullable(input.Priority, "priority"), + gqlVar(input.AllowedDomains, "allowedDomains"), + gqlVar(input.DeniedDomains, "deniedDomains"), + gqlVar(DohFallbackMethod(input.FallbackMethod), "fallbackMethod"), + gqlVar(input.Groups, "groups"), + gqlVar(newPrivacyCategoryConfigInput(input.PrivacyCategories), "privacyCategoryConfig"), + gqlVar(newSecurityCategoryConfigInput(input.SecurityCategories), "securityCategoryConfig"), + gqlVar(newContentCategoryConfigInput(input.ContentCategories), "contentCategoryConfig"), + cursor(query.CursorGroups), + pageLimit(defaultPageLimit), + ) + + var response query.UpdateDNSFilteringProfile + if err := client.mutate(ctx, &response, variables, opr, attr{id: input.ID}); err != nil { + return nil, err + } + + oprCtx := withOperationCtx(ctx, opr) + + if err := response.Entity.Groups.FetchPages(oprCtx, client.readDNSFilteringProfileGroupsAfter, variables); err != nil { + return nil, err //nolint + } + + return response.Entity.ToModel(), nil +} + +func (client *Client) readDNSFilteringProfileGroupsAfter(ctx context.Context, variables map[string]interface{}, cursor string) (*query.PaginatedResource[*query.GroupIDEdge], error) { + opr := resourceGroup.read().withCustomName("readDNSFilteringProfileGroupsAfter") + + variables[query.CursorGroups] = cursor + + response := query.ReadDNSFilteringProfileGroups{} + if err := client.query(ctx, &response, variables, opr, attr{id: "All"}); err != nil { + return nil, err + } + + return &response.DNSFilteringProfile.Groups.PaginatedResource, nil +} + +func (client *Client) DeleteDNSFilteringProfile(ctx context.Context, profileID string) error { + opr := resourceDNSFilteringProfile.delete() + + if profileID == "" { + return opr.apiError(ErrGraphqlIDIsEmpty) + } + + var response query.DeleteDNSFilteringProfile + + return client.mutate(ctx, &response, newVars(gqlID(profileID)), opr, attr{id: profileID}) +} diff --git a/twingate/internal/client/operation.go b/twingate/internal/client/operation.go index c70033aa..b0e5139a 100644 --- a/twingate/internal/client/operation.go +++ b/twingate/internal/client/operation.go @@ -12,16 +12,17 @@ import ( type resource string const ( - resourceConnector resource = "connector" - resourceConnectorToken resource = "connector token" - resourceGroup resource = "group" - resourceRemoteNetwork resource = "remote network" - resourceResource resource = "resource" - resourceResourceAccess resource = "resource access" - resourceSecurityPolicy resource = "security policy" - resourceServiceAccount resource = "service account" - resourceServiceKey resource = "service account key" - resourceUser resource = "user" + resourceConnector resource = "connector" + resourceConnectorToken resource = "connector token" + resourceGroup resource = "group" + resourceRemoteNetwork resource = "remote network" + resourceResource resource = "resource" + resourceResourceAccess resource = "resource access" + resourceSecurityPolicy resource = "security policy" + resourceServiceAccount resource = "service account" + resourceServiceKey resource = "service account key" + resourceUser resource = "user" + resourceDNSFilteringProfile resource = "DNS filtering profile" ) const ( diff --git a/twingate/internal/client/query/dns-filtering-profile-create.go b/twingate/internal/client/query/dns-filtering-profile-create.go new file mode 100644 index 00000000..42380609 --- /dev/null +++ b/twingate/internal/client/query/dns-filtering-profile-create.go @@ -0,0 +1,14 @@ +package query + +type CreateDNSFilteringProfile struct { + DNSFilteringProfileEntityResponse `graphql:"dnsFilteringProfileCreate(name: $name)"` +} + +type DNSFilteringProfileEntityResponse struct { + Entity *gqlDNSFilteringProfile + OkError +} + +func (r *DNSFilteringProfileEntityResponse) IsEmpty() bool { + return r == nil || r.Entity == nil +} diff --git a/twingate/internal/client/query/dns-filtering-profile-delete.go b/twingate/internal/client/query/dns-filtering-profile-delete.go new file mode 100644 index 00000000..6572d0c6 --- /dev/null +++ b/twingate/internal/client/query/dns-filtering-profile-delete.go @@ -0,0 +1,9 @@ +package query + +type DeleteDNSFilteringProfile struct { + OkError `graphql:"dnsFilteringProfileDelete(id: $id)"` +} + +func (q DeleteDNSFilteringProfile) IsEmpty() bool { + return false +} diff --git a/twingate/internal/client/query/dns-filtering-profile-read.go b/twingate/internal/client/query/dns-filtering-profile-read.go new file mode 100644 index 00000000..39b98216 --- /dev/null +++ b/twingate/internal/client/query/dns-filtering-profile-read.go @@ -0,0 +1,145 @@ +package query + +import ( + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/model" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/utils" +) + +type ReadDNSFilteringProfile struct { + DNSFilteringProfile *gqlDNSFilteringProfile `graphql:"dnsFilteringProfile(id: $id)"` +} + +type gqlDNSFilteringProfile struct { + IDName + Priority float64 + AllowedDomains []string + DeniedDomains []string + FallbackMethod string + Groups gqlGroupIDs `graphql:"groups(after: $groupsEndCursor, first: $pageLimit)"` + PrivacyCategoryConfig *PrivacyCategoryConfig + SecurityCategoryConfig *SecurityCategoryConfig + ContentCategoryConfig *ContentCategoryConfig +} + +type gqlGroupIDs struct { + PaginatedResource[*GroupIDEdge] +} + +func (g gqlGroupIDs) ToModel() []string { + return utils.Map[*GroupIDEdge, string](g.Edges, func(edge *GroupIDEdge) string { + return string(edge.Node.ID) + }) +} + +type GroupIDEdge struct { + Node *gqlGroupID +} + +type gqlGroupID struct { + IDName +} + +type PrivacyCategoryConfig struct { + BlockAffiliate bool + BlockDisguisedTrackers bool + BlockAdsAndTrackers bool +} + +type SecurityCategoryConfig struct { + EnableThreatIntelligenceFeeds bool + EnableGoogleSafeBrowsing bool + BlockCryptojacking bool + BlockIdnHomographs bool + BlockTyposquatting bool + BlockDnsRebinding bool //nolint:stylecheck + BlockNewlyRegisteredDomains bool + BlockDomainGenerationAlgorithms bool + BlockParkedDomains bool +} + +type ContentCategoryConfig struct { + BlockGambling bool + BlockDating bool + BlockAdultContent bool + BlockSocialMedia bool + BlockGames bool + BlockStreaming bool + BlockPiracy bool + EnableYoutubeRestrictedMode bool + EnableSafeSearch bool +} + +func (q ReadDNSFilteringProfile) IsEmpty() bool { + return q.DNSFilteringProfile == nil +} + +func (q ReadDNSFilteringProfile) ToModel() *model.DNSFilteringProfile { + if q.DNSFilteringProfile == nil { + return nil + } + + return q.DNSFilteringProfile.ToModel() +} + +func (p gqlDNSFilteringProfile) ToModel() *model.DNSFilteringProfile { + profile := &model.DNSFilteringProfile{ + ID: string(p.ID), + Name: p.Name, + Priority: p.Priority, + FallbackMethod: p.FallbackMethod, + AllowedDomains: p.AllowedDomains, + DeniedDomains: p.DeniedDomains, + Groups: p.Groups.ToModel(), + } + + if p.PrivacyCategoryConfig != nil { + profile.PrivacyCategories = &model.PrivacyCategories{ + BlockAffiliate: p.PrivacyCategoryConfig.BlockAffiliate, + BlockDisguisedTrackers: p.PrivacyCategoryConfig.BlockDisguisedTrackers, + BlockAdsAndTrackers: p.PrivacyCategoryConfig.BlockAdsAndTrackers, + } + } + + if p.SecurityCategoryConfig != nil { + profile.SecurityCategories = &model.SecurityCategory{ + EnableThreatIntelligenceFeeds: p.SecurityCategoryConfig.EnableThreatIntelligenceFeeds, + EnableGoogleSafeBrowsing: p.SecurityCategoryConfig.EnableGoogleSafeBrowsing, + BlockCryptojacking: p.SecurityCategoryConfig.BlockCryptojacking, + BlockIdnHomographs: p.SecurityCategoryConfig.BlockIdnHomographs, + BlockTyposquatting: p.SecurityCategoryConfig.BlockTyposquatting, + BlockDNSRebinding: p.SecurityCategoryConfig.BlockDnsRebinding, + BlockNewlyRegisteredDomains: p.SecurityCategoryConfig.BlockNewlyRegisteredDomains, + BlockDomainGenerationAlgorithms: p.SecurityCategoryConfig.BlockDomainGenerationAlgorithms, + BlockParkedDomains: p.SecurityCategoryConfig.BlockParkedDomains, + } + } + + if p.ContentCategoryConfig != nil { + profile.ContentCategories = &model.ContentCategory{ + BlockGambling: p.ContentCategoryConfig.BlockGambling, + BlockDating: p.ContentCategoryConfig.BlockDating, + BlockAdultContent: p.ContentCategoryConfig.BlockAdultContent, + BlockSocialMedia: p.ContentCategoryConfig.BlockSocialMedia, + BlockGames: p.ContentCategoryConfig.BlockGames, + BlockStreaming: p.ContentCategoryConfig.BlockStreaming, + BlockPiracy: p.ContentCategoryConfig.BlockPiracy, + EnableYoutubeRestrictedMode: p.ContentCategoryConfig.EnableYoutubeRestrictedMode, + EnableSafeSearch: p.ContentCategoryConfig.EnableSafeSearch, + } + } + + return profile +} + +type ReadDNSFilteringProfileGroups struct { + DNSFilteringProfile *gqlDNSFilteringProfileGroups `graphql:"dnsFilteringProfile(id: $id)"` +} + +func (q ReadDNSFilteringProfileGroups) IsEmpty() bool { + return q.DNSFilteringProfile == nil +} + +type gqlDNSFilteringProfileGroups struct { + IDName + Groups gqlGroupIDs `graphql:"groups(after: $groupsEndCursor, first: $pageLimit)"` +} diff --git a/twingate/internal/client/query/dns-filtering-profile-update.go b/twingate/internal/client/query/dns-filtering-profile-update.go new file mode 100644 index 00000000..4dc6bbb6 --- /dev/null +++ b/twingate/internal/client/query/dns-filtering-profile-update.go @@ -0,0 +1,5 @@ +package query + +type UpdateDNSFilteringProfile struct { + DNSFilteringProfileEntityResponse `graphql:"dnsFilteringProfileUpdate(id: $id, name: $name, priority: $priority, allowedDomains: $allowedDomains, deniedDomains: $deniedDomains, fallbackMethod: $fallbackMethod, groups: $groups, privacyCategoryConfig: $privacyCategoryConfig, securityCategoryConfig: $securityCategoryConfig, contentCategoryConfig: $contentCategoryConfig)"` +} diff --git a/twingate/internal/client/query/dns-filtering-profiles-read.go b/twingate/internal/client/query/dns-filtering-profiles-read.go new file mode 100644 index 00000000..10ac4012 --- /dev/null +++ b/twingate/internal/client/query/dns-filtering-profiles-read.go @@ -0,0 +1,29 @@ +package query + +import ( + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/model" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/utils" +) + +type ReadDNSFilteringProfiles struct { + DNSFilteringProfiles []*gqlShallowDNSFilteringProfile `graphql:"dnsFilteringProfiles"` +} + +type gqlShallowDNSFilteringProfile struct { + IDName + Priority float64 +} + +func (q ReadDNSFilteringProfiles) IsEmpty() bool { + return len(q.DNSFilteringProfiles) == 0 +} + +func (q ReadDNSFilteringProfiles) ToModel() []*model.DNSFilteringProfile { + return utils.Map(q.DNSFilteringProfiles, func(item *gqlShallowDNSFilteringProfile) *model.DNSFilteringProfile { + return &model.DNSFilteringProfile{ + ID: string(item.ID), + Name: item.Name, + Priority: item.Priority, + } + }) +} diff --git a/twingate/internal/model/dns-filtering-profile.go b/twingate/internal/model/dns-filtering-profile.go new file mode 100644 index 00000000..27d39535 --- /dev/null +++ b/twingate/internal/model/dns-filtering-profile.go @@ -0,0 +1,59 @@ +package model + +const ( + FallbackMethodAuto = "AUTO" + FallbackMethodStrict = "STRICT" +) + +var FallbackMethods = []string{FallbackMethodAuto, FallbackMethodStrict} //nolint + +type DNSFilteringProfile struct { + ID string + Name string + AllowedDomains []string + DeniedDomains []string + Groups []string + FallbackMethod string + Priority float64 + PrivacyCategories *PrivacyCategories + SecurityCategories *SecurityCategory + ContentCategories *ContentCategory +} + +type PrivacyCategories struct { + BlockAffiliate bool + BlockDisguisedTrackers bool + BlockAdsAndTrackers bool +} + +type SecurityCategory struct { + EnableThreatIntelligenceFeeds bool + EnableGoogleSafeBrowsing bool + BlockCryptojacking bool + BlockIdnHomographs bool + BlockTyposquatting bool + BlockDNSRebinding bool + BlockNewlyRegisteredDomains bool + BlockDomainGenerationAlgorithms bool + BlockParkedDomains bool +} + +type ContentCategory struct { + BlockGambling bool + BlockDating bool + BlockAdultContent bool + BlockSocialMedia bool + BlockGames bool + BlockStreaming bool + BlockPiracy bool + EnableYoutubeRestrictedMode bool + EnableSafeSearch bool +} + +func (p DNSFilteringProfile) GetName() string { + return p.Name +} + +func (p DNSFilteringProfile) GetID() string { + return p.ID +} diff --git a/twingate/internal/provider/datasource/all-datasources.go b/twingate/internal/provider/datasource/all-datasources.go index 3831603f..5f102178 100644 --- a/twingate/internal/provider/datasource/all-datasources.go +++ b/twingate/internal/provider/datasource/all-datasources.go @@ -1,19 +1,20 @@ package datasource const ( - TwingateGroup = "twingate_group" - TwingateGroups = "twingate_groups" - TwingateRemoteNetwork = "twingate_remote_network" - TwingateRemoteNetworks = "twingate_remote_networks" - TwingateUser = "twingate_user" - TwingateUsers = "twingate_users" - TwingateConnector = "twingate_connector" - TwingateConnectors = "twingate_connectors" - TwingateResource = "twingate_resource" - TwingateResources = "twingate_resources" - TwingateServiceAccounts = "twingate_service_accounts" - TwingateSecurityPolicy = "twingate_security_policy" // #nosec G101 - TwingateSecurityPolicies = "twingate_security_policies" + TwingateGroup = "twingate_group" + TwingateGroups = "twingate_groups" + TwingateRemoteNetwork = "twingate_remote_network" + TwingateRemoteNetworks = "twingate_remote_networks" + TwingateUser = "twingate_user" + TwingateUsers = "twingate_users" + TwingateConnector = "twingate_connector" + TwingateConnectors = "twingate_connectors" + TwingateResource = "twingate_resource" + TwingateResources = "twingate_resources" + TwingateServiceAccounts = "twingate_service_accounts" + TwingateSecurityPolicy = "twingate_security_policy" // #nosec G101 + TwingateSecurityPolicies = "twingate_security_policies" + TwingateDNSFilteringProfile = "twingate_dns_filtering_profile" computedDatasourceIDDescription = "The ID of this resource." diff --git a/twingate/internal/provider/datasource/converter.go b/twingate/internal/provider/datasource/converter.go index 5f7592de..d43c3652 100644 --- a/twingate/internal/provider/datasource/converter.go +++ b/twingate/internal/provider/datasource/converter.go @@ -3,6 +3,7 @@ package datasource import ( "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/attr" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -82,3 +83,17 @@ func convertRemoteNetworksToTerraform(networks []*model.RemoteNetwork) []remoteN } }) } + +func convertDomainsToTerraform(domains []string) *domainsModel { + return &domainsModel{ + Domains: convertStringListToSet(domains), + } +} + +func convertStringListToSet(items []string) types.Set { + values := utils.Map(items, func(item string) attr.Value { + return types.StringValue(item) + }) + + return types.SetValueMust(types.StringType, values) +} diff --git a/twingate/internal/provider/datasource/dns-filtering-profile.go b/twingate/internal/provider/datasource/dns-filtering-profile.go new file mode 100644 index 00000000..3c159748 --- /dev/null +++ b/twingate/internal/provider/datasource/dns-filtering-profile.go @@ -0,0 +1,311 @@ +package datasource + +import ( + "context" + "fmt" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/attr" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/client" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the desired interfaces. +var _ datasource.DataSource = &dnsFilteringProfile{} + +func NewDNSFilteringProfileDatasource() datasource.DataSource { + return &dnsFilteringProfile{} +} + +type dnsFilteringProfile struct { + client *client.Client +} + +type dnsFilteringProfileModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Priority types.Float64 `tfsdk:"priority"` + FallbackMethod types.String `tfsdk:"fallback_method"` + Groups types.Set `tfsdk:"groups"` + AllowedDomains *domainsModel `tfsdk:"allowed_domains"` + DeniedDomains *domainsModel `tfsdk:"denied_domains"` + ContentCategories *contentCategoriesModel `tfsdk:"content_categories"` + SecurityCategories *securityCategoriesModel `tfsdk:"security_categories"` + PrivacyCategories *privacyCategoriesModel `tfsdk:"privacy_categories"` +} + +type domainsModel struct { + Domains types.Set `tfsdk:"domains"` +} + +type privacyCategoriesModel struct { + BlockAffiliateLinks types.Bool `tfsdk:"block_affiliate_links"` + BlockDisguisedTrackers types.Bool `tfsdk:"block_disguised_trackers"` + BlockAdsAndTrackers types.Bool `tfsdk:"block_ads_and_trackers"` +} + +type securityCategoriesModel struct { + EnableThreatIntelligenceFeeds types.Bool `tfsdk:"enable_threat_intelligence_feeds"` + EnableGoogleSafeBrowsing types.Bool `tfsdk:"enable_google_safe_browsing"` + BlockCryptojacking types.Bool `tfsdk:"block_cryptojacking"` + BlockIdnHomoglyph types.Bool `tfsdk:"block_idn_homoglyph"` + BlockTyposquatting types.Bool `tfsdk:"block_typosquatting"` + BlockDNSRebinding types.Bool `tfsdk:"block_dns_rebinding"` + BlockNewlyRegisteredDomains types.Bool `tfsdk:"block_newly_registered_domains"` + BlockDomainGenerationAlgorithms types.Bool `tfsdk:"block_domain_generation_algorithms"` + BlockParkedDomains types.Bool `tfsdk:"block_parked_domains"` +} + +type contentCategoriesModel struct { + BlockGambling types.Bool `tfsdk:"block_gambling"` + BlockDating types.Bool `tfsdk:"block_dating"` + BlockAdultContent types.Bool `tfsdk:"block_adult_content"` + BlockSocialMedia types.Bool `tfsdk:"block_social_media"` + BlockGames types.Bool `tfsdk:"block_games"` + BlockStreaming types.Bool `tfsdk:"block_streaming"` + BlockPiracy types.Bool `tfsdk:"block_piracy"` + EnableYoutubeRestrictedMode types.Bool `tfsdk:"enable_youtube_restricted_mode"` + EnableSafesearch types.Bool `tfsdk:"enable_safesearch"` +} + +func (d *dnsFilteringProfile) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = TwingateDNSFilteringProfile +} + +func (d *dnsFilteringProfile) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*client.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *client.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *dnsFilteringProfile) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { //nolint:funlen + resp.Schema = schema.Schema{ + Description: "DNS filtering gives you the ability to control what websites your users can access. DNS filtering is only available on certain plans. For more information, see Twingate's [documentation](https://www.twingate.com/docs/dns-filtering). DNS filtering must be enabled for this data source to work. If DNS filtering isn't enabled, the provider will throw an error.", + Attributes: map[string]schema.Attribute{ + attr.ID: schema.StringAttribute{ + Required: true, + Description: "The DNS filtering profile's ID.", + }, + // computed + attr.Name: schema.StringAttribute{ + Computed: true, + Description: "The DNS filtering profile's name.", + }, + attr.Priority: schema.Float64Attribute{ + Computed: true, + Description: "A floating point number representing the profile's priority.", + }, + attr.FallbackMethod: schema.StringAttribute{ + Computed: true, + Description: "The DNS filtering profile's fallback method. One of AUTOMATIC or STRICT.", + }, + attr.Groups: schema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + Description: "A set of group IDs that have this as their DNS filtering profile. Defaults to an empty set.", + }, + }, + + Blocks: map[string]schema.Block{ + attr.AllowedDomains: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.Domains: schema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + Description: "A set of allowed domains.", + }, + }, + }, + attr.DeniedDomains: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.Domains: schema.SetAttribute{ + Computed: true, + ElementType: types.StringType, + Description: "A set of denied domains.", + }, + }, + }, + + attr.PrivacyCategories: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.BlockAffiliateLinks: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block affiliate links.", + }, + attr.BlockDisguisedTrackers: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block disguised third party trackers.", + }, + attr.BlockAdsAndTrackers: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block ads and trackers.", + }, + }, + }, + + attr.SecurityCategories: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.EnableThreatIntelligenceFeeds: schema.BoolAttribute{ + Computed: true, + Description: "Whether to filter content using threat intelligence feeds.", + }, + attr.EnableGoogleSafeBrowsing: schema.BoolAttribute{ + Computed: true, + Description: "Whether to use Google Safe browsing lists to block content.", + }, + attr.BlockCryptojacking: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block cryptojacking sites.", + }, + attr.BlockIdnHomoglyph: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block homoglyph attacks.", + }, + attr.BlockTyposquatting: schema.BoolAttribute{ + Computed: true, + Description: "Blocks typosquatted domains.", + }, + attr.BlockDNSRebinding: schema.BoolAttribute{ + Computed: true, + Description: "Blocks public DNS entries from returning private IP addresses.", + }, + attr.BlockNewlyRegisteredDomains: schema.BoolAttribute{ + Computed: true, + Description: "Blocks newly registered domains.", + }, + attr.BlockDomainGenerationAlgorithms: schema.BoolAttribute{ + Computed: true, + Description: "Blocks DGA domains.", + }, + attr.BlockParkedDomains: schema.BoolAttribute{ + Computed: true, + Description: "Block parked domains.", + }, + }, + }, + + attr.ContentCategories: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.BlockGambling: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block gambling content.", + }, + attr.BlockDating: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block dating content.", + }, + attr.BlockAdultContent: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block adult content.", + }, + attr.BlockSocialMedia: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block social media.", + }, + attr.BlockGames: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block games.", + }, + attr.BlockStreaming: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block streaming content.", + }, + attr.BlockPiracy: schema.BoolAttribute{ + Computed: true, + Description: "Whether to block piracy sites.", + }, + attr.EnableYoutubeRestrictedMode: schema.BoolAttribute{ + Computed: true, + Description: "Whether to force YouTube to use restricted mode.", + }, + attr.EnableSafesearch: schema.BoolAttribute{ + Computed: true, + Description: "Whether to force safe search.", + }, + }, + }, + }, + } +} + +func (d *dnsFilteringProfile) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:funlen + var data dnsFilteringProfileModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + profile, err := d.client.ReadDNSFilteringProfile(ctx, data.ID.ValueString()) + if err != nil { + addErr(&resp.Diagnostics, err, TwingateDNSFilteringProfile) + + return + } + + data.Name = types.StringValue(profile.Name) + data.Priority = types.Float64Value(profile.Priority) + data.FallbackMethod = types.StringValue(profile.FallbackMethod) + data.AllowedDomains = convertDomainsToTerraform(profile.AllowedDomains) + data.DeniedDomains = convertDomainsToTerraform(profile.DeniedDomains) + data.Groups = convertStringListToSet(profile.Groups) + + if profile.PrivacyCategories != nil { + data.PrivacyCategories = &privacyCategoriesModel{ + BlockAffiliateLinks: types.BoolValue(profile.PrivacyCategories.BlockAffiliate), + BlockDisguisedTrackers: types.BoolValue(profile.PrivacyCategories.BlockDisguisedTrackers), + BlockAdsAndTrackers: types.BoolValue(profile.PrivacyCategories.BlockAdsAndTrackers), + } + } + + if profile.ContentCategories != nil { + data.ContentCategories = &contentCategoriesModel{ + BlockGambling: types.BoolValue(profile.ContentCategories.BlockGambling), + BlockDating: types.BoolValue(profile.ContentCategories.BlockDating), + BlockAdultContent: types.BoolValue(profile.ContentCategories.BlockAdultContent), + BlockSocialMedia: types.BoolValue(profile.ContentCategories.BlockSocialMedia), + BlockGames: types.BoolValue(profile.ContentCategories.BlockGames), + BlockStreaming: types.BoolValue(profile.ContentCategories.BlockStreaming), + BlockPiracy: types.BoolValue(profile.ContentCategories.BlockPiracy), + EnableYoutubeRestrictedMode: types.BoolValue(profile.ContentCategories.EnableYoutubeRestrictedMode), + EnableSafesearch: types.BoolValue(profile.ContentCategories.EnableSafeSearch), + } + } + + if profile.SecurityCategories != nil { + data.SecurityCategories = &securityCategoriesModel{ + EnableThreatIntelligenceFeeds: types.BoolValue(profile.SecurityCategories.EnableThreatIntelligenceFeeds), + EnableGoogleSafeBrowsing: types.BoolValue(profile.SecurityCategories.EnableGoogleSafeBrowsing), + BlockCryptojacking: types.BoolValue(profile.SecurityCategories.BlockCryptojacking), + BlockIdnHomoglyph: types.BoolValue(profile.SecurityCategories.BlockIdnHomographs), + BlockTyposquatting: types.BoolValue(profile.SecurityCategories.BlockTyposquatting), + BlockDNSRebinding: types.BoolValue(profile.SecurityCategories.BlockDNSRebinding), + BlockNewlyRegisteredDomains: types.BoolValue(profile.SecurityCategories.BlockNewlyRegisteredDomains), + BlockDomainGenerationAlgorithms: types.BoolValue(profile.SecurityCategories.BlockDomainGenerationAlgorithms), + BlockParkedDomains: types.BoolValue(profile.SecurityCategories.BlockParkedDomains), + } + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/twingate/internal/provider/resource/all-resources.go b/twingate/internal/provider/resource/all-resources.go index 3f285921..eb325c12 100644 --- a/twingate/internal/provider/resource/all-resources.go +++ b/twingate/internal/provider/resource/all-resources.go @@ -1,14 +1,15 @@ package resource const ( - TwingateRemoteNetwork = "twingate_remote_network" - TwingateConnector = "twingate_connector" - TwingateConnectorTokens = "twingate_connector_tokens" - TwingateGroup = "twingate_group" - TwingateResource = "twingate_resource" - TwingateServiceAccount = "twingate_service_account" - TwingateServiceAccountKey = "twingate_service_account_key" - TwingateUser = "twingate_user" + TwingateRemoteNetwork = "twingate_remote_network" + TwingateConnector = "twingate_connector" + TwingateConnectorTokens = "twingate_connector_tokens" + TwingateGroup = "twingate_group" + TwingateResource = "twingate_resource" + TwingateServiceAccount = "twingate_service_account" + TwingateServiceAccountKey = "twingate_service_account_key" + TwingateUser = "twingate_user" + TwingateDNSFilteringProfile = "twingate_dns_filtering_profile" operationCreate = "create" operationRead = "read" diff --git a/twingate/internal/provider/resource/dns-filtering-profile.go b/twingate/internal/provider/resource/dns-filtering-profile.go new file mode 100644 index 00000000..49fbcd34 --- /dev/null +++ b/twingate/internal/provider/resource/dns-filtering-profile.go @@ -0,0 +1,695 @@ +package resource + +import ( + "context" + "errors" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/attr" + "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/stringvalidator" + tfattr "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the desired interfaces. +var _ resource.Resource = &dnsFilteringProfile{} +var _ resource.ResourceWithImportState = &dnsFilteringProfile{} + +func NewDNSFilteringProfile() resource.Resource { + return &dnsFilteringProfile{} +} + +type dnsFilteringProfile struct { + client *client.Client +} + +type dnsFilteringProfileModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Priority types.Float64 `tfsdk:"priority"` + FallbackMethod types.String `tfsdk:"fallback_method"` + Groups types.Set `tfsdk:"groups"` + AllowedDomains types.Object `tfsdk:"allowed_domains"` + DeniedDomains types.Object `tfsdk:"denied_domains"` + ContentCategories types.Object `tfsdk:"content_categories"` + SecurityCategories types.Object `tfsdk:"security_categories"` + PrivacyCategories types.Object `tfsdk:"privacy_categories"` +} + +func (r *dnsFilteringProfile) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = TwingateDNSFilteringProfile +} + +func (r *dnsFilteringProfile) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*client.Client) +} + +func (r *dnsFilteringProfile) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(attr.ID), req, resp) + + profile, err := r.client.ReadDNSFilteringProfile(ctx, req.ID) + if err != nil { + resp.Diagnostics.AddError("failed to import state", err.Error()) + + return + } + + resp.State.SetAttribute(ctx, path.Root(attr.FallbackMethod), types.StringValue(profile.FallbackMethod)) + resp.State.SetAttribute(ctx, path.Root(attr.Groups), convertStringListToSet(profile.Groups)) + + if len(profile.AllowedDomains) > 0 { + resp.State.SetAttribute(ctx, path.Root(attr.AllowedDomains), convertDomainsToTerraform(profile.AllowedDomains, nil)) + } + + if len(profile.DeniedDomains) > 0 { + resp.State.SetAttribute(ctx, path.Root(attr.DeniedDomains), convertDomainsToTerraform(profile.DeniedDomains, nil)) + } + + if profile.ContentCategories != nil { + resp.State.SetAttribute(ctx, path.Root(attr.ContentCategories), convertContentCategoriesToTerraform(profile.ContentCategories)) + } + + if profile.SecurityCategories != nil { + resp.State.SetAttribute(ctx, path.Root(attr.SecurityCategories), convertSecurityCategoriesToTerraform(profile.SecurityCategories)) + } + + if profile.PrivacyCategories != nil { + resp.State.SetAttribute(ctx, path.Root(attr.PrivacyCategories), convertPrivacyCategoriesToTerraform(profile.PrivacyCategories)) + } +} + +func (r *dnsFilteringProfile) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { //nolint + resp.Schema = schema.Schema{ + Description: "DNS filtering gives you the ability to control what websites your users can access. DNS filtering is only available on certain plans. For more information, see Twingate's [documentation](https://www.twingate.com/docs/dns-filtering). DNS filtering must be enabled for this resources to work. If DNS filtering isn't enabled, the provider will throw an error.", + Attributes: map[string]schema.Attribute{ + attr.Name: schema.StringAttribute{ + Required: true, + Description: "The DNS filtering profile's name.", + }, + attr.Priority: schema.Float64Attribute{ + Required: true, + Description: "A floating point number representing the profile's priority.", + }, + // optional + attr.FallbackMethod: schema.StringAttribute{ + Optional: true, + Computed: true, + Description: `The DNS filtering profile's fallback method. One of "AUTO" or "STRICT". Defaults to "STRICT".`, + Default: stringdefault.StaticString(model.FallbackMethodStrict), + Validators: []validator.String{ + stringvalidator.OneOf(model.FallbackMethods...), + }, + }, + attr.Groups: schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: "A set of group IDs that have this as their DNS filtering profile. Defaults to an empty set.", + Default: setdefault.StaticValue(defaultEmptySet()), + }, + + // computed + attr.ID: schema.StringAttribute{ + Computed: true, + Description: "Autogenerated ID of the DNS filtering profile.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + + Blocks: map[string]schema.Block{ + attr.AllowedDomains: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.IsAuthoritative: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether Terraform should override changes made outside of Terraform. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.Domains: schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: "A set of allowed domains. Defaults to an empty set.", + Default: setdefault.StaticValue(defaultEmptySet()), + }, + }, + }, + + attr.DeniedDomains: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.IsAuthoritative: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether Terraform should override changes made outside of Terraform. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.Domains: schema.SetAttribute{ + Optional: true, + Computed: true, + ElementType: types.StringType, + Description: "A set of denied domains. Defaults to an empty set.", + Default: setdefault.StaticValue(defaultEmptySet()), + }, + }, + }, + + //nolint:dupl + attr.ContentCategories: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.BlockGambling: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block gambling content. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockDating: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block dating content. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockAdultContent: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block adult content. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockSocialMedia: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block social media. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockGames: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block games. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockStreaming: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block streaming content. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockPiracy: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block piracy sites. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.EnableYoutubeRestrictedMode: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to force YouTube to use restricted mode. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.EnableSafesearch: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to force safe search. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + }, + }, + + //nolint:dupl + attr.SecurityCategories: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.EnableThreatIntelligenceFeeds: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to filter content using threat intelligence feeds. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.EnableGoogleSafeBrowsing: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to use Google Safe browsing lists to block content. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockCryptojacking: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block cryptojacking sites. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockIdnHomoglyph: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block homoglyph attacks. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockTyposquatting: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Blocks typosquatted domains. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockDNSRebinding: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Blocks public DNS entries from returning private IP addresses. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockNewlyRegisteredDomains: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Blocks newly registered domains. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockDomainGenerationAlgorithms: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Blocks DGA domains. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + attr.BlockParkedDomains: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Block parked domains. Defaults to true.", + Default: booldefault.StaticBool(true), + }, + }, + }, + + attr.PrivacyCategories: schema.SingleNestedBlock{ + Description: "A block with the following attributes.", + Attributes: map[string]schema.Attribute{ + attr.BlockAffiliateLinks: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block affiliate links. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockDisguisedTrackers: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block disguised third party trackers. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + attr.BlockAdsAndTrackers: schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether to block ads and trackers. Defaults to false.", + Default: booldefault.StaticBool(false), + }, + }, + }, + }, + } +} + +func defaultEmptySet() types.Set { + return types.SetValueMust(types.StringType, []tfattr.Value{}) +} + +func (r *dnsFilteringProfile) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan dnsFilteringProfileModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + profile, err := r.client.CreateDNSFilteringProfile(ctx, plan.Name.ValueString()) + + if profile != nil { + if !plan.Priority.IsNull() { + profile.Priority = plan.Priority.ValueFloat64() + } + + profile.FallbackMethod = plan.FallbackMethod.ValueString() + profile.Groups = convertSetToList(plan.Groups) + profile.AllowedDomains = convertDomains(plan.AllowedDomains) + profile.DeniedDomains = convertDomains(plan.DeniedDomains) + profile.PrivacyCategories = convertPrivacyCategories(plan.PrivacyCategories) + profile.ContentCategories = convertContentCategories(plan.ContentCategories) + profile.SecurityCategories = convertSecurityCategories(plan.SecurityCategories) + + profile, err = r.client.UpdateDNSFilteringProfile(ctx, profile) + } + + r.helper(ctx, profile, &plan, &resp.State, &resp.Diagnostics, err, operationCreate) +} + +func (r *dnsFilteringProfile) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state dnsFilteringProfileModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + profile, err := r.client.ReadDNSFilteringProfile(ctx, state.ID.ValueString()) + + if !convertBoolDefaultTrue(state.AllowedDomains.Attributes()[attr.IsAuthoritative]) { + profile.AllowedDomains = convertDomains(state.AllowedDomains) + } + + if !convertBoolDefaultTrue(state.DeniedDomains.Attributes()[attr.IsAuthoritative]) { + profile.DeniedDomains = convertDomains(state.DeniedDomains) + } + + r.helper(ctx, profile, &state, &resp.State, &resp.Diagnostics, err, operationRead) +} + +func (r *dnsFilteringProfile) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { //nolint:funlen + var state, plan dnsFilteringProfileModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + allowedDomains := convertDomains(plan.AllowedDomains) + deniedDomains := convertDomains(plan.DeniedDomains) + + profile := &model.DNSFilteringProfile{ + ID: state.ID.ValueString(), + Name: plan.Name.ValueString(), + Priority: plan.Priority.ValueFloat64(), + FallbackMethod: plan.FallbackMethod.ValueString(), + Groups: convertSetToList(plan.Groups), + AllowedDomains: allowedDomains, + DeniedDomains: deniedDomains, + PrivacyCategories: convertPrivacyCategories(plan.PrivacyCategories), + ContentCategories: convertContentCategories(plan.ContentCategories), + SecurityCategories: convertSecurityCategories(plan.SecurityCategories), + } + + allowedDomainsIsAuthoritative := convertBoolDefaultTrue(plan.AllowedDomains.Attributes()[attr.IsAuthoritative]) + deniedDomainsIsAuthoritative := convertBoolDefaultTrue(plan.DeniedDomains.Attributes()[attr.IsAuthoritative]) + + var ( + originAllowedDomains []string + originDeniedDomains []string + ) + + if !allowedDomainsIsAuthoritative || !deniedDomainsIsAuthoritative { + origin, err := r.client.ReadDNSFilteringProfile(ctx, profile.ID) + if err != nil { + r.helper(ctx, profile, &plan, &resp.State, &resp.Diagnostics, err, operationUpdate) + + return + } + + originAllowedDomains = origin.AllowedDomains + originDeniedDomains = origin.AllowedDomains + } + + if !allowedDomainsIsAuthoritative { + profile.AllowedDomains = setUnion(profile.AllowedDomains, originAllowedDomains) + } + + if !deniedDomainsIsAuthoritative { + profile.DeniedDomains = setUnion(profile.DeniedDomains, originDeniedDomains) + } + + var err error + profile, err = r.client.UpdateDNSFilteringProfile(ctx, profile) + + if profile != nil { + if !allowedDomainsIsAuthoritative { + profile.AllowedDomains = allowedDomains + } + + if !deniedDomainsIsAuthoritative { + profile.DeniedDomains = deniedDomains + } + } + + r.helper(ctx, profile, &plan, &resp.State, &resp.Diagnostics, err, operationUpdate) +} + +func (r *dnsFilteringProfile) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state dnsFilteringProfileModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteDNSFilteringProfile(ctx, state.ID.ValueString()) + addErr(&resp.Diagnostics, err, operationDelete, TwingateDNSFilteringProfile) +} + +func (r *dnsFilteringProfile) helper(ctx context.Context, profile *model.DNSFilteringProfile, state *dnsFilteringProfileModel, respState *tfsdk.State, diagnostics *diag.Diagnostics, err error, operation string) { //nolint:cyclop + if err != nil { + if errors.Is(err, client.ErrGraphqlResultIsEmpty) { + // clear state + respState.RemoveResource(ctx) + + return + } + + addErr(diagnostics, err, operation, TwingateDNSFilteringProfile) + + return + } + + state.ID = types.StringValue(profile.ID) + state.Name = types.StringValue(profile.Name) + state.Priority = types.Float64Value(profile.Priority) + state.FallbackMethod = types.StringValue(profile.FallbackMethod) + state.Groups = convertStringListToSet(profile.Groups) + + if !state.AllowedDomains.IsNull() { + state.AllowedDomains = convertDomainsToTerraform(profile.AllowedDomains, state.AllowedDomains.Attributes()[attr.IsAuthoritative]) + } + + if !state.DeniedDomains.IsNull() { + state.DeniedDomains = convertDomainsToTerraform(profile.DeniedDomains, state.DeniedDomains.Attributes()[attr.IsAuthoritative]) + } + + if !state.ContentCategories.IsNull() && profile.ContentCategories != nil { + state.ContentCategories = convertContentCategoriesToTerraform(profile.ContentCategories) + } + + if !state.SecurityCategories.IsNull() && profile.SecurityCategories != nil { + state.SecurityCategories = convertSecurityCategoriesToTerraform(profile.SecurityCategories) + } + + if !state.PrivacyCategories.IsNull() && profile.PrivacyCategories != nil { + state.PrivacyCategories = convertPrivacyCategoriesToTerraform(profile.PrivacyCategories) + } + + // Set refreshed state + diags := respState.Set(ctx, state) + diagnostics.Append(diags...) +} + +func convertContentCategoriesToTerraform(categories *model.ContentCategory) types.Object { + attributes := map[string]tfattr.Value{ + attr.BlockGambling: types.BoolValue(categories.BlockGambling), + attr.BlockDating: types.BoolValue(categories.BlockDating), + attr.BlockAdultContent: types.BoolValue(categories.BlockAdultContent), + attr.BlockSocialMedia: types.BoolValue(categories.BlockSocialMedia), + attr.BlockGames: types.BoolValue(categories.BlockGames), + attr.BlockStreaming: types.BoolValue(categories.BlockStreaming), + attr.BlockPiracy: types.BoolValue(categories.BlockPiracy), + attr.EnableYoutubeRestrictedMode: types.BoolValue(categories.EnableYoutubeRestrictedMode), + attr.EnableSafesearch: types.BoolValue(categories.EnableSafeSearch), + } + + return types.ObjectValueMust(contentCategoriesAttributeTypes(), attributes) +} + +func contentCategoriesAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.BlockGambling: types.BoolType, + attr.BlockDating: types.BoolType, + attr.BlockAdultContent: types.BoolType, + attr.BlockSocialMedia: types.BoolType, + attr.BlockGames: types.BoolType, + attr.BlockStreaming: types.BoolType, + attr.BlockPiracy: types.BoolType, + attr.EnableYoutubeRestrictedMode: types.BoolType, + attr.EnableSafesearch: types.BoolType, + } +} + +func convertSecurityCategoriesToTerraform(categories *model.SecurityCategory) types.Object { + attributes := map[string]tfattr.Value{ + attr.EnableThreatIntelligenceFeeds: types.BoolValue(categories.EnableThreatIntelligenceFeeds), + attr.EnableGoogleSafeBrowsing: types.BoolValue(categories.EnableGoogleSafeBrowsing), + attr.BlockCryptojacking: types.BoolValue(categories.BlockCryptojacking), + attr.BlockIdnHomoglyph: types.BoolValue(categories.BlockIdnHomographs), + attr.BlockTyposquatting: types.BoolValue(categories.BlockTyposquatting), + attr.BlockDNSRebinding: types.BoolValue(categories.BlockDNSRebinding), + attr.BlockNewlyRegisteredDomains: types.BoolValue(categories.BlockNewlyRegisteredDomains), + attr.BlockDomainGenerationAlgorithms: types.BoolValue(categories.BlockDomainGenerationAlgorithms), + attr.BlockParkedDomains: types.BoolValue(categories.BlockParkedDomains), + } + + return types.ObjectValueMust(securityCategoriesAttributeTypes(), attributes) +} + +func securityCategoriesAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.EnableThreatIntelligenceFeeds: types.BoolType, + attr.EnableGoogleSafeBrowsing: types.BoolType, + attr.BlockCryptojacking: types.BoolType, + attr.BlockIdnHomoglyph: types.BoolType, + attr.BlockTyposquatting: types.BoolType, + attr.BlockDNSRebinding: types.BoolType, + attr.BlockNewlyRegisteredDomains: types.BoolType, + attr.BlockDomainGenerationAlgorithms: types.BoolType, + attr.BlockParkedDomains: types.BoolType, + } +} + +func convertPrivacyCategoriesToTerraform(categories *model.PrivacyCategories) types.Object { + attributes := map[string]tfattr.Value{ + attr.BlockAffiliateLinks: types.BoolValue(categories.BlockAffiliate), + attr.BlockDisguisedTrackers: types.BoolValue(categories.BlockDisguisedTrackers), + attr.BlockAdsAndTrackers: types.BoolValue(categories.BlockAdsAndTrackers), + } + + return types.ObjectValueMust(privacyCategoriesAttributeTypes(), attributes) +} + +func privacyCategoriesAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.BlockAffiliateLinks: types.BoolType, + attr.BlockDisguisedTrackers: types.BoolType, + attr.BlockAdsAndTrackers: types.BoolType, + } +} + +func convertDomainsToTerraform(domains []string, isAuthoritative tfattr.Value) types.Object { + authoritative := types.BoolValue(true) + if isAuthoritative != nil { + authoritative = isAuthoritative.(types.Bool) + } + + attributes := map[string]tfattr.Value{ + attr.IsAuthoritative: authoritative, + attr.Domains: convertStringListToSet(domains), + } + + return types.ObjectValueMust(domainsAttributeTypes(), attributes) +} + +func domainsAttributeTypes() map[string]tfattr.Type { + return map[string]tfattr.Type{ + attr.IsAuthoritative: types.BoolType, + attr.Domains: types.SetType{ + ElemType: types.StringType, + }, + } +} + +func convertPrivacyCategories(obj types.Object) *model.PrivacyCategories { + attrs := obj.Attributes() + + return &model.PrivacyCategories{ + BlockAffiliate: convertBoolDefaultFalse(attrs[attr.BlockAffiliateLinks]), + BlockDisguisedTrackers: convertBoolDefaultFalse(attrs[attr.BlockDisguisedTrackers]), + BlockAdsAndTrackers: convertBoolDefaultFalse(attrs[attr.BlockAdsAndTrackers]), + } +} + +func convertSecurityCategories(obj types.Object) *model.SecurityCategory { + attrs := obj.Attributes() + + return &model.SecurityCategory{ + EnableThreatIntelligenceFeeds: convertBoolDefaultTrue(attrs[attr.EnableThreatIntelligenceFeeds]), + EnableGoogleSafeBrowsing: convertBoolDefaultTrue(attrs[attr.EnableGoogleSafeBrowsing]), + BlockCryptojacking: convertBoolDefaultTrue(attrs[attr.BlockCryptojacking]), + BlockIdnHomographs: convertBoolDefaultTrue(attrs[attr.BlockIdnHomoglyph]), + BlockTyposquatting: convertBoolDefaultTrue(attrs[attr.BlockTyposquatting]), + BlockDNSRebinding: convertBoolDefaultTrue(attrs[attr.BlockDNSRebinding]), + BlockNewlyRegisteredDomains: convertBoolDefaultTrue(attrs[attr.BlockNewlyRegisteredDomains]), + BlockDomainGenerationAlgorithms: convertBoolDefaultTrue(attrs[attr.BlockDomainGenerationAlgorithms]), + BlockParkedDomains: convertBoolDefaultTrue(attrs[attr.BlockParkedDomains]), + } +} + +func convertContentCategories(obj types.Object) *model.ContentCategory { + attrs := obj.Attributes() + + return &model.ContentCategory{ + BlockGambling: convertBoolDefaultFalse(attrs[attr.BlockGambling]), + BlockDating: convertBoolDefaultFalse(attrs[attr.BlockDating]), + BlockAdultContent: convertBoolDefaultFalse(attrs[attr.BlockAdultContent]), + BlockSocialMedia: convertBoolDefaultFalse(attrs[attr.BlockSocialMedia]), + BlockGames: convertBoolDefaultFalse(attrs[attr.BlockGames]), + BlockStreaming: convertBoolDefaultFalse(attrs[attr.BlockStreaming]), + BlockPiracy: convertBoolDefaultFalse(attrs[attr.BlockPiracy]), + EnableYoutubeRestrictedMode: convertBoolDefaultFalse(attrs[attr.EnableYoutubeRestrictedMode]), + EnableSafeSearch: convertBoolDefaultFalse(attrs[attr.EnableSafesearch]), + } +} + +func convertBoolDefaultTrue(boolAttr tfattr.Value) bool { + return convertBoolWithDefault(boolAttr, true) +} + +func convertBoolDefaultFalse(boolAttr tfattr.Value) bool { + return convertBoolWithDefault(boolAttr, false) +} + +func convertBoolWithDefault(value tfattr.Value, defaultValue bool) bool { + if value == nil || value.IsNull() || value.IsUnknown() { + return defaultValue + } + + return boolAttr(value) +} + +func boolAttr(boolAttr tfattr.Value) bool { + return boolAttr.(types.Bool).ValueBool() +} + +func convertDomains(obj types.Object) []string { + if obj.IsNull() || obj.IsUnknown() { + return []string{} + } + + return convertSetToList(obj.Attributes()[attr.Domains].(types.Set)) +} + +func convertSetToList(set types.Set) []string { + return utils.Map(set.Elements(), + func(item tfattr.Value) string { + return item.(types.String).ValueString() + }, + ) +} + +func convertStringListToSet(items []string) types.Set { + values := utils.Map(items, func(item string) tfattr.Value { + return types.StringValue(item) + }) + + return types.SetValueMust(types.StringType, values) +} diff --git a/twingate/internal/provider/resource/helper.go b/twingate/internal/provider/resource/helper.go index 304ffa9d..67f19d9f 100644 --- a/twingate/internal/provider/resource/helper.go +++ b/twingate/internal/provider/resource/helper.go @@ -147,3 +147,28 @@ func makeObjectsSet(ctx context.Context, objects ...types.Object) (types.Set, di return types.SetValue(obj.Type(ctx), items) } + +// setUnion - for given two sets A and B, +// If A = {1, 2} and B = {3, 4}, then the union of A and B is {1, 2, 3, 4}. +func setUnion(setA, setB []string) []string { + if len(setA) == 0 { + return setB + } + + if len(setB) == 0 { + return setA + } + + set := utils.MakeLookupMap(setA) + + for _, key := range setB { + set[key] = true + } + + result := make([]string, 0, len(set)) + for key := range set { + result = append(result, key) + } + + return result +} diff --git a/twingate/internal/test/acctests/datasource/dns-filtering-profile_test.go b/twingate/internal/test/acctests/datasource/dns-filtering-profile_test.go new file mode 100644 index 00000000..15ffdb1d --- /dev/null +++ b/twingate/internal/test/acctests/datasource/dns-filtering-profile_test.go @@ -0,0 +1,58 @@ +package datasource + +import ( + "fmt" + "testing" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/test" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/test/acctests" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDatasourceTwingateDNSFilteringProfile_basic(t *testing.T) { + testName := "t" + acctest.RandString(6) + profileName := test.RandomName() + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateConnectorDestroy, + Steps: []resource.TestStep{ + { + Config: testDatasourceTwingateDNSFilteringProfile(testName, profileName), + Check: acctests.ComposeTestCheckFunc( + resource.TestCheckOutput("profile_name", profileName), + resource.TestCheckOutput("profile_priority", "3"), + resource.TestCheckOutput("profile_fallback_method", "AUTO"), + ), + }, + }, + }) +} + +func testDatasourceTwingateDNSFilteringProfile(testName, profileName string) string { + return fmt.Sprintf(` + resource "twingate_dns_filtering_profile" "%[1]s" { + name = "%[2]s" + priority = 3 + fallback_method = "AUTO" + } + + data "twingate_dns_filtering_profile" "%[1]s" { + id = twingate_dns_filtering_profile.%[1]s.id + } + + output "profile_name" { + value = data.twingate_dns_filtering_profile.%[1]s.name + } + + output "profile_priority" { + value = data.twingate_dns_filtering_profile.%[1]s.priority + } + + output "profile_fallback_method" { + value = data.twingate_dns_filtering_profile.%[1]s.fallback_method + } + `, testName, profileName) +} diff --git a/twingate/internal/test/acctests/helper.go b/twingate/internal/test/acctests/helper.go index 92c44485..8dbe3989 100644 --- a/twingate/internal/test/acctests/helper.go +++ b/twingate/internal/test/acctests/helper.go @@ -46,6 +46,10 @@ func ErrServiceAccountsLenMismatch(expected, actual int) error { return fmt.Errorf("expected %d service accounts, actual - %d", expected, actual) //nolint } +func ErrDNSProfileAllowedDomainsLenMismatch(expected, actual int) error { + return fmt.Errorf("expected %d allowed domains, actual - %d", expected, actual) //nolint +} + func ErrGroupsLenMismatch(expected, actual int) error { return fmt.Errorf("expected %d groups, actual - %d", expected, actual) //nolint } @@ -221,6 +225,10 @@ func TerraformUser(name string) string { return ResourceName(resource.TwingateUser, name) } +func TerraformDNSFilteringProfile(name string) string { + return ResourceName(resource.TwingateDNSFilteringProfile, name) +} + func TerraformDatasourceUsers(name string) string { return DatasourceName(datasource.TwingateUsers, name) } @@ -592,6 +600,23 @@ func CheckTwingateConnectorDestroy(s *terraform.State) error { return nil } +func CheckTwingateDNSProfileDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != resource.TwingateDNSFilteringProfile { + continue + } + + profileID := rs.Primary.ID + + profile, _ := providerClient.ReadDNSFilteringProfile(context.Background(), profileID) + if profile != nil { + return fmt.Errorf("%w with ID %s", ErrResourceStillPresent, profileID) + } + } + + return nil +} + func RevokeTwingateServiceKey(resourceName string) sdk.TestCheckFunc { return func(s *terraform.State) error { resourceState, ok := s.RootModule().Resources[resourceName] @@ -757,6 +782,29 @@ func AddResourceServiceAccount(resourceName, serviceAccountName string) sdk.Test } } +func AddDNSProfileAllowedDomains(resourceName string, domains []string) sdk.TestCheckFunc { + return func(state *terraform.State) error { + profileID, err := getResourceID(state, resourceName) + if err != nil { + return err + } + + profile, err := providerClient.ReadDNSFilteringProfile(context.Background(), profileID) + if err != nil { + return fmt.Errorf("failed to fetch DNS profile with ID %s: %w", profileID, err) + } + + profile.AllowedDomains = domains + + _, err = providerClient.UpdateDNSFilteringProfile(context.Background(), profile) + if err != nil { + return fmt.Errorf("DNS profile with ID %s failed to set new domains: %w", profileID, err) + } + + return nil + } +} + func DeleteResourceServiceAccount(resourceName, serviceAccountName string) sdk.TestCheckFunc { return func(state *terraform.State) error { resourceID, err := getResourceID(state, resourceName) @@ -798,6 +846,26 @@ func CheckResourceServiceAccountsLen(resourceName string, expectedServiceAccount } } +func CheckDNSProfileAllowedDomainsLen(resourceName string, expectedLen int) sdk.TestCheckFunc { + return func(state *terraform.State) error { + resourceID, err := getResourceID(state, resourceName) + if err != nil { + return err + } + + profile, err := providerClient.ReadDNSFilteringProfile(context.Background(), resourceID) + if err != nil { + return fmt.Errorf("profile with ID %s failed to read: %w", resourceID, err) + } + + if len(profile.AllowedDomains) != expectedLen { + return ErrDNSProfileAllowedDomainsLenMismatch(expectedLen, len(profile.AllowedDomains)) + } + + return nil + } +} + func CheckResourceSecurityPolicy(resourceName string, expectedSecurityPolicyID string) sdk.TestCheckFunc { return func(state *terraform.State) error { resourceID, err := getResourceID(state, resourceName) diff --git a/twingate/internal/test/acctests/resource/dns-filtering-profile_test.go b/twingate/internal/test/acctests/resource/dns-filtering-profile_test.go new file mode 100644 index 00000000..a6d02fff --- /dev/null +++ b/twingate/internal/test/acctests/resource/dns-filtering-profile_test.go @@ -0,0 +1,579 @@ +package resource + +import ( + "fmt" + "strings" + "testing" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/attr" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/test" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/test/acctests" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + sdk "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +var ( + groupsLen = attr.Len(attr.Groups) +) + +func TestAccTwingateDNSFilteringProfileCreateWithDefaultValues(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileBase(testName, profileName, "2"), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.Priority, "2"), + sdk.TestCheckResourceAttr(theResource, attr.FallbackMethod, "STRICT"), + sdk.TestCheckResourceAttr(theResource, groupsLen, "0"), + ), + }, + }, + }) +} + +func testTwingateDNSFilteringProfileBase(testName, profileName, priority string) string { + return fmt.Sprintf(` + resource "twingate_dns_filtering_profile" "%[1]s" { + name = "%[2]s" + priority = "%[3]s" + } + `, testName, profileName, priority) + +} + +func genDomains(count int) []string { + domains := make([]string, 0, count) + + for i := 0; i < count; i++ { + domains = append(domains, test.RandomDomain()) + } + + return domains +} + +func listToString(list []string) string { + if len(list) == 0 { + return "[]" + } + + return fmt.Sprintf(`["%s"]`, strings.Join(list, `", "`)) +} + +func TestAccTwingateDNSFilteringProfileCreate(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + groups, groupResources := genNewGroupsWithName(testName, testName, 3) + groupsTF := strings.Join(groups, "\n") + groupResourcesTF := fmt.Sprintf(`"%s"`, strings.Join(groupResources, `", "`)) + + allowedDomains := genDomains(2) + deniedDomains := genDomains(1) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfile(groupsTF, testName, profileName, groupResourcesTF, allowedDomains, deniedDomains), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.Priority, "2"), + sdk.TestCheckResourceAttr(theResource, attr.FallbackMethod, "AUTO"), + sdk.TestCheckResourceAttr(theResource, groupsLen, "3"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.AllowedDomains, attr.IsAuthoritative), "false"), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.DeniedDomains, attr.IsAuthoritative), "true"), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.DeniedDomains, attr.Domains), "1"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.ContentCategories, attr.BlockAdultContent), "true"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.SecurityCategories, attr.BlockDNSRebinding), "false"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.SecurityCategories, attr.BlockNewlyRegisteredDomains), "false"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.PrivacyCategories, attr.BlockDisguisedTrackers), "true"), + ), + }, + }, + }) +} + +func testTwingateDNSFilteringProfile(groups, testName, profileName, groupResources string, allowedDomains, deniedDomains []string) string { + return fmt.Sprintf(` + # groups + %[1]s + + resource "twingate_dns_filtering_profile" "%[2]s" { + name = "%[3]s" + priority = 2 + fallback_method = "AUTO" + groups = toset(data.twingate_groups.test.groups[*].id) + + allowed_domains { + is_authoritative = false + domains = %[4]s + } + + denied_domains { + is_authoritative = true + domains = %[5]s + } + + content_categories { + block_adult_content = true + } + + security_categories { + block_dns_rebinding = false + block_newly_registered_domains = false + } + + privacy_categories { + block_disguised_trackers = true + } + + } + + data "twingate_groups" "test" { + name_prefix = "%[2]s" + + depends_on = [%[6]s] + } + + `, groups, testName, profileName, listToString(allowedDomains), listToString(deniedDomains), groupResources) + +} + +func genNewGroupsWithName(resourcePrefix, namePrefix string, count int) ([]string, []string) { + groups := make([]string, 0, count) + groupsResources := make([]string, 0, count) + + for i := 0; i < count; i++ { + resourceName := fmt.Sprintf("%s_%d", resourcePrefix, i+1) + groupName := fmt.Sprintf("%s_%d", namePrefix, i+1) + groups = append(groups, newTerraformGroup(resourceName, groupName)) + groupsResources = append(groupsResources, fmt.Sprintf("twingate_group.%s", resourceName)) + } + + return groups, groupsResources +} + +func TestAccTwingateDNSFilteringProfileUpdate(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + groups1, groupResources1 := genNewGroupsWithName(testName, testName, 2) + groupsTF1 := strings.Join(groups1, "\n") + groupResourcesTF1 := fmt.Sprintf(`"%s"`, strings.Join(groupResources1, `", "`)) + + groups2, groupResources2 := genNewGroupsWithName(testName, testName, 3) + groupsTF2 := strings.Join(groups2, "\n") + groupResourcesTF2 := fmt.Sprintf(`"%s"`, strings.Join(groupResources2, `", "`)) + + domains1 := genDomains(2) + domains2 := genDomains(3) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfile1(groupsTF1, testName, profileName, groupResourcesTF1, "3", "AUTO", true, domains1, true), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.Priority, "3"), + sdk.TestCheckResourceAttr(theResource, attr.FallbackMethod, "AUTO"), + sdk.TestCheckResourceAttr(theResource, groupsLen, "2"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.AllowedDomains, attr.IsAuthoritative), "true"), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.PrivacyCategories, attr.BlockDisguisedTrackers), "true"), + ), + }, + { + Config: testTwingateDNSFilteringProfile1(groupsTF2, testName, profileName, groupResourcesTF2, "2.5", "STRICT", true, domains2, false), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.Priority, "2.5"), + sdk.TestCheckResourceAttr(theResource, attr.FallbackMethod, "STRICT"), + sdk.TestCheckResourceAttr(theResource, groupsLen, "3"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.AllowedDomains, attr.IsAuthoritative), "true"), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "3"), + sdk.TestCheckResourceAttr(theResource, attr.PathAttr(attr.PrivacyCategories, attr.BlockDisguisedTrackers), "false"), + ), + }, + }, + }) +} + +func testTwingateDNSFilteringProfile1(groups, testName, profileName, groupResources, priority, fallBack string, isAuthoritative bool, domains []string, blockDisguisedTrackers bool) string { + return fmt.Sprintf(` + # groups + %[1]s + + resource "twingate_dns_filtering_profile" "%[2]s" { + name = "%[3]s" + priority = %[4]s + fallback_method = "%[5]s" + groups = toset(data.twingate_groups.test.groups[*].id) + + allowed_domains { + is_authoritative = %[6]v + domains = ["%[7]s"] + } + + privacy_categories { + block_disguised_trackers = %[8]v + } + + } + + data "twingate_groups" "test" { + name_prefix = "%[2]s" + + depends_on = [%[9]s] + } + + `, groups, testName, profileName, priority, fallBack, isAuthoritative, strings.Join(domains, `", "`), blockDisguisedTrackers, groupResources) + +} + +func TestAccTwingateDNSFilteringProfileUpdateIsAuthoritativeTrue(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + domains1 := genDomains(2) + newDomains := genDomains(1) + domains2 := genDomains(4) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, true, domains1), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + acctests.WaitTestFunc(), + // set new domains to the DNS profile using API + acctests.AddDNSProfileAllowedDomains(theResource, newDomains), + ), + // expecting drift - terraform going to remove unknown domains + ExpectNonEmptyPlan: true, + }, + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, true, domains2), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "4"), + ), + }, + }, + }) +} + +func testTwingateDNSFilteringProfileWithDomains(testName, profileName string, isAuthoritative bool, domains []string) string { + return fmt.Sprintf(` + resource "twingate_dns_filtering_profile" "%[1]s" { + name = "%[2]s" + priority = 5 + + allowed_domains { + is_authoritative = %[3]v + domains = ["%[4]s"] + } + } + `, testName, profileName, isAuthoritative, strings.Join(domains, `", "`)) + +} + +func TestAccTwingateDNSFilteringProfileUpdateIsAuthoritativeFalse(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + domains1 := genDomains(2) + newDomains := genDomains(1) + domains2 := genDomains(2) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, false, domains1), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + acctests.WaitTestFunc(), + // set new domains to the DNS profile using API + acctests.AddDNSProfileAllowedDomains(theResource, newDomains), + ), + }, + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, false, domains2), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + // check allowed domains using API + acctests.CheckDNSProfileAllowedDomainsLen(theResource, 3), + ), + }, + }, + }) +} + +func TestAccTwingateDNSFilteringProfileUpdateIsAuthoritativeFalseTrue(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + domains1 := genDomains(2) + domains2 := genDomains(4) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, false, domains1), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + ), + }, + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, true, domains2), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "4"), + // check allowed domains using API + acctests.CheckDNSProfileAllowedDomainsLen(theResource, 4), + ), + }, + }, + }) +} + +func TestAccTwingateDNSFilteringProfileUpdateIsAuthoritativeTrueFalse(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + domains1 := genDomains(2) + domains2 := genDomains(4) + newDomains := genDomains(5) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, true, domains1), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + ), + }, + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, false, domains2), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "4"), + // check allowed domains using API + acctests.CheckDNSProfileAllowedDomainsLen(theResource, 6), + acctests.WaitTestFunc(), + // set new domains to the DNS profile using API + acctests.AddDNSProfileAllowedDomains(theResource, newDomains), + ), + }, + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, false, domains2), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "4"), + // check allowed domains using API + acctests.CheckDNSProfileAllowedDomainsLen(theResource, 5), + ), + }, + }, + }) +} + +func TestAccTwingateDNSFilteringProfileRemoveAllowedDomains(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + domains1 := genDomains(2) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateDNSProfileDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileWithDomains(testName, profileName, true, domains1), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "2"), + ), + }, + { + Config: testTwingateDNSFilteringProfileBase(testName, profileName, "5"), + Check: acctests.ComposeTestCheckFunc( + sdk.TestCheckResourceAttr(theResource, attr.Name, profileName), + sdk.TestCheckResourceAttr(theResource, attr.LenAttr(attr.AllowedDomains, attr.Domains), "0"), + // check allowed domains using API + acctests.CheckDNSProfileAllowedDomainsLen(theResource, 0), + ), + }, + }, + }) +} + +func TestAccTwingateDNSFilteringProfileImport(t *testing.T) { + t.Parallel() + + testName := "t" + acctest.RandString(6) + theResource := acctests.TerraformDNSFilteringProfile(testName) + profileName := test.RandomName(testName) + + groups, groupResources := genNewGroupsWithName(testName, testName, 3) + groupsTF := strings.Join(groups, "\n") + groupResourcesTF := fmt.Sprintf(`"%s"`, strings.Join(groupResources, `", "`)) + + allowedDomains := genDomains(2) + deniedDomains := genDomains(1) + + sdk.Test(t, sdk.TestCase{ + ProtoV6ProviderFactories: acctests.ProviderFactories, + PreCheck: func() { acctests.PreCheck(t) }, + CheckDestroy: acctests.CheckTwingateResourceDestroy, + Steps: []sdk.TestStep{ + { + Config: testTwingateDNSFilteringProfileFull(groupsTF, testName, profileName, groupResourcesTF, allowedDomains, deniedDomains), + Check: acctests.ComposeTestCheckFunc( + acctests.CheckTwingateResourceExists(theResource), + ), + }, + { + ImportState: true, + ResourceName: theResource, + ImportStateCheck: acctests.CheckImportState(map[string]string{ + attr.Name: profileName, + attr.Priority: "3", + attr.FallbackMethod: "AUTO", + attr.LenAttr(attr.Groups): "3", + + attr.LenAttr(attr.AllowedDomains, attr.Domains): "2", + // we can't get this value from backend, and by default we have `true` + attr.PathAttr(attr.AllowedDomains, attr.IsAuthoritative): "true", + + attr.LenAttr(attr.DeniedDomains, attr.Domains): "1", + attr.PathAttr(attr.DeniedDomains, attr.IsAuthoritative): "true", + + attr.PathAttr(attr.ContentCategories, attr.BlockAdultContent): "true", + attr.PathAttr(attr.ContentCategories, attr.BlockGambling): "false", + attr.PathAttr(attr.ContentCategories, attr.BlockDating): "false", + attr.PathAttr(attr.ContentCategories, attr.BlockSocialMedia): "false", + attr.PathAttr(attr.ContentCategories, attr.BlockGames): "false", + attr.PathAttr(attr.ContentCategories, attr.BlockStreaming): "false", + attr.PathAttr(attr.ContentCategories, attr.BlockPiracy): "false", + attr.PathAttr(attr.ContentCategories, attr.EnableYoutubeRestrictedMode): "false", + attr.PathAttr(attr.ContentCategories, attr.EnableSafesearch): "false", + + attr.PathAttr(attr.SecurityCategories, attr.EnableThreatIntelligenceFeeds): "true", + attr.PathAttr(attr.SecurityCategories, attr.EnableGoogleSafeBrowsing): "true", + attr.PathAttr(attr.SecurityCategories, attr.BlockCryptojacking): "true", + attr.PathAttr(attr.SecurityCategories, attr.BlockIdnHomoglyph): "true", + attr.PathAttr(attr.SecurityCategories, attr.BlockTyposquatting): "true", + attr.PathAttr(attr.SecurityCategories, attr.BlockDNSRebinding): "false", + attr.PathAttr(attr.SecurityCategories, attr.BlockNewlyRegisteredDomains): "false", + attr.PathAttr(attr.SecurityCategories, attr.BlockDomainGenerationAlgorithms): "true", + attr.PathAttr(attr.SecurityCategories, attr.BlockParkedDomains): "true", + + attr.PathAttr(attr.PrivacyCategories, attr.BlockAffiliateLinks): "false", + attr.PathAttr(attr.PrivacyCategories, attr.BlockDisguisedTrackers): "true", + attr.PathAttr(attr.PrivacyCategories, attr.BlockAdsAndTrackers): "false", + }), + }, + }, + }) +} + +func testTwingateDNSFilteringProfileFull(groups, testName, profileName, groupResources string, allowedDomains, deniedDomains []string) string { + return fmt.Sprintf(` + # groups + %[1]s + + resource "twingate_dns_filtering_profile" "%[2]s" { + name = "%[3]s" + priority = 3 + fallback_method = "AUTO" + groups = toset(data.twingate_groups.test.groups[*].id) + + allowed_domains { + is_authoritative = false + domains = %[4]s + } + + denied_domains { + is_authoritative = true + domains = %[5]s + } + + content_categories { + block_adult_content = true + } + + security_categories { + block_dns_rebinding = false + block_newly_registered_domains = false + } + + privacy_categories { + block_disguised_trackers = true + } + + } + + data "twingate_groups" "test" { + name_prefix = "%[2]s" + + depends_on = [%[6]s] + } + + `, groups, testName, profileName, listToString(allowedDomains), listToString(deniedDomains), groupResources) + +} diff --git a/twingate/internal/test/client/dns-filtering-profile_test.go b/twingate/internal/test/client/dns-filtering-profile_test.go new file mode 100644 index 00000000..8bb0cce6 --- /dev/null +++ b/twingate/internal/test/client/dns-filtering-profile_test.go @@ -0,0 +1,597 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/model" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestClientDNSProfileCreateOk(t *testing.T) { + t.Run("Test Twingate Resource : Create DNS Profile Ok", func(t *testing.T) { + expected := &model.DNSFilteringProfile{ + ID: "test-id", + Name: "test", + Priority: 2, + Groups: []string{}, + FallbackMethod: "STRICT", + } + + jsonResponse := `{ + "data": { + "dnsFilteringProfileCreate": { + "entity": { + "id": "test-id", + "name": "test", + "priority": 2, + "fallbackMethod": "STRICT" + }, + "ok": true, + "error": null + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + profile, err := c.CreateDNSFilteringProfile(context.Background(), "test") + + assert.NoError(t, err) + assert.EqualValues(t, expected, profile) + }) +} + +func TestClientDNSProfileCreateError(t *testing.T) { + t.Run("Test Twingate Resource : Create DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileCreate": { + "ok": false, + "error": "error_1" + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + profile, err := c.CreateDNSFilteringProfile(context.Background(), "test") + + assert.EqualError(t, err, "failed to create DNS filtering profile with name test: error_1") + assert.Nil(t, profile) + }) +} + +func TestClientDNSProfileCreateRequestError(t *testing.T) { + t.Run("Test Twingate Resource : Create DNS Profile Request Error", func(t *testing.T) { + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewErrorResponder(errBadRequest)) + + profile, err := c.CreateDNSFilteringProfile(context.Background(), "test") + + assert.EqualError(t, err, graphqlErr(c, "failed to create DNS filtering profile with name test", errBadRequest)) + assert.Nil(t, profile) + }) +} + +func TestClientCreateEmptyDNSProfileError(t *testing.T) { + t.Run("Test Twingate Resource : Create Empty DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileCreate": null + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + profile, err := c.CreateDNSFilteringProfile(context.Background(), "") + + assert.EqualError(t, err, "failed to create DNS filtering profile: name is empty") + assert.Nil(t, profile) + }) +} + +func TestClientDNSProfileUpdateOk(t *testing.T) { + t.Run("Test Twingate Resource : Update DNS Profile Ok", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileUpdate": { + "entity": { + "id": "id", + "name": "test" + }, + "ok": true, + "error": null + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + _, err := c.UpdateDNSFilteringProfile(context.Background(), &model.DNSFilteringProfile{ID: "id", Name: "test"}) + + assert.NoError(t, err) + }) +} + +func TestClientDNSProfileUpdateError(t *testing.T) { + t.Run("Test Twingate Resource : Update DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileUpdate": { + "ok": false, + "error": "error_1" + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + const profileId = "g1" + _, err := c.UpdateDNSFilteringProfile(context.Background(), &model.DNSFilteringProfile{ID: profileId, Name: "test"}) + + assert.EqualError(t, err, fmt.Sprintf("failed to update DNS filtering profile with id %s: error_1", profileId)) + }) +} + +func TestClientDNSProfileUpdateRequestError(t *testing.T) { + t.Run("Test Twingate Resource : Update DNS Profile Request Error", func(t *testing.T) { + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewErrorResponder(errBadRequest)) + + const profileId = "g1" + _, err := c.UpdateDNSFilteringProfile(context.Background(), &model.DNSFilteringProfile{ID: profileId, Name: "test"}) + + assert.EqualError(t, err, graphqlErr(c, "failed to update DNS filtering profile with id "+profileId, errBadRequest)) + }) +} + +func TestClientDNSProfileUpdateEmptyResponse(t *testing.T) { + t.Run("Test Twingate Resource : Update DNS Profile - Empty Response", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileUpdate": { + "ok": true, + "entity": null + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + const profileId = "g1" + _, err := c.UpdateDNSFilteringProfile(context.Background(), &model.DNSFilteringProfile{ID: profileId, Name: "test"}) + + assert.EqualError(t, err, fmt.Sprintf("failed to update DNS filtering profile with id %s: query result is empty", profileId)) + }) +} + +func TestClientDNSProfileUpdateWithEmptyID(t *testing.T) { + t.Run("Test Twingate Resource : Update DNS Profile With Empty ID", func(t *testing.T) { + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + + _, err := c.UpdateDNSFilteringProfile(context.Background(), &model.DNSFilteringProfile{Name: "test"}) + + assert.EqualError(t, err, "failed to update DNS filtering profile: id is empty") + }) +} + +func TestClientDNSProfileReadOk(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Ok", func(t *testing.T) { + expected := &model.DNSFilteringProfile{ + ID: "id", + Name: "name", + Groups: []string{}, + } + + jsonResponse := `{ + "data": { + "dnsFilteringProfile": { + "id": "id", + "name": "name" + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + profile, err := c.ReadDNSFilteringProfile(context.Background(), "id") + + assert.NoError(t, err) + assert.Equal(t, expected, profile) + }) +} + +func TestClientDNSProfileReadError(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfile": null + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + const profileId = "g1" + profile, err := c.ReadDNSFilteringProfile(context.Background(), profileId) + + assert.Nil(t, profile) + assert.EqualError(t, err, fmt.Sprintf("failed to read DNS filtering profile with id %s: query result is empty", profileId)) + }) +} + +func TestClientDNSProfileReadRequestError(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Request Error", func(t *testing.T) { + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewErrorResponder(errBadRequest)) + + const profileId = "g1" + profile, err := c.ReadDNSFilteringProfile(context.Background(), profileId) + + assert.Nil(t, profile) + assert.EqualError(t, err, graphqlErr(c, "failed to read DNS filtering profile with id "+profileId, errBadRequest)) + }) +} + +func TestClientReadEmptyDNSProfileError(t *testing.T) { + t.Run("Test Twingate Resource : Read Empty DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfile": null + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + profile, err := c.ReadDNSFilteringProfile(context.Background(), "") + + assert.EqualError(t, err, "failed to read DNS filtering profile: id is empty") + assert.Nil(t, profile) + }) +} + +func TestClientDNSProfileReadErrorOnFetchPages(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Error On Fetch Pages", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfile": { + "id": "test-id", + "name": "name", + "groups": { + "pageInfo": { + "endCursor": "cursor-001", + "hasNextPage": true + }, + "edges": [ + { + "node": { + "id": "group-id" + } + } + ] + } + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + MultipleResponders( + httpmock.NewStringResponder(http.StatusOK, jsonResponse), + httpmock.NewErrorResponder(errBadRequest), + )) + + _, err := c.ReadDNSFilteringProfile(context.Background(), "test-id") + + assert.EqualError(t, err, graphqlErr(c, "failed to read group with id All", errBadRequest)) + }) +} + +func TestClientDNSProfileReadEmptyOnFetchPages(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Error On Fetch Pages", func(t *testing.T) { + response1 := `{ + "data": { + "dnsFilteringProfile": { + "id": "test-id", + "name": "name", + "groups": { + "pageInfo": { + "endCursor": "cursor-001", + "hasNextPage": true + }, + "edges": [ + { + "node": { + "id": "profile-id" + } + } + ] + } + } + } + }` + + response2 := `{ + "data": { + "dnsFilteringProfile": null + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + MultipleResponders( + httpmock.NewStringResponder(http.StatusOK, response1), + httpmock.NewStringResponder(http.StatusOK, response2), + )) + + profile, err := c.ReadDNSFilteringProfile(context.Background(), "test-id") + + assert.Nil(t, profile) + assert.EqualError(t, err, fmt.Sprintf(`failed to read group with id All: query result is empty`)) + }) +} + +func TestClientDNSProfileReadOkOnFetchPages(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Error On Fetch Pages", func(t *testing.T) { + expected := &model.DNSFilteringProfile{ + ID: "profile-id", + Name: "name", + Groups: []string{"group-1", "group-2"}, + } + + response1 := `{ + "data": { + "dnsFilteringProfile": { + "id": "profile-id", + "name": "name", + "groups": { + "pageInfo": { + "endCursor": "cursor-001", + "hasNextPage": true + }, + "edges": [ + { + "node": { + "id": "group-1" + } + } + ] + } + } + } + }` + + response2 := `{ + "data": { + "dnsFilteringProfile": { + "id": "profile-id", + "name": "name", + "groups": { + "pageInfo": { + "endCursor": "cursor-001", + "hasNextPage": false + }, + "edges": [ + { + "node": { + "id": "group-2" + } + } + ] + } + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + MultipleResponders( + httpmock.NewStringResponder(http.StatusOK, response1), + httpmock.NewStringResponder(http.StatusOK, response2), + )) + + profile, err := c.ReadDNSFilteringProfile(context.Background(), "profile-id") + + assert.NoError(t, err) + assert.Equal(t, expected, profile) + }) +} + +func TestClientDeleteDNSProfileOk(t *testing.T) { + t.Run("Test Twingate Resource : Delete DNS Profile Ok", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileDelete": { + "ok": true, + "error": null + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + err := c.DeleteDNSFilteringProfile(context.Background(), "profile-id") + + assert.NoError(t, err) + }) +} + +func TestClientDeleteEmptyDNSProfileError(t *testing.T) { + t.Run("Test Twingate Resource : Delete Empty DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileDelete": null + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + err := c.DeleteDNSFilteringProfile(context.Background(), "") + + assert.EqualError(t, err, "failed to delete DNS filtering profile: id is empty") + }) +} + +func TestClientDeleteDNSProfileError(t *testing.T) { + t.Run("Test Twingate Resource : Delete DNS Profile Error", func(t *testing.T) { + jsonResponse := `{ + "data": { + "dnsFilteringProfileDelete": { + "ok": false, + "error": "error_1" + } + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + err := c.DeleteDNSFilteringProfile(context.Background(), "profile-id") + + assert.EqualError(t, err, "failed to delete DNS filtering profile with id profile-id: error_1") + }) +} + +func TestClientDeleteDNSProfileRequestError(t *testing.T) { + t.Run("Test Twingate Resource : Delete DNS Profile Request Error", func(t *testing.T) { + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewErrorResponder(errBadRequest)) + + err := c.DeleteDNSFilteringProfile(context.Background(), "profile-id") + + assert.EqualError(t, err, graphqlErr(c, "failed to delete DNS filtering profile with id profile-id", errBadRequest)) + }) +} + +func TestClientDNSProfilesReadOk(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profiles Ok", func(t *testing.T) { + expected := []*model.DNSFilteringProfile{ + { + ID: "id1", + Name: "profile1", + }, + { + ID: "id2", + Name: "profile2", + }, + { + ID: "id3", + Name: "profile3", + }, + } + + jsonResponse := `{ + "data": { + "dnsFilteringProfiles": [ + { + "id": "id1", + "name": "profile1" + }, + { + "id": "id2", + "name": "profile2" + }, + { + "id": "id3", + "name": "profile3" + } + ] + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, jsonResponse)) + + profiles, err := c.ReadShallowDNSFilteringProfiles(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, expected, profiles) + }) +} + +func TestClientDNSProfilesReadError(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profile Error", func(t *testing.T) { + emptyResponse := `{ + "data": { + "dnsFilteringProfiles": null + } + }` + + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewStringResponder(200, emptyResponse)) + + profiles, err := c.ReadShallowDNSFilteringProfiles(context.Background()) + + assert.Nil(t, profiles) + assert.EqualError(t, err, "failed to read DNS filtering profile with id All: query result is empty") + }) +} + +func TestClientDNSProfilesReadRequestError(t *testing.T) { + t.Run("Test Twingate Resource : Read DNS Profiles Request Error", func(t *testing.T) { + c := newHTTPMockClient() + defer httpmock.DeactivateAndReset() + httpmock.RegisterResponder("POST", c.GraphqlServerURL, + httpmock.NewErrorResponder(errBadRequest)) + + profiles, err := c.ReadShallowDNSFilteringProfiles(context.Background()) + + assert.Nil(t, profiles) + assert.EqualError(t, err, graphqlErr(c, "failed to read DNS filtering profile with id All", errBadRequest)) + }) +} diff --git a/twingate/internal/test/helper.go b/twingate/internal/test/helper.go index dcba5f1a..31cdffa1 100644 --- a/twingate/internal/test/helper.go +++ b/twingate/internal/test/helper.go @@ -47,6 +47,12 @@ func RandomEmail() string { return fmt.Sprintf("%s_%s@%s.com", Prefix(), acctest.RandString(nameLen), acctest.RandString(domainLen)) } +func RandomDomain() string { + const domainLen = 6 + + return fmt.Sprintf("%s-%s.com", prefixName, acctest.RandString(domainLen)) +} + func RandomUserRole() string { return model.UserRoles[acctest.RandIntRange(0, len(model.UserRoles)-1)] } diff --git a/twingate/internal/test/sweepers/dns-filtering-profile_test.go b/twingate/internal/test/sweepers/dns-filtering-profile_test.go new file mode 100644 index 00000000..8c37888c --- /dev/null +++ b/twingate/internal/test/sweepers/dns-filtering-profile_test.go @@ -0,0 +1,33 @@ +package sweepers + +import ( + "context" + + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/client" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +const resourceDNSProfile = "twingate_dns_filtering_profile" + +func init() { + resource.AddTestSweepers(resourceDNSProfile, &resource.Sweeper{ + Name: resourceDNSProfile, + F: newTestSweeper(resourceDNSProfile, + func(client *client.Client, ctx context.Context) ([]Resource, error) { + resources, err := client.ReadShallowDNSFilteringProfiles(ctx) + if err != nil { + return nil, err + } + + items := make([]Resource, 0, len(resources)) + for _, r := range resources { + items = append(items, r) + } + return items, nil + }, + func(client *client.Client, ctx context.Context, id string) error { + return client.DeleteDNSFilteringProfile(ctx, id) + }, + ), + }) +} diff --git a/twingate/internal/test/sweepers/sweeper_test.go b/twingate/internal/test/sweepers/sweeper_test.go index 559f1976..d46dd0ba 100644 --- a/twingate/internal/test/sweepers/sweeper_test.go +++ b/twingate/internal/test/sweepers/sweeper_test.go @@ -3,7 +3,6 @@ package sweepers import ( "context" "fmt" - "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/test" "log" "os" "strings" @@ -12,6 +11,7 @@ import ( "github.com/Twingate/terraform-provider-twingate/v3/twingate" "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/client" + "github.com/Twingate/terraform-provider-twingate/v3/twingate/internal/test" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) diff --git a/twingate/provider.go b/twingate/provider.go index 4c8be2fa..6a5bf6ec 100644 --- a/twingate/provider.go +++ b/twingate/provider.go @@ -198,6 +198,7 @@ func (t Twingate) DataSources(ctx context.Context) []func() datasource.DataSourc twingateDatasource.NewSecurityPoliciesDatasource, twingateDatasource.NewResourceDatasource, twingateDatasource.NewResourcesDatasource, + twingateDatasource.NewDNSFilteringProfileDatasource, } } @@ -211,5 +212,6 @@ func (t Twingate) Resources(ctx context.Context) []func() resource.Resource { twingateResource.NewServiceKeyResource, twingateResource.NewUserResource, twingateResource.NewResourceResource, + twingateResource.NewDNSFilteringProfile, } }