diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60f6203..f16d089 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,10 +73,11 @@ jobs: TEST_USER: ${{ secrets.TEST_USER }} TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }} TEST_INSTANCE: ${{ secrets.TEST_INSTANCE }} - TEST_USER_ENTRY_ID: ${{ secrets.TEST_USER_ENTRY_ID }} + TEST_VAULT_ID: ${{ secrets.TEST_VAULT_ID }} TEST_CERTIFICATE_ENTRY_ID: ${{ secrets.TEST_CERTIFICATE_ENTRY_ID }} TEST_CERTIFICATE_FILE_PATH: '${{ runner.temp }}/test.p12' - TEST_VAULT_ID: ${{ secrets.TEST_VAULT_ID }} + TEST_HOST_ENTRY_ID: ${{ secrets.TEST_HOST_ENTRY_ID }} + TEST_USER_ENTRY_ID: ${{ secrets.TEST_USER_ENTRY_ID }} TEST_WEBSITE_ENTRY_ID: ${{ secrets.TEST_WEBSITE_ENTRY_ID }} with: github_token: ${{ secrets.DEVOLUTIONSBOT_TOKEN }} diff --git a/VERSION b/VERSION index ac39a10..78bc1ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.0 +0.10.0 diff --git a/authentication.go b/authentication.go index d73e159..1caf98c 100644 --- a/authentication.go +++ b/authentication.go @@ -116,6 +116,7 @@ func NewClient(username string, password string, baseUri string) (Client, error) UserCredential: (*EntryUserCredentialService)(&client.common), Certificate: (*EntryCertificateService)(&client.common), Website: (*EntryWebsiteService)(&client.common), + Host: (*EntryHostService)(&client.common), } client.Vaults = (*Vaults)(&client.common) diff --git a/entries.go b/entries.go index 758eceb..900a6c7 100644 --- a/entries.go +++ b/entries.go @@ -12,6 +12,7 @@ const ( type Entries struct { Certificate *EntryCertificateService + Host *EntryHostService UserCredential *EntryUserCredentialService Website *EntryWebsiteService } diff --git a/entry_host.go b/entry_host.go new file mode 100644 index 0000000..104d62d --- /dev/null +++ b/entry_host.go @@ -0,0 +1,235 @@ +package dvls + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type EntryHostService service + +// EntryHost represents a host entry in DVLS +type EntryHost struct { + ID string `json:"id,omitempty"` + VaultId string `json:"repositoryId"` + EntryName string `json:"name"` + Description string `json:"description"` + EntryFolderPath string `json:"group"` + ModifiedDate *ServerTime `json:"modifiedDate,omitempty"` + ConnectionType ServerConnectionType `json:"connectionType"` + ConnectionSubType ServerConnectionSubType `json:"connectionSubType"` + Tags []string `json:"keywords,omitempty"` + + HostDetails EntryHostAuthDetails `json:"data"` +} + +// MarshalJSON implements the json.Marshaler interface. +func (e EntryHost) MarshalJSON() ([]byte, error) { + raw := struct { + ID string `json:"id,omitempty"` + RepositoryId string `json:"repositoryId"` + Name string `json:"name"` + Description string `json:"description"` + Events struct { + OpenCommentPrompt bool `json:"openCommentPrompt"` + CredentialViewedPrompt bool `json:"credentialViewedPrompt"` + TicketNumberIsRequiredOnCredentialViewed bool `json:"ticketNumberIsRequiredOnCredentialViewed"` + TicketNumberIsRequiredOnClose bool `json:"ticketNumberIsRequiredOnClose"` + CredentialViewedCommentIsRequired bool `json:"credentialViewedCommentIsRequired"` + TicketNumberIsRequiredOnOpen bool `json:"ticketNumberIsRequiredOnOpen"` + CloseCommentIsRequired bool `json:"closeCommentIsRequired"` + OpenCommentPromptOnBrowserExtensionLink bool `json:"openCommentPromptOnBrowserExtensionLink"` + CloseCommentPrompt bool `json:"closeCommentPrompt"` + OpenCommentIsRequired bool `json:"openCommentIsRequired"` + WarnIfAlreadyOpened bool `json:"warnIfAlreadyOpened"` + } `json:"events"` + Data string `json:"data"` + Expiration string `json:"expiration"` + CheckOutMode int `json:"checkOutMode"` + Group string `json:"group"` + ConnectionType ServerConnectionType `json:"connectionType"` + ConnectionSubType ServerConnectionSubType `json:"connectionSubType"` + Keywords string `json:"keywords"` + }{} + + raw.ID = e.ID + raw.Keywords = sliceToKeywords(e.Tags) + raw.Description = e.Description + raw.RepositoryId = e.VaultId + raw.Group = e.EntryFolderPath + raw.ConnectionSubType = e.ConnectionSubType + raw.ConnectionType = e.ConnectionType + raw.Name = e.EntryName + sensitiveJson, err := json.Marshal(e.HostDetails) + if err != nil { + return nil, fmt.Errorf("failed to marshal sensitive data. error: %w", err) + } + + raw.Data = string(sensitiveJson) + + entryJson, err := json.Marshal(raw) + if err != nil { + return nil, err + } + + return entryJson, nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (e *EntryHost) UnmarshalJSON(d []byte) error { + raw := struct { + ID string `json:"id"` + Description string `json:"description"` + Name string `json:"name"` + Group string `json:"group"` + ModifiedDate *ServerTime `json:"modifiedDate"` + Keywords string `json:"keywords"` + RepositoryId string `json:"repositoryId"` + ConnectionType ServerConnectionType `json:"connectionType"` + ConnectionSubType ServerConnectionSubType `json:"connectionSubType"` + Data json.RawMessage `json:"data"` + }{} + + err := json.Unmarshal(d, &raw) + if err != nil { + return err + } + + e.ID = raw.ID + e.EntryName = raw.Name + e.ConnectionType = raw.ConnectionType + e.ConnectionSubType = raw.ConnectionSubType + e.ModifiedDate = raw.ModifiedDate + e.Description = raw.Description + e.EntryFolderPath = raw.Group + e.VaultId = raw.RepositoryId + e.Tags = keywordsToSlice(raw.Keywords) + + if len(raw.Data) > 0 { + if err := json.Unmarshal(raw.Data, &e.HostDetails); err != nil { + return fmt.Errorf("failed to unmarshal host details: %w", err) + } + } + + return nil +} + +// EntryHostAuthDetails represents host-specific fields +type EntryHostAuthDetails struct { + Username string + Password *string + Host string +} + +// MarshalJSON implements the json.Marshaler interface. +func (s EntryHostAuthDetails) MarshalJSON() ([]byte, error) { + raw := struct { + AutoFillLogin bool `json:"AutoFillLogin"` + AutoSubmit bool `json:"AutoSubmit"` + AutomaticRefreshTime int `json:"AutomaticRefreshTime"` + ChromeProxyType int `json:"ChromeProxyType"` + CustomJavaScript string `json:"CustomJavaScript"` + Host string `json:"Host"` + UserName string `json:"UserName"` + PasswordItem struct { + HasSensitiveData bool `json:"HasSensitiveData"` + SensitiveData string `json:"SensitiveData"` + } `json:"PasswordItem"` + VPN struct { + EnableAutoDetectIsOnlineVPN int `json:"EnableAutoDetectIsOnlineVPN"` + } `json:"VPN"` + }{} + + if s.Password != nil { + raw.PasswordItem.HasSensitiveData = true + raw.PasswordItem.SensitiveData = *s.Password + } else { + raw.PasswordItem.HasSensitiveData = false + } + + raw.UserName = s.Username + raw.Host = s.Host + + secretJson, err := json.Marshal(raw) + if err != nil { + return nil, err + } + + return secretJson, nil +} + +// GetHostDetails returns entry with the entry.HostDetails.Password field. +func (c *EntryHostService) GetHostDetails(entry EntryHost) (EntryHost, error) { + var respData struct { + Data string `json:"data"` + } + + reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entry.ID, "/sensitive-data") + if err != nil { + return EntryHost{}, fmt.Errorf("failed to build entry url. error: %w", err) + } + + resp, err := c.client.Request(reqUrl, http.MethodPost, nil) + if err != nil { + return EntryHost{}, fmt.Errorf("error while fetching sensitive data. error: %w", err) + } else if err = resp.CheckRespSaveResult(); err != nil { + return EntryHost{}, err + } + + if err := json.Unmarshal(resp.Response, &respData); err != nil { + return EntryHost{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + } + + var sensitiveDataResponse struct { + Data struct { + PasswordItem struct { + HasSensitiveData bool `json:"hasSensitiveData"` + SensitiveData *string `json:"sensitiveData,omitempty"` + } `json:"passwordItem"` + } `json:"data"` + } + + if err := json.Unmarshal([]byte(respData.Data), &sensitiveDataResponse); err != nil { + return EntryHost{}, fmt.Errorf("failed to unmarshal inner data. error: %w", err) + } + + if sensitiveDataResponse.Data.PasswordItem.HasSensitiveData { + entry.HostDetails.Password = sensitiveDataResponse.Data.PasswordItem.SensitiveData + } else { + entry.HostDetails.Password = nil + } + + return entry, nil +} + +// Get returns a single Entry specified by entryId. Call GetHostDetails with +// the returned Entry to fetch the password. +func (s *EntryHostService) Get(entryId string) (EntryHost, error) { + var respData struct { + Data EntryHost `json:"data"` + } + + reqUrl, err := url.JoinPath(s.client.baseUri, entryEndpoint, entryId) + if err != nil { + return EntryHost{}, fmt.Errorf("failed to build entry url: %w", err) + } + + resp, err := s.client.Request(reqUrl, http.MethodGet, nil) + if err != nil { + return EntryHost{}, fmt.Errorf("error fetching entry: %w", err) + } + + if err = resp.CheckRespSaveResult(); err != nil { + return EntryHost{}, err + } + if resp.Response == nil { + return EntryHost{}, fmt.Errorf("response body is nil for request to %s", reqUrl) + } + + if err := json.Unmarshal(resp.Response, &respData); err != nil { + return EntryHost{}, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return respData.Data, nil +} diff --git a/entry_host_test.go b/entry_host_test.go new file mode 100644 index 0000000..580363e --- /dev/null +++ b/entry_host_test.go @@ -0,0 +1,70 @@ +package dvls + +import ( + "os" + "reflect" + "testing" +) + +var ( + testHostEntryId string + testHostPassword = "testpass123" + testHostEntry EntryHost = EntryHost{ + Description: "Test host description", + EntryName: "TestHost", + ConnectionType: ServerConnectionHost, + Tags: []string{"Test tag 1", "Test tag 2", "host"}, + } +) + +const ( + testHostUsername string = "testuser" + testHost string = "host1234" +) + +func Test_EntryHost(t *testing.T) { + testHostEntryId = os.Getenv("TEST_HOST_ENTRY_ID") + testHostEntry.ID = testHostEntryId + testHostEntry.VaultId = testVaultId + testHostEntry.HostDetails = EntryHostAuthDetails{ + Username: testHostUsername, + Host: testHost, + } + + t.Run("GetEntry", test_GetHostEntry) + t.Run("GetEntryHost", test_GetHostDetails) +} + +func test_GetHostEntry(t *testing.T) { + entry, err := testClient.Entries.Host.Get(testHostEntry.ID) + if err != nil { + t.Fatal(err) + } + + testHostEntry.ModifiedDate = entry.ModifiedDate + if !reflect.DeepEqual(entry, testHostEntry) { + t.Fatalf("fetched entry did not match test entry. Expected %#v, got %#v", testHostEntry, entry) + } +} + +func test_GetHostDetails(t *testing.T) { + entry, err := testClient.Entries.Host.Get(testHostEntry.ID) + if err != nil { + t.Fatal(err) + } + + entryWithSensitiveData, err := testClient.Entries.Host.GetHostDetails(entry) + if err != nil { + t.Fatal(err) + } + + entry.HostDetails.Password = entryWithSensitiveData.HostDetails.Password + + expectedDetails := testHostEntry.HostDetails + + expectedDetails.Password = &testHostPassword + + if !reflect.DeepEqual(expectedDetails, entry.HostDetails) { + t.Fatalf("fetched secret did not match test secret. Expected %#v, got %#v", expectedDetails, entry.HostDetails) + } +} diff --git a/entry_website.go b/entry_website.go index 0bc5954..f3136e6 100644 --- a/entry_website.go +++ b/entry_website.go @@ -236,11 +236,3 @@ func (s *EntryWebsiteService) Get(entryId string) (EntryWebsite, error) { return respData.Data, nil } - -// NewEntryWebsiteAuthDetails returns an EntryWebsiteAuthDetails with an initialised EntryWebsiteAuthDetails.Password -func (c *EntryWebsiteService) NewWebsiteDetails(username, password string) EntryWebsiteAuthDetails { - return EntryWebsiteAuthDetails{ - Username: username, - Password: &password, - } -} diff --git a/entry_website_test.go b/entry_website_test.go index 789d8d5..d7271b1 100644 --- a/entry_website_test.go +++ b/entry_website_test.go @@ -7,8 +7,9 @@ import ( ) var ( - testWebsiteEntryId string - testWebsiteEntry EntryWebsite = EntryWebsite{ + testWebsiteEntryId string + testWebsitePassword = "testpass123" + testWebsiteEntry EntryWebsite = EntryWebsite{ Description: "Test website description", EntryName: "TestWebsite", ConnectionType: ServerConnectionWebBrowser, @@ -23,8 +24,6 @@ const ( testWebsiteBrowser string = "GoogleChrome" ) -var testWebsitePassword = "testpass123" - func Test_EntryWebsite(t *testing.T) { testWebsiteEntryId = os.Getenv("TEST_WEBSITE_ENTRY_ID") testWebsiteEntry.ID = testWebsiteEntryId