diff --git a/api/client.go b/api/client.go index 7288a9d..b161627 100644 --- a/api/client.go +++ b/api/client.go @@ -9,14 +9,13 @@ import ( // Client is the API message for Bytebase OpenAPI client. type Client interface { - // Auth - // Login will login the user and get the response. - Login() (*v1pb.LoginResponse, error) + // GetCaller returns the API caller. + GetCaller() *v1pb.User // Environment // CreateEnvironment creates the environment. CreateEnvironment(ctx context.Context, environmentID string, create *v1pb.Environment) (*v1pb.Environment, error) - // GetEnvironment gets the environment by id. + // GetEnvironment gets the environment by full name. GetEnvironment(ctx context.Context, environmentName string) (*v1pb.Environment, error) // ListEnvironment finds all environments. ListEnvironment(ctx context.Context, showDeleted bool) (*v1pb.ListEnvironmentsResponse, error) @@ -30,7 +29,7 @@ type Client interface { // Instance // ListInstance will return instances. ListInstance(ctx context.Context, showDeleted bool) (*v1pb.ListInstancesResponse, error) - // GetInstance gets the instance by id. + // GetInstance gets the instance by full name. GetInstance(ctx context.Context, instanceName string) (*v1pb.Instance, error) // CreateInstance creates the instance. CreateInstance(ctx context.Context, instanceID string, instance *v1pb.Instance) (*v1pb.Instance, error) @@ -62,7 +61,7 @@ type Client interface { UpdateDatabase(ctx context.Context, patch *v1pb.Database, updateMasks []string) (*v1pb.Database, error) // Project - // GetProject gets the project by resource id. + // GetProject gets the project by project full name. GetProject(ctx context.Context, projectName string) (*v1pb.Project, error) // ListProject list the projects, ListProject(ctx context.Context, showDeleted bool) (*v1pb.ListProjectsResponse, error) @@ -74,6 +73,10 @@ type Client interface { DeleteProject(ctx context.Context, projectName string) error // UndeleteProject undeletes the project. UndeleteProject(ctx context.Context, projectName string) (*v1pb.Project, error) + // GetProjectIAMPolicy gets the project IAM policy by project full name. + GetProjectIAMPolicy(ctx context.Context, projectName string) (*v1pb.IamPolicy, error) + // SetProjectIAMPolicy sets the project IAM policy. + SetProjectIAMPolicy(ctx context.Context, projectName string, iamPolicy *v1pb.IamPolicy) (*v1pb.IamPolicy, error) // Setting // ListSettings lists all settings. @@ -90,7 +93,7 @@ type Client interface { // VCS Provider // ListVCSProvider will returns all vcs providers. ListVCSProvider(ctx context.Context) (*v1pb.ListVCSProvidersResponse, error) - // GetVCSProvider gets the vcs by id. + // GetVCSProvider gets the vcs by full name. GetVCSProvider(ctx context.Context, name string) (*v1pb.VCSProvider, error) // CreateVCSProvider creates the vcs provider. CreateVCSProvider(ctx context.Context, vcsID string, vcs *v1pb.VCSProvider) (*v1pb.VCSProvider, error) @@ -102,7 +105,7 @@ type Client interface { // VCS Connector // ListVCSConnector will returns all vcs connector in a project. ListVCSConnector(ctx context.Context, projectName string) (*v1pb.ListVCSConnectorsResponse, error) - // GetVCSConnector gets the vcs connector by id. + // GetVCSConnector gets the vcs connector by full name. GetVCSConnector(ctx context.Context, name string) (*v1pb.VCSConnector, error) // CreateVCSConnector creates the vcs connector in a project. CreateVCSConnector(ctx context.Context, projectName, connectorID string, connector *v1pb.VCSConnector) (*v1pb.VCSConnector, error) @@ -110,4 +113,18 @@ type Client interface { UpdateVCSConnector(ctx context.Context, patch *v1pb.VCSConnector, updateMasks []string) (*v1pb.VCSConnector, error) // DeleteVCSConnector deletes the vcs provider. DeleteVCSConnector(ctx context.Context, name string) error + + // User + // ListUser list all users. + ListUser(ctx context.Context, showDeleted bool) (*v1pb.ListUsersResponse, error) + // CreateUser creates the user. + CreateUser(ctx context.Context, user *v1pb.User) (*v1pb.User, error) + // GetUser gets the user by name. + GetUser(ctx context.Context, userName string) (*v1pb.User, error) + // UpdateUser updates the user. + UpdateUser(ctx context.Context, patch *v1pb.User, updateMasks []string) (*v1pb.User, error) + // DeleteUser deletes the user by name. + DeleteUser(ctx context.Context, userName string) error + // UndeleteUser undeletes the user by name. + UndeleteUser(ctx context.Context, userName string) (*v1pb.User, error) } diff --git a/client/auth.go b/client/auth.go index a7a80e9..173fb93 100644 --- a/client/auth.go +++ b/client/auth.go @@ -12,11 +12,11 @@ import ( ) // Login will login the user and get the response. -func (c *client) Login() (*v1pb.LoginResponse, error) { - if c.auth.Email == "" || c.auth.Password == "" { +func (c *client) login(request *v1pb.LoginRequest) (*v1pb.LoginResponse, error) { + if request.Email == "" || request.Password == "" { return nil, errors.Errorf("define username and password") } - rb, err := protojson.Marshal(c.auth) + rb, err := protojson.Marshal(request) if err != nil { return nil, err } diff --git a/client/client.go b/client/client.go index 8eb04a3..8bc8e22 100644 --- a/client/client.go +++ b/client/client.go @@ -20,7 +20,7 @@ type client struct { version string client *http.Client token string - auth *v1pb.LoginRequest + caller *v1pb.User } // NewClient returns the new Bytebase API client. @@ -31,17 +31,16 @@ func NewClient(url, version, email, password string) (api.Client, error) { version: version, } - c.auth = &v1pb.LoginRequest{ + response, err := c.login(&v1pb.LoginRequest{ Email: email, Password: password, - } - - ar, err := c.Login() + }) if err != nil { return nil, err } - c.token = ar.Token + c.token = response.Token + c.caller = response.User return &c, nil } @@ -68,3 +67,8 @@ func (c *client) doRequest(req *http.Request) ([]byte, error) { return body, err } + +// GetCaller returns the API caller. +func (c *client) GetCaller() *v1pb.User { + return c.caller +} diff --git a/client/database.go b/client/database.go index 0677c17..5c2277a 100644 --- a/client/database.go +++ b/client/database.go @@ -9,7 +9,7 @@ import ( v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" ) -// GetDatabase gets the database by the database name. +// GetDatabase gets the database by the database full name. func (c *client) GetDatabase(ctx context.Context, databaseName string) (*v1pb.Database, error) { body, err := c.getResource(ctx, databaseName) if err != nil { diff --git a/client/environment.go b/client/environment.go index e742578..eb9bbf3 100644 --- a/client/environment.go +++ b/client/environment.go @@ -35,7 +35,7 @@ func (c *client) CreateEnvironment(ctx context.Context, environmentID string, cr return &env, nil } -// GetEnvironment gets the environment by id. +// GetEnvironment gets the environment by full name. func (c *client) GetEnvironment(ctx context.Context, environmentName string) (*v1pb.Environment, error) { body, err := c.getResource(ctx, environmentName) if err != nil { diff --git a/client/instance.go b/client/instance.go index 09172ac..8e453b2 100644 --- a/client/instance.go +++ b/client/instance.go @@ -30,7 +30,7 @@ func (c *client) ListInstance(ctx context.Context, showDeleted bool) (*v1pb.List return &res, nil } -// GetInstance gets the instance by id. +// GetInstance gets the instance by full name. func (c *client) GetInstance(ctx context.Context, instanceName string) (*v1pb.Instance, error) { body, err := c.getResource(ctx, instanceName) if err != nil { diff --git a/client/project.go b/client/project.go index 6b5e7ef..60c1498 100644 --- a/client/project.go +++ b/client/project.go @@ -10,7 +10,7 @@ import ( "google.golang.org/protobuf/encoding/protojson" ) -// GetProject gets the project by resource id. +// GetProject gets the project by project full name. func (c *client) GetProject(ctx context.Context, projectName string) (*v1pb.Project, error) { body, err := c.getResource(ctx, projectName) if err != nil { @@ -25,6 +25,49 @@ func (c *client) GetProject(ctx context.Context, projectName string) (*v1pb.Proj return &res, nil } +// GetProjectIAMPolicy gets the project IAM policy by project full name. +func (c *client) GetProjectIAMPolicy(ctx context.Context, projectName string) (*v1pb.IamPolicy, error) { + body, err := c.getResource(ctx, fmt.Sprintf("%s:getIamPolicy", projectName)) + if err != nil { + return nil, err + } + + var res v1pb.IamPolicy + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// SetProjectIAMPolicy sets the project IAM policy. +func (c *client) SetProjectIAMPolicy(ctx context.Context, projectName string, iamPolicy *v1pb.IamPolicy) (*v1pb.IamPolicy, error) { + payload, err := protojson.Marshal(&v1pb.SetIamPolicyRequest{ + Policy: iamPolicy, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/%s/%s:setIamPolicy", c.url, c.version, projectName), strings.NewReader(string(payload))) + + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var res v1pb.IamPolicy + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + // ListProject list the projects. func (c *client) ListProject(ctx context.Context, showDeleted bool) (*v1pb.ListProjectsResponse, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/projects?showDeleted=%v", c.url, c.version, showDeleted), nil) diff --git a/client/vcs.go b/client/vcs.go index e36382f..973641e 100644 --- a/client/vcs.go +++ b/client/vcs.go @@ -30,7 +30,7 @@ func (c *client) ListVCSProvider(ctx context.Context) (*v1pb.ListVCSProvidersRes return &res, nil } -// GetVCSProvider gets the vcs by id. +// GetVCSProvider gets the vcs by full name. func (c *client) GetVCSProvider(ctx context.Context, name string) (*v1pb.VCSProvider, error) { body, err := c.getResource(ctx, name) if err != nil { @@ -111,7 +111,7 @@ func (c *client) ListVCSConnector(ctx context.Context, projectName string) (*v1p return &res, nil } -// GetVCSConnector gets the vcs connector by id. +// GetVCSConnector gets the vcs connector by full name. func (c *client) GetVCSConnector(ctx context.Context, name string) (*v1pb.VCSConnector, error) { body, err := c.getResource(ctx, name) if err != nil { diff --git a/docs/data-sources/policy.md b/docs/data-sources/policy.md index 4ac2664..029fa32 100644 --- a/docs/data-sources/policy.md +++ b/docs/data-sources/policy.md @@ -49,7 +49,7 @@ Optional: - `database` (String) The database full name in instances/{instance resource id}/databases/{database name} format - `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ss.000Z format - `masking_level` (String) -- `member` (String) The member in user:{email} format. +- `member` (String) The member in user:{email} or group:{email} format. - `schema` (String) - `table` (String) diff --git a/docs/data-sources/project.md b/docs/data-sources/project.md index 140bde5..fed2d1c 100644 --- a/docs/data-sources/project.md +++ b/docs/data-sources/project.md @@ -21,10 +21,17 @@ The project data source. ### Read-Only +- `allow_modify_statement` (Boolean) Allow modifying statement after issue is created. +- `auto_enable_backup` (Boolean) Whether to automatically enable backup. +- `auto_resolve_issue` (Boolean) Enable auto resolve issue. - `databases` (List of Object) The databases in the project. (see [below for nested schema](#nestedatt--databases)) +- `enforce_issue_title` (Boolean) Enforce issue title created by user instead of generated by Bytebase. - `id` (String) The ID of this resource. - `key` (String) The project key. +- `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. - `workflow` (String) The project workflow. @@ -41,3 +48,24 @@ Read-Only: - `sync_state` (String) + +### 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 b718a02..078d399 100644 --- a/docs/data-sources/project_list.md +++ b/docs/data-sources/project_list.md @@ -29,10 +29,17 @@ The project data source list. Read-Only: +- `allow_modify_statement` (Boolean) +- `auto_enable_backup` (Boolean) +- `auto_resolve_issue` (Boolean) - `databases` (List of Object) (see [below for nested schema](#nestedobjatt--projects--databases)) +- `enforce_issue_title` (Boolean) - `key` (String) +- `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) - `workflow` (String) @@ -49,3 +56,24 @@ Read-Only: - `sync_state` (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 new file mode 100644 index 0000000..381d6e1 --- /dev/null +++ b/docs/data-sources/user.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_user Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The user data source. +--- + +# bytebase_user (Data Source) + +The user data source. + + + + +## Schema + +### Required + +- `name` (String) The user name in users/{user id or email} format. + +### Read-Only + +- `email` (String) The user email. +- `id` (String) The ID of this resource. +- `last_change_password_time` (String) The user last change password time. +- `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. +- `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. +- `type` (String) The user type. + + diff --git a/docs/data-sources/user_list.md b/docs/data-sources/user_list.md new file mode 100644 index 0000000..6a879dd --- /dev/null +++ b/docs/data-sources/user_list.md @@ -0,0 +1,43 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_user_list Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The user data source list. +--- + +# bytebase_user_list (Data Source) + +The user data source list. + + + + +## Schema + +### Optional + +- `show_deleted` (Boolean) Including removed users in the response. + +### Read-Only + +- `id` (String) The ID of this resource. +- `users` (List of Object) (see [below for nested schema](#nestedatt--users)) + + +### Nested Schema for `users` + +Read-Only: + +- `email` (String) +- `last_change_password_time` (String) +- `last_login_time` (String) +- `mfa_enabled` (Boolean) +- `name` (String) +- `phone` (String) +- `source` (String) +- `state` (String) +- `title` (String) +- `type` (String) + + diff --git a/docs/resources/policy.md b/docs/resources/policy.md index 315c84e..b26f102 100644 --- a/docs/resources/policy.md +++ b/docs/resources/policy.md @@ -49,7 +49,7 @@ Optional: - `database` (String) The database full name in instances/{instance resource id}/databases/{database name} format - `expire_timestamp` (String) The expiration timestamp in YYYY-MM-DDThh:mm:ss.000Z format - `masking_level` (String) -- `member` (String) The member in user:{email} format. +- `member` (String) The member in user:{email} or group:{email} format. - `schema` (String) - `table` (String) diff --git a/docs/resources/project.md b/docs/resources/project.md index 968b40c..31d0bae 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -23,7 +23,14 @@ The project resource. ### Optional +- `allow_modify_statement` (Boolean) Allow modifying statement after issue is created. +- `auto_enable_backup` (Boolean) Whether to automatically enable backup. +- `auto_resolve_issue` (Boolean) Enable auto resolve issue. - `databases` (Block List) The databases in the project. (see [below for nested schema](#nestedblock--databases)) +- `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. ### Read-Only @@ -34,13 +41,10 @@ The project resource. ### Nested Schema for `databases` -Required: - -- `name` (String) The database full name in instances/{instance id}/databases/{db name} format. - Optional: -- `labels` (Map of String) The deployment and policy control labels. +- `labels` (Map of String) The deployment and policy control labels. +- `name` (String) The database full name in instances/{instance id}/databases/{db name} format. Read-Only: @@ -50,3 +54,24 @@ Read-Only: - `sync_state` (String) The existence of a database on latest sync. + +### 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:ss.000Z 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 new file mode 100644 index 0000000..e6545be --- /dev/null +++ b/docs/resources/user.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_user Resource - terraform-provider-bytebase" +subcategory: "" +description: |- + The user resource. +--- + +# bytebase_user (Resource) + +The user resource. + + + + +## Schema + +### Required + +- `email` (String) The user email. +- `title` (String) The user title. + +### Optional + +- `password` (String, Sensitive) The user login password. +- `phone` (String) The user phone. +- `state` (String) The user is deleted or not. + +### Read-Only + +- `id` (String) The ID of this resource. +- `last_change_password_time` (String) The user last change password time. +- `last_login_time` (String) The user last login time. +- `mfa_enabled` (Boolean) The mfa_enabled flag means if the user has enabled MFA. +- `name` (String) The user name in users/{user id or email} format. +- `source` (String) Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID. +- `type` (String) The user type. + + diff --git a/examples/setup/main.tf b/examples/setup/main.tf index 34ee83e..d8697c7 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -96,11 +96,37 @@ resource "bytebase_instance" "prod" { } } +# Create a new user. +resource "bytebase_user" "project_developer" { + title = "Developer" + email = "developer@bytebase.com" +} + # Create a new project resource "bytebase_project" "sample_project" { + depends_on = [ + bytebase_user.project_developer + ] + resource_id = local.project_id title = "Sample project" key = "SAMM" + + members { + member = format("user:%s", bytebase_user.project_developer.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:49.637Z" + } + } } resource "bytebase_setting" "external_approval" { diff --git a/examples/users/main.tf b/examples/users/main.tf new file mode 100644 index 0000000..5ea0423 --- /dev/null +++ b/examples/users/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + bytebase = { + version = "1.0.4" + # 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" +} + +# List all users +data "bytebase_user_list" "all" {} + +output "all_users" { + value = data.bytebase_user_list.all +} diff --git a/provider/data_source_policy.go b/provider/data_source_policy.go index f0ce11a..2158dde 100644 --- a/provider/data_source_policy.go +++ b/provider/data_source_policy.go @@ -117,7 +117,7 @@ func getMaskingExceptionPolicySchema(computed bool) *schema.Schema { Computed: computed, Optional: true, ValidateFunc: validation.StringIsNotEmpty, - Description: "The member in user:{email} format.", + Description: "The member in user:{email} or group:{email} format.", }, "masking_level": { Type: schema.TypeString, @@ -289,6 +289,10 @@ func flattenMaskingExceptionPolicy(p *v1pb.MaskingExceptionPolicy) ([]interface{ raw["action"] = exception.Action.String() raw["masking_level"] = exception.MaskingLevel.String() + if exception.Condition == nil || exception.Condition.Expression == "" { + return nil, errors.Errorf("invalid exception policy condition") + } + expressions := strings.Split(exception.Condition.Expression, " && ") instanceID := "" databaseName := "" diff --git a/provider/data_source_project.go b/provider/data_source_project.go index 5a4f484..157a7be 100644 --- a/provider/data_source_project.go +++ b/provider/data_source_project.go @@ -1,12 +1,16 @@ package provider import ( + "bytes" "context" "fmt" + "strconv" + "strings" "time" "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" @@ -45,47 +49,158 @@ func dataSourceProject() *schema.Resource { Computed: true, Description: "The project workflow.", }, - "databases": { - Type: schema.TypeList, + "allow_modify_statement": { + Type: schema.TypeBool, Computed: true, - Description: "The databases in the project.", - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Computed: true, - Description: "The database full name in instances/{instance id}/databases/{db name} format.", - }, - "environment": { - Type: schema.TypeString, - Computed: true, - Description: "The database environment.", - }, - "sync_state": { - Type: schema.TypeString, - Computed: true, - Description: "The existence of a database on latest sync.", - }, - "successful_sync_time": { - Type: schema.TypeString, - Computed: true, - Description: "The latest synchronization time.", - }, - "schema_version": { - Type: schema.TypeString, - Computed: true, - Description: "The version of database schema.", - }, - "labels": { - Type: schema.TypeMap, - Computed: true, - Description: "The deployment and policy control labels.", - Elem: &schema.Schema{Type: schema.TypeString}, + Description: "Allow modifying statement after issue is created.", + }, + "auto_resolve_issue": { + Type: schema.TypeBool, + Computed: true, + Description: "Enable auto resolve issue.", + }, + "enforce_issue_title": { + Type: schema.TypeBool, + Computed: true, + Description: "Enforce issue title created by user instead of generated by Bytebase.", + }, + "auto_enable_backup": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether to automatically enable backup.", + }, + "skip_backup_errors": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether to skip backup errors and continue the data migration.", + }, + "postgres_database_tenant_mode": { + Type: schema.TypeBool, + 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.", + }, + "databases": getProjectDatabasesSchema(true), + "members": getProjectMembersSchema(true), + }, + } +} + +func getProjectDatabasesSchema(computed bool) *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Computed: computed, + Optional: !computed, + Description: "The databases in the project.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: computed, + Optional: !computed, + Description: "The database full name in instances/{instance id}/databases/{db name} format.", + }, + "environment": { + Type: schema.TypeString, + Computed: true, + Description: "The database environment.", + }, + "sync_state": { + Type: schema.TypeString, + Computed: true, + Description: "The existence of a database on latest sync.", + }, + "successful_sync_time": { + Type: schema.TypeString, + Computed: true, + Description: "The latest synchronization time.", + }, + "schema_version": { + Type: schema.TypeString, + Computed: true, + Description: "The version of database schema.", + }, + "labels": { + Type: schema.TypeMap, + Computed: computed, + Optional: !computed, + Description: "The deployment and policy control labels.", + Elem: &schema.Schema{Type: schema.TypeString}, + }, + }, + }, + } +} + +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:ss.000Z format", + }, }, }, + Set: func(i interface{}) int { + return internal.ToHashcodeInt(conditionHash(i)) + }, }, }, }, + Set: memberHash, } } @@ -97,17 +212,103 @@ func dataSourceProjectRead(ctx context.Context, d *schema.ResourceData, m interf return diag.FromErr(err) } + d.SetId(project.Name) + return setProject(ctx, c, d, project) +} + +func flattenDatabaseList(databases []*v1pb.Database) []interface{} { + dbList := []interface{}{} + for _, database := range databases { + db := map[string]interface{}{} + db["name"] = database.Name + db["environment"] = database.Environment + db["sync_state"] = database.SyncState.String() + db["successful_sync_time"] = database.SuccessfulSyncTime.AsTime().UTC().Format(time.RFC3339) + db["schema_version"] = database.SchemaVersion + db["labels"] = database.Labels + dbList = append(dbList, db) + } + return dbList +} + +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 setProject( + ctx context.Context, + client api.Client, + d *schema.ResourceData, + project *v1pb.Project, +) diag.Diagnostics { filter := fmt.Sprintf(`project == "%s"`, project.Name) - response, err := c.ListDatabase(ctx, "-", filter) + listDBResponse, err := client.ListDatabase(ctx, "-", filter) if err != nil { return diag.FromErr(err) } - d.SetId(project.Name) - return setProjectWithDatabases(d, project, response.Databases) -} + iamPolicy, err := client.GetProjectIAMPolicy(ctx, project.Name) + if err != nil { + return diag.Errorf("failed to get project iam with error: %v", err) + } -func setProjectWithDatabases(d *schema.ResourceData, project *v1pb.Project, databases []*v1pb.Database) diag.Diagnostics { d.SetId(project.Name) projectID, err := internal.GetProjectID(project.Name) @@ -132,21 +333,80 @@ func setProjectWithDatabases(d *schema.ResourceData, project *v1pb.Project, data if err := d.Set("workflow", project.Workflow.String()); err != nil { return diag.Errorf("cannot set workflow for project: %s", err.Error()) } - - dbList := []interface{}{} - for _, database := range databases { - db := map[string]interface{}{} - db["name"] = database.Name - db["environment"] = database.Environment - db["sync_state"] = database.SyncState.String() - db["successful_sync_time"] = database.SuccessfulSyncTime.AsTime().UTC().Format(time.RFC3339) - db["schema_version"] = database.SchemaVersion - db["labels"] = database.Labels - dbList = append(dbList, db) + if err := d.Set("allow_modify_statement", project.AllowModifyStatement); err != nil { + return diag.Errorf("cannot set allow_modify_statement for project: %s", err.Error()) + } + if err := d.Set("auto_resolve_issue", project.AutoResolveIssue); err != nil { + return diag.Errorf("cannot set auto_resolve_issue for project: %s", err.Error()) + } + if err := d.Set("enforce_issue_title", project.EnforceIssueTitle); err != nil { + return diag.Errorf("cannot set enforce_issue_title for project: %s", err.Error()) + } + if err := d.Set("auto_enable_backup", project.AutoEnableBackup); err != nil { + return diag.Errorf("cannot set auto_enable_backup for project: %s", err.Error()) + } + if err := d.Set("skip_backup_errors", project.SkipBackupErrors); err != nil { + return diag.Errorf("cannot set skip_backup_errors for project: %s", err.Error()) } - if err := d.Set("databases", dbList); err != nil { + if err := d.Set("postgres_database_tenant_mode", project.PostgresDatabaseTenantMode); err != nil { + return diag.Errorf("cannot set postgres_database_tenant_mode for project: %s", err.Error()) + } + + if err := d.Set("databases", flattenDatabaseList(listDBResponse.Databases)); err != nil { return diag.Errorf("cannot set databases for project: %s", err.Error()) } + 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()) + } + 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 6a7c5de..fce5bc3 100644 --- a/provider/data_source_project_list.go +++ b/provider/data_source_project_list.go @@ -54,46 +54,38 @@ func dataSourceProjectList() *schema.Resource { Computed: true, Description: "The project workflow.", }, - "databases": { - Type: schema.TypeList, + "allow_modify_statement": { + Type: schema.TypeBool, Computed: true, - Description: "The databases in the project.", - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Computed: true, - Description: "The database full name in instances/{instance id}/databases/{db name} format.", - }, - "environment": { - Type: schema.TypeString, - Computed: true, - Description: "The database environment.", - }, - "sync_state": { - Type: schema.TypeString, - Computed: true, - Description: "The existence of a database on latest sync.", - }, - "successful_sync_time": { - Type: schema.TypeString, - Computed: true, - Description: "The latest synchronization time.", - }, - "schema_version": { - Type: schema.TypeString, - Computed: true, - Description: "The version of database schema.", - }, - "labels": { - Type: schema.TypeMap, - Computed: true, - Description: "The deployment and policy control labels.", - Elem: &schema.Schema{Type: schema.TypeString}, - }, - }, - }, + Description: "Allow modifying statement after issue is created.", }, + "auto_resolve_issue": { + Type: schema.TypeBool, + Computed: true, + Description: "Enable auto resolve issue.", + }, + "enforce_issue_title": { + Type: schema.TypeBool, + Computed: true, + Description: "Enforce issue title created by user instead of generated by Bytebase.", + }, + "auto_enable_backup": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether to automatically enable backup.", + }, + "skip_backup_errors": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether to skip backup errors and continue the data migration.", + }, + "postgres_database_tenant_mode": { + Type: schema.TypeBool, + 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.", + }, + "databases": getProjectDatabasesSchema(true), + "members": getProjectMembersSchema(true), }, }, }, @@ -125,6 +117,12 @@ func dataSourceProjectListRead(ctx context.Context, d *schema.ResourceData, m in proj["title"] = project.Title proj["key"] = project.Key proj["workflow"] = project.Workflow.String() + proj["allow_modify_statement"] = project.AllowModifyStatement + proj["auto_resolve_issue"] = project.AutoResolveIssue + proj["enforce_issue_title"] = project.EnforceIssueTitle + proj["auto_enable_backup"] = project.AutoEnableBackup + proj["skip_backup_errors"] = project.AllowModifyStatement + proj["postgres_database_tenant_mode"] = project.PostgresDatabaseTenantMode filter := fmt.Sprintf(`project == "%s"`, project.Name) response, err := c.ListDatabase(ctx, "-", filter) @@ -132,18 +130,17 @@ func dataSourceProjectListRead(ctx context.Context, d *schema.ResourceData, m in return diag.FromErr(err) } - dbList := []interface{}{} - for _, database := range response.Databases { - db := map[string]interface{}{} - db["name"] = database.Name - db["environment"] = database.Environment - db["sync_state"] = database.SyncState.String() - db["successful_sync_time"] = database.SuccessfulSyncTime.AsTime().UTC().Format(time.RFC3339) - db["schema_version"] = database.SchemaVersion - db["labels"] = database.Labels - dbList = append(dbList, db) + proj["databases"] = flattenDatabaseList(response.Databases) + + 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["databases"] = dbList + proj["members"] = memberList projects = append(projects, proj) } diff --git a/provider/data_source_user.go b/provider/data_source_user.go new file mode 100644 index 0000000..45b458b --- /dev/null +++ b/provider/data_source_user.go @@ -0,0 +1,126 @@ +package provider + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + 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 dataSourceUser() *schema.Resource { + return &schema.Resource{ + Description: "The user data source.", + ReadContext: dataSourceUserRead, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: internal.ResourceNameValidation( + regexp.MustCompile(fmt.Sprintf("^%s", internal.UserNamePrefix)), + ), + Description: "The user name in users/{user id or email} format.", + }, + "title": { + Type: schema.TypeString, + Computed: true, + Description: "The user title.", + }, + "email": { + Type: schema.TypeString, + Computed: true, + Description: "The user email.", + }, + "phone": { + Type: schema.TypeString, + Computed: true, + Description: "The user phone.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The user type.", + }, + "mfa_enabled": { + Type: schema.TypeBool, + Computed: true, + Description: "The mfa_enabled flag means if the user has enabled MFA.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The user is deleted or not.", + }, + "last_login_time": { + Type: schema.TypeString, + Computed: true, + Description: "The user last login time.", + }, + "last_change_password_time": { + Type: schema.TypeString, + Computed: true, + Description: "The user last change password time.", + }, + "source": { + Type: schema.TypeString, + Computed: true, + Description: "Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", + }, + }, + } +} + +func dataSourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + userName := d.Get("name").(string) + + user, err := c.GetUser(ctx, userName) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(user.Name) + + return setUser(d, user) +} + +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()) + } + if err := d.Set("email", user.Email); err != nil { + return diag.Errorf("cannot set email for user: %s", err.Error()) + } + if err := d.Set("phone", user.Phone); err != nil { + return diag.Errorf("cannot set phone for user: %s", err.Error()) + } + if err := d.Set("type", user.UserType.String()); err != nil { + return diag.Errorf("cannot set type for user: %s", err.Error()) + } + if err := d.Set("mfa_enabled", user.MfaEnabled); err != nil { + return diag.Errorf("cannot set mfa_enabled for user: %s", err.Error()) + } + if err := d.Set("state", user.State.String()); err != nil { + return diag.Errorf("cannot set state for user: %s", err.Error()) + } + if p := user.Profile; p != nil { + if err := d.Set("last_login_time", p.LastLoginTime.AsTime().UTC().Format(time.RFC3339)); err != nil { + return diag.Errorf("cannot set last_login_time for user: %s", err.Error()) + } + if err := d.Set("last_change_password_time", p.LastChangePasswordTime.AsTime().UTC().Format(time.RFC3339)); err != nil { + return diag.Errorf("cannot set last_change_password_time for user: %s", err.Error()) + } + if err := d.Set("source", p.Source); err != nil { + return diag.Errorf("cannot set source for user: %s", err.Error()) + } + } + + return nil +} diff --git a/provider/data_source_user_list.go b/provider/data_source_user_list.go new file mode 100644 index 0000000..f53bdbb --- /dev/null +++ b/provider/data_source_user_list.go @@ -0,0 +1,120 @@ +package provider + +import ( + "context" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + "github.com/bytebase/terraform-provider-bytebase/api" +) + +func dataSourceUserList() *schema.Resource { + return &schema.Resource{ + Description: "The user data source list.", + ReadContext: dataSourceUserListRead, + Schema: map[string]*schema.Schema{ + "show_deleted": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Including removed users in the response.", + }, + "users": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The user name in users/{user id or email} format.", + }, + "title": { + Type: schema.TypeString, + Computed: true, + Description: "The user title.", + }, + "email": { + Type: schema.TypeString, + Computed: true, + Description: "The user email.", + }, + "phone": { + Type: schema.TypeString, + Computed: true, + Description: "The user phone.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The user type.", + }, + "mfa_enabled": { + Type: schema.TypeBool, + Computed: true, + Description: "The mfa_enabled flag means if the user has enabled MFA.", + }, + "state": { + Type: schema.TypeString, + Computed: true, + Description: "The user is deleted or not.", + }, + "last_login_time": { + Type: schema.TypeString, + Computed: true, + Description: "The user last login time.", + }, + "last_change_password_time": { + Type: schema.TypeString, + Computed: true, + Description: "The user last change password time.", + }, + "source": { + Type: schema.TypeString, + Computed: true, + Description: "Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", + }, + }, + }, + }, + }, + } +} + +func dataSourceUserListRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + response, err := c.ListUser(ctx, d.Get("show_deleted").(bool)) + if err != nil { + return diag.FromErr(err) + } + + users := make([]map[string]interface{}, 0) + for _, user := range response.Users { + raw := make(map[string]interface{}) + raw["name"] = user.Name + raw["email"] = user.Email + raw["title"] = user.Title + raw["phone"] = user.Phone + raw["type"] = user.UserType.String() + raw["mfa_enabled"] = user.MfaEnabled + raw["state"] = user.State.String() + if p := user.Profile; p != nil { + raw["source"] = p.Source + raw["last_login_time"] = p.LastLoginTime.AsTime().UTC().Format(time.RFC3339) + raw["last_change_password_time"] = p.LastChangePasswordTime.AsTime().UTC().Format(time.RFC3339) + } + users = append(users, raw) + } + if err := d.Set("users", users); err != nil { + return diag.FromErr(err) + } + + // always refresh + d.SetId(strconv.FormatInt(time.Now().Unix(), 10)) + + return nil +} diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index 884f75e..ca7c81e 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -51,9 +51,12 @@ func newMockClient(_, _, _ string) (api.Client, error) { }, nil } -// Login will login the user and get the response. -func (*mockClient) Login() (*v1pb.LoginResponse, error) { - return &v1pb.LoginResponse{}, nil +// GetCaller returns the API caller. +func (*mockClient) GetCaller() *v1pb.User { + return &v1pb.User{ + Name: "users/mock@bytease.com", + Email: "mock@bytease.com", + } } // CreateEnvironment creates the environment. @@ -470,6 +473,16 @@ func (c *mockClient) UndeleteProject(ctx context.Context, projectName string) (* return proj, nil } +// GetProjectIAMPolicy gets the project IAM policy by project full name. +func (*mockClient) GetProjectIAMPolicy(_ context.Context, _ string) (*v1pb.IamPolicy, error) { + return &v1pb.IamPolicy{}, nil +} + +// SetProjectIAMPolicy sets the project IAM policy. +func (*mockClient) SetProjectIAMPolicy(_ context.Context, _ string, _ *v1pb.IamPolicy) (*v1pb.IamPolicy, error) { + return &v1pb.IamPolicy{}, nil +} + // ListSettings lists all settings. func (c *mockClient) ListSettings(_ context.Context) (*v1pb.ListSettingsResponse, error) { settings := make([]*v1pb.Setting, 0) @@ -558,3 +571,33 @@ func (*mockClient) UpdateVCSConnector(_ context.Context, _ *v1pb.VCSConnector, _ func (*mockClient) DeleteVCSConnector(_ context.Context, _ string) error { return nil } + +// ListUser list all users. +func (*mockClient) ListUser(_ context.Context, _ bool) (*v1pb.ListUsersResponse, error) { + return nil, nil +} + +// GetUser gets the user by name. +func (*mockClient) GetUser(_ context.Context, _ string) (*v1pb.User, error) { + return nil, nil +} + +// CreateUser creates the user. +func (*mockClient) CreateUser(_ context.Context, _ *v1pb.User) (*v1pb.User, error) { + return nil, nil +} + +// UpdateUser updates the user. +func (*mockClient) UpdateUser(_ context.Context, _ *v1pb.User, _ []string) (*v1pb.User, error) { + return nil, nil +} + +// DeleteUser deletes the user by name. +func (*mockClient) DeleteUser(_ context.Context, _ string) error { + return nil +} + +// UndeleteUser undeletes the user by name. +func (*mockClient) UndeleteUser(_ context.Context, _ string) (*v1pb.User, error) { + return nil, nil +} diff --git a/provider/internal/utils.go b/provider/internal/utils.go index 3e1e877..becb7fe 100644 --- a/provider/internal/utils.go +++ b/provider/internal/utils.go @@ -2,6 +2,7 @@ package internal import ( "fmt" + "hash/crc32" "regexp" "strings" @@ -32,6 +33,8 @@ const ( VCSConnectorNamePrefix = "vcsConnectors/" // UserNamePrefix is the prefix for user name. UserNamePrefix = "users/" + // RoleNamePrefix is the prefix for role name. + RoleNamePrefix = "roles/" // ResourceIDPattern is the pattern for resource id. ResourceIDPattern = "[a-z]([a-z0-9-]{0,61}[a-z0-9])?" ) @@ -165,3 +168,24 @@ func getNameParentTokens(name string, tokenPrefixes ...string) ([]string, error) } return tokens, nil } + +// ValidateMemberBinding checks the member binding format. +func ValidateMemberBinding(member string) error { + if !strings.HasPrefix(member, "user:") && !strings.HasPrefix(member, "group:") { + return errors.Errorf("invalid member format") + } + return nil +} + +// ToHashcodeInt returns int by string. +func ToHashcodeInt(s string) int { + v := int(crc32.ChecksumIEEE([]byte(s))) + if v >= 0 { + return v + } + if -v >= 0 { + return -v + } + // v == MinInt + return 0 +} diff --git a/provider/provider.go b/provider/provider.go index 4abfe57..213db36 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -62,6 +62,8 @@ func NewProvider() *schema.Provider { "bytebase_vcs_provider_list": dataSourceVCSProviderList(), "bytebase_vcs_connector": dataSourceVCSConnector(), "bytebase_vcs_connector_list": dataSourceVCSConnectorList(), + "bytebase_user": dataSourceUser(), + "bytebase_user_list": dataSourceUserList(), }, ResourcesMap: map[string]*schema.Resource{ "bytebase_environment": resourceEnvironment(), @@ -71,6 +73,7 @@ func NewProvider() *schema.Provider { "bytebase_setting": resourceSetting(), "bytebase_vcs_provider": resourceVCSProvider(), "bytebase_vcs_connector": resourceVCSConnector(), + "bytebase_user": resourceUser(), }, } } diff --git a/provider/resource_policy.go b/provider/resource_policy.go index 35b373e..0c4c064 100644 --- a/provider/resource_policy.go +++ b/provider/resource_policy.go @@ -5,6 +5,7 @@ import ( "fmt" "regexp" "strings" + "time" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -266,11 +267,15 @@ func convertToMaskingExceptionPolicy(d *schema.ResourceData) (*v1pb.MaskingExcep expressions = append(expressions, fmt.Sprintf(`resource.column_name == "%s"`, column)) } if expire, ok := rawException["expire_timestamp"].(string); ok && expire != "" { - expressions = append(expressions, fmt.Sprintf(`request.time < timestamp("%s")`, 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))) } member := rawException["member"].(string) - if !strings.HasPrefix(member, "user:") { - return nil, errors.Errorf("member should in user:{email} format") + if err := internal.ValidateMemberBinding(member); err != nil { + return nil, err } policy.MaskingExceptions = append(policy.MaskingExceptions, &v1pb.MaskingExceptionPolicy_MaskingException{ Member: rawException["member"].(string), diff --git a/provider/resource_project.go b/provider/resource_project.go index 7f46dd8..49a9664 100644 --- a/provider/resource_project.go +++ b/provider/resource_project.go @@ -3,11 +3,15 @@ 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" "github.com/bytebase/terraform-provider-bytebase/api" "github.com/bytebase/terraform-provider-bytebase/provider/internal" @@ -57,48 +61,44 @@ func resourceProjct() *schema.Resource { Computed: true, Description: "The project workflow.", }, - "databases": { - Type: schema.TypeList, + "allow_modify_statement": { + Type: schema.TypeBool, Optional: true, - Description: "The databases in the project.", - Elem: &schema.Resource{ - Schema: map[string]*schema.Schema{ - "name": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringIsNotEmpty, - Description: "The database full name in instances/{instance id}/databases/{db name} format.", - }, - "environment": { - Type: schema.TypeString, - Computed: true, - Description: "The database environment.", - }, - "sync_state": { - Type: schema.TypeString, - Computed: true, - Description: "The existence of a database on latest sync.", - }, - "successful_sync_time": { - Type: schema.TypeString, - Computed: true, - Description: "The latest synchronization time.", - }, - "schema_version": { - Type: schema.TypeString, - Computed: true, - Description: "The version of database schema.", - }, - "labels": { - Type: schema.TypeMap, - Optional: true, - Computed: true, - Description: "The deployment and policy control labels.", - Elem: &schema.Schema{Type: schema.TypeString}, - }, - }, - }, + Default: false, + Description: "Allow modifying statement after issue is created.", }, + "auto_resolve_issue": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Enable auto resolve issue.", + }, + "enforce_issue_title": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Enforce issue title created by user instead of generated by Bytebase.", + }, + "auto_enable_backup": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to automatically enable backup.", + }, + "skip_backup_errors": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to skip backup errors and continue the data migration.", + }, + "postgres_database_tenant_mode": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to enable the database tenant mode for PostgreSQL. If enabled, the issue will be created with the pre-appended \"set role \" statement.", + }, + "databases": getProjectDatabasesSchema(false), + "members": getProjectMembersSchema(false), }, } } @@ -111,6 +111,12 @@ func resourceProjectCreate(ctx context.Context, d *schema.ResourceData, m interf title := d.Get("title").(string) key := d.Get("key").(string) + allowModifyStatement := d.Get("allow_modify_statement").(bool) + autoResolveIssue := d.Get("auto_resolve_issue").(bool) + enforceIssueTitle := d.Get("enforce_issue_title").(bool) + autoEnableBackup := d.Get("auto_enable_backup").(bool) + skipBackupErrors := d.Get("skip_backup_errors").(bool) + postgresDatabaseTenantMode := d.Get("postgres_database_tenant_mode").(bool) existedProject, err := c.GetProject(ctx, projectName) if err != nil { @@ -148,14 +154,38 @@ func resourceProjectCreate(ctx context.Context, d *schema.ResourceData, m interf if key != "" && key != existedProject.Key { updateMasks = append(updateMasks, "key") } + if allowModifyStatement != existedProject.AllowModifyStatement { + updateMasks = append(updateMasks, "allow_modify_statement") + } + if autoResolveIssue != existedProject.AutoResolveIssue { + updateMasks = append(updateMasks, "auto_resolve_issue") + } + if enforceIssueTitle != existedProject.EnforceIssueTitle { + updateMasks = append(updateMasks, "enforce_issue_title") + } + if autoEnableBackup != existedProject.AutoEnableBackup { + updateMasks = append(updateMasks, "auto_enable_backup") + } + if skipBackupErrors != existedProject.SkipBackupErrors { + updateMasks = append(updateMasks, "skip_backup_errors") + } + if postgresDatabaseTenantMode != existedProject.PostgresDatabaseTenantMode { + updateMasks = append(updateMasks, "postgres_database_tenant_mode") + } if len(updateMasks) > 0 { if _, err := c.UpdateProject(ctx, &v1pb.Project{ - Name: projectName, - Title: title, - Key: key, - State: v1pb.State_ACTIVE, - Workflow: existedProject.Workflow, + Name: projectName, + Title: title, + Key: key, + State: v1pb.State_ACTIVE, + Workflow: existedProject.Workflow, + AllowModifyStatement: allowModifyStatement, + AutoResolveIssue: autoResolveIssue, + EnforceIssueTitle: enforceIssueTitle, + AutoEnableBackup: autoEnableBackup, + SkipBackupErrors: skipBackupErrors, + PostgresDatabaseTenantMode: postgresDatabaseTenantMode, }, updateMasks); err != nil { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, @@ -167,11 +197,17 @@ func resourceProjectCreate(ctx context.Context, d *schema.ResourceData, m interf } } else { if _, err := c.CreateProject(ctx, projectID, &v1pb.Project{ - Name: projectName, - Title: title, - Key: key, - State: v1pb.State_ACTIVE, - Workflow: v1pb.Workflow_UI, + Name: projectName, + Title: title, + Key: key, + State: v1pb.State_ACTIVE, + Workflow: v1pb.Workflow_UI, + AllowModifyStatement: allowModifyStatement, + AutoResolveIssue: autoResolveIssue, + EnforceIssueTitle: enforceIssueTitle, + AutoEnableBackup: autoEnableBackup, + SkipBackupErrors: skipBackupErrors, + PostgresDatabaseTenantMode: postgresDatabaseTenantMode, }); err != nil { return diag.FromErr(err) } @@ -183,6 +219,10 @@ func resourceProjectCreate(ctx context.Context, d *schema.ResourceData, m interf diags = append(diags, diag...) return diags } + if diag := updateMembersInProject(ctx, d, c, d.Id()); diag != nil { + diags = append(diags, diag...) + return diags + } diag := resourceProjectRead(ctx, d, m) if diag != nil { @@ -230,14 +270,45 @@ func resourceProjectUpdate(ctx context.Context, d *schema.ResourceData, m interf if d.HasChange("key") { paths = append(paths, "key") } + if d.HasChange("allow_modify_statement") { + paths = append(paths, "allow_modify_statement") + } + if d.HasChange("auto_resolve_issue") { + paths = append(paths, "auto_resolve_issue") + } + if d.HasChange("enforce_issue_title") { + paths = append(paths, "enforce_issue_title") + } + if d.HasChange("auto_enable_backup") { + paths = append(paths, "auto_enable_backup") + } + if d.HasChange("skip_backup_errors") { + paths = append(paths, "skip_backup_errors") + } + if d.HasChange("postgres_database_tenant_mode") { + paths = append(paths, "postgres_database_tenant_mode") + } + + allowModifyStatement := d.Get("allow_modify_statement").(bool) + autoResolveIssue := d.Get("auto_resolve_issue").(bool) + enforceIssueTitle := d.Get("enforce_issue_title").(bool) + autoEnableBackup := d.Get("auto_enable_backup").(bool) + skipBackupErrors := d.Get("skip_backup_errors").(bool) + postgresDatabaseTenantMode := d.Get("postgres_database_tenant_mode").(bool) if len(paths) > 0 { if _, err := c.UpdateProject(ctx, &v1pb.Project{ - Name: projectName, - Title: d.Get("title").(string), - Key: d.Get("key").(string), - State: v1pb.State_ACTIVE, - Workflow: existedProject.Workflow, + Name: projectName, + Title: d.Get("title").(string), + Key: d.Get("key").(string), + State: v1pb.State_ACTIVE, + Workflow: existedProject.Workflow, + AllowModifyStatement: allowModifyStatement, + AutoResolveIssue: autoResolveIssue, + EnforceIssueTitle: enforceIssueTitle, + AutoEnableBackup: autoEnableBackup, + SkipBackupErrors: skipBackupErrors, + PostgresDatabaseTenantMode: postgresDatabaseTenantMode, }, paths); err != nil { diags = append(diags, diag.FromErr(err)...) return diags @@ -250,6 +321,12 @@ func resourceProjectUpdate(ctx context.Context, d *schema.ResourceData, m interf return diags } } + 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 { @@ -268,13 +345,7 @@ func resourceProjectRead(ctx context.Context, d *schema.ResourceData, m interfac return diag.FromErr(err) } - filter := fmt.Sprintf(`project == "%s"`, project.Name) - response, err := c.ListDatabase(ctx, "-", filter) - if err != nil { - return diag.Errorf("failed to list database with error: %v", err) - } - - return setProjectWithDatabases(d, project, response.Databases) + return setProject(ctx, c, d, project) } func resourceProjectDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { @@ -312,6 +383,9 @@ func updateDatabasesInProject(ctx context.Context, d *schema.ResourceData, clien for _, raw := range rawList { obj := raw.(map[string]interface{}) dbName := obj["name"].(string) + if _, _, err := internal.GetInstanceDatabaseID(dbName); err != nil { + return diag.Errorf("invalid database full name: %v", err.Error()) + } labels := map[string]string{} for key, val := range obj["labels"].(map[string]interface{}) { @@ -342,3 +416,88 @@ func updateDatabasesInProject(ctx context.Context, d *schema.ResourceData, clien return nil } + +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)) + } + 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 !existProjectOwner { + // Make sure we have the project owner. + iamPolicy.Bindings = append(iamPolicy.Bindings, &v1pb.Binding{ + Role: "roles/projectOwner", + Members: []string{ + fmt.Sprintf("user:%s", client.GetCaller().Email), + }, + }) + } + + if len(iamPolicy.Bindings) > 0 { + if _, err := client.SetProjectIAMPolicy(ctx, projectName, iamPolicy); err != nil { + return diag.Errorf("failed to update iam: %v", err.Error()) + } + } + return nil +} diff --git a/provider/resource_user.go b/provider/resource_user.go new file mode 100644 index 0000000..c06eb12 --- /dev/null +++ b/provider/resource_user.go @@ -0,0 +1,300 @@ +package provider + +import ( + "context" + "fmt" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + "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/bytebase/terraform-provider-bytebase/api" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func resourceUser() *schema.Resource { + return &schema.Resource{ + Description: "The user resource.", + ReadContext: resourceUserRead, + DeleteContext: resourceUserDelete, + CreateContext: resourceUserCreate, + UpdateContext: resourceUserUpdate, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "title": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + Description: "The user title.", + }, + "email": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringIsNotEmpty, + Description: "The user email.", + }, + "phone": { + Type: schema.TypeString, + Optional: true, + Description: "The user phone.", + }, + "password": { + Type: schema.TypeString, + Sensitive: true, + Optional: true, + Description: "The user login password.", + }, + "type": { + Type: schema.TypeString, + // TODO: support set type. + Computed: true, + // Optional: true, + // Default: v1pb.UserType_USER.String(), + Description: "The user type.", + // ValidateFunc: validation.StringInSlice([]string{ + // v1pb.UserType_USER.String(), + // v1pb.UserType_SERVICE_ACCOUNT.String(), + // }, false), + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The user name in users/{user id or email} format.", + }, + "mfa_enabled": { + Type: schema.TypeBool, + Computed: true, + Description: "The mfa_enabled flag means if the user has enabled MFA.", + }, + "state": { + Type: schema.TypeString, + Optional: true, + Default: v1pb.State_ACTIVE.String(), + ValidateFunc: validation.StringInSlice([]string{ + v1pb.State_ACTIVE.String(), + v1pb.State_DELETED.String(), + }, false), + Description: "The user is deleted or not.", + }, + "last_login_time": { + Type: schema.TypeString, + Computed: true, + Description: "The user last login time.", + }, + "last_change_password_time": { + Type: schema.TypeString, + Computed: true, + Description: "The user last change password time.", + }, + "source": { + Type: schema.TypeString, + Computed: true, + Description: "Source means where the user comes from. For now we support Entra ID SCIM sync, so the source could be Entra ID.", + }, + }, + } +} + +func resourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + fullName := d.Id() + user, err := c.GetUser(ctx, fullName) + if err != nil { + return diag.FromErr(err) + } + + return setUser(d, user) +} + +func resourceUserDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + fullName := d.Id() + + // Warning or errors can be collected in a slice type + var diags diag.Diagnostics + + if err := c.DeleteUser(ctx, fullName); err != nil { + return diag.FromErr(err) + } + + d.SetId("") + + return diags +} + +func resourceUserCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + email := d.Get("email").(string) + userName := fmt.Sprintf("%s%s", internal.UserNamePrefix, email) + + existedUser, err := c.GetUser(ctx, userName) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("get user %s failed with error: %v", userName, err)) + } + + title := d.Get("title").(string) + phone := d.Get("phone").(string) + password := d.Get("password").(string) + + var diags diag.Diagnostics + if existedUser != nil && err == nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "User already exists", + Detail: fmt.Sprintf("User %s already exists, try to exec the update operation", userName), + }) + + if existedUser.State == v1pb.State_DELETED { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "User is deleted", + Detail: fmt.Sprintf("User %s already deleted, try to undelete the user", userName), + }) + if _, err := c.UndeleteUser(ctx, userName); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to undelete user", + Detail: fmt.Sprintf("Undelete user %s failed, error: %v", userName, err), + }) + return diags + } + } + + updateMasks := []string{} + if email != "" && email != existedUser.Email { + updateMasks = append(updateMasks, "email") + } + if title != "" && title != existedUser.Title { + updateMasks = append(updateMasks, "title") + } + if password != "" { + updateMasks = append(updateMasks, "password") + } + if phone != "" && phone != existedUser.Phone { + updateMasks = append(updateMasks, "phone") + } + if len(updateMasks) > 0 { + if _, err := c.UpdateUser(ctx, &v1pb.User{ + Name: existedUser.Name, + Title: title, + Password: password, + Phone: phone, + Email: email, + UserType: existedUser.UserType, + State: v1pb.State_ACTIVE, + }, updateMasks); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to update user", + Detail: fmt.Sprintf("Update vcs user %s failed, error: %v", userName, err), + }) + return diags + } + } + d.SetId(existedUser.Name) + } else { + user, err := c.CreateUser(ctx, &v1pb.User{ + Name: userName, + Title: title, + Password: password, + Phone: phone, + Email: email, + UserType: v1pb.UserType_USER, + State: v1pb.State_ACTIVE, + }) + if err != nil { + return diag.FromErr(err) + } + d.SetId(user.Name) + } + + diag := resourceUserRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +} + +func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + userName := d.Id() + + if d.HasChange("type") { + return diag.Errorf("cannot change the user type") + } + + title := d.Get("title").(string) + phone := d.Get("phone").(string) + email := d.Get("email").(string) + password := d.Get("password").(string) + + existedUser, err := c.GetUser(ctx, userName) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("get user %s failed with error: %v", userName, err)) + return diag.FromErr(err) + } + + var diags diag.Diagnostics + if existedUser.State == v1pb.State_DELETED { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "User is deleted", + Detail: fmt.Sprintf("User %s already deleted, try to undelete the user", userName), + }) + if _, err := c.UndeleteUser(ctx, userName); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to undelete user", + Detail: fmt.Sprintf("Undelete user %s failed, error: %v", userName, err), + }) + return diags + } + } + + paths := []string{} + if d.HasChange("title") && title != "" { + paths = append(paths, "title") + } + if d.HasChange("email") && email != "" { + paths = append(paths, "email") + } + if d.HasChange("phone") { + paths = append(paths, "phone") + } + if d.HasChange("password") && password != "" { + paths = append(paths, "password") + } + + if len(paths) > 0 { + if _, err := c.UpdateUser(ctx, &v1pb.User{ + Name: existedUser.Name, + Title: title, + Email: email, + Phone: phone, + Password: password, + UserType: existedUser.UserType, + State: v1pb.State_ACTIVE, + }, paths); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to update user", + Detail: fmt.Sprintf("Update user %s failed, error: %v", userName, err), + }) + return diags + } + } + + diag := resourceUserRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +}