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.