diff --git a/.gitignore b/.gitignore index 0516ac8e0..d87f06ff2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ website/node_modules *.iml *.test *.iml +*.env website/vendor diff --git a/docs/data-sources/iam_policy.md b/docs/data-sources/iam_policy.md index 4c9eeeaf2..439297be3 100644 --- a/docs/data-sources/iam_policy.md +++ b/docs/data-sources/iam_policy.md @@ -32,3 +32,14 @@ data "ovh_iam_policy" "my_policy" { * `created_at` - Creation date of this group. * `updated_at` - Date of the last update of this group. * `read_only` - Indicates that the policy is a default one. +* `expired_at` - Expiration date of the policy. +* `conditions` - Conditions restricting the policy. + +### Conditions + +The `conditions` block returns: + +* `operator` - Operator to combine conditions. +* `condition` - List of condition blocks. Each condition supports: + * `operator` - Operator for this condition. + * `values` - Map of key-value pairs to match. diff --git a/docs/resources/iam_policy.md b/docs/resources/iam_policy.md index a08e6bc78..a537aea40 100644 --- a/docs/resources/iam_policy.md +++ b/docs/resources/iam_policy.md @@ -31,6 +31,54 @@ resource "ovh_iam_policy" "manager" { "account:apiovh:*", ] } + +resource "ovh_iam_policy" "ip_restricted_prod_access" { + name = "ip_restricted_prod_access" + description = "Allow access only from a specific IP to resources tagged prod" + identities = [ovh_me_identity_group.my_group.urn] + resources = ["urn:v1:eu:resource:vps:*"] + + allow = [ + "vps:apiovh:*", + ] + + conditions { + operator = "MATCH" + values = { + "resource.Tag(environment)" = "prod" + "request.IP" = "192.72.0.1" + } + } +} + +resource "ovh_iam_policy" "workdays_and_ip_restricted_and_expiring" { + name = "workdays_and_ip_restricted_and_expiring" + description = "Allow access only on workdays, expires end of 2026" + identities = [ovh_me_identity_group.my_group.urn] + resources = ["urn:v1:eu:resource:vps:*"] + + allow = [ + "vps:apiovh:*", + ] + + conditions { + operator = "AND" + condition { + operator = "MATCH" + values = { + "date(Europe/Paris).WeekDay.In" = "monday,tuesday,wednesday,thursday,friday" + } + } + condition { + operator = "MATCH" + values = { + "request.IP" = "192.72.0.1" + } + } + } + + expired_at = "2026-12-31T23:59:59Z" +} ``` ## Argument Reference @@ -43,6 +91,20 @@ resource "ovh_iam_policy" "manager" { * `except` - List of overrides of action that must not be allowed even if they are caught by allow. Only makes sens if allow contains wildcards. * `deny` - List of actions that will always be denied even if also allowed by this policy or another one. * `permissions_groups` - Set of permissions groups included in the policy. At evaluation, these permissions groups are each evaluated independently (notably, excepts actions only affect actions in the same permission group). +* `expired_at` - (Optional) Expiration date of the policy in RFC3339 format (e.g., `2025-12-31T23:59:59Z`). After this date, the policy will no longer be applied. +* `conditions` - (Optional) Conditions restrict permissions based on resource tags, date/time, or request attributes. See Conditions below. + +### Conditions + +The `conditions` block supports: + +* `operator` - (Required) Operator to combine conditions. Valid values are `AND`, `OR`, `NOT`, or `MATCH`. +* `condition` - (Optional) List of condition blocks. Each condition supports: + * `operator` - (Required) Operator for this condition (typically `MATCH`). + * `values` - (Optional) Map of key-value pairs to match. Keys can reference: + * Resource tags: `resource.Tag(tag_name)` (e.g., `resource.Tag(environment)`) + * Date/time: `date(timezone).WeekDay`, `date(timezone).WeekDay.In` (e.g., `date(Europe/Paris).WeekDay`) + * Request attributes: `request.IP` ## Attributes Reference diff --git a/examples/resources/iam_policy/example_1.tf b/examples/resources/iam_policy/example_1.tf index e1ee53e4c..64f0bb814 100644 --- a/examples/resources/iam_policy/example_1.tf +++ b/examples/resources/iam_policy/example_1.tf @@ -20,3 +20,51 @@ resource "ovh_iam_policy" "manager" { "account:apiovh:*", ] } + +resource "ovh_iam_policy" "ip_restricted_prod_access" { + name = "ip_restricted_prod_access" + description = "Allow access only from a specific IP to resources tagged prod" + identities = [ovh_me_identity_group.my_group.urn] + resources = ["urn:v1:eu:resource:vps:*"] + + allow = [ + "vps:apiovh:*", + ] + + conditions { + operator = "MATCH" + values = { + "resource.Tag(environment)" = "prod" + "request.IP" = "192.72.0.1" + } + } +} + +resource "ovh_iam_policy" "workdays_and_ip_restricted_and_expiring" { + name = "workdays_and_ip_restricted_and_expiring" + description = "Allow access only on workdays, expires end of 2026" + identities = [ovh_me_identity_group.my_group.urn] + resources = ["urn:v1:eu:resource:vps:*"] + + allow = [ + "vps:apiovh:*", + ] + + conditions { + operator = "AND" + condition { + operator = "MATCH" + values = { + "date(Europe/Paris).WeekDay.In" = "monday,tuesday,wednesday,thursday,friday" + } + } + condition { + operator = "MATCH" + values = { + "request.IP" = "192.72.0.1" + } + } + } + + expired_at = "2026-12-31T23:59:59Z" +} diff --git a/ovh/data_iam_policy.go b/ovh/data_iam_policy.go index d18ccf648..b21906794 100644 --- a/ovh/data_iam_policy.go +++ b/ovh/data_iam_policy.go @@ -9,6 +9,70 @@ import ( ) func dataSourceIamPolicy() *schema.Resource { + // Define the deepest level first (e.g., 3 levels deep) + conditionLevel3Schema := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "operator": { + Type: schema.TypeString, + Computed: true, + Description: "Operator for this condition (MATCH, AND, OR, NOT)", + }, + "values": { + Type: schema.TypeMap, + Computed: true, + Description: "Key-value pairs to match (e.g., resource.Tag(name), date(Europe/Paris).WeekDay, request.IP)", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + // No further "condition" Elem here to limit depth + }, + } + + // Define the second level of conditions, pointing to the third level + conditionLevel2Schema := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "operator": { + Type: schema.TypeString, + Computed: true, + Description: "Operator for this condition (MATCH, AND, OR, NOT)", + }, + "values": { + Type: schema.TypeMap, + Computed: true, + Description: "Key-value pairs to match (e.g., resource.Tag(name), date(Europe/Paris).WeekDay, request.IP)", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "condition": { + Type: schema.TypeList, + Computed: true, + Description: "A list of nested conditions. This is the recursive part.", + Elem: conditionLevel3Schema, // Points to the next level + }, + }, + } + + // Define the first level of conditions, pointing to the second level + conditionLevel1Schema := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "operator": { + Type: schema.TypeString, + Computed: true, + Description: "Operator for this condition (MATCH, AND, OR, NOT)", + }, + "values": { + Type: schema.TypeMap, + Computed: true, + Description: "Key-value pairs to match (e.g., resource.Tag(name), date(Europe/Paris).WeekDay, request.IP)", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "condition": { + Type: schema.TypeList, + Computed: true, + Description: "A list of nested conditions. This is the recursive part.", + Elem: conditionLevel2Schema, // Points to the next level + }, + }, + } + return &schema.Resource{ Schema: map[string]*schema.Schema{ "id": { @@ -81,6 +145,17 @@ func dataSourceIamPolicy() *schema.Resource { Type: schema.TypeBool, Computed: true, }, + "expired_at": { + Type: schema.TypeString, + Computed: true, + Description: "Expiration date of the policy, after this date it will no longer be applied", + }, + "conditions": { + Type: schema.TypeList, + Computed: true, + Description: "Conditions restrict permissions following resources, date or customer's information", + Elem: conditionLevel1Schema, // The top-level conditions use the first level schema + }, }, ReadContext: datasourceIamPolicyRead, } @@ -96,12 +171,20 @@ func datasourceIamPolicyRead(ctx context.Context, d *schema.ResourceData, meta a return diag.FromErr(err) } - for k, v := range pol.ToMap() { - err := d.Set(k, v) - if err != nil { - return diag.Errorf("key: %s; value: %v; err: %v", k, v, err) - } + // Debug: Log what we got from the API + polMap := pol.ToMap() + for k, v := range polMap { + d.Set(k, v) + } + + // Explicitly set the new attributes to ensure they're available + if pol.ExpiredAt != "" { + d.Set("expired_at", pol.ExpiredAt) + } + if pol.Conditions != nil { + d.Set("conditions", []interface{}{conditionsToMap(pol.Conditions)}) } + d.SetId(id) return nil } diff --git a/ovh/data_iam_policy_test.go b/ovh/data_iam_policy_test.go index 33e706f0b..43af8d3d0 100644 --- a/ovh/data_iam_policy_test.go +++ b/ovh/data_iam_policy_test.go @@ -73,6 +73,40 @@ func TestAccIamPolicyDataSource_basic(t *testing.T) { }) } +func TestAccIamPolicyDataSource_withConditionsAndExpiration(t *testing.T) { + name := acctest.RandomWithPrefix(test_prefix) + desc := "IAM policy with conditions and expiration created by Terraform Acc" + userName := acctest.RandomWithPrefix(test_prefix) + res := "urn:v1:eu:resource:vps:*" + expiration := "2025-12-31T23:59:59Z" + config := fmt.Sprintf(testAccIamPolicyDataSourceConfig, userName, userName, name, desc, res, expiration) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckCredentials(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "name", name), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "description", desc), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "expired_at", expiration), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.#", "1"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.operator", "OR"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.#", "2"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.0.operator", "MATCH"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.0.values.%", "2"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.0.values.resource.Tag(environment)", "production"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.0.values.resource.Tag(team)", "platform"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.1.operator", "MATCH"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.1.values.%", "1"), + resource.TestCheckResourceAttr("data.ovh_iam_policy.policy", "conditions.0.condition.1.values.date(Europe/Paris).WeekDay", "monday"), + ), + }, + }, + }) +} + func checkIamPolicyResourceAttr(name, polName, desc, resourceURN, allowAction, exceptAction, denyAction string) []resource.TestCheckFunc { // we are not checking identity urn because they are dynamic and depend on the test account NIC checks := []resource.TestCheckFunc{ @@ -158,3 +192,43 @@ output "keys_present" { ) } ` + +const testAccIamPolicyDataSourceConfig = ` +resource "ovh_me_identity_user" "test_user" { + login = "%s" + email = "%s@terraform.test" + password = "qwe123!@#" +} + +resource "ovh_iam_policy" "policy1" { + name = "%s" + description = "%s" + identities = [ovh_me_identity_user.test_user.urn] + resources = ["%s"] + allow = ["vps:apiovh:*"] + expired_at = "%s" + + conditions { + operator = "OR" + + condition { + operator = "MATCH" + values = { + "resource.Tag(environment)" = "production" + "resource.Tag(team)" = "platform" + } + } + + condition { + operator = "MATCH" + values = { + "date(Europe/Paris).WeekDay" = "monday" + } + } + } +} + +data "ovh_iam_policy" "policy" { + id = ovh_iam_policy.policy1.id +} +` diff --git a/ovh/resource_cloud_project_network_private.go b/ovh/resource_cloud_project_network_private.go index 5700dd683..68e47758f 100644 --- a/ovh/resource_cloud_project_network_private.go +++ b/ovh/resource_cloud_project_network_private.go @@ -211,7 +211,7 @@ func resourceCloudProjectNetworkPrivateRead(d *schema.ResourceData, meta interfa region_status["status"] = r.Regions[i].Status regions_status = append(regions_status, region_status) - regions = append(regions, fmt.Sprintf(r.Regions[i].Region)) + regions = append(regions, r.Regions[i].Region) } d.Set("regions_attributes", regions_attributes) d.Set("regions_openstack_ids", regions_openstack_ids) diff --git a/ovh/resource_iam_policy.go b/ovh/resource_iam_policy.go index 7c2f770b9..f3706cb7b 100644 --- a/ovh/resource_iam_policy.go +++ b/ovh/resource_iam_policy.go @@ -9,6 +9,70 @@ import ( ) func resourceIamPolicy() *schema.Resource { + // Define the deepest level first (e.g., 3 levels deep) + conditionLevel3Schema := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "operator": { + Type: schema.TypeString, + Required: true, + Description: "Operator for this condition (MATCH, AND, OR, NOT)", + }, + "values": { + Type: schema.TypeMap, + Optional: true, + Description: "Key-value pairs to match (e.g., resource.Tag(name), date(Europe/Paris).WeekDay, request.IP)", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + // No further "condition" Elem here to limit depth + }, + } + + // Define the second level of conditions, pointing to the third level + conditionLevel2Schema := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "operator": { + Type: schema.TypeString, + Required: true, + Description: "Operator for this condition (MATCH, AND, OR, NOT)", + }, + "values": { + Type: schema.TypeMap, + Optional: true, + Description: "Key-value pairs to match (e.g., resource.Tag(name), date(Europe/Paris).WeekDay, request.IP)", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "condition": { + Type: schema.TypeList, + Optional: true, + Description: "A list of nested conditions. This is the recursive part.", + Elem: conditionLevel3Schema, // Points to the next level + }, + }, + } + + // Define the first level of conditions, pointing to the second level + conditionLevel1Schema := &schema.Resource{ + Schema: map[string]*schema.Schema{ + "operator": { + Type: schema.TypeString, + Required: true, + Description: "Operator for this condition (MATCH, AND, OR, NOT)", + }, + "values": { + Type: schema.TypeMap, + Optional: true, + Description: "Key-value pairs to match (e.g., resource.Tag(name), date(Europe/Paris).WeekDay, request.IP)", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "condition": { + Type: schema.TypeList, + Optional: true, + Description: "A list of nested conditions. This is the recursive part.", + Elem: conditionLevel2Schema, // Points to the next level + }, + }, + } + return &schema.Resource{ Importer: &schema.ResourceImporter{ State: func(rd *schema.ResourceData, i interface{}) ([]*schema.ResourceData, error) { @@ -82,6 +146,18 @@ func resourceIamPolicy() *schema.Resource { Type: schema.TypeBool, Computed: true, }, + "expired_at": { + Type: schema.TypeString, + Optional: true, + Description: "Expiration date of the policy, after this date it will no longer be applied", + }, + "conditions": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "Conditions restrict permissions following resources, date or customer's information", + Elem: conditionLevel1Schema, // The top-level conditions use the first level schema + }, }, ReadContext: resourceIamPolicyRead, CreateContext: resourceIamPolicyCreate, @@ -92,13 +168,11 @@ func resourceIamPolicy() *schema.Resource { func resourceIamPolicyRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { config := meta.(*Config) - var pol IamPolicy err := config.OVHClient.GetWithContext(ctx, "/v2/iam/policy/"+url.PathEscape(d.Id()), &pol) if err != nil { return diag.FromErr(err) } - for k, v := range pol.ToMap() { d.Set(k, v) } @@ -107,59 +181,47 @@ func resourceIamPolicyRead(ctx context.Context, d *schema.ResourceData, meta any func resourceIamPolicyCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { config := meta.(*Config) - req := prepareIamPolicyCall(d) - var pol IamPolicy err := config.OVHClient.PostWithContext(ctx, "/v2/iam/policy", req, &pol) if err != nil { return diag.FromErr(err) } - for k, v := range pol.ToMap() { d.Set(k, v) } - d.SetId(pol.Id) return nil } func resourceIamPolicyUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { config := meta.(*Config) - req := prepareIamPolicyCall(d) - var pol IamPolicy err := config.OVHClient.PutWithContext(ctx, "/v2/iam/policy/"+url.PathEscape(d.Id()), req, &pol) if err != nil { return diag.FromErr(err) } - for k, v := range pol.ToMap() { d.Set(k, v) } - return nil } func resourceIamPolicyDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { config := meta.(*Config) - err := config.OVHClient.DeleteWithContext(ctx, "/v2/iam/policy/"+url.PathEscape(d.Id()), nil) if err != nil { return diag.FromErr(err) } - return nil } func prepareIamPolicyCall(d *schema.ResourceData) IamPolicy { var out IamPolicy - out.Name = d.Get("name").(string) out.Description = d.Get("description").(string) ids := d.Get("identities").(*schema.Set) - for _, id := range ids.List() { out.Identities = append(out.Identities, id.(string)) } @@ -192,5 +254,64 @@ func prepareIamPolicyCall(d *schema.ResourceData) IamPolicy { out.PermissionsGroups = append(out.PermissionsGroups, PermissionGroup{Urn: e.(string)}) } } + + if expiredAt, ok := d.GetOk("expired_at"); ok { + out.ExpiredAt = expiredAt.(string) + } + + if conditions, ok := d.GetOk("conditions"); ok { + out.Conditions = expandConditions(conditions.([]interface{})) + } + return out } + +// expandConditions converts Terraform schema data to IamConditions +func expandConditions(tfList []interface{}) *IamConditions { + if len(tfList) == 0 { + return nil + } + + tfMap := tfList[0].(map[string]interface{}) + conditions := &IamConditions{ + Operator: tfMap["operator"].(string), + } + + if values, ok := tfMap["values"].(map[string]interface{}); ok { + conditions.Values = make(map[string]string) + for k, v := range values { + conditions.Values[k] = v.(string) + } + } + + if condList, ok := tfMap["condition"].([]interface{}); ok { + for _, c := range condList { + condMap := c.(map[string]interface{}) + condition := expandCondition(condMap) + conditions.Conditions = append(conditions.Conditions, condition) + } + } + return conditions +} + +// expandCondition recursively expands a single condition +func expandCondition(tfMap map[string]interface{}) *IamCondition { + condition := &IamCondition{ + Operator: tfMap["operator"].(string), + } + if values, ok := tfMap["values"].(map[string]interface{}); ok { + condition.Values = make(map[string]string) + for k, v := range values { + condition.Values[k] = v.(string) + } + } + + // Handle nested conditions recursively + if nestedList, ok := tfMap["condition"].([]interface{}); ok { + for _, nested := range nestedList { + nestedMap := nested.(map[string]interface{}) + condition.Conditions = append(condition.Conditions, expandCondition(nestedMap)) + } + } + return condition +} diff --git a/ovh/resource_iam_policy_test.go b/ovh/resource_iam_policy_test.go index 07469b7b2..82f023a0b 100644 --- a/ovh/resource_iam_policy_test.go +++ b/ovh/resource_iam_policy_test.go @@ -106,6 +106,108 @@ func TestAccIamPolicy_deny(t *testing.T) { }) } +func TestAccIamPolicy_withConditions(t *testing.T) { + name := acctest.RandomWithPrefix(test_prefix) + desc := "IAM policy with conditions created by Terraform Acc" + userName := acctest.RandomWithPrefix(test_prefix) + res := "urn:v1:eu:resource:vps:*" + config := fmt.Sprintf(testAccIamPolicyConditionsConfig, userName, userName, name, desc, res) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckCredentials(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "name", name), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "description", desc), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.operator", "OR"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.condition.0.operator", "MATCH"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.condition.0.values.resource.Tag(environment)", "production"), + ), + }, + }, + }) +} + +func TestAccIamPolicy_withOrConditions(t *testing.T) { + name := acctest.RandomWithPrefix(test_prefix) + desc := "IAM policy with OR conditions created by Terraform Acc" + userName := acctest.RandomWithPrefix(test_prefix) + res := "urn:v1:eu:resource:vps:*" + config := fmt.Sprintf(testAccIamPolicyOrConditionsConfig, userName, userName, name, desc, res) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckCredentials(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "name", name), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "description", desc), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.operator", "OR"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.condition.0.operator", "MATCH"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.condition.0.values.resource.Tag(environment)", "production"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.condition.1.operator", "MATCH"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.condition.1.values.resource.Tag(team)", "platform"), + ), + }, + }, + }) +} + +func TestAccIamPolicy_withExpiration(t *testing.T) { + name := acctest.RandomWithPrefix(test_prefix) + desc := "IAM policy with expiration date created by Terraform Acc" + userName := acctest.RandomWithPrefix(test_prefix) + res := "urn:v1:eu:resource:vps:*" + expiration := "2025-12-31T23:59:59Z" + config := fmt.Sprintf(testAccIamPolicyWithExpirationConfig, userName, userName, name, desc, res, expiration) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckCredentials(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "name", name), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "description", desc), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "expired_at", expiration), + ), + }, + }, + }) +} + +func TestAccIamPolicy_withExpirationAndConditions(t *testing.T) { + name := acctest.RandomWithPrefix(test_prefix) + desc := "IAM policy with expiration and conditions created by Terraform Acc" + userName := acctest.RandomWithPrefix(test_prefix) + res := "urn:v1:eu:resource:vps:*" + expiration := "2025-12-31T23:59:59Z" + config := fmt.Sprintf(testAccIamPolicyWithExpirationAndConditionsConfig, userName, userName, name, desc, res, expiration) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheckCredentials(t) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "name", name), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "description", desc), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "expired_at", expiration), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.operator", "MATCH"), + resource.TestCheckResourceAttr("ovh_iam_policy.policy1", "conditions.0.values.resource.Tag(Environment)", "development"), + ), + }, + }, + }) +} + const testAccIamPolicyConfig = ` resource "ovh_me_identity_user" "test_user" { login = "%s" @@ -138,3 +240,116 @@ resource "ovh_iam_policy" "policy1" { deny = ["%s"] } ` + +const testAccIamPolicyConditionsConfig = ` +resource "ovh_me_identity_user" "test_user" { + login = "%s" + email = "%s@terraform.test" + password = "qwe123!@#" +} + +resource "ovh_iam_policy" "policy1" { + name = "%s" + description = "%s" + identities = [ovh_me_identity_user.test_user.urn] + resources = ["%s"] + allow = ["vps:apiovh:*"] + + conditions { + operator = "OR" + + condition { + operator = "MATCH" + values = { + "resource.Tag(environment)" = "production" + "resource.Tag(team)" = "platform" + } + } + + condition { + operator = "MATCH" + values = { + "date(Europe/Paris).WeekDay" = "monday" + } + } + } +} +` + +const testAccIamPolicyOrConditionsConfig = ` +resource "ovh_me_identity_user" "test_user" { + login = "%s" + email = "%s@terraform.test" + password = "qwe123!@#" +} + +resource "ovh_iam_policy" "policy1" { + name = "%s" + description = "%s" + identities = [ovh_me_identity_user.test_user.urn] + resources = ["%s"] + allow = ["vps:apiovh:*"] + + conditions { + operator = "OR" + + condition { + operator = "MATCH" + values = { + "resource.Tag(environment)" = "production" + } + } + + condition { + operator = "MATCH" + values = { + "resource.Tag(team)" = "platform" + } + } + } +} +` + +const testAccIamPolicyWithExpirationConfig = ` +resource "ovh_me_identity_user" "test_user" { + login = "%s" + email = "%s@terraform.test" + password = "qwe123!@#" +} + +resource "ovh_iam_policy" "policy1" { + name = "%s" + description = "%s" + identities = [ovh_me_identity_user.test_user.urn] + resources = ["%s"] + allow = ["vps:apiovh:*"] + expired_at = "%s" +} +` + +const testAccIamPolicyWithExpirationAndConditionsConfig = ` +resource "ovh_me_identity_user" "test_user" { + login = "%s" + email = "%s@terraform.test" + password = "qwe123!@#" +} + +resource "ovh_iam_policy" "policy1" { + name = "%s" + description = "%s" + + identities = [ovh_me_identity_user.test_user.urn] + resources = ["%s"] + + allow = ["dnsZone:apiovh:get"] + + expired_at = "%s" + + conditions { + operator = "MATCH" + values = { + "resource.Tag(Environment)" = "development" + } + } +} +` diff --git a/ovh/types_cloud_project_database.go b/ovh/types_cloud_project_database.go index 4b63e5023..fc666828d 100644 --- a/ovh/types_cloud_project_database.go +++ b/ovh/types_cloud_project_database.go @@ -23,7 +23,7 @@ import ( // Helper func diagnosticsToError(diags diag.Diagnostics) error { if diags.HasError() { - return fmt.Errorf(diags[slices.IndexFunc(diags, func(d diag.Diagnostic) bool { return d.Severity == diag.Error })].Summary) + return fmt.Errorf("%s", diags[slices.IndexFunc(diags, func(d diag.Diagnostic) bool { return d.Severity == diag.Error })].Summary) } return nil } diff --git a/ovh/types_iam.go b/ovh/types_iam.go index fab716873..4aad032fd 100644 --- a/ovh/types_iam.go +++ b/ovh/types_iam.go @@ -41,8 +41,10 @@ type IamPolicy struct { Resources []IamResource `json:"resources"` Permissions IamPermissions `json:"permissions"` PermissionsGroups []PermissionGroup `json:"permissionsGroups"` + Conditions *IamConditions `json:"conditions,omitempty"` CreatedAt string `json:"createdAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` + ExpiredAt string `json:"expiredAt,omitempty"` ReadOnly bool `json:"readOnly,omitempty"` Owner string `json:"owner,omitempty"` } @@ -51,6 +53,18 @@ type PermissionGroup struct { Urn string `json:"urn"` } +type IamConditions struct { + Operator string `json:"operator"` + Values map[string]string `json:"values,omitempty"` + Conditions []*IamCondition `json:"conditions,omitempty"` +} + +type IamCondition struct { + Operator string `json:"operator"` + Values map[string]string `json:"values,omitempty"` + Conditions []*IamCondition `json:"conditions,omitempty"` +} + func (p IamPolicy) ToMap() map[string]any { out := make(map[string]any, 0) out["name"] = p.Name @@ -94,10 +108,57 @@ func (p IamPolicy) ToMap() map[string]any { if p.UpdatedAt != "" { out["updated_at"] = p.UpdatedAt } + if p.ExpiredAt != "" { + out["expired_at"] = p.ExpiredAt + } + if p.Conditions != nil { + out["conditions"] = []interface{}{conditionsToMap(p.Conditions)} + } return out } +// conditionsToMap converts IamConditions to a map for Terraform state +func conditionsToMap(c *IamConditions) map[string]interface{} { + out := make(map[string]interface{}) + out["operator"] = c.Operator + + if len(c.Values) > 0 { + out["values"] = c.Values + } + + if len(c.Conditions) > 0 { + conditions := make([]interface{}, 0, len(c.Conditions)) + for _, cond := range c.Conditions { + conditions = append(conditions, conditionToMap(cond)) + } + out["condition"] = conditions + } + + return out +} + +// conditionToMap converts a single IamCondition to a map, handling nested conditions recursively +func conditionToMap(cond *IamCondition) map[string]interface{} { + condMap := make(map[string]interface{}) + condMap["operator"] = cond.Operator + + if len(cond.Values) > 0 { + condMap["values"] = cond.Values + } + + // Handle nested conditions recursively + if len(cond.Conditions) > 0 { + nestedConds := make([]interface{}, 0, len(cond.Conditions)) + for _, nested := range cond.Conditions { + nestedConds = append(nestedConds, conditionToMap(nested)) + } + condMap["condition"] = nestedConds + } + + return condMap +} + // IamResource represent a possible information returned when viewing a policy type IamResource struct { // URN is always returned and is the urn of the resource or resource group diff --git a/templates/data-sources/iam_policy.md.tmpl b/templates/data-sources/iam_policy.md.tmpl index ac33138d0..11ae3154f 100644 --- a/templates/data-sources/iam_policy.md.tmpl +++ b/templates/data-sources/iam_policy.md.tmpl @@ -32,3 +32,14 @@ Use this data source to retrieve am IAM policy. * `created_at` - Creation date of this group. * `updated_at` - Date of the last update of this group. * `read_only` - Indicates that the policy is a default one. +* `expired_at` - Expiration date of the policy. +* `conditions` - Conditions restricting the policy. + +### Conditions + +The `conditions` block returns: + +* `operator` - Operator to combine conditions. +* `condition` - List of condition blocks. Each condition supports: + * `operator` - Operator for this condition. + * `values` - Map of key-value pairs to match. diff --git a/templates/resources/iam_policy.md.tmpl b/templates/resources/iam_policy.md.tmpl index 441f679a3..8779305fe 100644 --- a/templates/resources/iam_policy.md.tmpl +++ b/templates/resources/iam_policy.md.tmpl @@ -24,6 +24,20 @@ Creates an IAM policy. * `except` - List of overrides of action that must not be allowed even if they are caught by allow. Only makes sens if allow contains wildcards. * `deny` - List of actions that will always be denied even if also allowed by this policy or another one. * `permissions_groups` - Set of permissions groups included in the policy. At evaluation, these permissions groups are each evaluated independently (notably, excepts actions only affect actions in the same permission group). +* `expired_at` - (Optional) Expiration date of the policy in RFC3339 format (e.g., `2025-12-31T23:59:59Z`). After this date, the policy will no longer be applied. +* `conditions` - (Optional) Conditions restrict permissions based on resource tags, date/time, or request attributes. See Conditions below. + +### Conditions + +The `conditions` block supports: + +* `operator` - (Required) Operator to combine conditions. Valid values are `AND`, `OR`, `NOT`, or `MATCH`. +* `condition` - (Optional) List of condition blocks. Each condition supports: + * `operator` - (Required) Operator for this condition (typically `MATCH`). + * `values` - (Optional) Map of key-value pairs to match. Keys can reference: + * Resource tags: `resource.Tag(tag_name)` (e.g., `resource.Tag(environment)`) + * Date/time: `date(timezone).WeekDay`, `date(timezone).WeekDay.In` (e.g., `date(Europe/Paris).WeekDay`) + * Request attributes: `request.IP` ## Attributes Reference