From fce28663e29e9343f3abfbba48bcda1936a8b27e Mon Sep 17 00:00:00 2001 From: Maxime Lagresle Date: Tue, 5 Nov 2024 08:13:58 +0100 Subject: [PATCH] add support for creating projects --- docs/resources/project.md | 39 +++++ .../resources/bitwarden_project/import.sh | 1 + .../resources/bitwarden_project/resource.tf | 3 + internal/bitwarden/client.go | 3 + .../bitwarden/embedded/secrets_manager.go | 90 +++++++++- .../models/{models.go => password_manager.go} | 1 + internal/bitwarden/models/secrets_manager.go | 13 +- .../bitwarden/test/test_secrets_manager.go | 160 ++++++++++++++++-- internal/bitwarden/webapi/client.go | 48 +++++- internal/bitwarden/webapi/models.go | 43 ++--- internal/provider/project.go | 39 +++++ internal/provider/provider.go | 1 + internal/provider/provider_utils_test.go | 21 +-- internal/provider/resource_project.go | 19 +++ internal/provider/resource_project_test.go | 66 ++++++++ internal/provider/resource_secret_test.go | 35 ++-- 16 files changed, 503 insertions(+), 79 deletions(-) create mode 100644 docs/resources/project.md create mode 100644 examples/resources/bitwarden_project/import.sh create mode 100644 examples/resources/bitwarden_project/resource.tf rename internal/bitwarden/models/{models.go => password_manager.go} (99%) create mode 100644 internal/provider/resource_project.go create mode 100644 internal/provider/resource_project_test.go diff --git a/docs/resources/project.md b/docs/resources/project.md new file mode 100644 index 0000000..c37d398 --- /dev/null +++ b/docs/resources/project.md @@ -0,0 +1,39 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "bitwarden_project Resource - terraform-provider-bitwarden" +subcategory: "" +description: |- + Manages a Project. +--- + +# bitwarden_project (Resource) + +Manages a Project. + +## Example Usage + +```terraform +resource "bitwarden_project" "example" { + name = "Example Project" +} +``` + + +## Schema + +### Required + +- `name` (String) Name. + +### Optional + +- `id` (String) Identifier. +- `organization_id` (String) Identifier of the organization. + +## Import + +Import is supported using the following syntax: + +```shell +$ terraform import bitwarden_project.example +``` diff --git a/examples/resources/bitwarden_project/import.sh b/examples/resources/bitwarden_project/import.sh new file mode 100644 index 0000000..d263e8c --- /dev/null +++ b/examples/resources/bitwarden_project/import.sh @@ -0,0 +1 @@ +$ terraform import bitwarden_project.example diff --git a/examples/resources/bitwarden_project/resource.tf b/examples/resources/bitwarden_project/resource.tf new file mode 100644 index 0000000..883fc9e --- /dev/null +++ b/examples/resources/bitwarden_project/resource.tf @@ -0,0 +1,3 @@ +resource "bitwarden_project" "example" { + name = "Example Project" +} diff --git a/internal/bitwarden/client.go b/internal/bitwarden/client.go index cfcd85a..e998910 100644 --- a/internal/bitwarden/client.go +++ b/internal/bitwarden/client.go @@ -25,8 +25,11 @@ type PasswordManager interface { } type SecretsManager interface { + CreateProject(ctx context.Context, project models.Project) (*models.Project, error) CreateSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) + DeleteProject(ctx context.Context, project models.Project) error DeleteSecret(ctx context.Context, secret models.Secret) error + EditProject(ctx context.Context, project models.Project) (*models.Project, error) EditSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) GetProject(ctx context.Context, project models.Project) (*models.Project, error) GetSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) diff --git a/internal/bitwarden/embedded/secrets_manager.go b/internal/bitwarden/embedded/secrets_manager.go index 013f42a..0c1909b 100644 --- a/internal/bitwarden/embedded/secrets_manager.go +++ b/internal/bitwarden/embedded/secrets_manager.go @@ -17,8 +17,11 @@ import ( ) type SecretsManager interface { + CreateProject(ctx context.Context, project models.Project) (*models.Project, error) CreateSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) + DeleteProject(ctx context.Context, project models.Project) error DeleteSecret(ctx context.Context, secret models.Secret) error + EditProject(ctx context.Context, project models.Project) (*models.Project, error) EditSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) GetProject(ctx context.Context, project models.Project) (*models.Project, error) GetSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) @@ -60,6 +63,33 @@ type secretsManager struct { serverURL string } +func (v *secretsManager) CreateProject(ctx context.Context, project models.Project) (*models.Project, error) { + if v.mainEncryptionKey == nil { + return nil, models.ErrLoggedOut + } + + var resProject *models.Project + + encProject, err := encryptProject(project, *v.mainEncryptionKey) + if err != nil { + return nil, fmt.Errorf("error encrypting project for creation: %w", err) + } + encProject.OrganizationID = v.mainOrganizationId + + resEncProject, err := v.client.CreateProject(ctx, *encProject) + + if err != nil { + return nil, fmt.Errorf("error creating project: %w", err) + } + + resProject, err = decryptProject(*resEncProject, *v.mainEncryptionKey) + if err != nil { + return nil, fmt.Errorf("error decrypting project after creation: %w", err) + } + + return resProject, nil +} + func (v *secretsManager) CreateSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) { if v.mainEncryptionKey == nil { return nil, models.ErrLoggedOut @@ -87,6 +117,15 @@ func (v *secretsManager) CreateSecret(ctx context.Context, secret models.Secret) return resSecret, nil } +func (v *secretsManager) DeleteProject(ctx context.Context, project models.Project) error { + err := v.client.DeleteProject(ctx, project.ID) + if err != nil { + return fmt.Errorf("error deleting project: %w", err) + } + + return nil +} + func (v *secretsManager) DeleteSecret(ctx context.Context, secret models.Secret) error { err := v.client.DeleteSecret(ctx, secret.ID) if err != nil { @@ -96,6 +135,32 @@ func (v *secretsManager) DeleteSecret(ctx context.Context, secret models.Secret) return nil } +func (v *secretsManager) EditProject(ctx context.Context, project models.Project) (*models.Project, error) { + if v.mainEncryptionKey == nil { + return nil, models.ErrLoggedOut + } + + var resProject *models.Project + + encProject, err := encryptProject(project, *v.mainEncryptionKey) + if err != nil { + return nil, fmt.Errorf("error encrypting project for edition: %w", err) + } + + resEncProject, err := v.client.EditProject(ctx, *encProject) + + if err != nil { + return nil, fmt.Errorf("error editing project: %w", err) + } + + resProject, err = decryptProject(*resEncProject, *v.mainEncryptionKey) + if err != nil { + return nil, fmt.Errorf("error decrypting project after edition: %w", err) + } + + return resProject, nil +} + func (v *secretsManager) EditSecret(ctx context.Context, secret models.Secret) (*models.Secret, error) { if v.mainEncryptionKey == nil { return nil, models.ErrLoggedOut @@ -301,18 +366,31 @@ func decryptSecret[T SecretType](webapiSecret T, mainEncryptionKey symmetrickey. }, nil } -func decryptProject(webapiProject webapi.Project, mainEncryptionKey symmetrickey.Key) (*models.Project, error) { - projectName, err := decryptStringIfNotEmpty(webapiProject.Name, mainEncryptionKey) +func decryptProject(project models.Project, mainEncryptionKey symmetrickey.Key) (*models.Project, error) { + projectName, err := decryptStringIfNotEmpty(project.Name, mainEncryptionKey) if err != nil { return nil, fmt.Errorf("error decrypting project name: %w", err) } return &models.Project{ - CreationDate: webapiProject.CreationDate, - ID: webapiProject.ID, + CreationDate: project.CreationDate, + ID: project.ID, + Name: projectName, + OrganizationID: project.OrganizationID, + RevisionDate: project.RevisionDate, + }, nil +} + +func encryptProject(project models.Project, mainEncryptionKey symmetrickey.Key) (*models.Project, error) { + projectName, err := encryptAsStringIfNotEmpty(project.Name, mainEncryptionKey) + if err != nil { + return nil, fmt.Errorf("error encrypt project name: %w", err) + } + + return &models.Project{ + ID: project.ID, Name: projectName, - OrganizationID: webapiProject.OrganizationID, - RevisionDate: webapiProject.RevisionDate, + OrganizationID: project.OrganizationID, }, nil } diff --git a/internal/bitwarden/models/models.go b/internal/bitwarden/models/password_manager.go similarity index 99% rename from internal/bitwarden/models/models.go rename to internal/bitwarden/models/password_manager.go index f08043c..9e8fd47 100644 --- a/internal/bitwarden/models/models.go +++ b/internal/bitwarden/models/password_manager.go @@ -60,6 +60,7 @@ const ( ObjectCipherDetails ObjectType = "cipherDetails" // when creating attachment data ObjectAttachmentFileUpload ObjectType = "attachment-fileUpload" // when creating attachment data ObjectApiKey ObjectType = "api-key" + ObjectProject ObjectType = "project" ObjectSecret ObjectType = "secret" ) diff --git a/internal/bitwarden/models/secrets_manager.go b/internal/bitwarden/models/secrets_manager.go index f4aff0a..5a49f2f 100644 --- a/internal/bitwarden/models/secrets_manager.go +++ b/internal/bitwarden/models/secrets_manager.go @@ -15,9 +15,12 @@ type Secret struct { } type Project struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - Name string `json:"name"` - CreationDate time.Time `json:"creationDate"` - RevisionDate time.Time `json:"revisionDate"` + ID string `json:"id,omitempty"` + OrganizationID string `json:"organizationId,omitempty"` + Name string `json:"name,omitempty"` + CreationDate time.Time `json:"creationDate,omitempty"` + RevisionDate time.Time `json:"revisionDate,omitempty"` + Read bool `json:"read,omitempty"` + Write bool `json:"write,omitempty"` + Object string `json:"object,omitempty"` } diff --git a/internal/bitwarden/test/test_secrets_manager.go b/internal/bitwarden/test/test_secrets_manager.go index 80b1812..58c9b1e 100644 --- a/internal/bitwarden/test/test_secrets_manager.go +++ b/internal/bitwarden/test/test_secrets_manager.go @@ -32,7 +32,8 @@ func NewTestSecretsManager() *testSecretsManager { }, issuedJwtTokens: map[string]string{}, knownClients: map[string]Clients{}, - knownOrganizations: map[string]string{}, + knownOrganizations: map[string]struct{}{}, + projectsStore: map[string]models.Project{}, secretsStore: map[string]webapi.Secret{}, } } @@ -41,7 +42,8 @@ type testSecretsManager struct { clientSideInformation ClientSideInformation issuedJwtTokens map[string]string knownClients map[string]Clients - knownOrganizations map[string]string + knownOrganizations map[string]struct{} + projectsStore map[string]models.Project secretsStore map[string]webapi.Secret } @@ -69,6 +71,10 @@ type CreateAccessTokenResponse struct { func (tsm *testSecretsManager) Run(ctx context.Context, serverPort int) { handler := mux.NewRouter() handler.HandleFunc("/api/organizations/{orgId}/secrets", tsm.handlerCreateGetSecret).Methods("POST", "GET") + handler.HandleFunc("/api/organizations/{orgId}/projects", tsm.handlerCreateProject).Methods("POST") + handler.HandleFunc("/api/projects/{projectId}", tsm.handlerGetProject).Methods("GET") + handler.HandleFunc("/api/projects/{projectId}", tsm.handlerEditProject).Methods("PUT") + handler.HandleFunc("/api/projects/delete", tsm.handlerDeleteProject).Methods("POST") handler.HandleFunc("/api/secrets/{secretId}", tsm.handlerGetSecret).Methods("GET") handler.HandleFunc("/api/secrets/{secretId}", tsm.handlerEditSecret).Methods("PUT") handler.HandleFunc("/api/secrets/delete", tsm.handlerDeleteSecret).Methods("POST") @@ -90,18 +96,17 @@ func (tsm *testSecretsManager) Run(ctx context.Context, serverPort int) { server.Shutdown(context.Background()) } -func (tsm *testSecretsManager) ClientCreateNewOrganization() (string, string, error) { +func (tsm *testSecretsManager) ClientCreateNewOrganization() (string, error) { encryptionKey, err := generateOrganizationKey() if err != nil { - return "", "", err + return "", err } orgId := uuid.New().String() - defaultProjectId := uuid.New().String() + tsm.knownOrganizations[orgId] = struct{}{} tsm.clientSideInformation.orgEncryptionKeys[orgId] = *encryptionKey - tsm.knownOrganizations[orgId] = defaultProjectId - return orgId, tsm.knownOrganizations[orgId], nil + return orgId, nil } func (tsm *testSecretsManager) ClientCreateAccessToken(orgId string) (string, error) { @@ -209,9 +214,47 @@ func (tsm *testSecretsManager) handlerCreateGetSecret(w http.ResponseWriter, r * } } +func (tsm *testSecretsManager) handlerCreateProject(w http.ResponseWriter, r *http.Request) { + orgId := mux.Vars(r)["orgId"] + + err := tsm.checkAuthentication(r.Header.Get("Authorization")) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var projectCreationRequest webapi.CreateProjectRequest + if err := json.Unmarshal(body, &projectCreationRequest); err != nil { + http.Error(w, "Failed to unmarshal request body", http.StatusBadRequest) + return + } + + project := models.Project{ + ID: uuid.New().String(), + Name: projectCreationRequest.Name, + OrganizationID: orgId, + CreationDate: time.Now(), + RevisionDate: time.Now(), + Object: string(models.ObjectProject), + } + tsm.projectsStore[project.ID] = project + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(project); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + func (tsm *testSecretsManager) handlerCreateSecret(w http.ResponseWriter, r *http.Request) { orgId := mux.Vars(r)["orgId"] - orgDefaultProjectID, v := tsm.knownOrganizations[orgId] + _, v := tsm.knownOrganizations[orgId] if !v { http.Error(w, "Invalid organization", http.StatusBadRequest) return @@ -236,6 +279,16 @@ func (tsm *testSecretsManager) handlerCreateSecret(w http.ResponseWriter, r *htt return } + projects := []models.Project{} + for _, v := range secretCreationRequest.ProjectIDs { + project, projectExists := tsm.projectsStore[v] + if !projectExists { + http.Error(w, "Project not found", http.StatusBadRequest) + return + } + projects = append(projects, project) + } + secret := webapi.Secret{ SecretSummary: webapi.SecretSummary{ ID: uuid.New().String(), @@ -243,12 +296,9 @@ func (tsm *testSecretsManager) handlerCreateSecret(w http.ResponseWriter, r *htt Key: secretCreationRequest.Key, CreationDate: time.Now(), RevisionDate: time.Now(), - Projects: []webapi.Project{{ - OrganizationID: orgId, - ID: orgDefaultProjectID, - }}, - Read: true, - Write: true, + Projects: projects, + Read: true, + Write: true, }, Value: secretCreationRequest.Value, Note: secretCreationRequest.Note, @@ -290,6 +340,25 @@ func (tsm *testSecretsManager) handlerGetSecrets(w http.ResponseWriter, r *http. } } +func (tsm *testSecretsManager) handlerGetProject(w http.ResponseWriter, r *http.Request) { + err := tsm.checkAuthentication(r.Header.Get("Authorization")) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + projectId := mux.Vars(r)["projectId"] + project, projectExists := tsm.projectsStore[projectId] + if !projectExists { + http.Error(w, "Project not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(project); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + func (tsm *testSecretsManager) handlerGetSecret(w http.ResponseWriter, r *http.Request) { err := tsm.checkAuthentication(r.Header.Get("Authorization")) if err != nil { @@ -309,6 +378,43 @@ func (tsm *testSecretsManager) handlerGetSecret(w http.ResponseWriter, r *http.R } } +func (tsm *testSecretsManager) handlerEditProject(w http.ResponseWriter, r *http.Request) { + err := tsm.checkAuthentication(r.Header.Get("Authorization")) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + projectId := mux.Vars(r)["projectId"] + project, projectExists := tsm.projectsStore[projectId] + if !projectExists { + http.Error(w, "Project not found", http.StatusNotFound) + return + } + + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var projectCreationRequest webapi.CreateProjectRequest + if err := json.Unmarshal(body, &projectCreationRequest); err != nil { + http.Error(w, "Failed to unmarshal request body", http.StatusBadRequest) + return + } + + project.RevisionDate = time.Now() + project.Name = projectCreationRequest.Name + tsm.projectsStore[projectId] = project + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(project); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + func (tsm *testSecretsManager) handlerEditSecret(w http.ResponseWriter, r *http.Request) { err := tsm.checkAuthentication(r.Header.Get("Authorization")) if err != nil { @@ -348,6 +454,32 @@ func (tsm *testSecretsManager) handlerEditSecret(w http.ResponseWriter, r *http. } } +func (tsm *testSecretsManager) handlerDeleteProject(w http.ResponseWriter, r *http.Request) { + err := tsm.checkAuthentication(r.Header.Get("Authorization")) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var IDs []string + if err := json.Unmarshal(body, &IDs); err != nil { + http.Error(w, "Failed to unmarshal request body", http.StatusBadRequest) + return + } + + for _, v := range IDs { + delete(tsm.projectsStore, v) + } + w.WriteHeader(http.StatusOK) +} + func (tsm *testSecretsManager) handlerDeleteSecret(w http.ResponseWriter, r *http.Request) { err := tsm.checkAuthentication(r.Header.Get("Authorization")) if err != nil { diff --git a/internal/bitwarden/webapi/client.go b/internal/bitwarden/webapi/client.go index 7d6f15f..b329406 100644 --- a/internal/bitwarden/webapi/client.go +++ b/internal/bitwarden/webapi/client.go @@ -32,23 +32,26 @@ type Client interface { CreateObjectAttachmentData(ctx context.Context, itemId, attachmentId string, data []byte) error CreateOrganization(ctx context.Context, req CreateOrganizationRequest) (*CreateOrganizationResponse, error) CreateOrgCollection(ctx context.Context, orgId string, req OrganizationCreationRequest) (*Collection, error) + CreateProject(ctx context.Context, project models.Project) (*models.Project, error) CreateSecret(ctx context.Context, secret models.Secret) (*Secret, error) DeleteFolder(ctx context.Context, objID string) error DeleteObject(ctx context.Context, objID string) error DeleteObjectAttachment(ctx context.Context, itemId, attachmentId string) error DeleteOrgCollection(ctx context.Context, orgID, collectionID string) error + DeleteProject(ctx context.Context, projectId string) error DeleteSecret(ctx context.Context, secretId string) error EditFolder(ctx context.Context, obj Folder) (*Folder, error) EditObject(context.Context, models.Object) (*models.Object, error) EditOrgCollection(ctx context.Context, orgId, objId string, obj OrganizationCreationRequest) (*Collection, error) + EditProject(context.Context, models.Project) (*models.Project, error) EditSecret(ctx context.Context, secret models.Secret) (*Secret, error) GetAPIKey(ctx context.Context, username, password string, kdfConfig models.KdfConfiguration) (*ApiKey, error) GetCollections(ctx context.Context, orgID string) ([]CollectionResponseItem, error) GetContentFromURL(ctx context.Context, url string) ([]byte, error) GetObjectAttachment(ctx context.Context, itemId, attachmentId string) (*models.Attachment, error) GetProfile(context.Context) (*Profile, error) - GetProject(ctx context.Context, projectId string) (*Project, error) - GetProjects(ctx context.Context, orgId string) ([]Project, error) + GetProject(ctx context.Context, projectId string) (*models.Project, error) + GetProjects(ctx context.Context, orgId string) ([]models.Project, error) GetSecret(ctx context.Context, secretId string) (*Secret, error) GetSecrets(ctx context.Context, orgId string) ([]SecretSummary, error) LoginWithAccessToken(ctx context.Context, clientId, clientSecret string) (*MachineTokenResponse, error) @@ -166,6 +169,18 @@ func (c *client) CreateOrgCollection(ctx context.Context, orgId string, req Orga return doRequest[Collection](ctx, c.httpClient, httpReq) } +func (c *client) CreateProject(ctx context.Context, project models.Project) (*models.Project, error) { + projectCreationRequest := CreateProjectRequest{ + Name: project.Name, + } + httpReq, err := c.prepareRequest(ctx, "POST", fmt.Sprintf("%s/api/organizations/%s/projects", c.serverURL, project.OrganizationID), projectCreationRequest) + if err != nil { + return nil, fmt.Errorf("error preparing secret creation request: %w", err) + } + + return doRequest[models.Project](ctx, c.httpClient, httpReq) +} + func (c *client) CreateSecret(ctx context.Context, secret models.Secret) (*Secret, error) { cipherCreationRequest := CreateSecretRequest{ Key: secret.Key, @@ -221,6 +236,17 @@ func (c *client) DeleteOrgCollection(ctx context.Context, orgID, collectionID st return err } +func (c *client) DeleteProject(ctx context.Context, projectId string) error { + IDs := []string{projectId} + httpReq, err := c.prepareRequest(ctx, "POST", fmt.Sprintf("%s/api/projects/delete", c.serverURL), IDs) + if err != nil { + return fmt.Errorf("error preparing project deletion request: %w", err) + } + + _, err = doRequest[[]byte](ctx, c.httpClient, httpReq) + return err +} + func (c *client) DeleteSecret(ctx context.Context, secretId string) error { IDs := []string{secretId} httpReq, err := c.prepareRequest(ctx, "POST", fmt.Sprintf("%s/api/secrets/delete", c.serverURL), IDs) @@ -267,6 +293,18 @@ func (c *client) EditOrgCollection(ctx context.Context, orgId, objId string, obj return doRequest[Collection](ctx, c.httpClient, req) } +func (c *client) EditProject(ctx context.Context, project models.Project) (*models.Project, error) { + projectEditionRequest := CreateProjectRequest{ + Name: project.Name, + } + httpReq, err := c.prepareRequest(ctx, "PUT", fmt.Sprintf("%s/api/projects/%s", c.serverURL, project.ID), projectEditionRequest) + if err != nil { + return nil, fmt.Errorf("error preparing secret edition request: %w", err) + } + + return doRequest[models.Project](ctx, c.httpClient, httpReq) +} + func (c *client) EditSecret(ctx context.Context, secret models.Secret) (*Secret, error) { cipherCreationRequest := CreateSecretRequest{ Key: secret.Key, @@ -334,16 +372,16 @@ func (c *client) GetProfile(ctx context.Context) (*Profile, error) { return doRequest[Profile](ctx, c.httpClient, httpReq) } -func (c *client) GetProject(ctx context.Context, projectId string) (*Project, error) { +func (c *client) GetProject(ctx context.Context, projectId string) (*models.Project, error) { httpReq, err := c.prepareRequest(ctx, "GET", fmt.Sprintf("%s/api/projects/%s", c.serverURL, projectId), nil) if err != nil { return nil, fmt.Errorf("error preparing project retrieval request: %w", err) } - return doRequest[Project](ctx, c.httpClient, httpReq) + return doRequest[models.Project](ctx, c.httpClient, httpReq) } -func (c *client) GetProjects(ctx context.Context, orgId string) ([]Project, error) { +func (c *client) GetProjects(ctx context.Context, orgId string) ([]models.Project, error) { httpReq, err := c.prepareRequest(ctx, "GET", fmt.Sprintf("%s/api/organizations/%s/projects", c.serverURL, orgId), nil) if err != nil { return nil, fmt.Errorf("error preparing projects retrieval request: %w", err) diff --git a/internal/bitwarden/webapi/models.go b/internal/bitwarden/webapi/models.go index 3fcfbcf..1a65307 100644 --- a/internal/bitwarden/webapi/models.go +++ b/internal/bitwarden/webapi/models.go @@ -177,32 +177,21 @@ type MachineTokenEncryptedPayload struct { EncryptionKey string `json:"encryptionKey"` } -type Project struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - Name string `json:"name"` - CreationDate time.Time `json:"creationDate"` - RevisionDate time.Time `json:"revisionDate"` - Read bool `json:"read"` - Write bool `json:"write"` - Object string `json:"object"` -} - type Projects struct { - Data []Project `json:"data"` - ContinuationToken *string `json:"continuationToken"` - Object string `json:"object"` + Data []models.Project `json:"data"` + ContinuationToken *string `json:"continuationToken"` + Object string `json:"object"` } type SecretSummary struct { - ID string `json:"id"` - OrganizationID string `json:"organizationId"` - Key string `json:"key"` - CreationDate time.Time `json:"creationDate"` - RevisionDate time.Time `json:"revisionDate"` - Projects []Project `json:"projects"` - Read bool `json:"read"` - Write bool `json:"write"` + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + Key string `json:"key"` + CreationDate time.Time `json:"creationDate"` + RevisionDate time.Time `json:"revisionDate"` + Projects []models.Project `json:"projects"` + Read bool `json:"read"` + Write bool `json:"write"` } type Secret struct { @@ -213,9 +202,9 @@ type Secret struct { } type SecretsWithProjectsList struct { - Secrets []SecretSummary `json:"secrets"` - Projects []Project `json:"projects"` - Object string `json:"object"` + Secrets []SecretSummary `json:"secrets"` + Projects []models.Project `json:"projects"` + Object string `json:"object"` } type CreateSecretRequest struct { @@ -226,6 +215,10 @@ type CreateSecretRequest struct { AccessPoliciesRequests *AccessPoliciesRequests `json:"accessPoliciesRequests,omitempty"` } +type CreateProjectRequest struct { + Name string `json:"name"` +} + type AccessPoliciesRequests struct { UserAccessPolicyRequests []interface{} `json:"userAccessPolicyRequests"` GroupAccessPolicyRequests []interface{} `json:"groupAccessPolicyRequests"` diff --git a/internal/provider/project.go b/internal/provider/project.go index 8b70b19..c9270f1 100644 --- a/internal/provider/project.go +++ b/internal/provider/project.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "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/maxlaverse/terraform-provider-bitwarden/internal/bitwarden" @@ -12,6 +13,39 @@ import ( type projectOperationFunc func(ctx context.Context, secret models.Project) (*models.Project, error) +func resourceCreateProject() secretsManagerOperation { + return func(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics { + return projectCreate(ctx, d, bwsClient) + } +} + +func resourceReadProjectIgnoreMissing(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics { + err := projectOperation(ctx, d, func(ctx context.Context, project models.Project) (*models.Project, error) { + return bwsClient.GetProject(ctx, project) + }) + + if errors.Is(err, models.ErrObjectNotFound) { + d.SetId("") + tflog.Warn(ctx, "Project not found, removing from state") + return diag.Diagnostics{} + } + + return diag.FromErr(err) +} + +func resourceUpdateProject(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics { + return diag.FromErr(projectOperation(ctx, d, bwsClient.EditProject)) +} +func resourceDeleteProject(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics { + return diag.FromErr(projectOperation(ctx, d, func(ctx context.Context, project models.Project) (*models.Project, error) { + return nil, bwsClient.DeleteProject(ctx, project) + })) +} + +func projectCreate(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics { + return diag.FromErr(projectOperation(ctx, d, bwsClient.CreateProject)) +} + func projectRead(ctx context.Context, d *schema.ResourceData, bwsClient bitwarden.SecretsManager) diag.Diagnostics { return diag.FromErr(projectOperation(ctx, d, func(ctx context.Context, projectReq models.Project) (*models.Project, error) { project, err := bwsClient.GetProject(ctx, projectReq) @@ -69,3 +103,8 @@ func projectDataFromStruct(_ context.Context, d *schema.ResourceData, project *m return nil } + +func resourceImportProject(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + d.SetId(d.Id()) + return []*schema.ResourceData{d}, nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 57b72de..84344f5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -135,6 +135,7 @@ func New(version string) func() *schema.Provider { "bitwarden_item_login": resourceItemLogin(), "bitwarden_item_secure_note": resourceItemSecureNote(), "bitwarden_org_collection": resourceOrgCollection(), + "bitwarden_project": resourceProject(), "bitwarden_secret": resourceSecret(), }, } diff --git a/internal/provider/provider_utils_test.go b/internal/provider/provider_utils_test.go index 7f574c8..4ef8c83 100644 --- a/internal/provider/provider_utils_test.go +++ b/internal/provider/provider_utils_test.go @@ -255,22 +255,24 @@ func tfConfigPasswordManagerProvider() string { `, testPassword, testServerURL, testEmail, useEmbeddedClientStr) } -func testOrRealSecretsManagerProvider(t *testing.T) (string, string, func()) { - tfProvider, testProjectId := tfConfigSecretsManagerProvider() - if len(testProjectId) > 0 { +func testOrRealSecretsManagerProvider(t *testing.T) (string, func()) { + tfProvider, defined := tfConfigSecretsManagerProvider() + if defined { + t.Logf("Using real Bitwarden Secrets Manager") stop := func() {} - return tfProvider, testProjectId, stop + return tfProvider, stop } else { + t.Logf("Spawning test Bitwarden Secrets Manager") return spawnTestSecretsManager(t) } } -func spawnTestSecretsManager(t *testing.T) (string, string, func()) { +func spawnTestSecretsManager(t *testing.T) (string, func()) { testSecretsManager := test.NewTestSecretsManager() ctx, stop := context.WithCancel(context.Background()) go testSecretsManager.Run(ctx, 8081) - orgId, testProjectId, err := testSecretsManager.ClientCreateNewOrganization() + orgId, err := testSecretsManager.ClientCreateNewOrganization() if err != nil { t.Fatal(err) } @@ -290,12 +292,11 @@ func spawnTestSecretsManager(t *testing.T) (string, string, func()) { } } `, accessToken) - return providerConfiguration, testProjectId, stop + return providerConfiguration, stop } -func tfConfigSecretsManagerProvider() (string, string) { +func tfConfigSecretsManagerProvider() (string, bool) { accessToken := os.Getenv("TEST_REAL_BWS_ACCESS_TOKEN") - testProjectId := os.Getenv("TEST_REAL_BWS_PROJECT_ID") return fmt.Sprintf(` provider "bitwarden" { access_token = "%s" @@ -304,7 +305,7 @@ func tfConfigSecretsManagerProvider() (string, string) { embedded_client = true } } -`, accessToken), testProjectId +`, accessToken), len(accessToken) > 0 } func getObjectID(n string, objectId *string) resource.TestCheckFunc { diff --git a/internal/provider/resource_project.go b/internal/provider/resource_project.go new file mode 100644 index 0000000..74a4e4d --- /dev/null +++ b/internal/provider/resource_project.go @@ -0,0 +1,19 @@ +package provider + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceProject() *schema.Resource { + resourceProjectSchema := projectSchema(Resource) + + return &schema.Resource{ + Description: "Manages a Project.", + CreateContext: withSecretsManager(resourceCreateProject()), + ReadContext: withSecretsManager(resourceReadProjectIgnoreMissing), + UpdateContext: withSecretsManager(resourceUpdateProject), + DeleteContext: withSecretsManager(resourceDeleteProject), + Schema: resourceProjectSchema, + Importer: resourceImporter(resourceImportProject), + } +} diff --git a/internal/provider/resource_project_test.go b/internal/provider/resource_project_test.go new file mode 100644 index 0000000..78353fb --- /dev/null +++ b/internal/provider/resource_project_test.go @@ -0,0 +1,66 @@ +package provider + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestResourceProject(t *testing.T) { + tfProvider, stop := testOrRealSecretsManagerProvider(t) + defer stop() + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: tfProvider + tfConfigResourceProject("foo", "project-foo"), + Check: checkProject("bitwarden_project.foo", "project-foo"), + }, + // Test Sourcing Project by ID + { + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigDataProjectByID("bitwarden_project.foo.id"), + Check: checkProject("data.bitwarden_project.foo_data", "project-foo"), + }, + // Test Sourcing Project by ID with NO MATCH + { + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigDataProjectByID("\"27a0007a-a517-4f25-8c2e-baf31ca3b034\""), + ExpectError: regexp.MustCompile("Error: object not found"), + }, + // Test Editing Project + { + Config: tfProvider + tfConfigResourceProject("foo", "project-bar"), + Check: checkProject("bitwarden_project.foo", "project-bar"), + }, + }, + }) +} + +func checkProject(fullRessourceName, projectName string) resource.TestCheckFunc { + return resource.ComposeTestCheckFunc( + resource.TestMatchResourceAttr(fullRessourceName, attributeID, regexp.MustCompile("^([a-z0-9-]+)$")), + resource.TestCheckResourceAttr(fullRessourceName, attributeName, projectName), + resource.TestMatchResourceAttr(fullRessourceName, attributeOrganizationID, regexp.MustCompile("^([a-z0-9-]+)$")), + ) +} +func tfConfigDataProjectByID(id string) string { + return fmt.Sprintf(` +data "bitwarden_project" "foo_data" { + provider = bitwarden + + id = %s +} +`, id) +} + +func tfConfigResourceProject(resourceName, projectName string) string { + return fmt.Sprintf(` + resource "bitwarden_project" "%s" { + provider = bitwarden + + name = "%s" + } +`, resourceName, projectName) +} diff --git a/internal/provider/resource_secret_test.go b/internal/provider/resource_secret_test.go index 246a5f7..8c47c5b 100644 --- a/internal/provider/resource_secret_test.go +++ b/internal/provider/resource_secret_test.go @@ -27,9 +27,11 @@ func TestResourceSecretSchema(t *testing.T) { } func TestResourceSecret(t *testing.T) { - tfProvider, testProjectId, stop := testOrRealSecretsManagerProvider(t) + tfProvider, stop := testOrRealSecretsManagerProvider(t) defer stop() + projectResourceId := "bitwarden_project.foo.id" + resource.Test(t, resource.TestCase{ ProviderFactories: providerFactories, Steps: []resource.TestStep{ @@ -42,40 +44,45 @@ func TestResourceSecret(t *testing.T) { ExpectError: regexp.MustCompile(": conflicts"), }, { - Config: tfProvider + tfConfigResourceSecret("foo", testProjectId), - Check: checkSecret("bitwarden_secret.foo", testProjectId), + Config: tfProvider + tfConfigResourceSecret("foo", "\"fake-project-id\""), + ExpectError: regexp.MustCompile("400!=200"), + }, + // Test Creating Secret with INEXISTENT Project + { + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigResourceSecret("foo", projectResourceId), + Check: checkSecret("bitwarden_secret.foo"), }, // Test Sourcing Secret by ID { - Config: tfProvider + tfConfigResourceSecret("foo", testProjectId) + tfConfigDataSecretByID("bitwarden_secret.foo.id"), - Check: checkSecret("data.bitwarden_secret.foo_data", testProjectId), + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigResourceSecret("foo", projectResourceId) + tfConfigDataSecretByID("bitwarden_secret.foo.id"), + Check: checkSecret("data.bitwarden_secret.foo_data"), }, // Test Sourcing Secret by ID with NO MATCH { - Config: tfProvider + tfConfigResourceSecret("foo", testProjectId) + tfConfigDataSecretByID("\"27a0007a-a517-4f25-8c2e-baf31ca3b034\""), + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigResourceSecret("foo", projectResourceId) + tfConfigDataSecretByID("\"27a0007a-a517-4f25-8c2e-baf31ca3b034\""), ExpectError: regexp.MustCompile("Error: object not found"), }, // Test Sourcing Secret by KEY { - Config: tfProvider + tfConfigResourceSecret("foo", testProjectId) + tfConfigDataSecretByKey(), - Check: checkSecret("data.bitwarden_secret.foo_data", testProjectId), + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigResourceSecret("foo", projectResourceId) + tfConfigDataSecretByKey(), + Check: checkSecret("data.bitwarden_secret.foo_data"), }, // Test Sourcing Secret with MULTIPLE MATCHES { - Config: tfProvider + tfConfigResourceSecret("foo", testProjectId) + tfConfigResourceSecret("foo2", testProjectId) + tfConfigDataSecretByKey(), + Config: tfProvider + tfConfigResourceProject("foo", "project-foo") + tfConfigResourceSecret("foo", projectResourceId) + tfConfigResourceSecret("foo2", projectResourceId) + tfConfigDataSecretByKey(), ExpectError: regexp.MustCompile("Error: too many objects found"), }, }, }) } -func checkSecret(fullRessourceName, testProjectId string) resource.TestCheckFunc { +func checkSecret(fullRessourceName string) resource.TestCheckFunc { return resource.ComposeTestCheckFunc( resource.TestMatchResourceAttr(fullRessourceName, attributeID, regexp.MustCompile("^([a-z0-9-]+)$")), resource.TestCheckResourceAttr(fullRessourceName, attributeKey, "login-bar"), resource.TestCheckResourceAttr(fullRessourceName, attributeValue, "value-bar"), resource.TestCheckResourceAttr(fullRessourceName, attributeNote, "note-bar"), - resource.TestCheckResourceAttr(fullRessourceName, attributeProjectID, testProjectId), + resource.TestMatchResourceAttr(fullRessourceName, attributeProjectID, regexp.MustCompile("^([a-z0-9-]+)$")), ) } func tfConfigDataSecretByID(id string) string { @@ -117,7 +124,7 @@ data "bitwarden_secret" "foo_data" { ` } -func tfConfigResourceSecret(resourceName, testProjectId string) string { +func tfConfigResourceSecret(resourceName, projectResourceId string) string { return fmt.Sprintf(` resource "bitwarden_secret" "%s" { provider = bitwarden @@ -125,7 +132,7 @@ func tfConfigResourceSecret(resourceName, testProjectId string) string { key = "login-bar" value = "value-bar" note = "note-bar" - project_id ="%s" + project_id = %s } -`, resourceName, testProjectId) +`, resourceName, projectResourceId) }