diff --git a/docs/data-sources/groups.md b/docs/data-sources/groups.md new file mode 100644 index 0000000..01808a5 --- /dev/null +++ b/docs/data-sources/groups.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "infisical_groups Data Source - terraform-provider-infisical" +subcategory: "" +description: |- + Interact with Infisical groups in your organization. +--- + +# infisical_groups (Data Source) + +Interact with Infisical groups in your organization. + +## Example Usage + +```terraform +terraform { + required_providers { + infisical = { + # version = + source = "infisical/infisical" + } + } +} + +provider "infisical" { + host = "https://app.infisical.com" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com + client_id = "<>" + client_secret = "<>" +} + + +data "infisical_groups" "groups" { + +} + +output "org-groups" { + value = data.infisical_groups.groups +} +``` + + +## Schema + +### Read-Only + +- `groups` (Attributes List) The groups list (see [below for nested schema](#nestedatt--groups)) + + +### Nested Schema for `groups` + +Read-Only: + +- `id` (String) The ID of the group +- `name` (String) The name of the group +- `org_id` (String) The organization ID of the group +- `role` (String) The role of the group in the organization +- `role_id` (String) The role ID of the group in the organization diff --git a/docs/resources/project_group.md b/docs/resources/project_group.md new file mode 100644 index 0000000..52f96ed --- /dev/null +++ b/docs/resources/project_group.md @@ -0,0 +1,77 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "infisical_project_group Resource - terraform-provider-infisical" +subcategory: "" +description: |- + Create project groups & save to Infisical. Only Machine Identity authentication is supported for this data source +--- + +# infisical_project_group (Resource) + +Create project groups & save to Infisical. Only Machine Identity authentication is supported for this data source + +## Example Usage + +```terraform +terraform { + required_providers { + infisical = { + # version = + source = "infisical/infisical" + } + } +} + +provider "infisical" { + host = "https://app.infisical.com" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com + client_id = "<>" + client_secret = "<>" +} + +resource "infisical_project" "example" { + name = "example" + slug = "example" +} + +resource "infisical_project_group" "group" { + project_id = infisical_project.example.id + group_slug = "my-group" + roles = [ + { + role_slug = "admin", + is_temporary = true, + temporary_access_start_time = "2024-09-19T12:43:13Z", + temporary_range = "1y" + }, + { + role_slug = "my-custom-role", + }, + ] +} +``` + + +## Schema + +### Required + +- `group_id` (String) The id of the group. +- `project_id` (String) The id of the project. +- `roles` (Attributes Set) The roles assigned to the project group (see [below for nested schema](#nestedatt--roles)) + +### Read-Only + +- `membership_id` (String) The membership Id of the project group + + +### Nested Schema for `roles` + +Required: + +- `role_slug` (String) The slug of the role + +Optional: + +- `is_temporary` (Boolean) Flag to indicate the assigned role is temporary or not. When is_temporary is true fields temporary_mode, temporary_range and temporary_access_start_time is required. +- `temporary_access_start_time` (String) ISO time for which temporary access should begin. This is in the format YYYY-MM-DDTHH:MM:SSZ e.g. 2024-09-19T12:43:13Z +- `temporary_range` (String) TTL for the temporary time. Eg: 1m, 1h, 1d. Default: 1h diff --git a/examples/data-sources/infisical_groups/data-source.tf b/examples/data-sources/infisical_groups/data-source.tf new file mode 100644 index 0000000..52e0fe3 --- /dev/null +++ b/examples/data-sources/infisical_groups/data-source.tf @@ -0,0 +1,23 @@ +terraform { + required_providers { + infisical = { + # version = + source = "infisical/infisical" + } + } +} + +provider "infisical" { + host = "https://app.infisical.com" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com + client_id = "<>" + client_secret = "<>" +} + + +data "infisical_groups" "groups" { + +} + +output "org-groups" { + value = data.infisical_groups.groups +} diff --git a/examples/resources/infisical_project_group/resource.tf b/examples/resources/infisical_project_group/resource.tf new file mode 100644 index 0000000..e411ef5 --- /dev/null +++ b/examples/resources/infisical_project_group/resource.tf @@ -0,0 +1,35 @@ +terraform { + required_providers { + infisical = { + # version = + source = "infisical/infisical" + } + } +} + +provider "infisical" { + host = "https://app.infisical.com" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com + client_id = "<>" + client_secret = "<>" +} + +resource "infisical_project" "example" { + name = "example" + slug = "example" +} + +resource "infisical_project_group" "group" { + project_id = infisical_project.example.id + group_slug = "my-group" + roles = [ + { + role_slug = "admin", + is_temporary = true, + temporary_access_start_time = "2024-09-19T12:43:13Z", + temporary_range = "1y" + }, + { + role_slug = "my-custom-role", + }, + ] +} diff --git a/internal/client/group.go b/internal/client/group.go new file mode 100644 index 0000000..9d13bdd --- /dev/null +++ b/internal/client/group.go @@ -0,0 +1,50 @@ +package infisicalclient + +import ( + "fmt" + "net/http" +) + +func (client Client) GetGroupById(request GetGroupByIdRequest) (Group, error) { + var groupResponse Group + response, err := client.Config.HttpClient. + R(). + SetResult(&groupResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("api/v1/groups/%s", request.ID)) + + if response.StatusCode() == http.StatusNotFound { + return Group{}, ErrNotFound + } + + if err != nil { + return Group{}, fmt.Errorf("CallGetGroupById: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return Group{}, fmt.Errorf("CallGetGroupById: Unsuccessful response. [response=%s]", response) + } + + return groupResponse, nil +} + +func (client Client) GetGroups() (GetGroupsResponse, error) { + var body GetGroupsResponse + + httpRequest := client.Config.HttpClient. + R(). + SetResult(&body). + SetHeader("User-Agent", USER_AGENT) + + response, err := httpRequest.Get("api/v1/groups") + + if err != nil { + return GetGroupsResponse{}, fmt.Errorf("GetGroups: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetGroupsResponse{}, fmt.Errorf("GetGroups: Unsuccessful response. [response=%v]", string(response.Body())) + } + + return body, nil +} diff --git a/internal/client/model.go b/internal/client/model.go index bdd0464..448dddd 100644 --- a/internal/client/model.go +++ b/internal/client/model.go @@ -263,6 +263,10 @@ type ProjectWithEnvironments struct { Environments []ProjectEnvironment `json:"environments"` } +type GetProjectByIdResponse struct { + Workspace ProjectWithEnvironments `json:"workspace"` +} + type ProjectMemberships struct { ID string `json:"id"` CreatedAt time.Time `json:"createdAt"` @@ -510,6 +514,10 @@ type GetProjectRequest struct { Slug string `json:"slug"` } +type GetProjectByIdRequest struct { + ID string `json:"id"` +} + type UpdateProjectRequest struct { Slug string `json:"slug"` ProjectName string `json:"name"` @@ -780,6 +788,116 @@ type GetProjectIdentitySpecificPrivilegeResponse struct { Privilege ProjectIdentitySpecificPrivilege `json:"privilege"` } +type ProjectGroupRole struct { + ID string `json:"id"` + Role string `json:"role"` + CustomRoleSlug string `json:"customRoleSlug"` + CustomRoleId string `json:"customRoleId"` + IsTemporary bool `json:"isTemporary"` + TemporaryMode string `json:"temporaryMode"` + TemporaryRange string `json:"temporaryRange"` + TemporaryAccessStartTime time.Time `json:"temporaryAccessStartTime"` + TemporaryAccessEndTime time.Time `json:"temporaryAccessEndTime"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ProjectGroup struct { + ID string `json:"id"` + GroupID string `json:"groupId"` + Roles []ProjectGroupRole +} + +type CreateProjectGroupRequestRoles struct { + Role string `json:"role"` + IsTemporary bool `json:"isTemporary"` + TemporaryMode string `json:"temporaryMode"` + TemporaryRange string `json:"temporaryRange"` + TemporaryAccessStartTime time.Time `json:"temporaryAccessStartTime"` +} + +type CreateProjectGroupRequest struct { + ProjectId string `json:"projectId"` + GroupId string `json:"groupId"` + Roles []CreateProjectGroupRequestRoles `json:"roles"` +} + +type CreateProjectGroupResponseMembers struct { + ID string `json:"id"` + GroupID string `json:"groupId"` +} + +type CreateProjectGroupResponse struct { + Membership CreateProjectGroupResponseMembers `json:"groupMembership"` +} + +type GetProjectGroupMembershipRequest struct { + ProjectId string `json:"projectId"` + GroupId string `json:"groupId"` +} + +type GetProjectGroupMembershipResponse struct { + Membership ProjectGroup `json:"groupMembership"` +} + +type UpdateProjectGroupRequestRoles struct { + Role string `json:"role"` + IsTemporary bool `json:"isTemporary"` + TemporaryMode string `json:"temporaryMode"` + TemporaryRange string `json:"temporaryRange"` + TemporaryAccessStartTime time.Time `json:"temporaryAccessStartTime"` +} + +type UpdateProjectGroupRequest struct { + ProjectId string `json:"projectId"` + GroupId string `json:"groupId"` + Roles []UpdateProjectGroupRequestRoles `json:"roles"` +} + +type UpdateProjectGroupResponse struct { + Roles []struct { + ID string `json:"id"` + Role string `json:"role"` + CustomRoleId string `json:"customRoleId"` + IsTemporary bool `json:"isTemporary"` + TemporaryMode string `json:"temporaryMode"` + TemporaryRange string `json:"temporaryRange"` + TemporaryAccessStartTime time.Time `json:"temporaryAccessStartTime"` + TemporaryAccessEndTime time.Time `json:"temporaryAccessEndTime"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + } `json:"roles"` +} + +type DeleteProjectGroupRequest struct { + ProjectId string `json:"projectId"` + GroupId string `json:"groupId"` +} + +type DeleteProjectGroupResponseMembers struct { + ID string `json:"id"` + GroupID string `json:"groupId"` +} + +type DeleteProjectGroupResponse struct { + Membership DeleteProjectGroupResponseMembers `json:"groupMembership"` +} + +type GetGroupByIdRequest struct { + ID string `json:"id"` +} + +type Group struct { + ID string `json:"id"` + OrgID string `json:"orgId"` + Name string `json:"name"` + Slug string `json:"slug"` + Role string `json:"role"` + RoleId string `json:"roleId"` +} + +type GetGroupsResponse []Group + type GetProjectTagsResponse struct { Tags []ProjectTag `json:"workspaceTags"` } diff --git a/internal/client/project.go b/internal/client/project.go index 17a7dbc..b3ad431 100644 --- a/internal/client/project.go +++ b/internal/client/project.go @@ -87,3 +87,22 @@ func (client Client) UpdateProject(request UpdateProjectRequest) (UpdateProjectR return projectResponse, nil } + +func (client Client) GetProjectById(request GetProjectByIdRequest) (ProjectWithEnvironments, error) { + var projectResponse GetProjectByIdResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("api/v1/workspace/%s", request.ID)) + + if err != nil { + return ProjectWithEnvironments{}, fmt.Errorf("CallGetProjectById: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return ProjectWithEnvironments{}, fmt.Errorf("CallGetProjectById: Unsuccessful response. [response=%s]", response) + } + + return projectResponse.Workspace, nil +} diff --git a/internal/client/project_group.go b/internal/client/project_group.go new file mode 100644 index 0000000..ac3574c --- /dev/null +++ b/internal/client/project_group.go @@ -0,0 +1,90 @@ +package infisicalclient + +import ( + "fmt" + "net/http" +) + +func (client Client) CreateProjectGroup(request CreateProjectGroupRequest) (CreateProjectGroupResponse, error) { + var responseData CreateProjectGroupResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&responseData). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("api/v2/workspace/%s/groups/%s", request.ProjectId, request.GroupId)) + + if err != nil { + return CreateProjectGroupResponse{}, fmt.Errorf("CallCreateProjectGroup: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return CreateProjectGroupResponse{}, fmt.Errorf("CallCreateProjectGroup: Unsuccessful response. [response=%s]", response) + } + + return responseData, nil +} + +func (client Client) GetProjectGroupMembership(request GetProjectGroupMembershipRequest) (GetProjectGroupMembershipResponse, error) { + var responseData GetProjectGroupMembershipResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&responseData). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Get(fmt.Sprintf("api/v2/workspace/%s/groups/%s", request.ProjectId, request.GroupId)) + + if response.StatusCode() == http.StatusNotFound { + return GetProjectGroupMembershipResponse{}, ErrNotFound + } + + if err != nil { + return GetProjectGroupMembershipResponse{}, fmt.Errorf("GetProjectGroupMembershipResponse: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetProjectGroupMembershipResponse{}, fmt.Errorf("GetProjectGroupMembershipResponse: Unsuccessful response. [response=%s]", response) + } + + return responseData, nil +} + +func (client Client) UpdateProjectGroup(request UpdateProjectGroupRequest) (UpdateProjectGroupResponse, error) { + var responseData UpdateProjectGroupResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&responseData). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Patch(fmt.Sprintf("api/v2/workspace/%s/groups/%s", request.ProjectId, request.GroupId)) + + if err != nil { + return UpdateProjectGroupResponse{}, fmt.Errorf("CallUpdateProjectGroup: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return UpdateProjectGroupResponse{}, fmt.Errorf("CallUpdateProjectGroup: Unsuccessful response. [response=%s]", response) + } + + return responseData, nil +} + +func (client Client) DeleteProjectGroup(request DeleteProjectGroupRequest) (DeleteProjectGroupResponse, error) { + var responseData DeleteProjectGroupResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&responseData). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Delete(fmt.Sprintf("/api/v2/workspace/%s/groups/%s", request.ProjectId, request.GroupId)) + + if err != nil { + return DeleteProjectGroupResponse{}, fmt.Errorf("CallDeleteProjectGroup: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return DeleteProjectGroupResponse{}, fmt.Errorf("CallDeleteProjectGroup: Unsuccessful response. [response=%s]", response) + } + + return responseData, nil +} diff --git a/internal/provider/datasource/groups_data_source.go b/internal/provider/datasource/groups_data_source.go new file mode 100644 index 0000000..4da8c22 --- /dev/null +++ b/internal/provider/datasource/groups_data_source.go @@ -0,0 +1,158 @@ +package datasource + +import ( + "context" + "fmt" + + infisical "terraform-provider-infisical/internal/client" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ datasource.DataSource = &GroupsDataSource{} + +func NewGroupsDataSource() datasource.DataSource { + return &GroupsDataSource{} +} + +// GroupsDataSource defines the data source implementation. +type GroupsDataSource struct { + client *infisical.Client +} + +// ExampleDataSourceModel describes the data source data model. +type GroupsDataSourceModel struct { + Groups types.List `tfsdk:"groups"` +} + +type InfisicalGroupDetails struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + OrgID types.String `tfsdk:"org_id"` + Role types.String `tfsdk:"role"` + RoleId types.String `tfsdk:"role_id"` +} + +func (d *GroupsDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_groups" +} + +func (d *GroupsDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Interact with Infisical groups in your organization.", + Attributes: map[string]schema.Attribute{ + "groups": schema.ListNestedAttribute{ + Description: "The groups list", + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of the group", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the group", + Computed: true, + }, + "org_id": schema.StringAttribute{ + Description: "The organization ID of the group", + Computed: true, + }, + "role": schema.StringAttribute{ + Description: "The role of the group in the organization", + Computed: true, + }, + "role_id": schema.StringAttribute{ + Description: "The role ID of the group in the organization", + Computed: true, + }, + }, + }, + }, + }, + } +} + +func (d *GroupsDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*infisical.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *GroupsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + + if !d.client.Config.IsMachineIdentityAuth { + resp.Diagnostics.AddError( + "Unable to fetch groups", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + var data GroupsDataSourceModel + + // Read Terraform configuration data into the model + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + groups, err := d.client.GetGroups() + if err != nil { + resp.Diagnostics.AddError( + "Something went wrong while fetching the groups", + "If the error is not clear, please get in touch at infisical.com/slack\n\n"+ + "Infisical Client Error: "+err.Error(), + ) + } + + planGroups := make([]InfisicalGroupDetails, len(groups)) + for i, el := range groups { + planGroups[i] = InfisicalGroupDetails{ + ID: types.StringValue(el.ID), + Name: types.StringValue(el.Name), + OrgID: types.StringValue(el.OrgID), + Role: types.StringValue(el.Role), + RoleId: types.StringValue(el.RoleId), + } + } + + stateGroups, diags := types.ListValueFrom(ctx, types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "org_id": types.StringType, + "role": types.StringType, + "role_id": types.StringType, + }, + }, planGroups) + + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + data.Groups = stateGroups + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 918bc9e..4f233c5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -225,6 +225,7 @@ func (p *infisicalProvider) DataSources(_ context.Context) []func() datasource.D infisicalDatasource.NewProjectDataSource, infisicalDatasource.NewSecretTagDataSource, infisicalDatasource.NewSecretFolderDataSource, + infisicalDatasource.NewGroupsDataSource, } } @@ -237,6 +238,7 @@ func (p *infisicalProvider) Resources(_ context.Context) []func() resource.Resou infisicalResource.NewProjectIdentityResource, infisicalResource.NewProjectRoleResource, infisicalResource.NewProjectIdentitySpecificPrivilegeResource, + infisicalResource.NewProjectGroupResource, infisicalResource.NewProjectSecretTagResource, infisicalResource.NewProjectSecretFolderResource, infisicalResource.NewProjectEnvironmentResource, diff --git a/internal/provider/resource/project_group.go b/internal/provider/resource/project_group.go new file mode 100644 index 0000000..571985e --- /dev/null +++ b/internal/provider/resource/project_group.go @@ -0,0 +1,442 @@ +package resource + +import ( + "context" + "fmt" + infisical "terraform-provider-infisical/internal/client" + infisicalclient "terraform-provider-infisical/internal/client" + "time" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &ProjectGroupResource{} +) + +// NewProjectResource is a helper function to simplify the provider implementation. +func NewProjectGroupResource() resource.Resource { + return &ProjectGroupResource{} +} + +// ProjectGroupResource is the resource implementation. +type ProjectGroupResource struct { + client *infisical.Client +} + +// projectResourceSourceModel describes the data source data model. +type ProjectGroupResourceModel struct { + ProjectID types.String `tfsdk:"project_id"` + GroupID types.String `tfsdk:"group_id"` + Roles []ProjectGroupRole `tfsdk:"roles"` + MembershipID types.String `tfsdk:"membership_id"` +} + +type ProjectGroupRole struct { + RoleSlug types.String `tfsdk:"role_slug"` + IsTemporary types.Bool `tfsdk:"is_temporary"` + TemporaryRange types.String `tfsdk:"temporary_range"` + TemporaryAccessStartTime types.String `tfsdk:"temporary_access_start_time"` +} + +// Metadata returns the resource type name. +func (r *ProjectGroupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project_group" +} + +// Schema defines the schema for the resource. +func (r *ProjectGroupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Create project groups & save to Infisical. Only Machine Identity authentication is supported for this data source", + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Description: "The id of the project.", + Required: true, + }, + "group_id": schema.StringAttribute{ + Description: "The id of the group.", + Required: true, + }, + "membership_id": schema.StringAttribute{ + Description: "The membership Id of the project group", + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "roles": schema.SetNestedAttribute{ + Required: true, + Description: "The roles assigned to the project group", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "role_slug": schema.StringAttribute{ + Description: "The slug of the role", + Required: true, + }, + "is_temporary": schema.BoolAttribute{ + Description: "Flag to indicate the assigned role is temporary or not. When is_temporary is true fields temporary_mode, temporary_range and temporary_access_start_time is required.", + Optional: true, + }, + "temporary_range": schema.StringAttribute{ + Description: "TTL for the temporary time. Eg: 1m, 1h, 1d. Default: 1h", + Optional: true, + }, + "temporary_access_start_time": schema.StringAttribute{ + Description: "ISO time for which temporary access should begin. This is in the format YYYY-MM-DDTHH:MM:SSZ e.g. 2024-09-19T12:43:13Z", + Optional: true, + }, + }, + }, + }, + }, + } +} + +// Configure adds the provider configured client to the resource. +func (r *ProjectGroupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*infisical.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Source Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +// Create creates the resource and sets the initial Terraform state. +func (r *ProjectGroupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if !r.client.Config.IsMachineIdentityAuth { + resp.Diagnostics.AddError( + "Unable to create project group", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + // Retrieve values from plan + var plan ProjectGroupResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var roles []infisical.CreateProjectGroupRequestRoles + var hasAtleastOnePermanentRole bool + for _, el := range plan.Roles { + isTemporary := el.IsTemporary.ValueBool() + temporaryRange := el.TemporaryRange.ValueString() + TemporaryAccessStartTime := time.Now().UTC() + + if !isTemporary { + hasAtleastOnePermanentRole = true + } + + temporaryMode := "" + if isTemporary { + temporaryMode = TEMPORARY_MODE_RELATIVE + + if el.TemporaryAccessStartTime.IsNull() { + resp.Diagnostics.AddError( + "Field temporary_access_start_time is required for temporary roles", + fmt.Sprintf("Must provide valid ISO timestamp (YYYY-MM-DDTHH:MM:SSZ) for field temporary_access_start_time, role %s", el.RoleSlug.ValueString()), + ) + return + } + } + + if isTemporary && temporaryRange == "" { + temporaryRange = TEMPORARY_RANGE_DEFAULT + } + + if el.TemporaryAccessStartTime.ValueString() != "" { + var err error + TemporaryAccessStartTime, err = time.Parse(time.RFC3339, el.TemporaryAccessStartTime.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing field temporary_access_start_time", + fmt.Sprintf("Must provider valid ISO timestamp for field temporary_access_start_time %s, role %s", el.TemporaryAccessStartTime.ValueString(), el.RoleSlug.ValueString()), + ) + return + } + } + + roles = append(roles, infisical.CreateProjectGroupRequestRoles{ + Role: el.RoleSlug.ValueString(), + IsTemporary: isTemporary, + TemporaryMode: temporaryMode, + TemporaryRange: temporaryRange, + TemporaryAccessStartTime: TemporaryAccessStartTime, + }) + } + + if !hasAtleastOnePermanentRole { + resp.Diagnostics.AddError("Error assigning role to group", "Must have atleast one permanent role") + return + } + + projectGroupResponse, err := r.client.CreateProjectGroup(infisical.CreateProjectGroupRequest{ + ProjectId: plan.ProjectID.ValueString(), + GroupId: plan.GroupID.ValueString(), + Roles: roles, + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error attaching group to project", + "Couldn't create project group to Infiscial, unexpected error: "+err.Error(), + ) + return + } + + plan.MembershipID = types.StringValue(projectGroupResponse.Membership.ID) + + if err != nil { + resp.Diagnostics.AddError( + "Error fetching group details", + "Couldn't find group in project, unexpected error: "+err.Error(), + ) + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +// Read refreshes the Terraform state with the latest data. +func (r *ProjectGroupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + if !r.client.Config.IsMachineIdentityAuth { + resp.Diagnostics.AddError( + "Unable to read project group", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + // Get current state + var state ProjectGroupResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectGroupMembership, err := r.client.GetProjectGroupMembership(infisical.GetProjectGroupMembershipRequest{ + ProjectId: state.ProjectID.ValueString(), + GroupId: state.GroupID.ValueString(), + }) + + if err != nil { + if err == infisicalclient.ErrNotFound { + resp.State.RemoveResource(ctx) + return + } else { + resp.Diagnostics.AddError( + "Error reading project group membership", + "Couldn't read project group membership from Infiscial, unexpected error: "+err.Error(), + ) + return + } + } + + stateRoleMap := make(map[string]ProjectGroupRole) + for _, role := range state.Roles { + stateRoleMap[role.RoleSlug.ValueString()] = role + } + + planRoles := make([]ProjectGroupRole, 0, len(projectGroupMembership.Membership.Roles)) + for _, el := range projectGroupMembership.Membership.Roles { + val := ProjectGroupRole{ + RoleSlug: types.StringValue(el.Role), + TemporaryRange: types.StringValue(el.TemporaryRange), + IsTemporary: types.BoolValue(el.IsTemporary), + TemporaryAccessStartTime: types.StringValue(el.TemporaryAccessStartTime.Format(time.RFC3339)), + } + + if el.CustomRoleId != "" { + val.RoleSlug = types.StringValue(el.CustomRoleSlug) + } + + /* + We do the following because we want to maintain the state when the API returns these properties + with default values. Without this, there will be unlimited drift because of the optional values. + */ + previousRoleState, ok := stateRoleMap[val.RoleSlug.ValueString()] + if ok { + if previousRoleState.IsTemporary.ValueBool() && el.IsTemporary { + if previousRoleState.TemporaryRange.IsNull() && el.TemporaryRange == TEMPORARY_RANGE_DEFAULT { + val.TemporaryRange = types.StringNull() + } + } + + if previousRoleState.IsTemporary.IsNull() && !el.IsTemporary { + val.IsTemporary = types.BoolNull() + } + } + + if !el.IsTemporary { + val.TemporaryRange = types.StringNull() + val.TemporaryAccessStartTime = types.StringNull() + } + + planRoles = append(planRoles, val) + } + + state.Roles = planRoles + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *ProjectGroupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if !r.client.Config.IsMachineIdentityAuth { + resp.Diagnostics.AddError( + "Unable to update project group", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + // Retrieve values from plan + var plan ProjectGroupResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + var state ProjectGroupResourceModel + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if plan.ProjectID != state.ProjectID { + resp.Diagnostics.AddError( + "Unable to update project ID", + fmt.Sprintf("Cannot change project ID, previous project: %s, new project: %s", state.ProjectID, plan.ProjectID), + ) + return + } + + if plan.GroupID != state.GroupID { + resp.Diagnostics.AddError( + "Unable to update project group", + fmt.Sprintf("Cannot change group ID, previous group: %s, new group: %s", state.GroupID, plan.GroupID), + ) + return + } + + var roles []infisical.UpdateProjectGroupRequestRoles + var hasAtleastOnePermanentRole bool + for _, el := range plan.Roles { + isTemporary := el.IsTemporary.ValueBool() + temporaryRange := el.TemporaryRange.ValueString() + TemporaryAccessStartTime := time.Now().UTC() + + if !isTemporary { + hasAtleastOnePermanentRole = true + } + + temporaryMode := "" + if isTemporary { + temporaryMode = TEMPORARY_MODE_RELATIVE + + if el.TemporaryAccessStartTime.IsNull() { + resp.Diagnostics.AddError( + "Field temporary_access_start_time is required for temporary roles", + fmt.Sprintf("Must provide valid ISO timestamp (YYYY-MM-DDTHH:MM:SSZ) for field temporary_access_start_time, role %s", el.RoleSlug.ValueString()), + ) + return + } + } + if isTemporary && temporaryRange == "" { + temporaryRange = "1h" + } + + if el.TemporaryAccessStartTime.ValueString() != "" { + var err error + TemporaryAccessStartTime, err = time.Parse(time.RFC3339, el.TemporaryAccessStartTime.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error parsing field temporary_access_start_time", + fmt.Sprintf("Must provider valid ISO timestamp for field temporary_access_start_time %s, role %s", el.TemporaryAccessStartTime.ValueString(), el.RoleSlug.ValueString()), + ) + return + } + } + + roles = append(roles, infisical.UpdateProjectGroupRequestRoles{ + Role: el.RoleSlug.ValueString(), + IsTemporary: isTemporary, + TemporaryMode: temporaryMode, + TemporaryRange: temporaryRange, + TemporaryAccessStartTime: TemporaryAccessStartTime, + }) + } + + if !hasAtleastOnePermanentRole { + resp.Diagnostics.AddError("Error assigning role to group", "Must have atleast one permanent role") + return + } + + _, err := r.client.UpdateProjectGroup(infisical.UpdateProjectGroupRequest{ + ProjectId: state.ProjectID.ValueString(), + GroupId: state.GroupID.ValueString(), + Roles: roles, + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error assigning roles to group", + "Couldn't update role, unexpected error: "+err.Error(), + ) + return + } + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *ProjectGroupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if !r.client.Config.IsMachineIdentityAuth { + resp.Diagnostics.AddError( + "Unable to delete project group", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + var state ProjectGroupResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.DeleteProjectGroup(infisical.DeleteProjectGroupRequest{ + ProjectId: state.ProjectID.ValueString(), + GroupId: state.GroupID.ValueString(), + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project group", + "Couldn't delete project group from Infiscial, unexpected error: "+err.Error(), + ) + } +} diff --git a/internal/provider/resource/project_user_resource.go b/internal/provider/resource/project_user_resource.go index d7d9097..6851001 100644 --- a/internal/provider/resource/project_user_resource.go +++ b/internal/provider/resource/project_user_resource.go @@ -47,6 +47,7 @@ type ProjectUserPersonalDetails struct { } const TEMPORARY_MODE_RELATIVE = "relative" +const TEMPORARY_RANGE_DEFAULT = "1h" type ProjectUserRole struct { ID types.String `tfsdk:"id"`