diff --git a/VERSION b/VERSION index 2fa3901..1c2de38 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.22 \ No newline at end of file +1.0.23 \ No newline at end of file diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md index 571116c..6779230 100644 --- a/docs/data-sources/group.md +++ b/docs/data-sources/group.md @@ -24,7 +24,6 @@ The group data source. - `description` (String) The group description. - `id` (String) The ID of this resource. - `members` (Set of Object) The members in the group. (see [below for nested schema](#nestedatt--members)) -- `roles` (Set of String) The group's roles in the workspace level - `source` (String) Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. - `title` (String) The group title. diff --git a/docs/data-sources/group_list.md b/docs/data-sources/group_list.md index fb47f7b..a21bd4f 100644 --- a/docs/data-sources/group_list.md +++ b/docs/data-sources/group_list.md @@ -28,7 +28,6 @@ Read-Only: - `description` (String) - `members` (Set of Object) (see [below for nested schema](#nestedobjatt--groups--members)) - `name` (String) -- `roles` (Set of String) - `source` (String) - `title` (String) diff --git a/docs/data-sources/iam_policy.md b/docs/data-sources/iam_policy.md new file mode 100644 index 0000000..c05a213 --- /dev/null +++ b/docs/data-sources/iam_policy.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_iam_policy Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The IAM policy data source. +--- + +# bytebase_iam_policy (Data Source) + +The IAM policy data source. + + + + +## Schema + +### Required + +- `parent` (String) The IAM policy parent name for the policy, support "projects/{resource id}" or "workspaces/-" + +### Optional + +- `iam_policy` (Block List, Max: 1) (see [below for nested schema](#nestedblock--iam_policy)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `iam_policy` + +Optional: + +- `binding` (Block Set) The binding in the IAM policy. (see [below for nested schema](#nestedblock--iam_policy--binding)) + + +### Nested Schema for `iam_policy.binding` + +Optional: + +- `condition` (Block Set) Match the condition limit. (see [below for nested schema](#nestedblock--iam_policy--binding--condition)) +- `members` (Set of String) A set of memebers. The value can be "allUsers", "user:{email}" or "group:{email}". +- `role` (String) The role full name in roles/{id} format. + + +### Nested Schema for `iam_policy.binding.condition` + +Optional: + +- `database` (String) The accessible database full name in instances/{instance resource id}/databases/{database name} format +- `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ssZ format +- `row_limit` (Number) The export row limit for exporter role +- `schema` (String) The accessible schema in the database +- `tables` (Set of String) The accessible table list + + diff --git a/docs/data-sources/project.md b/docs/data-sources/project.md index 4e07b32..42bdd48 100644 --- a/docs/data-sources/project.md +++ b/docs/data-sources/project.md @@ -27,30 +27,9 @@ The project data source. - `databases` (Set of String) The databases full name in the resource. - `enforce_issue_title` (Boolean) Enforce issue title created by user instead of generated by Bytebase. - `id` (String) The ID of this resource. -- `members` (Set of Object) The members in the project. (see [below for nested schema](#nestedatt--members)) - `name` (String) The project full name in projects/{resource id} format. - `postgres_database_tenant_mode` (Boolean) Whether to enable the database tenant mode for PostgreSQL. If enabled, the issue will be created with the pre-appended "set role " statement. - `skip_backup_errors` (Boolean) Whether to skip backup errors and continue the data migration. - `title` (String) The project title. - -### Nested Schema for `members` - -Read-Only: - -- `condition` (Set of Object) (see [below for nested schema](#nestedobjatt--members--condition)) -- `member` (String) -- `role` (String) - - -### Nested Schema for `members.condition` - -Read-Only: - -- `database` (String) -- `expire_timestamp` (String) -- `row_limit` (Number) -- `schema` (String) -- `tables` (Set of String) - diff --git a/docs/data-sources/project_list.md b/docs/data-sources/project_list.md index 965c24c..07a03b6 100644 --- a/docs/data-sources/project_list.md +++ b/docs/data-sources/project_list.md @@ -36,31 +36,10 @@ Read-Only: - `auto_resolve_issue` (Boolean) - `databases` (Set of String) - `enforce_issue_title` (Boolean) -- `members` (Set of Object) (see [below for nested schema](#nestedobjatt--projects--members)) - `name` (String) - `postgres_database_tenant_mode` (Boolean) - `resource_id` (String) - `skip_backup_errors` (Boolean) - `title` (String) - -### Nested Schema for `projects.members` - -Read-Only: - -- `condition` (Set of Object) (see [below for nested schema](#nestedobjatt--projects--members--condition)) -- `member` (String) -- `role` (String) - - -### Nested Schema for `projects.members.condition` - -Read-Only: - -- `database` (String) -- `expire_timestamp` (String) -- `row_limit` (Number) -- `schema` (String) -- `tables` (Set of String) - diff --git a/docs/data-sources/user.md b/docs/data-sources/user.md index a51e7c7..381d6e1 100644 --- a/docs/data-sources/user.md +++ b/docs/data-sources/user.md @@ -27,7 +27,6 @@ The user data source. - `last_login_time` (String) The user last login time. - `mfa_enabled` (Boolean) The mfa_enabled flag means if the user has enabled MFA. - `phone` (String) The user phone. -- `roles` (Set of String) The user's roles in the workspace level - `source` (String) Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. - `state` (String) The user is deleted or not. - `title` (String) The user title. diff --git a/docs/data-sources/user_list.md b/docs/data-sources/user_list.md index 2d9ba4d..b1e2870 100644 --- a/docs/data-sources/user_list.md +++ b/docs/data-sources/user_list.md @@ -39,7 +39,6 @@ Read-Only: - `mfa_enabled` (Boolean) - `name` (String) - `phone` (String) -- `roles` (Set of String) - `source` (String) - `state` (String) - `title` (String) diff --git a/docs/resources/group.md b/docs/resources/group.md index 84ed19f..daa7cd9 100644 --- a/docs/resources/group.md +++ b/docs/resources/group.md @@ -24,7 +24,6 @@ The group resource. Workspace domain is required for creating groups. ### Optional - `description` (String) The group description. -- `roles` (Set of String) The group's roles in the workspace level ### Read-Only diff --git a/docs/resources/iam_policy.md b/docs/resources/iam_policy.md new file mode 100644 index 0000000..3457946 --- /dev/null +++ b/docs/resources/iam_policy.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_iam_policy Resource - terraform-provider-bytebase" +subcategory: "" +description: |- + The IAM policy resource. +--- + +# bytebase_iam_policy (Resource) + +The IAM policy resource. + + + + +## Schema + +### Required + +- `parent` (String) The IAM policy parent name for the policy, support "projects/{resource id}" or "workspaces/-" + +### Optional + +- `iam_policy` (Block List, Max: 1) (see [below for nested schema](#nestedblock--iam_policy)) + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `iam_policy` + +Optional: + +- `binding` (Block Set) The binding in the IAM policy. (see [below for nested schema](#nestedblock--iam_policy--binding)) + + +### Nested Schema for `iam_policy.binding` + +Optional: + +- `condition` (Block Set) Match the condition limit. (see [below for nested schema](#nestedblock--iam_policy--binding--condition)) +- `members` (Set of String) A set of memebers. The value can be "allUsers", "user:{email}" or "group:{email}". +- `role` (String) The role full name in roles/{id} format. + + +### Nested Schema for `iam_policy.binding.condition` + +Optional: + +- `database` (String) The accessible database full name in instances/{instance resource id}/databases/{database name} format +- `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ssZ format +- `row_limit` (Number) The export row limit for exporter role +- `schema` (String) The accessible schema in the database +- `tables` (Set of String) The accessible table list + + diff --git a/docs/resources/project.md b/docs/resources/project.md index 5973d01..a03b88e 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -27,7 +27,6 @@ The project resource. - `auto_resolve_issue` (Boolean) Enable auto resolve issue. - `databases` (Set of String) The databases full name in the resource. - `enforce_issue_title` (Boolean) Enforce issue title created by user instead of generated by Bytebase. -- `members` (Block Set) The members in the project. (see [below for nested schema](#nestedblock--members)) - `postgres_database_tenant_mode` (Boolean) Whether to enable the database tenant mode for PostgreSQL. If enabled, the issue will be created with the pre-appended "set role " statement. - `skip_backup_errors` (Boolean) Whether to skip backup errors and continue the data migration. @@ -36,24 +35,4 @@ The project resource. - `id` (String) The ID of this resource. - `name` (String) The project full name in projects/{resource id} format. - -### Nested Schema for `members` - -Optional: - -- `condition` (Block Set) Match the condition limit. (see [below for nested schema](#nestedblock--members--condition)) -- `member` (String) The member in user:{email} or group:{email} format. -- `role` (String) The role full name in roles/{id} format. - - -### Nested Schema for `members.condition` - -Optional: - -- `database` (String) The accessible database full name in instances/{instance resource id}/databases/{database name} format -- `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ssZ format -- `row_limit` (Number) The export row limit for exporter role -- `schema` (String) The accessible schema in the database -- `tables` (Set of String) The accessible table list - diff --git a/docs/resources/user.md b/docs/resources/user.md index 71d19a7..4bb327f 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -24,7 +24,6 @@ The user resource. - `password` (String, Sensitive) The user login password. - `phone` (String) The user phone. -- `roles` (Set of String) The user's roles in the workspace level - `type` (String) The user type. ### Read-Only diff --git a/examples/database/main.tf b/examples/database/main.tf index d100e82..2ef4fe9 100644 --- a/examples/database/main.tf +++ b/examples/database/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/environments/main.tf b/examples/environments/main.tf index 62de4e5..655382f 100644 --- a/examples/environments/main.tf +++ b/examples/environments/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/groups/main.tf b/examples/groups/main.tf index 4d9014f..306b2ba 100644 --- a/examples/groups/main.tf +++ b/examples/groups/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/iamPolicy/main.tf b/examples/iamPolicy/main.tf new file mode 100644 index 0000000..8b1f4e0 --- /dev/null +++ b/examples/iamPolicy/main.tf @@ -0,0 +1,34 @@ +terraform { + required_providers { + bytebase = { + version = "1.0.23" + # For local development, please use "terraform.local/bytebase/bytebase" instead + source = "registry.terraform.io/bytebase/bytebase" + } + } +} + +provider "bytebase" { + # You need to replace the account and key with your Bytebase service account. + service_account = "terraform@service.bytebase.com" + service_key = "bbs_BxVIp7uQsARl8nR92ZZV" + # The Bytebase service URL. You can use the external URL in production. + # Check the docs about external URL: https://www.bytebase.com/docs/get-started/install/external-url + url = "https://bytebase.example.com" +} + +data "bytebase_iam_policy" "workspace_iam" { + parent = "workspaces/-" +} + +output "workspace_iam" { + value = data.bytebase_iam_policy.workspace_iam +} + +data "bytebase_iam_policy" "project_iam" { + parent = "projects/project-sample" +} + +output "project_iam" { + value = data.bytebase_iam_policy.project_iam +} diff --git a/examples/instances/main.tf b/examples/instances/main.tf index 3793fe9..6ee057d 100644 --- a/examples/instances/main.tf +++ b/examples/instances/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/policies/main.tf b/examples/policies/main.tf index b5b09dd..4660ef3 100644 --- a/examples/policies/main.tf +++ b/examples/policies/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } @@ -27,7 +27,8 @@ output "masking_exception_policy" { } data "bytebase_policy" "global_masking_policy" { - type = "MASKING_RULE" + parent = "workspaces/-" + type = "MASKING_RULE" } output "global_masking_policy" { diff --git a/examples/projects/main.tf b/examples/projects/main.tf index eafb10e..f45471a 100644 --- a/examples/projects/main.tf +++ b/examples/projects/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/roles/main.tf b/examples/roles/main.tf index 69fa358..b183285 100644 --- a/examples/roles/main.tf +++ b/examples/roles/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/settings/main.tf b/examples/settings/main.tf index 94c3fa4..a3cddd2 100644 --- a/examples/settings/main.tf +++ b/examples/settings/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/setup/iam.tf b/examples/setup/iam.tf new file mode 100644 index 0000000..1ebfdbc --- /dev/null +++ b/examples/setup/iam.tf @@ -0,0 +1,88 @@ +# Workspace level IAM. +resource "bytebase_iam_policy" "workspace_iam" { + depends_on = [ + bytebase_user.workspace_dba, + bytebase_user.workspace_auditor, + bytebase_user.project_developer, + bytebase_user.service_account, + bytebase_group.developers, + bytebase_role.auditor + ] + + parent = "workspaces/-" + + iam_policy { + binding { + role = "roles/workspaceAdmin" + members = [ + format("user:%s", local.service_account), + format("user:%s", bytebase_user.workspace_dba.email), + ] + } + + binding { + role = "roles/workspaceDBA" + members = [ + format("user:%s", bytebase_user.workspace_dba.email), + format("user:%s", bytebase_user.service_account.email), + ] + } + + binding { + role = bytebase_role.auditor.name + members = [ + format("user:%s", bytebase_user.workspace_auditor.email) + ] + } + + binding { + role = "roles/projectViewer" + members = [ + format("user:%s", bytebase_user.project_developer.email), + format("group:%s", bytebase_group.developers.email), + ] + } + } +} + +# Project level IAM +resource "bytebase_iam_policy" "project_iam" { + depends_on = [ + bytebase_project.sample_project, + bytebase_user.workspace_dba, + bytebase_user.project_developer, + bytebase_group.developers + ] + + parent = bytebase_project.sample_project.name + + iam_policy { + binding { + role = "roles/projectOwner" + members = [ + format("user:%s", bytebase_user.workspace_dba.email) + ] + } + + binding { + role = "roles/projectDeveloper" + members = [ + "allUsers", + format("group:%s", bytebase_group.developers.email) + ] + } + + binding { + role = "roles/projectExporter" + members = [ + format("user:%s", bytebase_user.project_developer.email) + ] + condition { + database = "instances/test-sample-instance/databases/employee" + tables = ["dept_emp", "dept_manager"] + row_limit = 10000 + expire_timestamp = "2027-03-09T16:17:49Z" + } + } + } +} diff --git a/examples/setup/main.tf b/examples/setup/main.tf index 079e548..0924ede 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -1,24 +1,16 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } } } -provider "bytebase" { - # You need to replace the account and key with your Bytebase service account. - service_account = "terraform@service.bytebase.com" - service_key = "bbs_BxVIp7uQsARl8nR92ZZV" - # The Bytebase service URL. You can use the external URL in production. - # Check the docs about external URL: https://www.bytebase.com/docs/get-started/install/external-url - url = "https://bytebase.example.com" -} - # Correspond to the sample data Bytebase generates during onboarding. locals { + service_account = "terraform@service.bytebase.com" environment_id_test = "test" environment_id_prod = "prod" instance_id_test = "test-sample-instance" @@ -26,6 +18,16 @@ locals { project_id = "project-sample" } + +provider "bytebase" { + # You need to replace the account and key with your Bytebase service account. + service_account = local.service_account + service_key = "bbs_BxVIp7uQsARl8nR92ZZV" + # The Bytebase service URL. You can use the external URL in production. + # Check the docs about external URL: https://www.bytebase.com/docs/get-started/install/external-url + url = "https://bytebase.example.com" +} + resource "bytebase_setting" "workspace_profile" { name = "bb.workspace.profile" diff --git a/examples/setup/project.tf b/examples/setup/project.tf index 2cd13ff..688fd56 100644 --- a/examples/setup/project.tf +++ b/examples/setup/project.tf @@ -1,35 +1,11 @@ # Create or update sample project, and grant roles for users and groups. resource "bytebase_project" "sample_project" { depends_on = [ - bytebase_user.workspace_dba, - bytebase_user.project_developer, - bytebase_group.developers, bytebase_instance.test ] resource_id = local.project_id title = "Sample project" - members { - member = format("user:%s", bytebase_user.workspace_dba.email) - role = "roles/projectOwner" - } - - members { - member = format("group:%s", bytebase_group.developers.email) - role = "roles/projectDeveloper" - } - - members { - member = format("user:%s", bytebase_user.project_developer.email) - role = "roles/projectExporter" - condition { - database = "instances/test-sample-instance/databases/employee" - tables = ["dept_emp", "dept_manager"] - row_limit = 10000 - expire_timestamp = "2027-03-09T16:17:49Z" - } - } - databases = bytebase_instance.test.databases } diff --git a/examples/setup/users.tf b/examples/setup/users.tf index c681f47..43fd6a4 100644 --- a/examples/setup/users.tf +++ b/examples/setup/users.tf @@ -2,54 +2,31 @@ resource "bytebase_user" "workspace_dba" { title = "DBA" email = "dba@bytebase.com" - - # Grant workspace level roles. - roles = ["roles/workspaceDBA"] } # Create or update the user. resource "bytebase_user" "workspace_auditor" { - depends_on = [ - bytebase_user.workspace_dba, - bytebase_role.auditor - ] title = "Auditor" email = "auditor@bytebase.com" - - # Grant workspace level roles. - roles = [bytebase_role.auditor.name] } # Create or update the user. resource "bytebase_user" "project_developer" { - depends_on = [ - bytebase_user.workspace_auditor - ] - title = "Developer" email = "developer@bytebase.com" - - # Grant workspace level roles, will grant projectViewer for this user in all - roles = ["roles/projectViewer"] } resource "bytebase_user" "service_account" { - depends_on = [ - bytebase_user.project_developer - ] title = "CI Bot" email = "ci-bot@service.bytebase.com" type = "SERVICE_ACCOUNT" - roles = ["roles/workspaceDBA"] } - # Create or update the group. resource "bytebase_group" "developers" { depends_on = [ bytebase_user.workspace_dba, bytebase_user.project_developer, - bytebase_user.service_account, # group requires the domain. bytebase_setting.workspace_profile ] @@ -66,6 +43,4 @@ resource "bytebase_group" "developers" { member = format("users/%s", bytebase_user.project_developer.email) role = "MEMBER" } - - roles = ["roles/projectViewer"] } diff --git a/examples/sql_review/main.tf b/examples/sql_review/main.tf index 93d5512..4328808 100644 --- a/examples/sql_review/main.tf +++ b/examples/sql_review/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/users/main.tf b/examples/users/main.tf index 8d219c3..5206a3f 100644 --- a/examples/users/main.tf +++ b/examples/users/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.22" + version = "1.0.23" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/provider/data_source_group.go b/provider/data_source_group.go index e343abc..9f11af5 100644 --- a/provider/data_source_group.go +++ b/provider/data_source_group.go @@ -1,6 +1,7 @@ package provider import ( + "bytes" "context" "fmt" "regexp" @@ -42,14 +43,6 @@ func dataSourceGroup() *schema.Resource { Computed: true, Description: "Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", }, - "roles": { - Type: schema.TypeSet, - Computed: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "The group's roles in the workspace level", - }, "members": { Type: schema.TypeSet, Computed: true, @@ -83,15 +76,10 @@ func dataSourceGroupRead(ctx context.Context, d *schema.ResourceData, m interfac } d.SetId(group.Name) - return setGroup(ctx, c, d, group) + return setGroup(d, group) } -func setGroup(ctx context.Context, client api.Client, d *schema.ResourceData, group *v1pb.Group) diag.Diagnostics { - workspaceIAM, err := client.GetWorkspaceIAMPolicy(ctx) - if err != nil { - return diag.Errorf("cannot get workspace IAM with error: %s", err.Error()) - } - +func setGroup(d *schema.ResourceData, group *v1pb.Group) diag.Diagnostics { if err := d.Set("name", group.Name); err != nil { return diag.Errorf("cannot set name for group: %s", err.Error()) } @@ -115,13 +103,20 @@ func setGroup(ctx context.Context, client api.Client, d *schema.ResourceData, gr if err := d.Set("members", schema.NewSet(memberHash, memberList)); err != nil { return diag.Errorf("cannot set members for group: %s", err.Error()) } - groupEmail, err := internal.GetGroupEmail(group.Name) - if err != nil { - return diag.Errorf("failed to parse group email: %v", err) + + return nil +} + +func memberHash(rawMember interface{}) int { + var buf bytes.Buffer + member := rawMember.(map[string]interface{}) + + if v, ok := member["member"].(string); ok { + _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) } - if err := d.Set("roles", getRolesInIAM(workspaceIAM, fmt.Sprintf("group:%s", groupEmail))); err != nil { - return diag.Errorf("cannot set roles for user: %s", err.Error()) + if v, ok := member["role"].(string); ok { + _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) } - return nil + return internal.ToHashcodeInt(buf.String()) } diff --git a/provider/data_source_group_list.go b/provider/data_source_group_list.go index d597a07..3e320cc 100644 --- a/provider/data_source_group_list.go +++ b/provider/data_source_group_list.go @@ -2,7 +2,6 @@ package provider import ( "context" - "fmt" "strconv" "time" @@ -10,7 +9,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/bytebase/terraform-provider-bytebase/api" - "github.com/bytebase/terraform-provider-bytebase/provider/internal" ) func dataSourceGroupList() *schema.Resource { @@ -43,14 +41,6 @@ func dataSourceGroupList() *schema.Resource { Computed: true, Description: "Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", }, - "roles": { - Type: schema.TypeSet, - Computed: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "The group's roles in the workspace level", - }, "members": { Type: schema.TypeSet, Computed: true, @@ -86,11 +76,6 @@ func dataSourceGroupListRead(ctx context.Context, d *schema.ResourceData, m inte return diag.FromErr(err) } - workspaceIAM, err := c.GetWorkspaceIAMPolicy(ctx) - if err != nil { - return diag.Errorf("cannot get workspace IAM with error: %s", err.Error()) - } - groups := make([]map[string]interface{}, 0) for _, group := range response.Groups { raw := make(map[string]interface{}) @@ -108,11 +93,6 @@ func dataSourceGroupListRead(ctx context.Context, d *schema.ResourceData, m inte } raw["members"] = schema.NewSet(memberHash, memberList) - groupEmail, err := internal.GetGroupEmail(group.Name) - if err != nil { - return diag.Errorf("failed to parse group email: %v", err) - } - raw["roles"] = getRolesInIAM(workspaceIAM, fmt.Sprintf("group:%s", groupEmail)) groups = append(groups, raw) } diff --git a/provider/data_source_iam_policy.go b/provider/data_source_iam_policy.go new file mode 100644 index 0000000..299b1f2 --- /dev/null +++ b/provider/data_source_iam_policy.go @@ -0,0 +1,272 @@ +package provider + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pkg/errors" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + + "github.com/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func dataSourceIAMPolicy() *schema.Resource { + return &schema.Resource{ + Description: "The IAM policy data source.", + ReadContext: dataSourceIAMPolicyRead, + Schema: map[string]*schema.Schema{ + "parent": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: internal.ResourceNameValidation( + // workspace policy + regexp.MustCompile("^workspaces/-$"), + // project policy + regexp.MustCompile(fmt.Sprintf("^%s%s$", internal.ProjectNamePrefix, internal.ResourceIDPattern)), + ), + Description: `The IAM policy parent name for the policy, support "projects/{resource id}" or "workspaces/-"`, + }, + "iam_policy": getIAMPolicySchema(true), + }, + } +} + +func getIAMPolicySchema(computed bool) *schema.Schema { + return &schema.Schema{ + Computed: computed, + Optional: true, + Default: nil, + Type: schema.TypeList, + MinItems: 0, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "binding": getIAMBindingSchema(false), + }, + }, + } +} + +func getIAMBindingSchema(computed bool) *schema.Schema { + return &schema.Schema{ + Type: schema.TypeSet, + Computed: computed, + Optional: !computed, + Description: "The binding in the IAM policy.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "role": { + Type: schema.TypeString, + Computed: computed, + Optional: !computed, + Description: "The role full name in roles/{id} format.", + }, + "members": { + Type: schema.TypeSet, + Computed: computed, + Optional: !computed, + Description: `A set of memebers. The value can be "allUsers", "user:{email}" or "group:{email}".`, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "condition": { + Type: schema.TypeSet, + Computed: computed, + Optional: true, + Description: "Match the condition limit.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "database": { + Type: schema.TypeString, + Computed: computed, + Optional: true, + Description: "The accessible database full name in instances/{instance resource id}/databases/{database name} format", + }, + "schema": { + Type: schema.TypeString, + Computed: computed, + Optional: true, + Description: "The accessible schema in the database", + }, + "tables": { + Type: schema.TypeSet, + Computed: computed, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Set: schema.HashString, + Description: "The accessible table list", + }, + "row_limit": { + Type: schema.TypeInt, + Computed: computed, + Optional: true, + Description: "The export row limit for exporter role", + }, + "expire_timestamp": { + Type: schema.TypeString, + Computed: computed, + Optional: true, + Description: "The expiration timestamp in YYYY-MM-DDThh:mm:ssZ format", + }, + }, + }, + Set: func(i interface{}) int { + return internal.ToHashcodeInt(conditionHash(i)) + }, + }, + }, + }, + Set: bindingHash, + } +} + +func dataSourceIAMPolicyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + parent := d.Get("parent").(string) + + var iamPolicy *v1pb.IamPolicy + + if strings.HasPrefix(parent, internal.ProjectNamePrefix) { + projectIAM, err := c.GetProjectIAMPolicy(ctx, parent) + if err != nil { + return diag.FromErr(err) + } + iamPolicy = projectIAM + } else { + workspaceIAM, err := c.GetWorkspaceIAMPolicy(ctx) + if err != nil { + return diag.FromErr(err) + } + iamPolicy = workspaceIAM + } + + d.SetId(parent) + return setIAMPolicyMessage(d, iamPolicy) +} + +func setIAMPolicyMessage(d *schema.ResourceData, iamPolicy *v1pb.IamPolicy) diag.Diagnostics { + flattenPolicy, err := flattenIAMPolicy(iamPolicy) + if err != nil { + return diag.FromErr(err) + } + if err := d.Set("iam_policy", flattenPolicy); err != nil { + return diag.Errorf("cannot set iam_policy: %s", err.Error()) + } + return nil +} + +func flattenIAMPolicy(p *v1pb.IamPolicy) ([]interface{}, error) { + bindingList := []interface{}{} + for _, binding := range p.Bindings { + rawBinding := map[string]interface{}{} + rawCondition := map[string]interface{}{} + if condition := binding.Condition; condition != nil && condition.Expression != "" { + expressions := strings.Split(condition.Expression, " && ") + for _, expression := range expressions { + if strings.HasPrefix(expression, `resource.database == "`) { + rawCondition["database"] = strings.TrimSuffix( + strings.TrimPrefix(expression, `resource.database == "`), + `"`, + ) + } + if strings.HasPrefix(expression, `resource.schema == "`) { + rawCondition["schema"] = strings.TrimSuffix( + strings.TrimPrefix(expression, `resource.schema == "`), + `"`, + ) + } + if strings.HasPrefix(expression, `resource.table in [`) { + tableStr := strings.TrimSuffix( + strings.TrimPrefix(expression, `resource.table in [`), + `]`, + ) + rawTableList := []string{} + for _, t := range strings.Split(tableStr, ",") { + rawTableList = append(rawTableList, strings.TrimSuffix( + strings.TrimPrefix(t, `"`), + `"`, + )) + } + rawCondition["tables"] = rawTableList + } + if strings.HasPrefix(expression, `request.row_limit <= `) { + i, err := strconv.Atoi(strings.TrimPrefix(expression, `request.row_limit <= `)) + if err != nil { + return nil, errors.Errorf("cannot convert %s to int with error: %s", expression, err.Error()) + } + rawCondition["row_limit"] = i + } + if strings.HasPrefix(expression, "request.time < ") { + rawCondition["expire_timestamp"] = strings.TrimSuffix( + strings.TrimPrefix(expression, `request.time < timestamp("`), + `")`, + ) + } + } + } + + rawBinding["condition"] = schema.NewSet(func(i interface{}) int { + return internal.ToHashcodeInt(conditionHash(i)) + }, []interface{}{rawCondition}) + rawBinding["role"] = binding.Role + rawBinding["members"] = binding.Members + bindingList = append(bindingList, rawBinding) + } + + policy := map[string]interface{}{ + "binding": schema.NewSet(bindingHash, bindingList), + } + return []interface{}{policy}, nil +} + +func bindingHash(rawBinding interface{}) int { + var buf bytes.Buffer + binding := rawBinding.(map[string]interface{}) + + if v, ok := binding["role"].(string); ok { + _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) + } + + if condition, ok := binding["condition"].(*schema.Set); ok && condition.Len() > 0 && condition.List()[0] != nil { + rawCondition := condition.List()[0].(map[string]interface{}) + _, _ = buf.WriteString(conditionHash(rawCondition)) + } + + return internal.ToHashcodeInt(buf.String()) +} + +func conditionHash(rawCondition interface{}) string { + var buf bytes.Buffer + condition := rawCondition.(map[string]interface{}) + + if v, ok := condition["database"].(string); ok { + _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) + } + if v, ok := condition["schema"].(string); ok { + _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) + } + if v, ok := condition["tables"].(*schema.Set); ok { + for _, t := range v.List() { + _, _ = buf.WriteString(fmt.Sprintf("table.%s-", t.(string))) + } + } + if v, ok := condition["row_limit"].(int); ok { + _, _ = buf.WriteString(fmt.Sprintf("%d-", v)) + } + if v, ok := condition["expire_timestamp"].(string); ok { + _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) + } + + return buf.String() +} diff --git a/provider/data_source_policy.go b/provider/data_source_policy.go index a94ada7..699cd9a 100644 --- a/provider/data_source_policy.go +++ b/provider/data_source_policy.go @@ -29,7 +29,7 @@ func dataSourcePolicy() *schema.Resource { Default: "", ValidateDiagFunc: internal.ResourceNameValidation( // workspace policy - regexp.MustCompile("^$"), + regexp.MustCompile("^workspaces/-$"), // environment policy regexp.MustCompile(fmt.Sprintf("^%s%s$", internal.EnvironmentNamePrefix, internal.ResourceIDPattern)), // instance policy diff --git a/provider/data_source_policy_list.go b/provider/data_source_policy_list.go index 809b39f..b4d3a5f 100644 --- a/provider/data_source_policy_list.go +++ b/provider/data_source_policy_list.go @@ -27,7 +27,7 @@ func dataSourcePolicyList() *schema.Resource { Default: "", ValidateDiagFunc: internal.ResourceNameValidation( // workspace policy - regexp.MustCompile("^$"), + regexp.MustCompile("^workspaces/-$"), // environment policy regexp.MustCompile(fmt.Sprintf("^%s%s$", internal.EnvironmentNamePrefix, internal.ResourceIDPattern)), // instance policy diff --git a/provider/data_source_project.go b/provider/data_source_project.go index fa2b710..9bc7e3a 100644 --- a/provider/data_source_project.go +++ b/provider/data_source_project.go @@ -1,17 +1,13 @@ package provider import ( - "bytes" "context" "fmt" - "strconv" - "strings" "time" "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/pkg/errors" "github.com/bytebase/terraform-provider-bytebase/api" "github.com/bytebase/terraform-provider-bytebase/provider/internal" @@ -70,7 +66,6 @@ func dataSourceProject() *schema.Resource { Computed: true, Description: "Whether to enable the database tenant mode for PostgreSQL. If enabled, the issue will be created with the pre-appended \"set role \" statement.", }, - "members": getProjectMembersSchema(true), "databases": getDatabasesSchema(true), }, } @@ -88,79 +83,6 @@ func getDatabasesSchema(computed bool) *schema.Schema { } } -func getProjectMembersSchema(computed bool) *schema.Schema { - return &schema.Schema{ - Type: schema.TypeSet, - Computed: computed, - Optional: !computed, - Description: "The members in the project.", - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "member": { - Type: schema.TypeString, - Computed: computed, - Optional: !computed, - Description: "The member in user:{email} or group:{email} format.", - }, - "role": { - Type: schema.TypeString, - Computed: computed, - Optional: !computed, - Description: "The role full name in roles/{id} format.", - }, - "condition": { - Type: schema.TypeSet, - Computed: computed, - Optional: true, - Description: "Match the condition limit.", - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "database": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - Description: "The accessible database full name in instances/{instance resource id}/databases/{database name} format", - }, - "schema": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - Description: "The accessible schema in the database", - }, - "tables": { - Type: schema.TypeSet, - Computed: computed, - Optional: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Set: schema.HashString, - Description: "The accessible table list", - }, - "row_limit": { - Type: schema.TypeInt, - Computed: computed, - Optional: true, - Description: "The export row limit for exporter role", - }, - "expire_timestamp": { - Type: schema.TypeString, - Computed: computed, - Optional: true, - Description: "The expiration timestamp in YYYY-MM-DDThh:mm:ssZ format", - }, - }, - }, - Set: func(i interface{}) int { - return internal.ToHashcodeInt(conditionHash(i)) - }, - }, - }, - }, - Set: memberHash, - } -} - func dataSourceProjectRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { c := m.(api.Client) projectName := fmt.Sprintf("%s%s", internal.ProjectNamePrefix, d.Get("resource_id").(string)) @@ -173,67 +95,6 @@ func dataSourceProjectRead(ctx context.Context, d *schema.ResourceData, m interf return setProject(ctx, c, d, project) } -func flattenMemberList(iamPolicy *v1pb.IamPolicy) ([]interface{}, error) { - memberList := []interface{}{} - for _, binding := range iamPolicy.Bindings { - rawCondition := map[string]interface{}{} - if condition := binding.Condition; condition != nil && condition.Expression != "" { - expressions := strings.Split(condition.Expression, " && ") - for _, expression := range expressions { - if strings.HasPrefix(expression, `resource.database == "`) { - rawCondition["database"] = strings.TrimSuffix( - strings.TrimPrefix(expression, `resource.database == "`), - `"`, - ) - } - if strings.HasPrefix(expression, `resource.schema == "`) { - rawCondition["schema"] = strings.TrimSuffix( - strings.TrimPrefix(expression, `resource.schema == "`), - `"`, - ) - } - if strings.HasPrefix(expression, `resource.table in [`) { - tableStr := strings.TrimSuffix( - strings.TrimPrefix(expression, `resource.table in [`), - `]`, - ) - rawTableList := []string{} - for _, t := range strings.Split(tableStr, ",") { - rawTableList = append(rawTableList, strings.TrimSuffix( - strings.TrimPrefix(t, `"`), - `"`, - )) - } - rawCondition["tables"] = rawTableList - } - if strings.HasPrefix(expression, `request.row_limit <= `) { - i, err := strconv.Atoi(strings.TrimPrefix(expression, `request.row_limit <= `)) - if err != nil { - return nil, errors.Errorf("cannot convert %s to int with error: %s", expression, err.Error()) - } - rawCondition["row_limit"] = i - } - if strings.HasPrefix(expression, "request.time < ") { - rawCondition["expire_timestamp"] = strings.TrimSuffix( - strings.TrimPrefix(expression, `request.time < timestamp("`), - `")`, - ) - } - } - } - for _, member := range binding.Members { - rawMember := map[string]interface{}{} - rawMember["member"] = member - rawMember["role"] = binding.Role - rawMember["condition"] = schema.NewSet(func(i interface{}) int { - return internal.ToHashcodeInt(conditionHash(i)) - }, []interface{}{rawCondition}) - memberList = append(memberList, rawMember) - } - } - return memberList, nil -} - func flattenDatabaseList(databases []*v1pb.Database) []interface{} { dbList := []interface{}{} for _, database := range databases { @@ -257,11 +118,6 @@ func setProject( return diag.FromErr(err) } - iamPolicy, err := client.GetProjectIAMPolicy(ctx, project.Name) - if err != nil { - return diag.Errorf("failed to get project iam with error: %v", err) - } - d.SetId(project.Name) projectID, err := internal.GetProjectID(project.Name) @@ -310,64 +166,5 @@ func setProject( "ms": time.Since(startTime).Milliseconds(), }) - startTime = time.Now() - memberList, err := flattenMemberList(iamPolicy) - if err != nil { - return diag.FromErr(err) - } - if err := d.Set("members", schema.NewSet(memberHash, memberList)); err != nil { - return diag.Errorf("cannot set members for project: %s", err.Error()) - } - - tflog.Debug(ctx, "[read project] set project members", map[string]interface{}{ - "project": project.Name, - "members": len(memberList), - "ms": time.Since(startTime).Milliseconds(), - }) - return nil } - -func memberHash(rawMember interface{}) int { - var buf bytes.Buffer - member := rawMember.(map[string]interface{}) - - if v, ok := member["member"].(string); ok { - _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) - } - if v, ok := member["role"].(string); ok { - _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) - } - - if condition, ok := member["condition"].(*schema.Set); ok && condition.Len() > 0 && condition.List()[0] != nil { - rawCondition := condition.List()[0].(map[string]interface{}) - _, _ = buf.WriteString(conditionHash(rawCondition)) - } - - return internal.ToHashcodeInt(buf.String()) -} - -func conditionHash(rawCondition interface{}) string { - var buf bytes.Buffer - condition := rawCondition.(map[string]interface{}) - - if v, ok := condition["database"].(string); ok { - _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) - } - if v, ok := condition["schema"].(string); ok { - _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) - } - if v, ok := condition["tables"].(*schema.Set); ok { - for _, t := range v.List() { - _, _ = buf.WriteString(fmt.Sprintf("table.%s-", t.(string))) - } - } - if v, ok := condition["row_limit"].(int); ok { - _, _ = buf.WriteString(fmt.Sprintf("%d-", v)) - } - if v, ok := condition["expire_timestamp"].(string); ok { - _, _ = buf.WriteString(fmt.Sprintf("%s-", v)) - } - - return buf.String() -} diff --git a/provider/data_source_project_list.go b/provider/data_source_project_list.go index 050e694..2f6a24f 100644 --- a/provider/data_source_project_list.go +++ b/provider/data_source_project_list.go @@ -90,7 +90,6 @@ func dataSourceProjectList() *schema.Resource { Computed: true, Description: "Whether to enable the database tenant mode for PostgreSQL. If enabled, the issue will be created with the pre-appended \"set role \" statement.", }, - "members": getProjectMembersSchema(true), "databases": getDatabasesSchema(true), }, }, @@ -146,16 +145,6 @@ func dataSourceProjectListRead(ctx context.Context, d *schema.ResourceData, m in databaseList := flattenDatabaseList(databases) proj["databases"] = databaseList - iamPolicy, err := c.GetProjectIAMPolicy(ctx, project.Name) - if err != nil { - return diag.Errorf("failed to get project iam with error: %v", err) - } - memberList, err := flattenMemberList(iamPolicy) - if err != nil { - return diag.FromErr(err) - } - proj["members"] = schema.NewSet(memberHash, memberList) - projects = append(projects, proj) } diff --git a/provider/data_source_setting.go b/provider/data_source_setting.go index 4886711..65dae93 100644 --- a/provider/data_source_setting.go +++ b/provider/data_source_setting.go @@ -331,6 +331,7 @@ func getWorkspaceProfileSetting(computed bool) *schema.Schema { } } +// TODO(ed): API changed. func getWorkspaceApprovalSetting(computed bool) *schema.Schema { return &schema.Schema{ Computed: computed, diff --git a/provider/data_source_user.go b/provider/data_source_user.go index 644a060..cdc3ecb 100644 --- a/provider/data_source_user.go +++ b/provider/data_source_user.go @@ -43,14 +43,6 @@ func dataSourceUser() *schema.Resource { Computed: true, Description: "The user phone.", }, - "roles": { - Type: schema.TypeSet, - Computed: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "The user's roles in the workspace level", - }, "type": { Type: schema.TypeString, Computed: true, @@ -96,28 +88,10 @@ func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface d.SetId(user.Name) - return setUser(ctx, c, d, user) -} - -func getRolesInIAM(iamPolicy *v1pb.IamPolicy, memberBinding string) []string { - roles := []string{} - - for _, binding := range iamPolicy.Bindings { - for _, member := range binding.Members { - if member == memberBinding { - roles = append(roles, binding.Role) - } - } - } - return roles + return setUser(d, user) } -func setUser(ctx context.Context, client api.Client, d *schema.ResourceData, user *v1pb.User) diag.Diagnostics { - workspaceIAM, err := client.GetWorkspaceIAMPolicy(ctx) - if err != nil { - return diag.Errorf("cannot get workspace IAM with error: %s", err.Error()) - } - +func setUser(d *schema.ResourceData, user *v1pb.User) diag.Diagnostics { if err := d.Set("title", user.Title); err != nil { return diag.Errorf("cannot set title for user: %s", err.Error()) } @@ -152,9 +126,5 @@ func setUser(ctx context.Context, client api.Client, d *schema.ResourceData, use return diag.Errorf("cannot set source for user: %s", err.Error()) } } - if err := d.Set("roles", getRolesInIAM(workspaceIAM, fmt.Sprintf("user:%s", user.Email))); err != nil { - return diag.Errorf("cannot set roles for user: %s", err.Error()) - } - return nil } diff --git a/provider/data_source_user_list.go b/provider/data_source_user_list.go index f928233..d5332da 100644 --- a/provider/data_source_user_list.go +++ b/provider/data_source_user_list.go @@ -91,14 +91,6 @@ func dataSourceUserList() *schema.Resource { Computed: true, Description: "The user type.", }, - "roles": { - Type: schema.TypeSet, - Computed: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "The user's roles in the workspace level", - }, "mfa_enabled": { Type: schema.TypeBool, Computed: true, @@ -159,11 +151,6 @@ func dataSourceUserListRead(ctx context.Context, d *schema.ResourceData, m inter return diag.FromErr(err) } - workspaceIAM, err := c.GetWorkspaceIAMPolicy(ctx) - if err != nil { - return diag.Errorf("cannot get workspace IAM with error: %s", err.Error()) - } - users := make([]map[string]interface{}, 0) for _, user := range allUsers { raw := make(map[string]interface{}) @@ -179,7 +166,6 @@ func dataSourceUserListRead(ctx context.Context, d *schema.ResourceData, m inter raw["last_login_time"] = p.LastLoginTime.AsTime().UTC().Format(time.RFC3339) raw["last_change_password_time"] = p.LastChangePasswordTime.AsTime().UTC().Format(time.RFC3339) } - raw["roles"] = getRolesInIAM(workspaceIAM, fmt.Sprintf("user:%s", user.Email)) users = append(users, raw) } if err := d.Set("users", users); err != nil { diff --git a/provider/internal/utils.go b/provider/internal/utils.go index 44a1600..24a9e8d 100644 --- a/provider/internal/utils.go +++ b/provider/internal/utils.go @@ -229,6 +229,9 @@ func getNameParentTokens(name string, tokenPrefixes ...string) ([]string, error) // ValidateMemberBinding checks the member binding format. func ValidateMemberBinding(member string) error { + if member == "allUsers" { + return nil + } if !strings.HasPrefix(member, "user:") && !strings.HasPrefix(member, "group:") { return errors.Errorf("invalid member format") } diff --git a/provider/provider.go b/provider/provider.go index dc49d1c..5d39023 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -68,6 +68,7 @@ func NewProvider() *schema.Provider { "bytebase_database_list": dataSourceDatabaseList(), "bytebase_review_config": dataSourceReviewConfig(), "bytebase_review_config_list": dataSourceReviewConfigList(), + "bytebase_iam_policy": dataSourceIAMPolicy(), }, ResourcesMap: map[string]*schema.Resource{ "bytebase_environment": resourceEnvironment(), @@ -80,6 +81,7 @@ func NewProvider() *schema.Provider { "bytebase_group": resourceGroup(), "bytebase_database": resourceDatabase(), "bytebase_review_config": resourceReviewConfig(), + "bytebase_iam_policy": resourceIAMPolicy(), }, } } diff --git a/provider/resource_environment.go b/provider/resource_environment.go index 35e08d6..9a017b3 100644 --- a/provider/resource_environment.go +++ b/provider/resource_environment.go @@ -18,6 +18,7 @@ import ( var environmentTitleRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`) +// TODO(ed): API changed. func resourceEnvironment() *schema.Resource { return &schema.Resource{ Description: "The environment resource.", diff --git a/provider/resource_group.go b/provider/resource_group.go index cb9bb69..b6c7e4d 100644 --- a/provider/resource_group.go +++ b/provider/resource_group.go @@ -55,14 +55,6 @@ func resourceGroup() *schema.Resource { Computed: true, Description: "Source means where the group comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", }, - "roles": { - Type: schema.TypeSet, - Optional: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "The group's roles in the workspace level", - }, "members": { Type: schema.TypeSet, Required: true, @@ -104,7 +96,7 @@ func resourceGroupRead(ctx context.Context, d *schema.ResourceData, m interface{ return diag.FromErr(err) } - return setGroup(ctx, c, d, group) + return setGroup(d, group) } func resourceGroupDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { @@ -180,19 +172,6 @@ func resourceGroupCreate(ctx context.Context, d *schema.ResourceData, m interfac } } - roles, diagnostic := getRoles(d) - if diagnostic != nil { - return diagnostic - } - if err := patchWorkspaceIAMPolicy(ctx, c, fmt.Sprintf("group:%s", groupEmail), roles); err != nil { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Failed to patch group roles", - Detail: fmt.Sprintf("Update roles for group %s failed, error: %v", groupName, err), - }) - return diags - } - d.SetId(groupName) diag := resourceGroupRead(ctx, d, m) @@ -246,22 +225,6 @@ func resourceGroupUpdate(ctx context.Context, d *schema.ResourceData, m interfac } } - if d.HasChange("roles") { - roles, diagnostic := getRoles(d) - if diagnostic != nil { - return diagnostic - } - groupEmail := d.Get("email").(string) - if err := patchWorkspaceIAMPolicy(ctx, c, fmt.Sprintf("group:%s", groupEmail), roles); err != nil { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Failed to patch group roles", - Detail: fmt.Sprintf("Update roles for group %s failed, error: %v", groupName, err), - }) - return diags - } - } - diag := resourceGroupRead(ctx, d, m) if diag != nil { diags = append(diags, diag...) diff --git a/provider/resource_iam_policy.go b/provider/resource_iam_policy.go new file mode 100644 index 0000000..186f361 --- /dev/null +++ b/provider/resource_iam_policy.go @@ -0,0 +1,163 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pkg/errors" + "google.golang.org/genproto/googleapis/type/expr" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + + "github.com/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func resourceIAMPolicy() *schema.Resource { + return &schema.Resource{ + Description: "The IAM policy resource.", + CreateContext: resourceIAMPolicyUpsert, + ReadContext: dataSourceIAMPolicyRead, + UpdateContext: resourceIAMPolicyUpsert, + DeleteContext: resourceIAMPolicyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "parent": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: internal.ResourceNameValidation( + // workspace policy + regexp.MustCompile("^workspaces/-$"), + // project policy + regexp.MustCompile(fmt.Sprintf("^%s%s$", internal.ProjectNamePrefix, internal.ResourceIDPattern)), + ), + Description: `The IAM policy parent name for the policy, support "projects/{resource id}" or "workspaces/-"`, + }, + "iam_policy": getIAMPolicySchema(false), + }, + } +} + +func resourceIAMPolicyUpsert(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + parent := d.Get("parent").(string) + + iamPolicy, err := convertToIAMPolicy(d) + if err != nil { + return diag.FromErr(err) + } + request := &v1pb.SetIamPolicyRequest{ + Resource: parent, + Policy: iamPolicy, + } + if strings.HasPrefix(parent, internal.ProjectNamePrefix) { + if _, err := c.SetProjectIAMPolicy(ctx, parent, request); err != nil { + return diag.FromErr(err) + } + } else { + if _, err := c.SetWorkspaceIAMPolicy(ctx, request); err != nil { + return diag.FromErr(err) + } + } + d.SetId(parent) + return dataSourceIAMPolicyRead(ctx, d, m) +} + +func resourceIAMPolicyDelete(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + var diags diag.Diagnostics + + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Unsupport delete IAM policy", + }) + d.SetId("") + + return diags +} + +func convertToIAMPolicy(d *schema.ResourceData) (*v1pb.IamPolicy, error) { + rawList, ok := d.Get("iam_policy").([]interface{}) + if !ok || len(rawList) != 1 { + return nil, errors.Errorf("invalid iam_policy") + } + + raw := rawList[0].(map[string]interface{}) + bindingList, ok := raw["binding"].(*schema.Set) + if !ok { + return nil, errors.Errorf("invalid binding") + } + + policy := &v1pb.IamPolicy{} + + for _, binding := range bindingList.List() { + rawBinding := binding.(map[string]interface{}) + + role := rawBinding["role"].(string) + if !strings.HasPrefix(role, internal.RoleNamePrefix) { + return nil, errors.Errorf("invalid role format, role must in roles/{id} format") + } + + binding := &v1pb.Binding{ + Role: role, + } + + members, ok := rawBinding["members"].(*schema.Set) + if !ok { + return nil, errors.Errorf("invalid members") + } + if members.Len() == 0 { + return nil, errors.Errorf("empty members") + } + for _, member := range members.List() { + if err := internal.ValidateMemberBinding(member.(string)); err != nil { + return nil, errors.Wrapf(err, "invalid member: %v", member) + } + binding.Members = append(binding.Members, member.(string)) + } + + expressions := []string{} + if condition, ok := rawBinding["condition"].(*schema.Set); ok { + if condition.Len() > 1 { + return nil, errors.Errorf("should only set one condition") + } + if condition.Len() == 1 && condition.List()[0] != nil { + rawCondition := condition.List()[0].(map[string]interface{}) + if database, ok := rawCondition["database"].(string); ok && database != "" { + expressions = append(expressions, fmt.Sprintf(`resource.database == "%s"`, database)) + } + if schema, ok := rawCondition["schema"].(string); ok { + expressions = append(expressions, fmt.Sprintf(`resource.schema == "%s"`, schema)) + } + if tables, ok := rawCondition["tables"].(*schema.Set); ok && tables.Len() > 0 { + tableList := []string{} + for _, table := range tables.List() { + tableList = append(tableList, fmt.Sprintf(`"%s"`, table.(string))) + } + expressions = append(expressions, fmt.Sprintf(`resource.table in [%s]`, strings.Join(tableList, ","))) + } + if rowLimit, ok := rawCondition["row_limit"].(int); ok && rowLimit > 0 { + expressions = append(expressions, fmt.Sprintf(`request.row_limit <= %d`, rowLimit)) + } + if expire, ok := rawCondition["expire_timestamp"].(string); ok && expire != "" { + formattedTime, err := time.Parse(time.RFC3339, expire) + if err != nil { + return nil, errors.Wrapf(err, "invalid time: %v", expire) + } + expressions = append(expressions, fmt.Sprintf(`request.time < timestamp("%s")`, formattedTime.Format(time.RFC3339))) + } + } + } + binding.Condition = &expr.Expr{ + Expression: strings.Join(expressions, " && "), + } + policy.Bindings = append(policy.Bindings, binding) + } + return policy, nil +} diff --git a/provider/resource_policy.go b/provider/resource_policy.go index c9bc8d3..d9ad87c 100644 --- a/provider/resource_policy.go +++ b/provider/resource_policy.go @@ -35,7 +35,7 @@ func resourcePolicy() *schema.Resource { Required: true, ValidateDiagFunc: internal.ResourceNameValidation( // workspace policy - regexp.MustCompile("^$"), + regexp.MustCompile("^workspaces/-$"), // environment policy regexp.MustCompile(fmt.Sprintf("^%s%s$", internal.EnvironmentNamePrefix, internal.ResourceIDPattern)), // instance policy @@ -303,6 +303,9 @@ func convertToMaskingExceptionPolicy(d *schema.ResourceData) (*v1pb.MaskingExcep expressions = append(expressions, fmt.Sprintf(`request.time < timestamp("%s")`, formattedTime.Format(time.RFC3339))) } member := rawException["member"].(string) + if member == "allUsers" { + return nil, errors.Errorf("not support allUsers in masking_exception_policy") + } if err := internal.ValidateMemberBinding(member); err != nil { return nil, err } diff --git a/provider/resource_project.go b/provider/resource_project.go index c879065..9de0d6d 100644 --- a/provider/resource_project.go +++ b/provider/resource_project.go @@ -3,15 +3,12 @@ package provider import ( "context" "fmt" - "strings" "time" "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/hashicorp/terraform-plugin-sdk/v2/helper/validation" - "github.com/pkg/errors" - "google.golang.org/genproto/googleapis/type/expr" "google.golang.org/protobuf/types/known/fieldmaskpb" "github.com/bytebase/terraform-provider-bytebase/api" @@ -87,7 +84,6 @@ func resourceProjct() *schema.Resource { Computed: true, Description: "Whether to enable the database tenant mode for PostgreSQL. If enabled, the issue will be created with the pre-appended \"set role \" statement.", }, - "members": getProjectMembersSchema(false), "databases": getDatabasesSchema(false), }, } @@ -202,11 +198,6 @@ func resourceProjectCreate(ctx context.Context, d *schema.ResourceData, m interf return diags } - if diag := updateMembersInProject(ctx, d, c, d.Id()); diag != nil { - diags = append(diags, diag...) - return diags - } - tflog.Debug(ctx, "[upsert project] start reading project", map[string]interface{}{ "project": projectName, }) @@ -307,13 +298,6 @@ func resourceProjectUpdate(ctx context.Context, d *schema.ResourceData, m interf } } - if d.HasChange("members") { - if diag := updateMembersInProject(ctx, d, c, d.Id()); diag != nil { - diags = append(diags, diag...) - return diags - } - } - diag := resourceProjectRead(ctx, d, m) if diag != nil { diags = append(diags, diag...) @@ -355,88 +339,6 @@ func resourceProjectDelete(ctx context.Context, d *schema.ResourceData, m interf return diags } -func updateMembersInProject(ctx context.Context, d *schema.ResourceData, client api.Client, projectName string) diag.Diagnostics { - memberSet, ok := d.Get("members").(*schema.Set) - if !ok { - return nil - } - - iamPolicy := &v1pb.IamPolicy{} - existProjectOwner := false - - for _, m := range memberSet.List() { - rawMember := m.(map[string]interface{}) - expressions := []string{} - - if condition, ok := rawMember["condition"].(*schema.Set); ok { - if condition.Len() > 1 { - return diag.Errorf("should only set one condition") - } - if condition.Len() == 1 && condition.List()[0] != nil { - rawCondition := condition.List()[0].(map[string]interface{}) - if database, ok := rawCondition["database"].(string); ok && database != "" { - expressions = append(expressions, fmt.Sprintf(`resource.database == "%s"`, database)) - } - if schema, ok := rawCondition["schema"].(string); ok { - expressions = append(expressions, fmt.Sprintf(`resource.schema == "%s"`, schema)) - } - if tables, ok := rawCondition["tables"].(*schema.Set); ok && tables.Len() > 0 { - tableList := []string{} - for _, table := range tables.List() { - tableList = append(tableList, fmt.Sprintf(`"%s"`, table.(string))) - } - expressions = append(expressions, fmt.Sprintf(`resource.table in [%s]`, strings.Join(tableList, ","))) - } - if rowLimit, ok := rawCondition["row_limit"].(int); ok && rowLimit > 0 { - expressions = append(expressions, fmt.Sprintf(`request.row_limit <= %d`, rowLimit)) - } - if expire, ok := rawCondition["expire_timestamp"].(string); ok && expire != "" { - formattedTime, err := time.Parse(time.RFC3339, expire) - if err != nil { - return diag.FromErr(errors.Wrapf(err, "invalid time: %v", expire)) - } - expressions = append(expressions, fmt.Sprintf(`request.time < timestamp("%s")`, formattedTime.Format(time.RFC3339))) - } - } - } - - member := rawMember["member"].(string) - role := rawMember["role"].(string) - if role == "roles/projectOwner" { - existProjectOwner = true - } - - if err := internal.ValidateMemberBinding(member); err != nil { - return diag.FromErr(err) - } - if !strings.HasPrefix(role, internal.RoleNamePrefix) { - return diag.Errorf("invalid role format") - } - - iamPolicy.Bindings = append(iamPolicy.Bindings, &v1pb.Binding{ - Members: []string{member}, - Role: role, - Condition: &expr.Expr{ - Expression: strings.Join(expressions, " && "), - }, - }) - } - - if len(iamPolicy.Bindings) > 0 { - if !existProjectOwner { - return diag.Errorf("require at least 1 member with roles/projectOwner role") - } - - if _, err := client.SetProjectIAMPolicy(ctx, projectName, &v1pb.SetIamPolicyRequest{ - Policy: iamPolicy, - Etag: iamPolicy.Etag, - }); err != nil { - return diag.Errorf("failed to update iam for project %s with error: %v", projectName, err.Error()) - } - } - return nil -} - const batchSize = 100 func updateDatabasesInProject(ctx context.Context, d *schema.ResourceData, client api.Client, projectName string) diag.Diagnostics { diff --git a/provider/resource_project_test.go b/provider/resource_project_test.go index 2add3a5..04e931a 100644 --- a/provider/resource_project_test.go +++ b/provider/resource_project_test.go @@ -72,11 +72,6 @@ func testAccCheckProjectResource(identifier, resourceID, title string) string { resource "bytebase_project" "%s" { resource_id = "%s" title = "%s" - - members { - member = "user:mock@bytebase.com" - role = "roles/projectOwner" - } } `, identifier, resourceID, title) } diff --git a/provider/resource_user.go b/provider/resource_user.go index 2248dfa..d7c0503 100644 --- a/provider/resource_user.go +++ b/provider/resource_user.go @@ -3,7 +3,6 @@ package provider import ( "context" "fmt" - "slices" "strings" v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" @@ -11,7 +10,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" - "github.com/pkg/errors" "github.com/bytebase/terraform-provider-bytebase/api" "github.com/bytebase/terraform-provider-bytebase/provider/internal" @@ -57,14 +55,6 @@ func resourceUser() *schema.Resource { Computed: true, Description: "The service key for service account.", }, - "roles": { - Type: schema.TypeSet, - Optional: true, - Elem: &schema.Schema{ - Type: schema.TypeString, - }, - Description: "The user's roles in the workspace level", - }, "type": { Type: schema.TypeString, Optional: true, @@ -118,7 +108,7 @@ func resourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{} return diag.FromErr(err) } - return setUser(ctx, c, d, user) + return setUser(d, user) } func resourceUserDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { @@ -230,19 +220,6 @@ func resourceUserCreate(ctx context.Context, d *schema.ResourceData, m interface d.SetId(user.Name) } - roles, diagnostic := getRoles(d) - if diagnostic != nil { - return diagnostic - } - if err := patchWorkspaceIAMPolicy(ctx, c, fmt.Sprintf("user:%s", email), roles); err != nil { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Failed to patch user roles", - Detail: fmt.Sprintf("Update roles for user %s failed, error: %v", userName, err), - }) - return diags - } - diag := resourceUserRead(ctx, d, m) if diag != nil { diags = append(diags, diag...) @@ -319,21 +296,6 @@ func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, m interface } } - if d.HasChange("roles") { - roles, diagnostic := getRoles(d) - if diagnostic != nil { - return diagnostic - } - if err := patchWorkspaceIAMPolicy(ctx, c, fmt.Sprintf("user:%s", email), roles); err != nil { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Failed to patch user roles", - Detail: fmt.Sprintf("Update roles for user %s failed, error: %v", userName, err), - }) - return diags - } - } - diag := resourceUserRead(ctx, d, m) if diag != nil { diags = append(diags, diag...) @@ -341,61 +303,3 @@ func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, m interface return diags } - -func getRoles(d *schema.ResourceData) ([]string, diag.Diagnostics) { - rawRoles := d.Get("roles").(*schema.Set) - roleList := []string{} - - for _, rawRole := range rawRoles.List() { - role := rawRole.(string) - if !strings.HasPrefix(role, internal.RoleNamePrefix) { - return nil, diag.Errorf("role must in the roles/{id} format") - } - roleList = append(roleList, rawRole.(string)) - } - return roleList, nil -} - -func patchWorkspaceIAMPolicy(ctx context.Context, client api.Client, member string, roles []string) error { - workspaceIamPolicy, err := client.GetWorkspaceIAMPolicy(ctx) - if err != nil { - return errors.Errorf("cannot get workspace IAM with error: %s", err.Error()) - } - roleMap := map[string]bool{} - for _, role := range roles { - roleMap[role] = true - } - - for _, binding := range workspaceIamPolicy.Bindings { - index := slices.Index(binding.Members, member) - if !roleMap[binding.Role] { - if index >= 0 { - binding.Members = slices.Delete(binding.Members, index, index+1) - } - } else { - if index < 0 { - binding.Members = append(binding.Members, member) - } - } - - delete(roleMap, binding.Role) - } - - for role := range roleMap { - workspaceIamPolicy.Bindings = append(workspaceIamPolicy.Bindings, &v1pb.Binding{ - Role: role, - Members: []string{ - member, - }, - }) - } - - if _, err := client.SetWorkspaceIAMPolicy(ctx, &v1pb.SetIamPolicyRequest{ - Policy: workspaceIamPolicy, - Etag: workspaceIamPolicy.Etag, - }); err != nil { - return err - } - - return nil -}