From f02c61e0169e3a0151d0a5fb4966a981458c60b6 Mon Sep 17 00:00:00 2001 From: ecmadao Date: Thu, 20 Feb 2025 16:58:42 +0800 Subject: [PATCH] feat: support custom roles (#97) * feat: support custom roles * chore: update example --- VERSION | 2 +- api/client.go | 12 ++ client/database.go | 2 +- client/role.go | 92 +++++++++++ docs/data-sources/role.md | 31 ++++ docs/data-sources/role_list.md | 35 +++++ docs/resources/role.md | 34 ++++ examples/environments/main.tf | 2 +- examples/groups/main.tf | 2 +- examples/instances/main.tf | 2 +- examples/policies/main.tf | 2 +- examples/projects/main.tf | 2 +- examples/roles/main.tf | 25 +++ examples/settings/main.tf | 2 +- examples/setup/main.tf | 2 +- examples/setup/role.tf | 8 + examples/setup/users.tf | 12 ++ examples/users/main.tf | 2 +- examples/vcs/main.tf | 2 +- provider/data_source_role.go | 69 +++++++++ provider/data_source_role_list.go | 102 ++++++++++++ provider/internal/mock_client.go | 59 +++++++ provider/internal/utils.go | 9 ++ provider/provider.go | 3 + provider/resource_role.go | 247 ++++++++++++++++++++++++++++++ 25 files changed, 749 insertions(+), 11 deletions(-) create mode 100644 client/role.go create mode 100644 docs/data-sources/role.md create mode 100644 docs/data-sources/role_list.md create mode 100644 docs/resources/role.md create mode 100644 examples/roles/main.tf create mode 100644 examples/setup/role.tf create mode 100644 provider/data_source_role.go create mode 100644 provider/data_source_role_list.go create mode 100644 provider/resource_role.go diff --git a/VERSION b/VERSION index e92964f..c678b02 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.17 \ No newline at end of file +1.0.18 \ No newline at end of file diff --git a/api/client.go b/api/client.go index c2e9705..a561dfb 100644 --- a/api/client.go +++ b/api/client.go @@ -134,6 +134,18 @@ type Client interface { // UndeleteUser undeletes the user by name. UndeleteUser(ctx context.Context, userName string) (*v1pb.User, error) + // Role + // ListRole will returns all roles. + ListRole(ctx context.Context) (*v1pb.ListRolesResponse, error) + // DeleteRole deletes the role by name. + DeleteRole(ctx context.Context, name string) error + // CreateRole creates the role. + CreateRole(ctx context.Context, roleID string, role *v1pb.Role) (*v1pb.Role, error) + // GetRole gets the role by full name. + GetRole(ctx context.Context, name string) (*v1pb.Role, error) + // UpdateRole updates the role. + UpdateRole(ctx context.Context, patch *v1pb.Role, updateMasks []string) (*v1pb.Role, error) + // Group // ListGroup list all groups. ListGroup(ctx context.Context) (*v1pb.ListGroupsResponse, error) diff --git a/client/database.go b/client/database.go index 56ae208..65bb474 100644 --- a/client/database.go +++ b/client/database.go @@ -69,7 +69,7 @@ func (c *client) listDatabasePerPage(ctx context.Context, parent, filter, pageTo parent, url.QueryEscape(filter), pageSize, - pageToken, + url.QueryEscape(pageToken), ) req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) diff --git a/client/role.go b/client/role.go new file mode 100644 index 0000000..e99ea39 --- /dev/null +++ b/client/role.go @@ -0,0 +1,92 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "strings" + + v1pb "github.com/bytebase/bytebase/proto/generated-go/v1" + "google.golang.org/protobuf/encoding/protojson" +) + +// GetRole gets the role by full name. +func (c *client) GetRole(ctx context.Context, name string) (*v1pb.Role, error) { + body, err := c.getResource(ctx, name) + if err != nil { + return nil, err + } + + var res v1pb.Role + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// CreateRole creates the role. +func (c *client) CreateRole(ctx context.Context, roleID string, role *v1pb.Role) (*v1pb.Role, error) { + payload, err := protojson.Marshal(role) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/%s/roles?roleId=%s", c.url, c.version, roleID), strings.NewReader(string(payload))) + + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var res v1pb.Role + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// DeleteRole deletes the role by name. +func (c *client) DeleteRole(ctx context.Context, name string) error { + return c.deleteResource(ctx, name) +} + +// UpdateRole updates the role. +func (c *client) UpdateRole(ctx context.Context, patch *v1pb.Role, updateMasks []string) (*v1pb.Role, error) { + body, err := c.updateResource(ctx, patch.Name, patch, updateMasks, false /* allow missing = false*/) + if err != nil { + return nil, err + } + + var res v1pb.Role + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} + +// ListRole will returns all roles. +func (c *client) ListRole(ctx context.Context) (*v1pb.ListRolesResponse, error) { + req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/roles", c.url, c.version), nil) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + var res v1pb.ListRolesResponse + if err := ProtojsonUnmarshaler.Unmarshal(body, &res); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/docs/data-sources/role.md b/docs/data-sources/role.md new file mode 100644 index 0000000..03da0ed --- /dev/null +++ b/docs/data-sources/role.md @@ -0,0 +1,31 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_role Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The role data source. +--- + +# bytebase_role (Data Source) + +The role data source. + + + + +## Schema + +### Required + +- `resource_id` (String) The role unique resource id. + +### Read-Only + +- `description` (String) The role description. +- `id` (String) The ID of this resource. +- `name` (String) The role full name in roles/{resource id} format. +- `permissions` (Set of String) The role permissions. +- `title` (String) The role title. +- `type` (String) The role type. + + diff --git a/docs/data-sources/role_list.md b/docs/data-sources/role_list.md new file mode 100644 index 0000000..b9d5e0e --- /dev/null +++ b/docs/data-sources/role_list.md @@ -0,0 +1,35 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_role_list Data Source - terraform-provider-bytebase" +subcategory: "" +description: |- + The role data source list. +--- + +# bytebase_role_list (Data Source) + +The role data source list. + + + + +## Schema + +### Read-Only + +- `id` (String) The ID of this resource. +- `roles` (List of Object) (see [below for nested schema](#nestedatt--roles)) + + +### Nested Schema for `roles` + +Read-Only: + +- `description` (String) +- `name` (String) +- `permissions` (Set of String) +- `resource_id` (String) +- `title` (String) +- `type` (String) + + diff --git a/docs/resources/role.md b/docs/resources/role.md new file mode 100644 index 0000000..878f50a --- /dev/null +++ b/docs/resources/role.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bytebase_role Resource - terraform-provider-bytebase" +subcategory: "" +description: |- + The role resource. Require ENTERPRISE subscription. Check the docs https://www.bytebase.com/docs/administration/custom-roles/?source=terraform for more information. +--- + +# bytebase_role (Resource) + +The role resource. Require ENTERPRISE subscription. Check the docs https://www.bytebase.com/docs/administration/custom-roles/?source=terraform for more information. + + + + +## Schema + +### Required + +- `permissions` (Set of String) The role permissions. All permissions should start with "bb." prefix. +- `resource_id` (String) The role unique resource id. +- `title` (String) The role title. + +### Optional + +- `description` (String) The role description. + +### Read-Only + +- `id` (String) The ID of this resource. +- `name` (String) The role full name in roles/{resource id} format. +- `type` (String) The role type. + + diff --git a/examples/environments/main.tf b/examples/environments/main.tf index 696d05b..b254e47 100644 --- a/examples/environments/main.tf +++ b/examples/environments/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # 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 d161524..2f29574 100644 --- a/examples/groups/main.tf +++ b/examples/groups/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/instances/main.tf b/examples/instances/main.tf index 5e91029..18eea87 100644 --- a/examples/instances/main.tf +++ b/examples/instances/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # 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 af25ed4..ea6fb9b 100644 --- a/examples/policies/main.tf +++ b/examples/policies/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/projects/main.tf b/examples/projects/main.tf index d90cd2f..4ddb66c 100644 --- a/examples/projects/main.tf +++ b/examples/projects/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # 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 new file mode 100644 index 0000000..a9b05bf --- /dev/null +++ b/examples/roles/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + bytebase = { + version = "1.0.18" + # 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_role_list" "all" { +} + +output "all_roles" { + value = data.bytebase_role_list.all +} diff --git a/examples/settings/main.tf b/examples/settings/main.tf index 75746ee..b3f6272 100644 --- a/examples/settings/main.tf +++ b/examples/settings/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/setup/main.tf b/examples/setup/main.tf index be186b4..babe852 100644 --- a/examples/setup/main.tf +++ b/examples/setup/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/setup/role.tf b/examples/setup/role.tf new file mode 100644 index 0000000..703f01f --- /dev/null +++ b/examples/setup/role.tf @@ -0,0 +1,8 @@ +resource "bytebase_role" "auditor" { + resource_id = "auditor-role" + title = "Auditor role" + description = "This role can only list audit logs" + permissions = [ + "bb.auditLogs.search" + ] +} diff --git a/examples/setup/users.tf b/examples/setup/users.tf index 79bcc90..88c23e8 100644 --- a/examples/setup/users.tf +++ b/examples/setup/users.tf @@ -7,6 +7,18 @@ resource "bytebase_user" "workspace_dba" { roles = ["roles/workspaceDBA"] } +# Create or update the user. +resource "bytebase_user" "workspace_auditor" { + depends_on = [ + 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 = [ diff --git a/examples/users/main.tf b/examples/users/main.tf index ff19c47..dcc0340 100644 --- a/examples/users/main.tf +++ b/examples/users/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/examples/vcs/main.tf b/examples/vcs/main.tf index 22e8fb6..907cd92 100644 --- a/examples/vcs/main.tf +++ b/examples/vcs/main.tf @@ -1,7 +1,7 @@ terraform { required_providers { bytebase = { - version = "1.0.17" + version = "1.0.18" # For local development, please use "terraform.local/bytebase/bytebase" instead source = "registry.terraform.io/bytebase/bytebase" } diff --git a/provider/data_source_role.go b/provider/data_source_role.go new file mode 100644 index 0000000..ed3edb3 --- /dev/null +++ b/provider/data_source_role.go @@ -0,0 +1,69 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "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 dataSourceRole() *schema.Resource { + return &schema.Resource{ + Description: "The role data source.", + ReadContext: dataSourceRoleRead, + Schema: map[string]*schema.Schema{ + "resource_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: internal.ResourceIDValidation, + Description: "The role unique resource id.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The role full name in roles/{resource id} format.", + }, + "title": { + Type: schema.TypeString, + Computed: true, + Description: "The role title.", + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "The role description.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The role type.", + }, + "permissions": { + Type: schema.TypeSet, + Computed: true, + Description: "The role permissions.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func dataSourceRoleRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + roleName := fmt.Sprintf("%s%s", internal.RoleNamePrefix, d.Get("resource_id").(string)) + + role, err := c.GetRole(ctx, roleName) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(role.Name) + + return setRole(d, role) +} diff --git a/provider/data_source_role_list.go b/provider/data_source_role_list.go new file mode 100644 index 0000000..0de9a82 --- /dev/null +++ b/provider/data_source_role_list.go @@ -0,0 +1,102 @@ +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" + "github.com/bytebase/terraform-provider-bytebase/provider/internal" +) + +func dataSourceRoleList() *schema.Resource { + return &schema.Resource{ + Description: "The role data source list.", + ReadContext: dataSourceRoleListRead, + Schema: map[string]*schema.Schema{ + "roles": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "resource_id": { + Type: schema.TypeString, + Computed: true, + Description: "The role unique resource id.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The role full name in roles/{resource id} format.", + }, + "title": { + Type: schema.TypeString, + Computed: true, + Description: "The role title.", + }, + "description": { + Type: schema.TypeString, + Computed: true, + Description: "The role description.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The role type.", + }, + "permissions": { + Type: schema.TypeSet, + Computed: true, + Description: "The role permissions.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + }, + }, + } +} + +func dataSourceRoleListRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + // Warning or errors can be collected in a slice type + var diags diag.Diagnostics + + response, err := c.ListRole(ctx) + if err != nil { + return diag.FromErr(err) + } + + roles := []map[string]interface{}{} + for _, role := range response.Roles { + roleID, err := internal.GetRoleID(role.Name) + if err != nil { + return diag.FromErr(err) + } + + raw := make(map[string]interface{}) + raw["resource_id"] = roleID + raw["name"] = role.Name + raw["title"] = role.Title + raw["description"] = role.Description + raw["type"] = role.Type.String() + raw["permissions"] = role.Permissions + + roles = append(roles, raw) + } + + if err := d.Set("roles", roles); err != nil { + return diag.FromErr(err) + } + + // always refresh + d.SetId(strconv.FormatInt(time.Now().Unix(), 10)) + + return diags +} diff --git a/provider/internal/mock_client.go b/provider/internal/mock_client.go index ef26e58..a9e9a27 100644 --- a/provider/internal/mock_client.go +++ b/provider/internal/mock_client.go @@ -25,6 +25,7 @@ var settingMap map[string]*v1pb.Setting var vcsProviderMap map[string]*v1pb.VCSProvider var vcsConnectorMap map[string]*v1pb.VCSConnector var userMap map[string]*v1pb.User +var roleMap map[string]*v1pb.Role var groupMap map[string]*v1pb.Group func init() { @@ -39,6 +40,7 @@ func init() { vcsProviderMap = map[string]*v1pb.VCSProvider{} vcsConnectorMap = map[string]*v1pb.VCSConnector{} userMap = map[string]*v1pb.User{} + roleMap = map[string]*v1pb.Role{} groupMap = map[string]*v1pb.Group{} } @@ -54,6 +56,7 @@ type mockClient struct { vcsProviderMap map[string]*v1pb.VCSProvider vcsConnectorMap map[string]*v1pb.VCSConnector userMap map[string]*v1pb.User + roleMap map[string]*v1pb.Role groupMap map[string]*v1pb.Group workspaceIAMPolicy *v1pb.IamPolicy } @@ -72,6 +75,7 @@ func newMockClient(_, _, _ string) (api.Client, error) { vcsProviderMap: vcsProviderMap, vcsConnectorMap: vcsConnectorMap, userMap: userMap, + roleMap: roleMap, groupMap: groupMap, workspaceIAMPolicy: &v1pb.IamPolicy{}, }, nil @@ -842,3 +846,58 @@ func (c *mockClient) SetWorkspaceIAMPolicy(_ context.Context, update *v1pb.SetIa } return c.workspaceIAMPolicy, nil } + +// ListRole will returns all roles. +func (c *mockClient) ListRole(_ context.Context) (*v1pb.ListRolesResponse, error) { + roles := make([]*v1pb.Role, 0) + for _, role := range c.roleMap { + roles = append(roles, role) + } + + return &v1pb.ListRolesResponse{ + Roles: roles, + }, nil +} + +// GetRole gets the role by full name. +func (c *mockClient) GetRole(_ context.Context, roleName string) (*v1pb.Role, error) { + role, ok := c.roleMap[roleName] + if !ok { + return nil, errors.Errorf("Cannot found role %s", roleName) + } + + return role, nil +} + +// CreateRole creates the role. +func (c *mockClient) CreateRole(_ context.Context, roleID string, role *v1pb.Role) (*v1pb.Role, error) { + roleName := fmt.Sprintf("%s%s", RoleNamePrefix, roleID) + role.Name = roleName + c.roleMap[roleName] = role + return role, nil +} + +// UpdateRole updates the role. +func (c *mockClient) UpdateRole(ctx context.Context, role *v1pb.Role, updateMasks []string) (*v1pb.Role, error) { + existed, err := c.GetRole(ctx, role.Name) + if err != nil { + return nil, err + } + if slices.Contains(updateMasks, "title") { + existed.Title = role.Title + } + if slices.Contains(updateMasks, "description") { + existed.Description = role.Description + } + if slices.Contains(updateMasks, "permissions") { + existed.Permissions = role.Permissions + } + c.roleMap[existed.Name] = existed + return c.roleMap[existed.Name], nil +} + +// DeleteRole deletes the role by name. +func (c *mockClient) DeleteRole(_ context.Context, roleName string) error { + delete(c.roleMap, roleName) + return nil +} diff --git a/provider/internal/utils.go b/provider/internal/utils.go index 5c9d75e..d40dd3b 100644 --- a/provider/internal/utils.go +++ b/provider/internal/utils.go @@ -144,6 +144,15 @@ func GetProjectID(name string) (string, error) { return tokens[0], nil } +// GetRoleID will parse the role resource id. +func GetRoleID(name string) (string, error) { + tokens, err := getNameParentTokens(name, RoleNamePrefix) + if err != nil { + return "", err + } + return tokens[0], nil +} + // GetInstanceDatabaseID will parse the instance resource id and database name. func GetInstanceDatabaseID(name string) (string, string, error) { // the instance request should be instances/{instance-id}/databases/{database-id} diff --git a/provider/provider.go b/provider/provider.go index 9cdb094..7702d4d 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -64,6 +64,8 @@ func NewProvider() *schema.Provider { "bytebase_vcs_connector_list": dataSourceVCSConnectorList(), "bytebase_user": dataSourceUser(), "bytebase_user_list": dataSourceUserList(), + "bytebase_role": dataSourceRole(), + "bytebase_role_list": dataSourceRoleList(), "bytebase_group": dataSourceGroup(), "bytebase_group_list": dataSourceGroupList(), "bytebase_database": dataSourceDatabase(), @@ -78,6 +80,7 @@ func NewProvider() *schema.Provider { "bytebase_vcs_provider": resourceVCSProvider(), "bytebase_vcs_connector": resourceVCSConnector(), "bytebase_user": resourceUser(), + "bytebase_role": resourceRole(), "bytebase_group": resourceGroup(), "bytebase_database": resourceDatabase(), }, diff --git a/provider/resource_role.go b/provider/resource_role.go new file mode 100644 index 0000000..d039fd9 --- /dev/null +++ b/provider/resource_role.go @@ -0,0 +1,247 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + 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 resourceRole() *schema.Resource { + return &schema.Resource{ + Description: "The role resource. Require ENTERPRISE subscription. Check the docs https://www.bytebase.com/docs/administration/custom-roles/?source=terraform for more information.", + ReadContext: resourceRoleRead, + DeleteContext: resourceRoleDelete, + CreateContext: resourceRoleCreate, + UpdateContext: resourceRoleUpdate, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "resource_id": { + Type: schema.TypeString, + Required: true, + ValidateFunc: internal.ResourceIDValidation, + Description: "The role unique resource id.", + }, + "title": { + Type: schema.TypeString, + Required: true, + Description: "The role title.", + ValidateFunc: validation.StringIsNotEmpty, + }, + "description": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The role description.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The role full name in roles/{resource id} format.", + }, + "type": { + Type: schema.TypeString, + Computed: true, + Description: "The role type.", + }, + "permissions": { + Type: schema.TypeSet, + Required: true, + MinItems: 1, + Description: "The role permissions. All permissions should start with \"bb.\" prefix.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + } +} + +func resourceRoleRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + fullName := d.Id() + role, err := c.GetRole(ctx, fullName) + if err != nil { + return diag.FromErr(err) + } + + return setRole(d, role) +} + +func setRole(d *schema.ResourceData, role *v1pb.Role) diag.Diagnostics { + roleID, err := internal.GetRoleID(role.Name) + if err != nil { + return diag.FromErr(err) + } + + if err := d.Set("resource_id", roleID); err != nil { + return diag.Errorf("cannot set resource_id for role: %s", err.Error()) + } + if err := d.Set("title", role.Title); err != nil { + return diag.Errorf("cannot set title for role: %s", err.Error()) + } + if err := d.Set("name", role.Name); err != nil { + return diag.Errorf("cannot set name for role: %s", err.Error()) + } + if err := d.Set("description", role.Description); err != nil { + return diag.Errorf("cannot set description for role: %s", err.Error()) + } + if err := d.Set("type", role.Type.String()); err != nil { + return diag.Errorf("cannot set type for role: %s", err.Error()) + } + if err := d.Set("permissions", role.Permissions); err != nil { + return diag.Errorf("cannot set permissions for role: %s", err.Error()) + } + + return nil +} + +func resourceRoleDelete(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.DeleteRole(ctx, fullName); err != nil { + return diag.FromErr(err) + } + + d.SetId("") + + return diags +} + +func resourceRoleCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + c := m.(api.Client) + + roleID := d.Get("resource_id").(string) + roleName := fmt.Sprintf("%s%s", internal.RoleNamePrefix, roleID) + + existedRole, err := c.GetRole(ctx, roleName) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("get role %s failed with error: %v", roleName, err)) + } + + title := d.Get("title").(string) + description := d.Get("description").(string) + + permissions, diagnostic := getRolePermissions(d) + if diagnostic != nil { + return diagnostic + } + + role := &v1pb.Role{ + Name: roleName, + Title: title, + Description: description, + Permissions: permissions, + } + + var diags diag.Diagnostics + if existedRole != nil && err == nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Warning, + Summary: "Role already exists", + Detail: fmt.Sprintf("Role %s already exists, try to exec the update operation", roleName), + }) + + if _, err := c.UpdateRole(ctx, role, []string{"title", "description", "permissions"}); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to update role", + Detail: fmt.Sprintf("Update role %s failed, error: %v", roleName, err), + }) + return diags + } + } else { + if _, err := c.CreateRole(ctx, roleID, role); err != nil { + return diag.FromErr(err) + } + } + + d.SetId(roleName) + + diag := resourceRoleRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +} + +func getRolePermissions(d *schema.ResourceData) ([]string, diag.Diagnostics) { + permissions := []string{} + rawSet, ok := d.Get("permissions").(*schema.Set) + if !ok { + return nil, diag.Errorf("invalid role permissions") + } + for _, raw := range rawSet.List() { + permission := raw.(string) + if !strings.HasPrefix(permission, "bb.") { + return nil, diag.Errorf("permission should start with \"bb.\" prefix.") + } + permissions = append(permissions, permission) + } + return permissions, nil +} + +func resourceRoleUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + if d.HasChange("resource_id") { + return diag.Errorf("cannot change the resource id") + } + + c := m.(api.Client) + roleName := d.Id() + + existedRole, err := c.GetRole(ctx, roleName) + if err != nil { + tflog.Debug(ctx, fmt.Sprintf("get role %s failed with error: %v", roleName, err)) + } + + updateMasks := []string{} + if d.HasChange("title") { + updateMasks = append(updateMasks, "title") + } + if d.HasChange("description") { + updateMasks = append(updateMasks, "description") + } + if d.HasChange("permissions") { + updateMasks = append(updateMasks, "permissions") + permissions, diagnostic := getRolePermissions(d) + if err != nil { + return diagnostic + } + existedRole.Permissions = permissions + } + + var diags diag.Diagnostics + if len(updateMasks) > 0 { + if _, err := c.UpdateRole(ctx, existedRole, updateMasks); err != nil { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Failed to update role", + Detail: fmt.Sprintf("Update role %s failed, error: %v", roleName, err), + }) + return diags + } + } + + diag := resourceRoleRead(ctx, d, m) + if diag != nil { + diags = append(diags, diag...) + } + + return diags +}