From b181f4a9256460ed9963f3c145c2d6c5886ddf37 Mon Sep 17 00:00:00 2001 From: ottramst Date: Wed, 20 Nov 2024 12:50:48 +0200 Subject: [PATCH] Add organization_collection resource (#14) * Add organization_collection resource * Fix issues with older versions --- docs/resources/organization_collection.md | 49 +++ .../import.sh | 1 + .../resource.tf | 8 + internal/provider/provider.go | 1 + internal/provider/resource_organization.go | 9 - .../resource_organization_collection.go | 334 ++++++++++++++++++ .../resource_organization_collection_test.go | 107 ++++++ .../provider/resource_organization_test.go | 2 - internal/vaultwarden/auth.go | 10 + internal/vaultwarden/auth_user.go | 36 ++ internal/vaultwarden/crypt/encryption.go | 18 +- .../encryptedstring/encryptedstring.go | 5 +- .../{keybuilder.go => shared_key.go} | 40 ++- internal/vaultwarden/models/collection.go | 12 + internal/vaultwarden/models/organization.go | 14 +- internal/vaultwarden/models/user.go | 9 +- internal/vaultwarden/organization.go | 12 + .../vaultwarden/organization_collection.go | 114 ++++++ internal/vaultwarden/profile.go | 23 ++ 19 files changed, 765 insertions(+), 39 deletions(-) create mode 100644 docs/resources/organization_collection.md create mode 100644 examples/resources/vaultwarden_organization_collection/import.sh create mode 100644 examples/resources/vaultwarden_organization_collection/resource.tf create mode 100644 internal/provider/resource_organization_collection.go create mode 100644 internal/provider/resource_organization_collection_test.go rename internal/vaultwarden/keybuilder/{keybuilder.go => shared_key.go} (64%) create mode 100644 internal/vaultwarden/models/collection.go create mode 100644 internal/vaultwarden/organization_collection.go create mode 100644 internal/vaultwarden/profile.go diff --git a/docs/resources/organization_collection.md b/docs/resources/organization_collection.md new file mode 100644 index 0000000..23962e4 --- /dev/null +++ b/docs/resources/organization_collection.md @@ -0,0 +1,49 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "vaultwarden_organization_collection Resource - vaultwarden" +subcategory: "" +description: |- + This resource creates a Vaultwarden organization collection. +--- + +# vaultwarden_organization_collection (Resource) + +This resource creates a Vaultwarden organization collection. + +## Example Usage + +```terraform +resource "vaultwarden_organization" "example" { + name = "Example" +} + +resource "vaultwarden_organization_collection" "example" { + organization_id = vaultwarden_organization.example.id + name = "Example Collection" +} +``` + + +## Schema + +### Required + +- `name` (String) The name of the organization collection +- `organization_id` (String) ID of the organization that the collection belongs to + +### Optional + +- `external_id` (String) An optional identifier that can be assigned to the collection for integration with external systems. This identifier is not generated by Vaultwarden and must be provided explicitly. It is typically used to link the collection to external systems, such as directory services (e.g., LDAP, Active Directory) or custom automation workflows. + +### Read-Only + +- `id` (String) ID of the organization collection +- `last_updated` (String) Timestamp of the last update + +## Import + +Import is supported using the following syntax: + +```shell +terraform import vaultwarden_organization_collection.example / +``` diff --git a/examples/resources/vaultwarden_organization_collection/import.sh b/examples/resources/vaultwarden_organization_collection/import.sh new file mode 100644 index 0000000..03dd30e --- /dev/null +++ b/examples/resources/vaultwarden_organization_collection/import.sh @@ -0,0 +1 @@ +terraform import vaultwarden_organization_collection.example / diff --git a/examples/resources/vaultwarden_organization_collection/resource.tf b/examples/resources/vaultwarden_organization_collection/resource.tf new file mode 100644 index 0000000..492c71f --- /dev/null +++ b/examples/resources/vaultwarden_organization_collection/resource.tf @@ -0,0 +1,8 @@ +resource "vaultwarden_organization" "example" { + name = "Example" +} + +resource "vaultwarden_organization_collection" "example" { + organization_id = vaultwarden_organization.example.id + name = "Example Collection" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 5ade312..9cccd0e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -287,6 +287,7 @@ func (p *VaultwardenProvider) Resources(ctx context.Context) []func() resource.R return []func() resource.Resource{ UserInviteResource, OrganizationResource, + OrganizationCollectionResource, } } diff --git a/internal/provider/resource_organization.go b/internal/provider/resource_organization.go index 6ba8571..8100f65 100644 --- a/internal/provider/resource_organization.go +++ b/internal/provider/resource_organization.go @@ -13,7 +13,6 @@ import ( "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden" "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/models" - "time" ) // Ensure provider defined types fully satisfy framework interfaces. @@ -32,7 +31,6 @@ type Organization struct { // OrganizationModel describes the resource data model. type OrganizationModel struct { ID types.String `tfsdk:"id"` - LastUpdated types.String `tfsdk:"last_updated"` Name types.String `tfsdk:"name"` BillingEmail types.String `tfsdk:"billing_email"` CollectionName types.String `tfsdk:"collection_name"` @@ -53,10 +51,6 @@ func (r *Organization) Schema(ctx context.Context, req resource.SchemaRequest, r stringplanmodifier.UseStateForUnknown(), }, }, - "last_updated": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Timestamp of the last update", - }, "name": schema.StringAttribute{ MarkdownDescription: "The name of the organization", Required: true, @@ -129,7 +123,6 @@ func (r *Organization) Create(ctx context.Context, req resource.CreateRequest, r data.ID = types.StringValue(orgResp.ID) data.Name = types.StringValue(orgResp.Name) data.BillingEmail = types.StringValue(orgResp.BillingEmail) - data.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) // Write logs using the tflog package // Documentation: https://terraform.io/plugin/log @@ -191,8 +184,6 @@ func (r *Organization) Update(ctx context.Context, req resource.UpdateRequest, r return } - data.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) - // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } diff --git a/internal/provider/resource_organization_collection.go b/internal/provider/resource_organization_collection.go new file mode 100644 index 0000000..cc11a6b --- /dev/null +++ b/internal/provider/resource_organization_collection.go @@ -0,0 +1,334 @@ +package provider + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/path" + "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" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/crypt" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/encryptedstring" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/models" + "strings" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &OrganizationCollection{} +var _ resource.ResourceWithImportState = &OrganizationCollection{} + +func OrganizationCollectionResource() resource.Resource { + return &OrganizationCollection{} +} + +// OrganizationCollection defines the resource implementation. +type OrganizationCollection struct { + client *vaultwarden.Client +} + +// OrganizationCollectionModel describes the resource data model. +type OrganizationCollectionModel struct { + ID types.String `tfsdk:"id"` + OrganizationID types.String `tfsdk:"organization_id"` + ExternalID types.String `tfsdk:"external_id"` + Name types.String `tfsdk:"name"` + // TODO: Add groups + // TODO: Add users +} + +func (r *OrganizationCollection) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization_collection" +} + +func (r *OrganizationCollection) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "This resource creates a Vaultwarden organization collection.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "ID of the organization collection", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization_id": schema.StringAttribute{ + MarkdownDescription: "ID of the organization that the collection belongs to", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + stringplanmodifier.RequiresReplace(), + }, + }, + "external_id": schema.StringAttribute{ + MarkdownDescription: "An optional identifier that can be assigned to the collection for integration with external systems. This identifier is not generated by Vaultwarden and must be provided explicitly. It is typically used to link the collection to external systems, such as directory services (e.g., LDAP, Active Directory) or custom automation workflows.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the organization collection", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *OrganizationCollection) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*vaultwarden.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *vaultwarden.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *OrganizationCollection) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data OrganizationCollectionModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Call the client method to create the organization + collection := models.Collection{ + Name: data.Name.ValueString(), + } + + // Set external_id if it's not null in the plan + if !data.ExternalID.IsNull() { + collection.ExternalID = data.ExternalID.ValueString() + } + + collResp, err := r.client.CreateOrganizationCollection(ctx, data.OrganizationID.ValueString(), collection) + if err != nil { + resp.Diagnostics.AddError( + "Error creating Vaultwarden organization collection", + "Could not create organization collection, unexpected error: "+err.Error(), + ) + return + } + + // Map response body to schema and populate Computed attribute values + data.ID = types.StringValue(collResp.ID) + + // If we're trying to set an external_id, but the API returns empty or null, + // keep our desired value from the configuration + // See: https://github.com/dani-garcia/vaultwarden/pull/3690 + if collResp.ExternalID == "" && !data.ExternalID.IsNull() { + // Keep the existing external_id from our state + } else if collResp.ExternalID == "" { + data.ExternalID = types.StringNull() + } else { + data.ExternalID = types.StringValue(collResp.ExternalID) + } + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, fmt.Sprintf("created a new organization with ID: %s", data.ID)) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationCollection) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data OrganizationCollectionModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Get refreshed data from the client + collResp, err := r.client.GetOrganizationCollection(ctx, data.OrganizationID.ValueString(), data.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError( + "Error reading Vaultwarden organization collection", + "Could not read organization collection, unexpected error: "+err.Error(), + ) + return + } + + // Get organization data from cache + orgSecret, exists := r.client.AuthState.Organizations[data.OrganizationID.ValueString()] + if !exists { + resp.Diagnostics.AddError( + "Error reading Vaultwarden organization collection", + "Could not read organization collection, organization not found or not authenticated", + ) + return + } + + // Convert the collection name to an EncryptedString + encryptedName, err := encryptedstring.NewFromEncryptedValue(collResp.Name) + if err != nil { + resp.Diagnostics.AddError( + "Error reading Vaultwarden organization collection", + "Could not read organization collection, failed to parse encrypted collection name: "+err.Error(), + ) + return + } + + // Decrypt the collection name + decryptedBytes, err := crypt.Decrypt(encryptedName, &orgSecret.Key) + if err != nil { + resp.Diagnostics.AddError( + "Error decrypting collection name", + err.Error(), + ) + return + } + + // Overwrite the model with the refreshed data + data.Name = types.StringValue(string(decryptedBytes)) + + // If we're trying to set an external_id, but the API returns empty or null, + // keep our desired value from the configuration + // See: https://github.com/dani-garcia/vaultwarden/pull/3690 + if collResp.ExternalID == "" && !data.ExternalID.IsNull() { + // Keep the existing external_id from our state + } else if collResp.ExternalID == "" { + data.ExternalID = types.StringNull() + } else { + data.ExternalID = types.StringValue(collResp.ExternalID) + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationCollection) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data OrganizationCollectionModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Update the organization collection if needed + collection := models.Collection{ + Name: data.Name.ValueString(), + ExternalID: data.ExternalID.ValueString(), + } + + if _, err := r.client.UpdateOrganizationCollection(ctx, data.OrganizationID.ValueString(), data.ID.ValueString(), collection); err != nil { + resp.Diagnostics.AddError( + "Error updating Vaultwarden organization collection", + "Could not update organization collection, unexpected error: "+err.Error(), + ) + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *OrganizationCollection) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data OrganizationCollectionModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + // Delete the organization collection + if err := r.client.DeleteOrganizationCollection(ctx, data.OrganizationID.ValueString(), data.ID.ValueString()); err != nil { + resp.Diagnostics.AddError( + "Error deleting Vaultwarden organization collection", + "Could not delete organization collection, unexpected error: "+err.Error(), + ) + return + } +} + +func (r *OrganizationCollection) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, "/") + if len(idParts) != 2 { + resp.Diagnostics.AddError( + "Invalid ID format", + "Expected import identifier with format: organization_id/collection_id", + ) + return + } + + organizationID := idParts[0] + collectionID := idParts[1] + + // Set the organization_id and id attributes + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), organizationID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), collectionID)...) + + // After setting the IDs, fetch the current state of the resource + collection, err := r.client.GetOrganizationCollection(ctx, idParts[0], idParts[1]) + if err != nil { + resp.Diagnostics.AddError( + "Error importing organization collection", + fmt.Sprintf("Cannot read organization collection %s: %v", req.ID, err), + ) + return + } + + // Get organization data from cache + orgSecret, exists := r.client.AuthState.Organizations[idParts[0]] + if !exists { + resp.Diagnostics.AddError( + "Error importing organization collection", + "Could not read organization collection, organization not found or not authenticated", + ) + return + } + + // Convert and decrypt the name + encryptedName, err := encryptedstring.NewFromEncryptedValue(collection.Name) + if err != nil { + resp.Diagnostics.AddError( + "Error importing organization collection", + fmt.Sprintf("Failed to parse encrypted name: %v", err), + ) + return + } + + decryptedBytes, err := crypt.Decrypt(encryptedName, &orgSecret.Key) + if err != nil { + resp.Diagnostics.AddError( + "Error importing organization collection", + fmt.Sprintf("Failed to decrypt name: %v", err), + ) + return + } + + // Set the name + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), string(decryptedBytes))...) + + // Set external_id if it exists + if collection.ExternalID != "" { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("external_id"), collection.ExternalID)...) + } +} diff --git a/internal/provider/resource_organization_collection_test.go b/internal/provider/resource_organization_collection_test.go new file mode 100644 index 0000000..53e6a14 --- /dev/null +++ b/internal/provider/resource_organization_collection_test.go @@ -0,0 +1,107 @@ +package provider + +import ( + "fmt" + "github.com/brianvoe/gofakeit/v7" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/test" + "testing" +) + +func TestAccOrganizationCollection(t *testing.T) { + // Generate random data for the test + orgName := gofakeit.Company() + collectionName := gofakeit.ProductName() + updatedCollectionName := gofakeit.ProductName() + externalID := gofakeit.UUID() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccOrganizationCollectionConfig(orgName, collectionName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("vaultwarden_organization_collection.test", "name", collectionName), + resource.TestCheckResourceAttrSet("vaultwarden_organization_collection.test", "id"), + resource.TestCheckResourceAttrSet("vaultwarden_organization_collection.test", "organization_id"), + // external_id should be null/empty initially + resource.TestCheckNoResourceAttr("vaultwarden_organization_collection.test", "external_id"), + ), + }, + // Update and Read testing + { + Config: testAccOrganizationCollectionConfigUpdated(orgName, updatedCollectionName, externalID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("vaultwarden_organization_collection.test", "name", updatedCollectionName), + resource.TestCheckResourceAttr("vaultwarden_organization_collection.test", "external_id", externalID), + resource.TestCheckResourceAttrSet("vaultwarden_organization_collection.test", "id"), + resource.TestCheckResourceAttrSet("vaultwarden_organization_collection.test", "organization_id"), + ), + }, + // ImportState testing + { + ResourceName: "vaultwarden_organization_collection.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"external_id"}, // Ignore external_id during import verification + ImportStateIdFunc: func(s *terraform.State) (string, error) { + rs, ok := s.RootModule().Resources["vaultwarden_organization_collection.test"] + if !ok { + return "", fmt.Errorf("resource not found in state") + } + + return fmt.Sprintf("%s/%s", + rs.Primary.Attributes["organization_id"], + rs.Primary.Attributes["id"]), nil + }, + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +// Base configuration +func testAccOrganizationCollectionConfig(orgName, collectionName string) string { + return fmt.Sprintf(` +provider "vaultwarden" { + endpoint = %[1]q + email = %[2]q + master_password = %[3]q + admin_token = %[4]q +} + +resource "vaultwarden_organization" "test" { + name = %[5]q +} + +resource "vaultwarden_organization_collection" "test" { + organization_id = vaultwarden_organization.test.id + name = %[6]q +} +`, test.TestBaseURL, test.TestEmail, test.TestPassword, test.TestAdminToken, orgName, collectionName) +} + +// Updated configuration with modified name and external_id +func testAccOrganizationCollectionConfigUpdated(orgName, collectionName, externalID string) string { + return fmt.Sprintf(` +provider "vaultwarden" { + endpoint = %[1]q + email = %[2]q + master_password = %[3]q + admin_token = %[4]q +} + +resource "vaultwarden_organization" "test" { + name = %[5]q +} + +resource "vaultwarden_organization_collection" "test" { + organization_id = vaultwarden_organization.test.id + name = %[6]q + external_id = %[7]q +} +`, test.TestBaseURL, test.TestEmail, test.TestPassword, test.TestAdminToken, orgName, collectionName, externalID) +} diff --git a/internal/provider/resource_organization_test.go b/internal/provider/resource_organization_test.go index 2264059..bdde448 100644 --- a/internal/provider/resource_organization_test.go +++ b/internal/provider/resource_organization_test.go @@ -25,7 +25,6 @@ func TestAccOrganization(t *testing.T) { resource.TestCheckResourceAttr("vaultwarden_organization.test", "billing_email", test.TestEmail), resource.TestCheckResourceAttr("vaultwarden_organization.test", "collection_name", "Default Collection"), resource.TestCheckResourceAttrSet("vaultwarden_organization.test", "id"), - resource.TestCheckResourceAttrSet("vaultwarden_organization.test", "last_updated"), ), }, // Update and Read testing @@ -37,7 +36,6 @@ func TestAccOrganization(t *testing.T) { // collection_name shouldn't change on update resource.TestCheckResourceAttr("vaultwarden_organization.test", "collection_name", "Default Collection"), resource.TestCheckResourceAttrSet("vaultwarden_organization.test", "id"), - resource.TestCheckResourceAttrSet("vaultwarden_organization.test", "last_updated"), ), }, // ImportState testing diff --git a/internal/vaultwarden/auth.go b/internal/vaultwarden/auth.go index 6e6161f..2d40104 100644 --- a/internal/vaultwarden/auth.go +++ b/internal/vaultwarden/auth.go @@ -4,6 +4,7 @@ import ( "crypto/rsa" "fmt" "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/models" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/symmetrickey" "net/http" "strings" "time" @@ -19,6 +20,12 @@ const ( AuthMethodOAuth2 ) +type OrganizationSecret struct { + Key symmetrickey.Key + OrganizationUUID string + Name string +} + // AuthState holds the current authentication state type AuthState struct { // Admin authentication @@ -29,6 +36,9 @@ type AuthState struct { TokenExpiresAt time.Time // JWT expiration time PrivateKey *rsa.PrivateKey KdfConfig *models.KdfConfiguration + + // Organizations data + Organizations map[string]OrganizationSecret } // validateCredentials ensures that the provided credentials meet the requirements diff --git a/internal/vaultwarden/auth_user.go b/internal/vaultwarden/auth_user.go index 082221f..1766ab9 100644 --- a/internal/vaultwarden/auth_user.go +++ b/internal/vaultwarden/auth_user.go @@ -7,6 +7,7 @@ import ( "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/helpers" "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/keybuilder" "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/models" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/symmetrickey" "net/http" "net/url" "time" @@ -115,6 +116,41 @@ func (c *Client) userLogin(ctx context.Context) error { c.AuthState.PrivateKey = privateKey c.AuthState.TokenExpiresAt = expirationTime + // Getch the user profile + user, err := c.GetProfile(ctx) + if err != nil { + return fmt.Errorf("failed to get user profile: %w", err) + } + + // Initialize or clear org keys map + c.AuthState.Organizations = make(map[string]OrganizationSecret) + + // Save organizations to auth state + for _, org := range user.Organizations { + if !org.Enabled || org.Key == "" { + continue + } + + // Decrypt the organization key + decryptedKeyBytes, err := keybuilder.RSADecrypt(org.Key, c.AuthState.PrivateKey) + if err != nil { + return fmt.Errorf("failed to decrypt organization key for org %s: %w", org.ID, err) + } + + // Convert decrypted key to symmetrickey.Key + decryptedKey, err := symmetrickey.NewFromRawBytes(decryptedKeyBytes) + if err != nil { + return fmt.Errorf("failed to construct symmetric key for org %s: %w", org.ID, err) + } + + // Store the decrypted key and org info + c.AuthState.Organizations[org.ID] = OrganizationSecret{ + Key: *decryptedKey, + OrganizationUUID: org.ID, + Name: org.Name, + } + } + return nil } diff --git a/internal/vaultwarden/crypt/encryption.go b/internal/vaultwarden/crypt/encryption.go index e05783e..ab112c0 100644 --- a/internal/vaultwarden/crypt/encryption.go +++ b/internal/vaultwarden/crypt/encryption.go @@ -50,20 +50,22 @@ func DecryptEncryptionKey(encryptedKeyStr string, key symmetrickey.Key) (*symmet if err != nil { return nil, fmt.Errorf("error decrypting encryption key: %w", err) } - if encKeyCipher.Key.EncryptionType == symmetrickey.AesCbc256_B64 { + + switch encKeyCipher.Key.EncryptionType { + case symmetrickey.AesCbc256_B64: decEncKey, err = Decrypt(encKeyCipher, &key) if err != nil { return nil, fmt.Errorf("error decrypting encryption key: %w", err) } - } else if encKeyCipher.Key.EncryptionType == symmetrickey.AesCbc256_HmacSha256_B64 { + case symmetrickey.AesCbc256_HmacSha256_B64: newKey := key.StretchKey() decEncKey, err = Decrypt(encKeyCipher, &newKey) if err != nil { return nil, fmt.Errorf("error decrypting encryption key: %w", err) } - } else { - return nil, fmt.Errorf("unsupported encryption key type") + default: + return nil, fmt.Errorf("unsupported encryption key type: %d", encKeyCipher.Key.EncryptionType) } encryptionKey, err := symmetrickey.NewFromRawBytes(decEncKey) @@ -76,22 +78,22 @@ func DecryptEncryptionKey(encryptedKeyStr string, key symmetrickey.Key) (*symmet func DecryptPrivateKey(encryptedPrivateKeyStr string, encryptionKey symmetrickey.Key) (*rsa.PrivateKey, error) { encString, err := encryptedstring.NewFromEncryptedValue(encryptedPrivateKeyStr) if err != nil { - return nil, fmt.Errorf("error decrypting private key: %w", err) + return nil, fmt.Errorf("failed to parse encrypted private key value: %w", err) } decryptedPrivateKey, err := Decrypt(encString, &encryptionKey) if err != nil { - return nil, fmt.Errorf("error decrypting private key: %w", err) + return nil, fmt.Errorf("failed to decrypt the private key using the provided encryption key: %w", err) } p, err := x509.ParsePKCS8PrivateKey(decryptedPrivateKey) if err != nil { - return nil, fmt.Errorf("error parse private key: %w", err) + return nil, fmt.Errorf("failed to parse decrypted private key in PKCS8 format: %w", err) } privateKey, ok := p.(*rsa.PrivateKey) if !ok { - return nil, fmt.Errorf("failed to convert to rsa.PrivateKey") + return nil, fmt.Errorf("parsed private key is not an RSA private key") } return privateKey, nil diff --git a/internal/vaultwarden/encryptedstring/encryptedstring.go b/internal/vaultwarden/encryptedstring/encryptedstring.go index 0fe3a5d..579fa3c 100644 --- a/internal/vaultwarden/encryptedstring/encryptedstring.go +++ b/internal/vaultwarden/encryptedstring/encryptedstring.go @@ -33,8 +33,9 @@ func New(iv, data, hmac []byte, key symmetrickey.Key) EncryptedString { func NewFromEncryptedValue(encryptedValue string) (*EncryptedString, error) { if len(encryptedValue) == 0 { - return nil, fmt.Errorf("supposedly encrypted string is empty") + return nil, fmt.Errorf("the provided encrypted value is empty") } + var encPieces []string encString := EncryptedString{} @@ -42,7 +43,7 @@ func NewFromEncryptedValue(encryptedValue string) (*EncryptedString, error) { if len(headerPieces) == 2 { s, err := strconv.ParseInt(headerPieces[0], 10, 8) if err != nil { - return nil, fmt.Errorf("unable to parse encryption type: %w", err) + return nil, fmt.Errorf("unable to parse encryption type from header: %w, header: %s", err, headerPieces[0]) } encString.Key.EncryptionType = symmetrickey.EncryptionType(s) encPieces = strings.Split(headerPieces[1], "|") diff --git a/internal/vaultwarden/keybuilder/keybuilder.go b/internal/vaultwarden/keybuilder/shared_key.go similarity index 64% rename from internal/vaultwarden/keybuilder/keybuilder.go rename to internal/vaultwarden/keybuilder/shared_key.go index cd10e25..ff3a83f 100644 --- a/internal/vaultwarden/keybuilder/keybuilder.go +++ b/internal/vaultwarden/keybuilder/shared_key.go @@ -6,9 +6,30 @@ import ( "crypto/sha1" "encoding/base64" "fmt" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/encryptedstring" "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/symmetrickey" ) +func GenerateSharedKey(publicKey *rsa.PublicKey) (string, *symmetrickey.Key, error) { + sharedKey := make([]byte, 64) + _, err := rand.Read(sharedKey) + if err != nil { + return "", nil, fmt.Errorf("error generating random bytes: %w", err) + } + + newKey, err := symmetrickey.NewFromRawBytes(sharedKey) + if err != nil { + return "", nil, fmt.Errorf("error creating new symmetric crypto key") + } + + encryptedsharedKey, err := RSAEncrypt(sharedKey, publicKey) + if err != nil { + return "", nil, fmt.Errorf("error encrypting shared key") + } + + return encryptedsharedKey, newKey, nil +} + func RSAEncrypt(data []byte, publicKey *rsa.PublicKey) (string, error) { encryptedBytes, err := rsa.EncryptOAEP( sha1.New(), @@ -23,22 +44,19 @@ func RSAEncrypt(data []byte, publicKey *rsa.PublicKey) (string, error) { return fmt.Sprintf("%d.%s", symmetrickey.Rsa2048_OaepSha1_B64, base64.StdEncoding.EncodeToString(encryptedBytes)), nil } -func GenerateSharedKey(publicKey *rsa.PublicKey) (string, *symmetrickey.Key, error) { - sharedKey := make([]byte, 64) - _, err := rand.Read(sharedKey) +func RSADecrypt(data string, privateKey *rsa.PrivateKey) ([]byte, error) { + s, err := encryptedstring.NewFromEncryptedValue(data) if err != nil { - return "", nil, fmt.Errorf("error generating random bytes: %w", err) + return nil, fmt.Errorf("failed to create encrypted string from data: %w", err) } - - newKey, err := symmetrickey.NewFromRawBytes(sharedKey) - if err != nil { - return "", nil, fmt.Errorf("error creating new symmetric crypto key") + if s.Key.EncryptionType != symmetrickey.Rsa2048_OaepSha1_B64 { + return nil, fmt.Errorf("encType !=4") } - encryptedsharedKey, err := RSAEncrypt(sharedKey, publicKey) + clearText, err := rsa.DecryptOAEP(sha1.New(), nil, privateKey, s.Data, nil) if err != nil { - return "", nil, fmt.Errorf("error encrypting shared key") + return nil, fmt.Errorf("failed decryptRSA to decrypt text: %w", err) } - return encryptedsharedKey, newKey, nil + return clearText, nil } diff --git a/internal/vaultwarden/models/collection.go b/internal/vaultwarden/models/collection.go new file mode 100644 index 0000000..8e18cdd --- /dev/null +++ b/internal/vaultwarden/models/collection.go @@ -0,0 +1,12 @@ +package models + +// Collection represents a collection of items +type Collection struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + ExternalID string `json:"externalId"` + Name string `json:"name"` + Groups []string `json:"groups"` + Users []string `json:"users"` + Object string `json:"object"` +} diff --git a/internal/vaultwarden/models/organization.go b/internal/vaultwarden/models/organization.go index 6f89d00..b70898c 100644 --- a/internal/vaultwarden/models/organization.go +++ b/internal/vaultwarden/models/organization.go @@ -4,9 +4,17 @@ package models type Organization struct { ID string `json:"id"` Name string `json:"name"` - BillingEmail string `json:"billingEmail"` - CollectionName string `json:"collectionName"` + BillingEmail string `json:"billingEmail,omitempty"` + CollectionName string `json:"collectionName,omitempty"` Key string `json:"key"` - Keys KeyPair `json:"keys"` + Keys KeyPair `json:"keys,omitempty"` PlanType int64 `json:"planType"` + Enabled bool `json:"enabled,omitempty"` +} + +// OrganizationCollections represents a list of collections in an organization +type OrganizationCollections struct { + ContinuationToken string `json:"continuationToken"` + Data []Collection `json:"data"` + Object string `json:"object"` } diff --git a/internal/vaultwarden/models/user.go b/internal/vaultwarden/models/user.go index e57c7e4..6741a56 100644 --- a/internal/vaultwarden/models/user.go +++ b/internal/vaultwarden/models/user.go @@ -1,8 +1,9 @@ package models type User struct { - ID string `json:"id"` - Email string `json:"email"` - Key string `json:"key"` - PrivateKey string `json:"privateKey"` + ID string `json:"id"` + Email string `json:"email"` + Key string `json:"key"` + PrivateKey string `json:"privateKey"` + Organizations []Organization `json:"organizations,omitempty"` } diff --git a/internal/vaultwarden/organization.go b/internal/vaultwarden/organization.go index 4c150ed..8021c94 100644 --- a/internal/vaultwarden/organization.go +++ b/internal/vaultwarden/organization.go @@ -57,6 +57,18 @@ func (c *Client) CreateOrganization(ctx context.Context, org models.Organization return nil, fmt.Errorf("failed to create organization: %w", err) } + // Cache the organization secret + if c.AuthState != nil { + if c.AuthState.Organizations == nil { + c.AuthState.Organizations = make(map[string]OrganizationSecret) + } + c.AuthState.Organizations[orgResp.ID] = OrganizationSecret{ + Key: *sharedKey, + OrganizationUUID: orgResp.ID, + Name: orgResp.Name, + } + } + return &orgResp, nil } diff --git a/internal/vaultwarden/organization_collection.go b/internal/vaultwarden/organization_collection.go new file mode 100644 index 0000000..353c471 --- /dev/null +++ b/internal/vaultwarden/organization_collection.go @@ -0,0 +1,114 @@ +package vaultwarden + +import ( + "context" + "fmt" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/crypt" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/models" + "net/http" +) + +// CreateOrganizationCollection creates a new Vaultwarden organization collection +func (c *Client) CreateOrganizationCollection(ctx context.Context, orgID string, collection models.Collection) (*models.Collection, error) { + // First ensure we have valid authentication + if err := c.ensureUserAuth(ctx); err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + + // Get organization data from cache + orgSecret, exists := c.AuthState.Organizations[orgID] + if !exists { + return nil, fmt.Errorf("organization %s not found in cache", orgID) + } + + // Encrypt the collection name using the cached key + collectionName, err := crypt.EncryptAsString([]byte(collection.Name), orgSecret.Key) + if err != nil { + return nil, fmt.Errorf("failed to encrypt collection name: %w", err) + } + collection.Name = collectionName + + // Set empty lists for groups and users when none are provided + if collection.Groups == nil { + collection.Groups = []string{} + } + + if collection.Users == nil { + collection.Users = []string{} + } + + var collectionResp models.Collection + if _, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/organizations/%s/collections", orgID), collection, &collectionResp); err != nil { + return nil, fmt.Errorf("failed to create organization collection: %w", err) + } + + return &collectionResp, nil +} + +// GetOrganizationCollection retrieves a specific collection from an organization +func (c *Client) GetOrganizationCollection(ctx context.Context, orgID string, collectionID string) (*models.Collection, error) { + var listResp models.OrganizationCollections + if _, err := c.doRequest( + ctx, + http.MethodGet, + fmt.Sprintf("/api/organizations/%s/collections", orgID), + nil, + &listResp, + ); err != nil { + return nil, fmt.Errorf("failed to list organization collections: %w", err) + } + + // Find the specific collection in the response + for _, collection := range listResp.Data { + if collection.ID == collectionID { + return &collection, nil + } + } + + return nil, fmt.Errorf("collection %s not found in organization %s", collectionID, orgID) +} + +// UpdateOrganizationCollection updates an existing Vaultwarden organization collection +func (c *Client) UpdateOrganizationCollection(ctx context.Context, orgID, colID string, collection models.Collection) (*models.Collection, error) { + // First ensure we have valid authentication + if err := c.ensureUserAuth(ctx); err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + + // Get organization data from cache + orgSecret, exists := c.AuthState.Organizations[orgID] + if !exists { + return nil, fmt.Errorf("organization %s not found in cache", orgID) + } + + // Encrypt the collection name using the cached key + collectionName, err := crypt.EncryptAsString([]byte(collection.Name), orgSecret.Key) + if err != nil { + return nil, fmt.Errorf("failed to encrypt collection name: %w", err) + } + collection.Name = collectionName + + // Set empty lists for groups and users when none are provided + if collection.Groups == nil { + collection.Groups = []string{} + } + + if collection.Users == nil { + collection.Users = []string{} + } + + var collectionResp models.Collection + if _, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/organizations/%s/collections/%s", orgID, colID), collection, &collectionResp); err != nil { + return nil, fmt.Errorf("failed to update organization collection: %w", err) + } + + return &collectionResp, nil +} + +func (c *Client) DeleteOrganizationCollection(ctx context.Context, orgID, colID string) error { + if _, err := c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/organizations/%s/collections/%s", orgID, colID), nil, nil); err != nil { + return fmt.Errorf("failed to delete organization collection: %w", err) + } + + return nil +} diff --git a/internal/vaultwarden/profile.go b/internal/vaultwarden/profile.go new file mode 100644 index 0000000..cd8f607 --- /dev/null +++ b/internal/vaultwarden/profile.go @@ -0,0 +1,23 @@ +package vaultwarden + +import ( + "context" + "fmt" + "github.com/ottramst/terraform-provider-vaultwarden/internal/vaultwarden/models" + "net/http" +) + +// GetProfile retrieves the user's profile +func (c *Client) GetProfile(ctx context.Context) (*models.User, error) { + // Ensure we have valid authentication + if err := c.ensureUserAuth(ctx); err != nil { + return nil, fmt.Errorf("authentication required: %w", err) + } + + var user models.User + if _, err := c.doRequest(ctx, http.MethodGet, "/api/accounts/profile", nil, &user); err != nil { + return nil, fmt.Errorf("failed to get profile: %w", err) + } + + return &user, nil +}