diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63e0b39d..b5dd7b54 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,7 @@ jobs: OPAL_TEST_KNOWN_OPAL_APP_ADMIN_OWNER_ID: ${{ secrets.OPAL_TEST_KNOWN_OPAL_APP_ADMIN_OWNER_ID }} OPAL_TEST_KNOWN_GITHUB_TEST_REPO_2_RESOURCE_ID: ${{ secrets.OPAL_TEST_KNOWN_GITHUB_TEST_REPO_2_RESOURCE_ID }} OPAL_TEST_KNOWN_OPAL_GROUP_ID: ${{ secrets.OPAL_TEST_KNOWN_OPAL_GROUP_ID }} + OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID: ${{ secrets.OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID }} - name: Clean up test organization run: make sweep env: @@ -58,6 +59,7 @@ jobs: OPAL_TEST_KNOWN_OPAL_APP_ADMIN_OWNER_ID: ${{ secrets.OPAL_TEST_KNOWN_OPAL_APP_ADMIN_OWNER_ID }} OPAL_TEST_KNOWN_GITHUB_TEST_REPO_2_RESOURCE_ID: ${{ secrets.OPAL_TEST_KNOWN_GITHUB_TEST_REPO_2_RESOURCE_ID }} OPAL_TEST_KNOWN_OPAL_GROUP_ID: ${{ secrets.OPAL_TEST_KNOWN_OPAL_GROUP_ID }} + OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID: ${{ secrets.OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID }} - name: Check for doc changes id: changes run: | diff --git a/.vscode/settings.json b/.vscode/settings.json index 19deeae7..b07214b0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { + "go.testEnvFile": "${workspaceFolder}/.envrc.local.vscode", "go.testEnvVars": { - "TF_ACC": 1, - "TF_LOG": "DEBUG", + "TF_ACC": "1", + "TF_LOG": "DEBUG" } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 25576dc6..2ac41b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ BREAKING CHANGES: NEW FEATURES: - adds support for multi-stage approvals +- adds support for `on_call_schedules` in group resources. Example: + +```terraform +resource "opal_on_call_schedule" "security_oncall_rotation" { + third_party_provider = "PAGER_DUTY" + remote_id = "PNXHVAA" +} + +# Example group usage +resource "opal_group" "security" { + // ... + + on_call_schedule { + id = opal_on_call_schedule.security_oncall_rotation.id + } +``` ## v0.0.4 - Fixes a bug for owner user parsing diff --git a/docs/resources/group.md b/docs/resources/group.md index 53120a45..04dbcdcd 100644 --- a/docs/resources/group.md +++ b/docs/resources/group.md @@ -137,11 +137,12 @@ resource "opal_group" "google_group_example" { ### Optional -- `audit_message_channel` (Block List) An audit message channel for this group. (see [below for nested schema](#nestedblock--audit_message_channel)) +- `audit_message_channel` (Block Set) An audit message channel for this group. (see [below for nested schema](#nestedblock--audit_message_channel)) - `auto_approval` (Boolean) Automatically approve all requests for this group without review. - `description` (String) The description of the group. - `is_requestable` (Boolean) Allow users to create an access request for this group. By default, any group is requestable. - `max_duration` (Number) The maximum duration for which this group can be requested (in minutes). By default, the max duration is indefinite access. +- `on_call_schedule` (Block Set) An on call schedule for this group. (see [below for nested schema](#nestedblock--on_call_schedule)) - `recommended_duration` (Number) The recommended duration for which the group should be requested (in minutes). Will be the default value in a request. Use -1 to set to indefinite. - `remote_info` (Block List, Max: 1) Remote info that is required for the creation of remote groups. (see [below for nested schema](#nestedblock--remote_info)) - `request_template_id` (String) The ID of a request template for this group. You can get this ID from the URL in the Opal web app. @@ -165,6 +166,14 @@ Required: - `id` (String) The ID of the message channel for this group. + +### Nested Schema for `on_call_schedule` + +Required: + +- `id` (String) The UUID of the on call schedule for this group. + + ### Nested Schema for `remote_info` diff --git a/docs/resources/on_call_schedule.md b/docs/resources/on_call_schedule.md new file mode 100644 index 00000000..a4cc7b00 --- /dev/null +++ b/docs/resources/on_call_schedule.md @@ -0,0 +1,47 @@ +--- +page_title: "opal_on_call_schedule Resource - terraform-provider-opal" +subcategory: "" +description: |- + An Opal OnCallSchedule resource. +--- + +# opal_on_call_schedule (Resource) + +An Opal OnCallSchedule resource. + +## Example Usage + +```terraform +resource "opal_on_call_schedule" "security_oncall_rotation" { + third_party_provider = "PAGER_DUTY" + remote_id = "PNXHVAA" +} + +# Example group usage +resource "opal_group" "security" { + // ... + + on_call_schedule { + id = opal_on_call_schedule.security_oncall_rotation.id + } + + // or if an UUID is already present in Opal + on_call_schedule { + id = "878ba05b-33f0-4dd5-a199-09efc06abcf7" + } +} +``` + + +## Schema + +### Required + +- `remote_id` (String) The remote ID of the on call schedule. +- `third_party_provider` (String) The provider of the on call schedule (i.e. PAGER_DUTY, OPSGENIE). + +### Read-Only + +- `id` (String) The ID of the on call schedule. + +Please [file a ticket](https://github.com/opalsecurity/terraform-provider-opal/issues) to discuss use cases that are not yet supported in the provider. diff --git a/examples/resources/on_call_schedule.tf b/examples/resources/on_call_schedule.tf new file mode 100644 index 00000000..f81fa356 --- /dev/null +++ b/examples/resources/on_call_schedule.tf @@ -0,0 +1,18 @@ +resource "opal_on_call_schedule" "security_oncall_rotation" { + third_party_provider = "PAGER_DUTY" + remote_id = "PNXHVAA" +} + +# Example group usage +resource "opal_group" "security" { + // ... + + on_call_schedule { + id = opal_on_call_schedule.security_oncall_rotation.id + } + + // or if an UUID is already present in Opal + on_call_schedule { + id = "878ba05b-33f0-4dd5-a199-09efc06abcf7" + } +} diff --git a/opal/group.go b/opal/group.go index c84f3d0e..9c90520d 100644 --- a/opal/group.go +++ b/opal/group.go @@ -163,7 +163,7 @@ func resourceGroup() *schema.Resource { }, "audit_message_channel": { Description: "An audit message channel for this group.", - Type: schema.TypeList, + Type: schema.TypeSet, Optional: true, Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ @@ -200,6 +200,20 @@ func resourceGroup() *schema.Resource { Optional: true, Default: true, }, + "on_call_schedule": { + Description: "An on call schedule for this group.", + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Description: "The UUID of the on call schedule for this group.", + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, }, } } @@ -320,6 +334,12 @@ func resourceGroupCreate(ctx context.Context, d *schema.ResourceData, m any) dia } } + if _, ok := d.GetOk("on_call_schedule"); ok { + if diag := resourceGroupUpdateOnCallSchedules(ctx, d, client); diag != nil { + return diag + } + } + return resourceGroupRead(ctx, d, m) } @@ -348,7 +368,7 @@ func resourceGroupUpdateVisibility(ctx context.Context, d *schema.ResourceData, func resourceGroupUpdateAuditMessageChannels(ctx context.Context, d *schema.ResourceData, client *opal.APIClient) diag.Diagnostics { var channelIDs []string if auditMessageChannelsI, ok := d.GetOk("audit_message_channel"); ok { - rawChannels := auditMessageChannelsI.([]any) + rawChannels := auditMessageChannelsI.(*schema.Set).List() for _, rawChannel := range rawChannels { channel := rawChannel.(map[string]any) channelIDs = append(channelIDs, channel["id"].(string)) @@ -363,6 +383,24 @@ func resourceGroupUpdateAuditMessageChannels(ctx context.Context, d *schema.Reso return nil } +func resourceGroupUpdateOnCallSchedules(ctx context.Context, d *schema.ResourceData, client *opal.APIClient) diag.Diagnostics { + var onCallScheduleIDs []string + if onCallSchedulesI, ok := d.GetOk("on_call_schedule"); ok { + rawOnCallSchedules := onCallSchedulesI.(*schema.Set).List() + for _, rawOnCallSchedule := range rawOnCallSchedules { + onCallSchedule := rawOnCallSchedule.(map[string]any) + onCallScheduleIDs = append(onCallScheduleIDs, onCallSchedule["id"].(string)) + } + } + + onCallScheduleList := *opal.NewOnCallScheduleIDList(onCallScheduleIDs) + if _, _, err := client.GroupsApi.SetGroupOnCallSchedules(ctx, d.Id()).OnCallScheduleIDList(onCallScheduleList).Execute(); err != nil { + return diagFromErr(ctx, err) + } + + return nil +} + func resourceGroupUpdateResources(ctx context.Context, d *schema.ResourceData, client *opal.APIClient) diag.Diagnostics { var rawResources []any if resourceI, ok := d.GetOk("resource"); ok { @@ -501,6 +539,18 @@ func resourceGroupRead(ctx context.Context, d *schema.ResourceData, m any) diag. } d.Set("audit_message_channel", auditChannels) + onCallSchedulesResponse, _, err := client.GroupsApi.GetGroupOnCallSchedules(ctx, group.GroupId).Execute() + if err != nil { + return diagFromErr(ctx, err) + } + onCallSchedules := make([]any, 0, len(onCallSchedulesResponse.OnCallSchedules)) + for _, onCallSchedule := range onCallSchedulesResponse.OnCallSchedules { + onCallSchedules = append(onCallSchedules, map[string]any{ + "id": onCallSchedule.OnCallScheduleId, + }) + } + d.Set("on_call_schedule", onCallSchedules) + reviewerStages, _, err := client.GroupsApi.GetGroupReviewerStages(ctx, group.GroupId).Execute() if err != nil { return diagFromErr(ctx, err) @@ -598,6 +648,12 @@ func resourceGroupUpdate(ctx context.Context, d *schema.ResourceData, m any) dia } } + if d.HasChange("on_call_schedule") { + if diag := resourceGroupUpdateOnCallSchedules(ctx, d, client); diag != nil { + return diag + } + } + if d.HasChange("reviewer_stage") { reviewerStages := any([]any{}) if reviewersStagesBlock, ok := d.GetOk("reviewer_stage"); ok { diff --git a/opal/group_test.go b/opal/group_test.go index cbf72f51..0ab7e738 100644 --- a/opal/group_test.go +++ b/opal/group_test.go @@ -3,12 +3,13 @@ package opal import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" - "github.com/opalsecurity/opal-go" "os" "strings" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/opalsecurity/opal-go" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) @@ -16,6 +17,7 @@ import ( var knownOpalAppID = os.Getenv("OPAL_TEST_KNOWN_OPAL_APP_ID") var knownOpalAppAdminOwnerID = os.Getenv("OPAL_TEST_KNOWN_OPAL_APP_ADMIN_OWNER_ID") var knownGithubRepoResourceID = os.Getenv("OPAL_TEST_KNOWN_GITHUB_TEST_REPO_2_RESOURCE_ID") +var knownOnCallScheduleID = os.Getenv("OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID") func TestAccGroup_Import(t *testing.T) { baseName := "tf_acc_group_test_" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) @@ -367,6 +369,25 @@ func TestAccGroup_Remote(t *testing.T) { }) } +func TestAccGroup_OnCallSchedule(t *testing.T) { + baseName := "tf_acc_group_test_" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + resourceName := "opal_group." + baseName + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccGroupResourceWithReviewer(baseName, baseName, fmt.Sprintf(`on_call_schedule { id = "%s" }`, knownOnCallScheduleID)), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "on_call_schedule.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "on_call_schedule.*", map[string]string{"id": knownOnCallScheduleID}), + ), + }, + }, + }) +} + func testAccGroupResourceWithAccessLevel(resourceID, accessLevelRemoteID string) string { return fmt.Sprintf(` resource { diff --git a/opal/on_call_schedule.go b/opal/on_call_schedule.go new file mode 100644 index 00000000..d9cd251b --- /dev/null +++ b/opal/on_call_schedule.go @@ -0,0 +1,94 @@ +package opal + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/opalsecurity/opal-go" +) + +var allowedOnCallScheduleProviders = enumSliceToStringSlice(opal.AllowedOnCallScheduleProviderEnumEnumValues) + +func resourceOnCallSchedule() *schema.Resource { + return &schema.Resource{ + Description: "An Opal OnCallSchedule resource.", + CreateContext: resourceOnCallScheduleCreate, + ReadContext: resourceOnCallScheduleRead, + DeleteContext: resourceOnCallScheduleDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "id": { + Description: "The ID of the on call schedule.", + Type: schema.TypeString, + Computed: true, + }, + "third_party_provider": { + Description: "The provider of the on call schedule (i.e. PAGER_DUTY, OPSGENIE).", + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(allowedOnCallScheduleProviders, false), + ForceNew: true, + Required: true, + }, + "remote_id": { + Description: "The remote ID of the on call schedule.", + Type: schema.TypeString, + ForceNew: true, + Required: true, + }, + }, + } +} + +func resourceOnCallScheduleCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*opal.APIClient) + + provider := opal.OnCallScheduleProviderEnum(d.Get("third_party_provider").(string)) + remoteID := d.Get("remote_id").(string) + + createInfo := opal.NewCreateOnCallScheduleInfo(provider, remoteID) + + onCallSchedule, _, err := client.OnCallSchedulesApi.CreateOnCallSchedule(ctx).CreateOnCallScheduleInfo(*createInfo).Execute() + if err != nil { + return diagFromErr(ctx, err) + } + tflog.Debug(ctx, "Created on call schedule", map[string]any{ + "provider": provider, + "id": onCallSchedule.OnCallScheduleId, + "remoteID": remoteID, + }) + + d.SetId(onCallSchedule.GetOnCallScheduleId()) + return resourceOnCallScheduleRead(ctx, d, m) +} + +func resourceOnCallScheduleRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + client := m.(*opal.APIClient) + + id := d.Get("id").(string) + onCallSchedule, _, err := client.OnCallSchedulesApi.GetOnCallSchedule(ctx, id).Execute() + if err != nil { + return diagFromErr(ctx, err) + } + + d.SetId(onCallSchedule.GetOnCallScheduleId()) + if err := multierror.Append( + d.Set("third_party_provider", onCallSchedule.ThirdPartyProvider), + d.Set("remote_id", onCallSchedule.RemoteId), + ); err.ErrorOrNil() != nil { + return diagFromErr(ctx, err) + } + + return nil +} + +func resourceOnCallScheduleDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + // TODO: Implement + return nil +} diff --git a/opal/provider.go b/opal/provider.go index 17b1b0fe..ed3ec05c 100644 --- a/opal/provider.go +++ b/opal/provider.go @@ -39,10 +39,11 @@ func NewProvider() *schema.Provider { }, }, ResourcesMap: map[string]*schema.Resource{ - "opal_owner": resourceOwner(), - "opal_resource": resourceResource(), - "opal_group": resourceGroup(), - "opal_message_channel": resourceMessageChannel(), + "opal_owner": resourceOwner(), + "opal_resource": resourceResource(), + "opal_group": resourceGroup(), + "opal_message_channel": resourceMessageChannel(), + "opal_on_call_schedule": resourceOnCallSchedule(), }, DataSourcesMap: map[string]*schema.Resource{ "opal_app": dataSourceApp(), diff --git a/opal/provider_test.go b/opal/provider_test.go index f7865b5d..2cd602e3 100644 --- a/opal/provider_test.go +++ b/opal/provider_test.go @@ -83,6 +83,10 @@ func testAccPreCheck(t *testing.T) { if os.Getenv("OPAL_TEST_KNOWN_OPAL_GROUP_ID") == "" { t.Fatal("OPAL_TEST_KNOWN_OPAL_GROUP_ID must be set for acceptance tests. You should get this value from an Opal group in the test organization.") } + + if os.Getenv("OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID") == "" { + t.Fatal("OPAL_TEST_KNOWN_ON_CALL_SCHEDULE_ID must be set for acceptance tests. You should get this value from an imported Opal on call schedule in the test organization.") + } } func TestMain(m *testing.M) { diff --git a/templates/resources/on_call_schedule.md.tmpl b/templates/resources/on_call_schedule.md.tmpl new file mode 100644 index 00000000..f706ab5d --- /dev/null +++ b/templates/resources/on_call_schedule.md.tmpl @@ -0,0 +1,18 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +{{ tffile "examples/resources/on_call_schedule.tf" }} + +{{ .SchemaMarkdown | trimspace }} + +Please [file a ticket](https://github.com/opalsecurity/terraform-provider-opal/issues) to discuss use cases that are not yet supported in the provider.