diff --git a/client/team.go b/client/team.go index 624cc983..e22a5f8a 100644 --- a/client/team.go +++ b/client/team.go @@ -19,12 +19,21 @@ type SamlRoleAccessGroupID struct { AccessGroupID string `json:"accessGroupId"` } -type SamlRole struct { +type SamlRoleAPI struct { Role *string AccessGroupID *SamlRoleAccessGroupID } -func (f *SamlRole) UnmarshalJSON(data []byte) error { +type SamlRolesAPI map[string]SamlRoleAPI + +type SamlRole struct { + Role *string `json:"role"` + AccessGroupID *string `json:"accessGroupId"` +} + +type SamlRoles map[string]SamlRole + +func (f *SamlRoleAPI) UnmarshalJSON(data []byte) error { var role string if err := json.Unmarshal(data, &role); err == nil { f.Role = &role @@ -38,10 +47,8 @@ func (f *SamlRole) UnmarshalJSON(data []byte) error { return fmt.Errorf("received json is neither Role string nor AccessGroupID map") } -type SamlRoles map[string]string - func (f *SamlRoles) UnmarshalJSON(data []byte) error { - var result map[string]SamlRole + var result SamlRolesAPI if err := json.Unmarshal(data, &result); err != nil { return err } @@ -50,7 +57,14 @@ func (f *SamlRoles) UnmarshalJSON(data []byte) error { k := k v := v if v.Role != nil { - tmp[k] = *(v.Role) + tmp[k] = SamlRole{ + Role: v.Role, + } + } + if v.AccessGroupID != nil { + tmp[k] = SamlRole{ + AccessGroupID: &v.AccessGroupID.AccessGroupID, + } } } *f = tmp @@ -119,9 +133,37 @@ func (c *Client) GetTeam(ctx context.Context, idOrSlug string) (t Team, err erro return t, err } +type UpdateSamlConfigRole struct { + Role *string `json:"role"` + AccessGroupID *string `json:"accessGroupId"` +} + type UpdateSamlConfig struct { - Enforced bool `json:"enforced"` - Roles map[string]string `json:"roles"` + Enforced bool `json:"enforced"` + Roles map[string]UpdateSamlConfigRole `json:"roles"` +} + +func (r *UpdateSamlConfig) MarshalJSON() ([]byte, error) { + roles := map[string]any{} + for k, v := range r.Roles { + if v.Role != nil && v.AccessGroupID != nil { + return nil, fmt.Errorf("bad union") + } + if v.Role != nil { + roles[k] = v.Role + } else if v.AccessGroupID != nil { + roles[k] = map[string]any{ + "accessGroupId": v.AccessGroupID, + } + } else { + return nil, fmt.Errorf("bad union") + } + + } + return json.Marshal(map[string]any{ + "enforced": r.Enforced, + "roles": roles, + }) } type UpdateTeamRequest struct { diff --git a/docs/data-sources/team_config.md b/docs/data-sources/team_config.md index 856565e0..ec3138ab 100644 --- a/docs/data-sources/team_config.md +++ b/docs/data-sources/team_config.md @@ -54,6 +54,13 @@ Read-Only: 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. +- `roles` (Attributes Map) Directory groups to role or access group mappings. For each directory group, either a role or access group id is specified. (see [below for nested schema](#nestedatt--saml--roles)) + + +### Nested Schema for `saml.roles` + +Read-Only: + +- `access_group_id` (String) The access group the assign is assigned to. +- `role` (String) The team level role the user is assigned. One of 'MEMBER', 'OWNER', 'VIEWER', 'DEVELOPER', 'BILLING' or 'CONTRIBUTOR'. diff --git a/docs/resources/team_config.md b/docs/resources/team_config.md index 3e811834..d5bc02fc 100644 --- a/docs/resources/team_config.md +++ b/docs/resources/team_config.md @@ -78,5 +78,12 @@ Required: 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. +- `roles` (Attributes Map) Directory groups to role or access group mappings. For each directory group, specify either a role or access group id. (see [below for nested schema](#nestedatt--saml--roles)) + + +### Nested Schema for `saml.roles` + +Optional: + +- `access_group_id` (String) The access group id to assign to the user. +- `role` (String) The team level role to assign to the user. One of 'MEMBER', 'OWNER', 'VIEWER', 'DEVELOPER', 'BILLING' or 'CONTRIBUTOR'. diff --git a/vercel/data_source_team_config.go b/vercel/data_source_team_config.go index dc82e19a..80026570 100644 --- a/vercel/data_source_team_config.go +++ b/vercel/data_source_team_config.go @@ -112,14 +112,21 @@ func (d *teamConfigDataSource) Schema(_ context.Context, _ datasource.SchemaRequ 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.", + "roles": schema.MapNestedAttribute{ + Description: "Directory groups to role or access group mappings. For each directory group, either a role or access group id is specified.", Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + Description: "The team level role the user is assigned. One of 'MEMBER', 'OWNER', 'VIEWER', 'DEVELOPER', 'BILLING' or 'CONTRIBUTOR'.", + Computed: true, + }, + "access_group_id": schema.StringAttribute{ + Description: "The access group the assign is assigned to.", + Computed: true, + }, + }, + }, }, }, Computed: true, diff --git a/vercel/resource_team_config.go b/vercel/resource_team_config.go index d1bb9bc0..fd653819 100644 --- a/vercel/resource_team_config.go +++ b/vercel/resource_team_config.go @@ -16,6 +16,7 @@ import ( "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/mapdefault" "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" @@ -29,8 +30,9 @@ import ( // Ensure the implementation satisfies the expected interfaces. var ( - _ resource.Resource = &teamConfigResource{} - _ resource.ResourceWithConfigure = &teamConfigResource{} + _ resource.Resource = &teamConfigResource{} + _ resource.ResourceWithConfigure = &teamConfigResource{} + _ resource.ResourceWithUpgradeState = &teamConfigResource{} ) func newTeamConfigResource() resource.Resource { @@ -65,6 +67,7 @@ func (r *teamConfigResource) Configure(ctx context.Context, req resource.Configu func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ + Version: 1, Description: "Manages the configuration of an existing Vercel Team.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ @@ -124,29 +127,33 @@ func (r *teamConfigResource) Schema(_ context.Context, req resource.SchemaReques Description: "Indicates if SAML is enforced for the team.", Required: true, }, - "roles": schema.MapAttribute{ - Description: "Directory groups to role or access group mappings.", + "roles": schema.MapNestedAttribute{ + Description: "Directory groups to role or access group mappings. For each directory group, specify either a role or access group id.", Optional: true, - ElementType: types.StringType, - Validators: []validator.Map{ - // Validate only this attribute or roles is configured. - mapvalidator.ExactlyOneOf( - path.MatchRoot("saml.roles"), - path.MatchRoot("saml.access_group_id"), - ), - }, - }, - "access_group_id": schema.StringAttribute{ - Description: "The ID of the access group to use for the team.", - Optional: true, - Validators: []validator.String{ - stringvalidator.RegexMatches(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.MatchRoot("saml.roles"), - path.MatchRoot("saml.access_group_id"), - ), + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role": schema.StringAttribute{ + Description: "The team level role to assign to the user. One of 'MEMBER', 'OWNER', 'VIEWER', 'DEVELOPER', 'BILLING' or 'CONTRIBUTOR'.", + Optional: true, + Validators: []validator.String{ + stringvalidator.OneOf("MEMBER", "OWNER", "VIEWER", "DEVELOPER", "BILLING", "CONTRIBUTOR"), + }, + }, + "access_group_id": schema.StringAttribute{ + Description: "The access group id to assign to the user.", + Optional: true, + }, + }, }, + Validators: []validator.Map{validateSamlRoles()}, + Default: mapdefault.StaticValue(types.MapValueMust(types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "role": types.StringType, + "access_group_id": types.StringType, + }, + }, map[string]attr.Value{})), + PlanModifiers: []planmodifier.Map{mapplanmodifier.UseStateForUnknown()}, }, }, Optional: true, @@ -222,35 +229,40 @@ type SamlDirectory struct { State types.String `tfsdk:"state"` } +// only one of these is non-nil +type SamlRoles struct { + Role types.String `tfsdk:"role"` + AccessGroupID types.String `tfsdk:"access_group_id"` +} + type Saml struct { + Enforced types.Bool `tfsdk:"enforced"` + Roles map[string]SamlRoles `tfsdk:"roles"` +} + +// for backwards compatibility +type SamlV0 struct { 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}, +var samlRoleAttrType = map[string]attr.Type{ + "role": types.StringType, "access_group_id": types.StringType, } -func (s *Saml) toUpdateSamlConfig(ctx context.Context) *client.UpdateSamlConfig { - if s == nil { - return nil - } +var samlRoleType = types.ObjectType{ + AttrTypes: samlRoleAttrType, +} - 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 +var samlRolesType = types.MapType{ + ElemType: samlRoleType, +} - return config +var samlAttrTypes = map[string]attr.Type{ + "enforced": types.BoolType, + "roles": samlRolesType, } type EnableConfig struct { @@ -292,6 +304,23 @@ func (r *RemoteCaching) toUpdateTeamRequest() *client.RemoteCaching { } } +func (r *Saml) toUpdateTeamRequest() *client.UpdateSamlConfig { + if r == nil { + return nil + } + roles := map[string]client.UpdateSamlConfigRole{} + for k, v := range r.Roles { + roles[k] = client.UpdateSamlConfigRole{ + Role: v.Role.ValueStringPointer(), + AccessGroupID: v.AccessGroupID.ValueStringPointer(), + } + } + return &client.UpdateSamlConfig{ + Enforced: r.Enforced.ValueBool(), + Roles: roles, + } +} + func (t *TeamConfig) toUpdateTeamRequest(ctx context.Context, avatar string, stateSlug types.String) (client.UpdateTeamRequest, diag.Diagnostics) { slug := t.Slug.ValueString() if stateSlug.ValueString() == t.Slug.ValueString() { @@ -340,7 +369,7 @@ func (t *TeamConfig) toUpdateTeamRequest(ctx context.Context, avatar string, sta RemoteCaching: rc.toUpdateTeamRequest(), HideIPAddresses: hideIPAddressses, HideIPAddressesInLogDrains: hideIPAddresssesInLogDrains, - Saml: saml.toUpdateSamlConfig(ctx), + Saml: saml.toUpdateTeamRequest(), }, nil } @@ -357,23 +386,29 @@ func convertResponseToTeamConfig(ctx context.Context, response client.Team, avat } 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 + if response.Saml != nil { + roles := map[string]SamlRoles{} + for k, v := range response.Saml.Roles { + role := SamlRoles{} + if v.Role != nil { + role = SamlRoles{ + Role: types.StringPointerValue(v.Role), + } + } + if v.AccessGroupID != nil { + role = SamlRoles{ + AccessGroupID: types.StringPointerValue(v.AccessGroupID), + } } - samlValue["roles"] = roles + roles[k] = role } + var diags diag.Diagnostics - saml, diags = types.ObjectValue(samlAttrTypes, samlValue) + saml, diags = types.ObjectValueFrom(ctx, samlAttrTypes, &Saml{ + Enforced: types.BoolValue(response.Saml.Enforced), + Roles: roles, + }) + if diags.HasError() { return TeamConfig{}, diags } @@ -584,3 +619,217 @@ func (r *teamConfigResource) Delete(ctx context.Context, req resource.DeleteRequ // We don't actually delete the team, just remove it from state resp.State.RemoveResource(ctx) } + +// https://developer.hashicorp.com/terraform/plugin/framework/resources/state-upgrade#implementing-state-upgrade-support +func (r *teamConfigResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { + return map[int64]resource.StateUpgrader{ + // State upgrade implementation from 0 to 1. + // roles.saml.access_group_id has been removed + // roles.saml.roles is now a map of objects with role and access_group_id, instead of a map of strings + 0: { + PriorSchema: &schema.Schema{ + Description: "Manages the configuration of an existing Vercel Team.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the existing Vercel Team.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the team.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "slug": schema.StringAttribute{ + Description: "The slug of the team. Will be used in the URL of the team's dashboard.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "avatar": schema.MapAttribute{ + Description: "The `avatar` should be a the 'file' attribute from a vercel_file data source.", + Optional: true, + PlanModifiers: []planmodifier.Map{mapplanmodifier.RequiresReplace()}, + ElementType: types.StringType, + Validators: []validator.Map{ + mapvalidator.SizeAtLeast(1), + mapvalidator.SizeAtMost(1), + }, + }, + "description": schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Description: "A description of the team.", + }, + "sensitive_environment_variable_policy": schema.StringAttribute{ + Description: "Ensures that all environment variables created by members of this team will be created as Sensitive Environment Variables which can only be decrypted by Vercel's deployment system.: one of on, off or default.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringvalidator.OneOf("on", "off"), + }, + }, + "email_domain": schema.StringAttribute{ + Optional: true, + Computed: true, + 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, + ElementType: types.StringType, + Validators: []validator.Map{ + // Validate only this attribute or roles is configured. + mapvalidator.ExactlyOneOf( + path.MatchRoot("saml.roles"), + path.MatchRoot("saml.access_group_id"), + ), + }, + }, + "access_group_id": schema.StringAttribute{ + Description: "The ID of the access group to use for the team.", + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches(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.MatchRoot("saml.roles"), + path.MatchRoot("saml.access_group_id"), + ), + }, + }, + }, + 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()}, + Description: "A code that can be used to join this team. Only visible to Team owners.", + }, + "preview_deployment_suffix": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Computed: true, + Description: "The hostname that is used as the preview deployment suffix.", + }, + "remote_caching": schema.SingleNestedAttribute{ + 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.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + }, + }, + }, + "enable_preview_feedback": schema.StringAttribute{ + Description: "Enables the Vercel Toolbar on your preview deployments: one of on, off or default.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringvalidator.OneOf("default", "on", "off"), + }, + }, + "enable_production_feedback": schema.StringAttribute{ + Description: "Enables the Vercel Toolbar on your production deployments: one of on, off or default.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + Validators: []validator.String{ + stringvalidator.OneOf("default", "on", "off"), + }, + }, + "hide_ip_addresses": schema.BoolAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + Description: "Indicates if ip addresses should be accessible in o11y tooling.", + }, + "hide_ip_addresses_in_log_drains": schema.BoolAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()}, + Description: "Indicates if ip addresses should be accessible in log drains.", + }, + }, + }, + StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { + var priorStateData TeamConfig + + resp.Diagnostics.Append(req.State.Get(ctx, &priorStateData)...) + + if resp.Diagnostics.HasError() { + return + } + + upgradedStateData := TeamConfig{ + ID: priorStateData.ID, + Avatar: priorStateData.Avatar, + Name: priorStateData.Name, + Slug: priorStateData.Slug, + Description: priorStateData.Description, + InviteCode: priorStateData.InviteCode, + SensitiveEnvironmentVariablePolicy: priorStateData.SensitiveEnvironmentVariablePolicy, + EmailDomain: priorStateData.EmailDomain, + PreviewDeploymentSuffix: priorStateData.PreviewDeploymentSuffix, + RemoteCaching: priorStateData.RemoteCaching, + EnablePreviewFeedback: priorStateData.EnablePreviewFeedback, + EnableProductionFeedback: priorStateData.EnableProductionFeedback, + HideIPAddresses: priorStateData.HideIPAddresses, + HideIPAddressesInLogDrains: priorStateData.HideIPAddressesInLogDrains, + } + + if !priorStateData.Saml.IsNull() { + var samlV0 *SamlV0 + diags := priorStateData.Saml.As(ctx, &samlV0, basetypes.ObjectAsOptions{ + UnhandledNullAsEmpty: true, + UnhandledUnknownAsEmpty: true, + }) + if diags.HasError() { + return + } + // samlV0 did not correctly handle access groups, so don't need to upgrade them. + // we do need to upgrade the roles object to the new format. + roles := map[string]SamlRoles{} + for k, v := range samlV0.Roles.Elements() { + role := v.String() + roles[k] = SamlRoles{ + Role: types.StringPointerValue(&role), + } + } + saml, diags := types.ObjectValueFrom(ctx, samlAttrTypes, &Saml{ + Enforced: samlV0.Enforced, + Roles: roles, + }) + if diags.HasError() { + return + } + upgradedStateData.Saml = saml + } + + resp.Diagnostics.Append(resp.State.Set(ctx, upgradedStateData)...) + }, + }, + } +} diff --git a/vercel/validator_saml_roles.go b/vercel/validator_saml_roles.go new file mode 100644 index 00000000..34329848 --- /dev/null +++ b/vercel/validator_saml_roles.go @@ -0,0 +1,71 @@ +package vercel + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type samlRolesValidator struct{} + +var _ validator.Map = &samlRolesValidator{} + +func validateSamlRoles() validator.Map { + return &samlRolesValidator{} +} + +func (v *samlRolesValidator) Description(ctx context.Context) string { + return "Validates that exactly one of role or access_group_id is defined for each SAML role entry" +} + +func (v *samlRolesValidator) MarkdownDescription(ctx context.Context) string { + return "Validates that exactly one of role or access_group_id is defined for each SAML role entry" +} + +func (v *samlRolesValidator) ValidateMap(ctx context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + // Get all the map keys + keys := req.ConfigValue.Elements() + + // For each key in the map + for key, value := range keys { + // Convert the value to an object + obj, ok := value.(types.Object) + + if !ok || obj.IsNull() || obj.IsUnknown() { + resp.Diagnostics.AddAttributeError( + req.Path.AtMapKey(key), + "Invalid SAML Role Configuration: "+key, + "Expected an object with role or access_group_id", + ) + continue + } + + role := obj.Attributes()["role"] + accessGroupID := obj.Attributes()["access_group_id"] + + // Check if both are set + if !role.IsNull() && !role.IsUnknown() && !accessGroupID.IsNull() && !accessGroupID.IsUnknown() { + resp.Diagnostics.AddAttributeError( + req.Path.AtMapKey(key), + "Invalid SAML Role Configuration: "+key, + "Only one of 'role' or 'access_group_id' can be set, not both", + ) + continue + } + + // Check if neither is set + if (role.IsNull() || role.IsUnknown()) && (accessGroupID.IsNull() || accessGroupID.IsUnknown()) { + resp.Diagnostics.AddAttributeError( + req.Path.AtMapKey(key), + "Invalid SAML Role Configuration: "+key, + "Either 'role' or 'access_group_id' must be set", + ) + continue + } + } +}