From 36428e09e36e001e6d2b45a0152ba80026e66131 Mon Sep 17 00:00:00 2001 From: Douglas Harcourt Parsons Date: Tue, 29 Oct 2024 14:55:52 +0000 Subject: [PATCH] Support Saml on team config --- client/team.go | 15 +-- docs/data-sources/team_config.md | 11 ++ docs/resources/team_config.md | 14 +++ vercel/data_source_team_config.go | 22 +++- vercel/resource_team_config.go | 183 ++++++++++++++++++++---------- 5 files changed, 169 insertions(+), 76 deletions(-) diff --git a/client/team.go b/client/team.go index 15a800e8..29036b3a 100644 --- a/client/team.go +++ b/client/team.go @@ -14,20 +14,9 @@ type TeamCreateRequest struct { Plan string `json:"plan"` } -type SamlConnection struct { - Status string `json:"status"` -} - -type SamlDirectory struct { - Type string `json:"type"` - State string `json:"state"` -} - type SamlConfig struct { - Connection *SamlConnection `json:"connection"` - Directory *SamlDirectory `json:"directory"` - Enforced bool `json:"enforced,omitempty"` - Roles map[string]string `json:"roles,omitempty"` + Enforced bool `json:"enforced,omitempty"` + Roles map[string]string `json:"roles,omitempty"` } type TaxID struct { diff --git a/docs/data-sources/team_config.md b/docs/data-sources/team_config.md index b7b74c8d..856565e0 100644 --- a/docs/data-sources/team_config.md +++ b/docs/data-sources/team_config.md @@ -37,6 +37,7 @@ data "vercel_team_config" "example" { - `name` (String) The name of the team. - `preview_deployment_suffix` (String) The hostname that is used as the preview deployment suffix. - `remote_caching` (Attributes) Configuration for Remote Caching. (see [below for nested schema](#nestedatt--remote_caching)) +- `saml` (Attributes) Configuration for SAML authentication. (see [below for nested schema](#nestedatt--saml)) - `sensitive_environment_variable_policy` (String) The policy for sensitive environment variables. - `slug` (String) The slug of the team. Used in the URL of the team's dashboard. @@ -46,3 +47,13 @@ data "vercel_team_config" "example" { Read-Only: - `enabled` (Boolean) Indicates if Remote Caching is enabled. + + + +### Nested Schema for `saml` + +Read-Only: + +- `access_group_id` (String) The ID of the access group to use for the team. +- `enforced` (Boolean) Indicates if SAML is enforced for the team. +- `roles` (Map of String) Directory groups to role or access group mappings. diff --git a/docs/resources/team_config.md b/docs/resources/team_config.md index 53731774..959582f7 100644 --- a/docs/resources/team_config.md +++ b/docs/resources/team_config.md @@ -53,6 +53,7 @@ resource "vercel_team_config" "example" { - `name` (String) The name of the team. - `preview_deployment_suffix` (String) The hostname that is used as the preview deployment suffix. - `remote_caching` (Attributes) Configuration for Remote Caching. (see [below for nested schema](#nestedatt--remote_caching)) +- `saml` (Attributes) Configuration for SAML authentication. (see [below for nested schema](#nestedatt--saml)) - `sensitive_environment_variable_policy` (String) - `slug` (String) The slug of the team. Will be used in the URL of the team's dashboard. @@ -66,3 +67,16 @@ resource "vercel_team_config" "example" { Optional: - `enabled` (Boolean) Indicates if Remote Caching is enabled. + + + +### Nested Schema for `saml` + +Required: + +- `enforced` (Boolean) Indicates if SAML is enforced for the team. + +Optional: + +- `access_group_id` (String) The ID of the access group to use for the team. +- `roles` (Map of String) Directory groups to role or access group mappings. diff --git a/vercel/data_source_team_config.go b/vercel/data_source_team_config.go index 91e98602..dc82e19a 100644 --- a/vercel/data_source_team_config.go +++ b/vercel/data_source_team_config.go @@ -106,6 +106,25 @@ func (d *teamConfigDataSource) Schema(_ context.Context, _ datasource.SchemaRequ Computed: true, Description: "Indicates if ip addresses should be accessible in log drains.", }, + "saml": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "enforced": schema.BoolAttribute{ + Description: "Indicates if SAML is enforced for the team.", + Computed: true, + }, + "roles": schema.MapAttribute{ + Description: "Directory groups to role or access group mappings.", + Computed: true, + ElementType: types.StringType, + }, + "access_group_id": schema.StringAttribute{ + Description: "The ID of the access group to use for the team.", + Computed: true, + }, + }, + Computed: true, + Description: "Configuration for SAML authentication.", + }, }, } } @@ -124,7 +143,7 @@ type TeamConfigData struct { EnableProductionFeedback types.String `tfsdk:"enable_production_feedback"` HideIPAddresses types.Bool `tfsdk:"hide_ip_addresses"` HideIPAddressesInLogDrains types.Bool `tfsdk:"hide_ip_addresses_in_log_drains"` - // Saml types.Object `tfsdk:"saml"` + Saml types.Object `tfsdk:"saml"` } func (d *teamConfigDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { @@ -165,6 +184,7 @@ func (d *teamConfigDataSource) Read(ctx context.Context, req datasource.ReadRequ HideIPAddresses: out.HideIPAddresses, HideIPAddressesInLogDrains: out.HideIPAddressesInLogDrains, RemoteCaching: out.RemoteCaching, + Saml: out.Saml, }) resp.Diagnostics.Append(diags...) } diff --git a/vercel/resource_team_config.go b/vercel/resource_team_config.go index 1acffc76..8ba30389 100644 --- a/vercel/resource_team_config.go +++ b/vercel/resource_team_config.go @@ -2,16 +2,22 @@ package vercel import ( "context" + "encoding/json" "fmt" "os" + "regexp" "strings" + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -111,54 +117,40 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, Description: "Hostname that'll be matched with emails on sign-up to automatically join the Team.", }, - /* - "saml": schema.SingleNestedAttribute{ - Attributes: map[string]schema.Attribute{ - "enforced": schema.BoolAttribute{ - Description: "Indicates if SAML is enforced for the team.", - Required: true, - }, - "roles": schema.MapAttribute{ - Description: "Directory groups to role or access group mappings.", - Optional: true, - }, - "access_group_id": schema.StringAttribute{ - // TODO - enforce either accessGroupId or roles. - Description: "The ID of the access group to use for the team.", - Optional: true, - Validators: []validator.String{ - stringRegex(regexp.MustCompile("^ag_[A-z0-9_ -]+$"), "Access group ID must be a valid access group"), - }, - }, - "connection": schema.SingleNestedAttribute{ - Attributes: map[string]schema.Attribute{ - "status": schema.StringAttribute{ - Computed: true, - Description: "The current status of the connection.", - }, - }, - Description: "Info about the SAML connection.", - Computed: true, + "saml": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "enforced": schema.BoolAttribute{ + Description: "Indicates if SAML is enforced for the team.", + Required: true, + }, + "roles": schema.MapAttribute{ + Description: "Directory groups to role or access group mappings.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Map{ + // Validate only this attribute or roles is configured. + mapvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("saml.access_group_id"), + }...), }, - "directory": schema.SingleNestedAttribute{ - Attributes: map[string]schema.Attribute{ - "type": schema.StringAttribute{ - Computed: true, - Description: "The identity provider type.", - }, - "state": schema.StringAttribute{ - Computed: true, - Description: "The current state of the SAML connection.", - }, - }, - Description: "Info about the SAML directory.", - Computed: true, + }, + "access_group_id": schema.StringAttribute{ + Description: "The ID of the access group to use for the team.", + Optional: true, + Validators: []validator.String{ + stringRegex(regexp.MustCompile("^ag_[A-z0-9_ -]+$"), "Access group ID must be a valid access group"), + // Validate only this attribute or roles is configured. + stringvalidator.ExactlyOneOf(path.Expressions{ + path.MatchRoot("saml.roles"), + }...), }, }, - Optional: true, - Description: "Configuration for SAML authentication.", }, - */ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, + Description: "Configuration for SAML authentication.", + }, "invite_code": schema.StringAttribute{ Computed: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, @@ -171,9 +163,10 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques Description: "The hostname that is used as the preview deployment suffix.", }, "remote_caching": schema.SingleNestedAttribute{ - Description: "Configuration for Remote Caching.", - Optional: true, - Computed: true, + Description: "Configuration for Remote Caching.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.UseStateForUnknown()}, Attributes: map[string]schema.Attribute{ "enabled": schema.BoolAttribute{ Description: "Indicates if Remote Caching is enabled.", @@ -215,7 +208,6 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques } } -/* type SamlConnection struct { Status types.String `tfsdk:"status"` } @@ -226,16 +218,35 @@ type SamlDirectory struct { } type Saml struct { - Enforced types.Bool `tfsdk:"enforced"` - Roles types.Map `tfsdk:"roles"` - AccessGroupId types.String `tfsdk:"access_group_id"` - Connection *SamlConnection `tfsdk:"connection"` - Directory *SamlDirectory `tfsdk:"directory"` + Enforced types.Bool `tfsdk:"enforced"` + Roles types.Map `tfsdk:"roles"` + AccessGroupId types.String `tfsdk:"access_group_id"` +} + +var samlAttrTypes = map[string]attr.Type{ + "enforced": types.BoolType, + "roles": types.MapType{ElemType: types.StringType}, + "access_group_id": types.StringType, } -func (s Saml) toUpdateSamlConfig() *client.UpdateSamlConfig { +func (s *Saml) toUpdateSamlConfig(ctx context.Context) *client.UpdateSamlConfig { + if s == nil { + return nil + } + + config := &client.UpdateSamlConfig{ + Enforced: s.Enforced.ValueBool(), + } + roles := map[string]string{} + if !s.AccessGroupId.IsNull() { + roles["accessGroupId"] = s.AccessGroupId.ValueString() + } else { + s.Roles.ElementsAs(ctx, &roles, false) + } + config.Roles = roles + + return config } -*/ type EnableConfig struct { Enabled types.Bool `tfsdk:"enabled"` @@ -256,7 +267,7 @@ type TeamConfig struct { EnableProductionFeedback types.String `tfsdk:"enable_production_feedback"` HideIPAddresses types.Bool `tfsdk:"hide_ip_addresses"` HideIPAddressesInLogDrains types.Bool `tfsdk:"hide_ip_addresses_in_log_drains"` - // Saml types.Object `tfsdk:"saml"` + Saml types.Object `tfsdk:"saml"` } type RemoteCaching struct { @@ -291,6 +302,25 @@ func (t *TeamConfig) toUpdateTeamRequest(ctx context.Context, avatar string, sta return client.UpdateTeamRequest{}, diags } + var saml *Saml + diags = t.Saml.As(ctx, &saml, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: true, + UnhandledUnknownAsEmpty: true, + }) + if diags.HasError() { + return client.UpdateTeamRequest{}, diags + } + + var hideIPAddressses *bool + if !t.HideIPAddresses.IsUnknown() && !t.HideIPAddresses.IsNull() { + v := t.HideIPAddresses.ValueBool() + hideIPAddressses = &v + } + var hideIPAddresssesInLogDrains *bool + if !t.HideIPAddressesInLogDrains.IsUnknown() && !t.HideIPAddressesInLogDrains.IsNull() { + v := t.HideIPAddressesInLogDrains.ValueBool() + hideIPAddresssesInLogDrains = &v + } return client.UpdateTeamRequest{ TeamID: t.ID.ValueString(), Avatar: avatar, @@ -303,9 +333,9 @@ func (t *TeamConfig) toUpdateTeamRequest(ctx context.Context, avatar string, sta EnableProductionFeedback: t.EnableProductionFeedback.ValueString(), SensitiveEnvironmentVariablePolicy: t.SensitiveEnvironmentVariablePolicy.ValueString(), RemoteCaching: rc.toUpdateTeamRequest(), - HideIPAddresses: t.HideIPAddresses.ValueBoolPointer(), - HideIPAddressesInLogDrains: t.HideIPAddressesInLogDrains.ValueBoolPointer(), - // Saml: t.Saml.toUpdateSamlConfig(), + HideIPAddresses: hideIPAddressses, + HideIPAddressesInLogDrains: hideIPAddresssesInLogDrains, + Saml: saml.toUpdateSamlConfig(ctx), }, nil } @@ -320,6 +350,30 @@ func convertResponseToTeamConfig(ctx context.Context, response client.Team, avat return TeamConfig{}, diags } } + + saml := types.ObjectNull(samlAttrTypes) + if response.Saml != nil && response.Saml.Roles != nil { + samlValue := map[string]attr.Value{ + "enforced": types.BoolValue(response.Saml.Enforced), + "roles": types.MapNull(types.StringType), + "access_group_id": types.StringNull(), + } + if response.Saml.Roles["accessGroupId"] != "" { + samlValue["access_group_id"] = types.StringValue(response.Saml.Roles["accessGroupId"]) + } else { + roles, diags := types.MapValueFrom(ctx, types.StringType, response.Saml.Roles) + if diags.HasError() { + return TeamConfig{}, diags + } + samlValue["roles"] = roles + } + var diags diag.Diagnostics + saml, diags = types.ObjectValue(samlAttrTypes, samlValue) + if diags.HasError() { + return TeamConfig{}, diags + } + } + return TeamConfig{ Avatar: avatar, ID: types.StringValue(response.ID), @@ -335,7 +389,7 @@ func convertResponseToTeamConfig(ctx context.Context, response client.Team, avat HideIPAddresses: types.BoolPointerValue(response.HideIPAddresses), HideIPAddressesInLogDrains: types.BoolPointerValue(response.HideIPAddressesInLogDrains), RemoteCaching: remoteCaching, - // Saml: types.StringValue(response.Saml), + Saml: saml, }, nil } @@ -419,8 +473,10 @@ func (r *teamConfigResource) Create(ctx context.Context, req resource.CreateRequ return } - tflog.Info(ctx, "updated Team Configuration", map[string]interface{}{ - "team_id": response.ID, + jsonResp, _ := json.Marshal(response) + tflog.Info(ctx, "created Team Configuration", map[string]interface{}{ + "team_id": response.ID, + "response": string(jsonResp), }) teamConfig, diags := convertResponseToTeamConfig(ctx, response, plan.Avatar) @@ -454,6 +510,9 @@ func (r *teamConfigResource) Read(ctx context.Context, req resource.ReadRequest, } result, diags := convertResponseToTeamConfig(ctx, out, state.Avatar) + tflog.Info(ctx, "result", map[string]any{ + "result": result, + }) if diags.HasError() { resp.Diagnostics.Append(diags...) return