From c9e805bef6f8a5de575fae0c50205e2f03f8542d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Larivi=C3=A8re?= Date: Tue, 3 Jun 2025 09:54:15 -0400 Subject: [PATCH] feat: refactor credentials entry struct to support more sub types --- .github/workflows/test.yml | 2 - VERSION | 2 +- authentication.go | 8 +- entries.go | 123 +++++-- attachments.go => entry_attachments.go | 0 entry_certificate_test.go | 2 +- entry_credential.go | 302 ++++++++++++++++ entry_credential_test.go | 477 +++++++++++++++++++++++++ entry_user_credentials.go | 192 ---------- entry_user_credentials_test.go | 104 ------ utils.go | 39 ++ vaults_test.go | 4 +- 12 files changed, 919 insertions(+), 336 deletions(-) rename attachments.go => entry_attachments.go (100%) create mode 100644 entry_credential.go create mode 100644 entry_credential_test.go delete mode 100644 entry_user_credentials.go delete mode 100644 entry_user_credentials_test.go create mode 100644 utils.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f16d089..2629668 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -64,8 +64,6 @@ jobs: uses: ./.github/workflows/tailscale with: auth_key: ${{ secrets.TAILSCALE_AUTH_KEY_EPHEMERAL }} - exit_node: 100.99.49.20 - accept_dns: true - name: Test application uses: ./.github/workflows/go-test diff --git a/VERSION b/VERSION index d9df1bb..ac454c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.11.0 +0.12.0 diff --git a/authentication.go b/authentication.go index c4c3ccf..c0363fe 100644 --- a/authentication.go +++ b/authentication.go @@ -60,10 +60,10 @@ func NewClient(appKey string, appSecret string, baseUri string) (Client, error) client.common.client = &client client.Entries = &Entries{ - UserCredential: (*EntryUserCredentialService)(&client.common), - Certificate: (*EntryCertificateService)(&client.common), - Website: (*EntryWebsiteService)(&client.common), - Host: (*EntryHostService)(&client.common), + Credential: (*EntryCredentialService)(&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 900a6c7..6388f2d 100644 --- a/entries.go +++ b/entries.go @@ -1,51 +1,114 @@ package dvls import ( - "strconv" + "encoding/json" + "fmt" "strings" ) const ( entryEndpoint string = "/api/connections/partial" entryConnectionsEndpoint string = "/api/connections" + entryBasePublicEndpoint string = "/api/v1/vault/{vaultId}/entry" + entryPublicEndpoint string = "/api/v1/vault/{vaultId}/entry/{id}" ) type Entries struct { - Certificate *EntryCertificateService - Host *EntryHostService - UserCredential *EntryUserCredentialService - Website *EntryWebsiteService -} - -func keywordsToSlice(kw string) []string { - var spacedTag bool - tags := strings.FieldsFunc(string(kw), func(r rune) bool { - if r == '"' { - spacedTag = !spacedTag - } - return !spacedTag && r == ' ' - }) - for i, v := range tags { - unquotedTag, err := strconv.Unquote(v) - if err != nil { - continue - } + Certificate *EntryCertificateService + Host *EntryHostService + Credential *EntryCredentialService + Website *EntryWebsiteService +} + +type Entry struct { + ID string `json:"id,omitempty"` + VaultId string `json:"vaultId,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` + SubType string `json:"subType"` + + Data EntryData `json:"data,omitempty"` + + Description string `json:"description"` + ModifiedBy string `json:"modifiedBy,omitempty"` + ModifiedOn *ServerTime `json:"modifiedOn,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CreatedOn *ServerTime `json:"createdOn,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type EntryData any + +func (e *Entry) GetType() string { + return e.Type +} + +func (e *Entry) GetSubType() string { + return e.SubType +} + +var entryFactories = map[string]func() EntryData{ + "Credential/AccessCode": func() EntryData { return &EntryCredentialAccessCodeData{} }, + "Credential/ApiKey": func() EntryData { return &EntryCredentialApiKeyData{} }, + "Credential/AzureServicePrincipal": func() EntryData { return &EntryCredentialAzureServicePrincipalData{} }, + "Credential/ConnectionString": func() EntryData { return &EntryCredentialConnectionStringData{} }, + "Credential/Default": func() EntryData { return &EntryCredentialDefaultData{} }, + "Credential/PrivateKey": func() EntryData { return &EntryCredentialPrivateKeyData{} }, +} - tags[i] = unquotedTag +func (e *Entry) UnmarshalJSON(data []byte) error { + type alias Entry + raw := &struct { + Data json.RawMessage `json:"data"` + *alias + }{ + alias: (*alias)(e), } - return tags + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + key := fmt.Sprintf("%s/%s", raw.Type, raw.SubType) + factory, ok := entryFactories[key] + if !ok { + return fmt.Errorf("unsupported entry type/subtype: %s", key) + } + + dataStruct := factory() + if err := json.Unmarshal(raw.Data, dataStruct); err != nil { + return fmt.Errorf("failed to unmarshal entry data: %w", err) + } + + e.Data = dataStruct + + return nil } -func sliceToKeywords(kw []string) string { - keywords := []string(kw) - for i, v := range keywords { - if strings.Contains(v, " ") { - kw[i] = "\"" + v + "\"" - } +func (e Entry) MarshalJSON() ([]byte, error) { + type alias Entry + + dataBytes, err := json.Marshal(e.Data) + if err != nil { + return nil, err } - kString := strings.Join(keywords, " ") + return json.Marshal(&struct { + Data json.RawMessage `json:"data"` + *alias + }{ + Data: dataBytes, + alias: (*alias)(&e), + }) +} + +func entryPublicEndpointReplacer(vaultId string, entryId string) string { + replacer := strings.NewReplacer("{vaultId}", vaultId, "{id}", entryId) + return replacer.Replace(entryPublicEndpoint) +} - return kString +func entryPublicBaseEndpointReplacer(vaultId string) string { + replacer := strings.NewReplacer("{vaultId}", vaultId) + return replacer.Replace(entryBasePublicEndpoint) } diff --git a/attachments.go b/entry_attachments.go similarity index 100% rename from attachments.go rename to entry_attachments.go diff --git a/entry_certificate_test.go b/entry_certificate_test.go index 775429c..473a9b4 100644 --- a/entry_certificate_test.go +++ b/entry_certificate_test.go @@ -16,7 +16,7 @@ var ( testCertificateEntry EntryCertificate = EntryCertificate{ VaultId: testVaultId, Name: "TestK8sCertificate", - Password: testEntryPassword, + Password: "TestK8sCertificatePassword", Tags: []string{"test", "k8s"}, CertificateIdentifier: "test", } diff --git a/entry_credential.go b/entry_credential.go new file mode 100644 index 0000000..b85ae83 --- /dev/null +++ b/entry_credential.go @@ -0,0 +1,302 @@ +package dvls + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +const ( + EntryCredentialType string = "Credential" + + EntryCredentialSubTypeAccessCode string = "AccessCode" + EntryCredentialSubTypeApiKey string = "ApiKey" + EntryCredentialSubTypeAzureServicePrincipal string = "AzureServicePrincipal" + EntryCredentialSubTypeConnectionString string = "ConnectionString" + EntryCredentialSubTypeDefault string = "Default" + EntryCredentialSubTypePrivateKey string = "PrivateKey" +) + +type EntryCredentialService service + +type EntryCredentialAccessCodeData struct { + Password string `json:"password,omitempty"` +} + +type EntryCredentialApiKeyData struct { + ApiID string `json:"apiId,omitempty"` + ApiKey string `json:"apiKey,omitempty"` + TenantID string `json:"tenantId,omitempty"` +} + +type EntryCredentialAzureServicePrincipalData struct { + ClientID string `json:"clientId,omitempty"` + ClientSecret string `json:"clientSecret,omitempty"` + TenantID string `json:"tenantId,omitempty"` +} + +type EntryCredentialConnectionStringData struct { + ConnectionString string `json:"connectionString,omitempty"` +} + +type EntryCredentialDefaultData struct { + Domain string `json:"domain,omitempty"` + Password string `json:"password,omitempty"` + Username string `json:"username,omitempty"` +} + +type EntryCredentialPrivateKeyData struct { + PrivateKey string `json:"privateKeyData,omitempty"` + PublicKey string `json:"publicKeyData,omitempty"` + OverridePassword string `json:"privateKeyOverridePassword,omitempty"` + Passphrase string `json:"privateKeyPassPhrase,omitempty"` +} + +func (e *Entry) GetCredentialAccessCodeData() (*EntryCredentialAccessCodeData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryCredentialAccessCodeData) + return data, ok +} + +func (e *Entry) GetCredentialApiKeyData() (*EntryCredentialApiKeyData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryCredentialApiKeyData) + return data, ok +} + +func (e *Entry) GetCredentialAzureServicePrincipalData() (*EntryCredentialAzureServicePrincipalData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryCredentialAzureServicePrincipalData) + return data, ok +} + +func (e *Entry) GetCredentialConnectionStringData() (*EntryCredentialConnectionStringData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryCredentialConnectionStringData) + return data, ok +} + +func (e *Entry) GetCredentialDefaultData() (*EntryCredentialDefaultData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryCredentialDefaultData) + return data, ok +} + +func (e *Entry) GetCredentialPrivayeKey() (*EntryCredentialPrivateKeyData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryCredentialPrivateKeyData) + return data, ok +} + +// validateEntry checks if an Entry has the required fields and valid type/subtype. +func (c *EntryCredentialService) validateEntry(entry *Entry) error { + if entry.VaultId == "" { + return fmt.Errorf("entry must have a VaultId") + } + + if entry.GetType() != EntryCredentialType { + return fmt.Errorf("unsupported entry type (%s). Only %s is supported", entry.GetType(), EntryCredentialType) + } + + supportedSubTypes := []string{ + EntryCredentialSubTypeAccessCode, + EntryCredentialSubTypeApiKey, + EntryCredentialSubTypeAzureServicePrincipal, + EntryCredentialSubTypeConnectionString, + EntryCredentialSubTypeDefault, + EntryCredentialSubTypePrivateKey, + } + + subType := entry.GetSubType() + isSupported := false + for _, t := range supportedSubTypes { + if subType == t { + isSupported = true + break + } + } + + if !isSupported { + return fmt.Errorf("unsupported entry subtype (%s). Supported subtypes: %v", subType, supportedSubTypes) + } + + return nil +} + +// Get returns a single EntryCredential +func (c *EntryCredentialService) Get(entry Entry) (Entry, error) { + return c.GetById(entry.VaultId, entry.ID) +} + +// Get returns a single EntryCredential based on vault ID and entry ID. +func (c *EntryCredentialService) GetById(vaultId string, entryId string) (Entry, error) { + if vaultId == "" || entryId == "" { + return Entry{}, fmt.Errorf("both entry ID and vault ID are required") + } + + var entry Entry + entryUri := entryPublicEndpointReplacer(vaultId, entryId) + + reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) + if err != nil { + return Entry{}, fmt.Errorf("failed to build entry url. error: %w", err) + } + + resp, err := c.client.Request(reqUrl, http.MethodGet, nil) + if err != nil { + return Entry{}, fmt.Errorf("error while fetching entry. error: %w", err) + } + + err = entry.UnmarshalJSON(resp.Response) + if err != nil { + return Entry{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + } + + entry.VaultId = vaultId + + return entry, nil +} + +// New creates a new EntryCredential +func (c *EntryCredentialService) New(entry Entry) (string, error) { + if err := c.validateEntry(&entry); err != nil { + return "", err + } + + newEntryRequest := struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path string `json:"path,omitempty"` + Type string `json:"type"` + SubType string `json:"subType"` + Data EntryData `json:"data"` + Tags []string `json:"tags,omitempty"` + }{ + Name: entry.Name, + Description: entry.Description, + Path: entry.Path, + Type: entry.GetType(), + SubType: entry.GetSubType(), + Data: entry.Data, + Tags: entry.Tags, + } + + baseEntryEndpoint := entryPublicBaseEndpointReplacer(entry.VaultId) + reqUrl, err := url.JoinPath(c.client.baseUri, baseEntryEndpoint) + if err != nil { + return "", fmt.Errorf("failed to build entry url. error: %w", err) + } + + body, err := json.Marshal(newEntryRequest) + if err != nil { + return "", fmt.Errorf("failed to marshal body. error: %w", err) + } + + resp, err := c.client.Request(reqUrl, http.MethodPost, bytes.NewBuffer(body)) + if err != nil { + return "", fmt.Errorf("error while creating entry. error: %w", err) + } + + newEntryResponse := struct { + Id string `json:"id"` + }{} + + err = json.Unmarshal(resp.Response, &newEntryResponse) + if err != nil { + return "", fmt.Errorf("failed to unmarshal response body. error: %w", err) + } + return newEntryResponse.Id, nil +} + +// Update updates an EntryCredential +func (c *EntryCredentialService) Update(entry Entry) (Entry, error) { + if err := c.validateEntry(&entry); err != nil { + return Entry{}, err + } + + if entry.ID == "" { + return Entry{}, fmt.Errorf("entry ID is required for updates") + } + + updateEntryRequest := struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path string `json:"path,omitempty"` + Data EntryData `json:"data"` + Tags []string `json:"tags,omitempty"` + }{ + Name: entry.Name, + Description: entry.Description, + Path: entry.Path, + Data: entry.Data, + Tags: entry.Tags, + } + + entryUri := entryPublicEndpointReplacer(entry.VaultId, entry.ID) + reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) + if err != nil { + return Entry{}, fmt.Errorf("failed to build entry url. error: %w", err) + } + + body, err := json.Marshal(updateEntryRequest) + if err != nil { + return Entry{}, fmt.Errorf("failed to marshal body. error: %w", err) + } + + _, err = c.client.Request(reqUrl, http.MethodPut, bytes.NewBuffer(body)) + if err != nil { + return Entry{}, fmt.Errorf("error while updating entry. error: %w", err) + } + + entry, err = c.GetById(entry.VaultId, entry.ID) + if err != nil { + return Entry{}, fmt.Errorf("update succeeded but failed to fetch updated entry: %w", err) + } + + return entry, nil +} + +// Delete deletes an entry +func (c *EntryCredentialService) Delete(e Entry) error { + return c.DeleteByID(e.VaultId, e.ID) +} + +// Delete deletes an entry based on vault ID and entry ID +func (c *EntryCredentialService) DeleteByID(vaultId string, entryId string) error { + if vaultId == "" || entryId == "" { + return fmt.Errorf("both entry ID and vault ID are required") + } + + entryUri := entryPublicEndpointReplacer(vaultId, entryId) + reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) + if err != nil { + return fmt.Errorf("failed to build delete entry url. error: %w", err) + } + + _, err = c.client.Request(reqUrl, http.MethodDelete, nil) + if err != nil { + return fmt.Errorf("error while deleting entry. error: %w", err) + } + + return nil +} diff --git a/entry_credential_test.go b/entry_credential_test.go new file mode 100644 index 0000000..33559ba --- /dev/null +++ b/entry_credential_test.go @@ -0,0 +1,477 @@ +package dvls + +import ( + "testing" +) + +var ( + testCredentialAccessCodeEntryID *string + testCredentialAccessCodeEntry *Entry + + testCredentialApiKeyEntryID *string + testCredentialApiKeyEntry *Entry + + testCredentialAzureServicePrincipalEntryID *string + testCredentialAzureServicePrincipalEntry *Entry + + testCredentialConnectionStringEntryID *string + testCredentialConnectionStringEntry *Entry + + testCredentialDefaultEntryID *string + testCredentialDefaultEntry *Entry + + testCredentialPrivateKeyEntryID *string + testCredentialPrivateKeyEntry *Entry +) + +func Test_EntryUserCredentials(t *testing.T) { + if !t.Run("NewEntry", test_NewUserEntry) { + t.Skip("Skipping subsequent tests due to failure in NewEntry") + return + } + + if !t.Run("GetEntry", test_GetUserEntry) { + t.Skip("Skipping subsequent tests due to failure in GetEntry") + return + } + + if !t.Run("UpdateEntry", test_UpdateUserEntry) { + t.Skip("Skipping subsequent tests due to failure in UpdateEntry") + return + } + + if !t.Run("DeleteEntry", test_DeleteUserEntry) { + t.Skip("Skipping subsequent tests due to failure in DeleteEntry") + return + } +} + +func test_NewUserEntry(t *testing.T) { + // Notes: all entries values are random and for testing purposes only. + + // Credential/AccessCode + testCredentialAccessCodeEntry := Entry{ + ID: "", + VaultId: testVaultId, + Name: "TestGoDvlsAccessCode", + Path: "go-dvls\\accesscode", + Description: "Test AccessCode entry", + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeAccessCode, + Data: EntryCredentialAccessCodeData{ + Password: "abc-123", + }, + Tags: []string{"accesscode"}, + } + + newCredentialAccessCodeEntryID, err := testClient.Entries.Credential.New(testCredentialAccessCodeEntry) + if err != nil { + t.Fatalf("Failed to create new AccessCode entry: %v", err) + } + + if newCredentialAccessCodeEntryID == "" { + t.Fatal("New AccessCode entry ID is empty after creation.") + } + + testCredentialAccessCodeEntryID = &newCredentialAccessCodeEntryID + + // Credential/ApiKey + testCredentialApiKeyEntry := Entry{ + ID: "", + VaultId: testVaultId, + Name: "TestGoDvlsApiKey", + Path: "go-dvls\\apikey", + Description: "Test ApiKey entry", + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeApiKey, + Data: EntryCredentialApiKeyData{ + ApiID: "abcd1234-abcd-1234-abcd-1234abcd1234", + ApiKey: "123-abc", + TenantID: "00000000-aaaa-bbbb-cccc-000000000000", + }, + Tags: []string{"apikey"}, + } + + newCredentialApiKeyEntryID, err := testClient.Entries.Credential.New(testCredentialApiKeyEntry) + if err != nil { + t.Fatalf("Failed to create new ApiKey entry: %v", err) + } + + if newCredentialApiKeyEntryID == "" { + t.Fatal("New ApiKey entry ID is empty after creation.") + } + + testCredentialApiKeyEntryID = &newCredentialApiKeyEntryID + + // Credential/AzureServicePrincipal + testCredentialAzureServicePrincipalEntry := Entry{ + ID: "", + VaultId: testVaultId, + Name: "TestGoDvlsAzureServicePrincipal", + Path: "go-dvls\\azureserviceprincipal", + Description: "Test AzureServicePrincipal entry", + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeAzureServicePrincipal, + Data: EntryCredentialAzureServicePrincipalData{ + ClientID: "abcd1234-abcd-1234-abcd-1234abcd1234", + ClientSecret: "123-abc", + TenantID: "00000000-aaaa-bbbb-cccc-000000000000", + }, + Tags: []string{"azureserviceprincipal"}, + } + + newCredentialAzureServicePrincipalEntryID, err := testClient.Entries.Credential.New(testCredentialAzureServicePrincipalEntry) + if err != nil { + t.Fatalf("Failed to create new AzureServicePrincipal entry: %v", err) + } + + if newCredentialAzureServicePrincipalEntryID == "" { + t.Fatal("New AzureServicePrincipal entry ID is empty after creation.") + } + + testCredentialAzureServicePrincipalEntryID = &newCredentialAzureServicePrincipalEntryID + + // Credential/ConnectionString + testCredentialConnectionStringEntry := Entry{ + ID: "", + VaultId: testVaultId, + Name: "TestGoDvlsConnectionString", + Path: "go-dvls\\connectionstring", + Description: "Test ConnectionString entry", + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeConnectionString, + Data: EntryCredentialConnectionStringData{ + ConnectionString: "Server=tcp:example.database.windows.net,1433;Initial Catalog=exampledb;Persist Security Info=False;User ID=exampleuser;Password=examplepassword;", + }, + Tags: []string{"connectionstring"}, + } + + newCredentialConnectionStringEntryID, err := testClient.Entries.Credential.New(testCredentialConnectionStringEntry) + if err != nil { + t.Fatalf("Failed to create new ConnectionString entry: %v", err) + } + + if newCredentialConnectionStringEntryID == "" { + t.Fatal("New ConnectionString entry ID is empty after creation.") + } + + testCredentialConnectionStringEntryID = &newCredentialConnectionStringEntryID + + // Credential/Default + testCredentialDefaultEntry := Entry{ + VaultId: testVaultId, + Name: "TestGoDvlsUsernamePassword", + Path: "go-dvls\\usernamepassword", + Description: "Test Username/Password entry", + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeDefault, + Data: EntryCredentialDefaultData{ + Domain: "www.example.com", + Password: "abc-123", + Username: "john.doe", + }, + Tags: []string{"usernamepassword"}, + } + + newCredentialDefaultEntryID, err := testClient.Entries.Credential.New(testCredentialDefaultEntry) + if err != nil { + t.Fatalf("Failed to create new Default entry: %v", err) + } + + if newCredentialDefaultEntryID == "" { + t.Fatal("New Default entry ID is empty after creation.") + } + + testCredentialDefaultEntryID = &newCredentialDefaultEntryID + + // Credential/PrivateKey + testCredentialPrivateKeyEntry := Entry{ + ID: "", + VaultId: testVaultId, + Name: "TestGoDvlsPrivateKey", + Path: "go-dvls\\privatekey", + Description: "Test Secret entry", + Type: EntryCredentialType, + SubType: EntryCredentialSubTypePrivateKey, + Data: EntryCredentialPrivateKeyData{ + PrivateKey: "-----BEGIN PRIVATE KEY-----\abcdefghijklmnopqrstuvwxyz1234567890...\n-----END PRIVATE", + PublicKey: "-----BEGIN PUBLIC KEY-----\abcdefghijklmnopqrstuvwxyz...\n-----END PUBLIC KEY-----", + OverridePassword: "override-password", + Passphrase: "passphrase", + }, + Tags: []string{"testtag"}, + } + + newCredentialPrivateKeyEntryID, err := testClient.Entries.Credential.New(testCredentialPrivateKeyEntry) + if err != nil { + t.Fatalf("Failed to create new PrivateKey entry: %v", err) + } + + if newCredentialPrivateKeyEntryID == "" { + t.Fatal("New PrivateKey entry ID is empty after creation.") + } + + testCredentialPrivateKeyEntryID = &newCredentialPrivateKeyEntryID +} + +func test_GetUserEntry(t *testing.T) { + // Credential/AccessCode + credentialAccessCodeEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialAccessCodeEntryID) + if err != nil { + t.Fatalf("Failed to get AccessCode entry: %v", err) + } + + if credentialAccessCodeEntry.ID == "" { + t.Fatalf("AccessCode entry ID is empty after GET: %v", credentialAccessCodeEntry) + } + + testCredentialAccessCodeEntry = &credentialAccessCodeEntry + + // Credential/ApiKey + credentialApiKeyEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialApiKeyEntryID) + if err != nil { + t.Fatalf("Failed to get ApiKey entry: %v", err) + } + + if credentialApiKeyEntry.ID == "" { + t.Fatalf("ApiKey entry ID is empty after GET: %v", credentialApiKeyEntry) + } + + testCredentialApiKeyEntry = &credentialApiKeyEntry + + // Credential/AzureServicePrincipal + credentialAzureServicePrincipalEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialAzureServicePrincipalEntryID) + if err != nil { + t.Fatalf("Failed to get AzureServicePrincipal entry: %v", err) + } + + if credentialAzureServicePrincipalEntry.ID == "" { + t.Fatalf("AzureServicePrincipal entry ID is empty after GET: %v", credentialAzureServicePrincipalEntry) + } + + testCredentialAzureServicePrincipalEntry = &credentialAzureServicePrincipalEntry + + // Credential/ConnectionString + credentialConnectionStringEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialConnectionStringEntryID) + if err != nil { + t.Fatalf("Failed to get ConnectionString entry: %v", err) + } + + if credentialConnectionStringEntry.ID == "" { + t.Fatalf("ConnectionString entry ID is empty after GET: %v", credentialConnectionStringEntry) + } + + testCredentialConnectionStringEntry = &credentialConnectionStringEntry + + // Credential/Default + credentialDefaultEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialDefaultEntryID) + if err != nil { + t.Fatalf("Failed to get Default entry: %v", err) + } + + if credentialDefaultEntry.ID == "" { + t.Fatalf("Default entry ID is empty after GET: %v", credentialDefaultEntry) + } + + testCredentialDefaultEntry = &credentialDefaultEntry + + // Credential/PrivateKey + credentialPrivateKeyEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialPrivateKeyEntryID) + if err != nil { + t.Fatalf("Failed to get PrivateKey entry: %v", err) + } + + if credentialPrivateKeyEntry.ID == "" { + t.Fatalf("PrivateKey entry ID is empty after GET: %v", credentialPrivateKeyEntry) + } + + testCredentialPrivateKeyEntry = &credentialPrivateKeyEntry +} + +func test_UpdateUserEntry(t *testing.T) { + // Credential/AccessCode + updatedCredentialAccessCodeEntry := *testCredentialAccessCodeEntry + updatedCredentialAccessCodeEntry.Name = updatedCredentialAccessCodeEntry.Name + "Updated" + updatedCredentialAccessCodeEntry.Path = updatedCredentialAccessCodeEntry.Path + "\\updated" + updatedCredentialAccessCodeEntry.Description = updatedCredentialAccessCodeEntry.Description + " updated" + updatedCredentialAccessCodeEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags + + updatedAccessCodeData, ok := updatedCredentialAccessCodeEntry.GetCredentialAccessCodeData() + if !ok { + t.Fatalf("Failed to get credential AccessCode data from entry: %v", updatedCredentialAccessCodeEntry) + } + updatedAccessCodeData.Password = updatedAccessCodeData.Password + "-updated" + updatedCredentialAccessCodeEntry.Data = updatedAccessCodeData + + updatedCredentialAccessCodeEntry, err := testClient.Entries.Credential.Update(updatedCredentialAccessCodeEntry) + if err != nil { + t.Fatalf("Failed to update AccessCode entry: %v", err) + } + + // Credential/ApiKey + updatedCredentialApiKeyEntry := *testCredentialApiKeyEntry + updatedCredentialApiKeyEntry.Name = updatedCredentialApiKeyEntry.Name + "Updated" + updatedCredentialApiKeyEntry.Path = updatedCredentialApiKeyEntry.Path + "\\updated" + updatedCredentialApiKeyEntry.Description = updatedCredentialApiKeyEntry.Description + " updated" + updatedCredentialApiKeyEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags + + updatedApiKeyData, ok := updatedCredentialApiKeyEntry.GetCredentialApiKeyData() + if !ok { + t.Fatalf("Failed to get credential ApiKey data from entry: %v", updatedCredentialApiKeyEntry) + } + + updatedApiKeyData.ApiKey = updatedApiKeyData.ApiKey + "-updated" + updatedCredentialApiKeyEntry.Data = updatedApiKeyData + + updatedCredentialApiKeyEntry, err = testClient.Entries.Credential.Update(updatedCredentialApiKeyEntry) + if err != nil { + t.Fatalf("Failed to update ApiKey entry: %v", err) + } + + // Credential/AzureServicePrincipal + updatedCredentialAzureServicePrincipalEntry := *testCredentialAzureServicePrincipalEntry + updatedCredentialAzureServicePrincipalEntry.Name = updatedCredentialAzureServicePrincipalEntry.Name + "Updated" + updatedCredentialAzureServicePrincipalEntry.Path = updatedCredentialAzureServicePrincipalEntry.Path + "\\updated" + updatedCredentialAzureServicePrincipalEntry.Description = updatedCredentialAzureServicePrincipalEntry.Description + " updated" + updatedCredentialAzureServicePrincipalEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags + + updatedAzureServicePrincipalData, ok := updatedCredentialAzureServicePrincipalEntry.GetCredentialAzureServicePrincipalData() + if !ok { + t.Fatalf("Failed to get credential AzureServicePrincipal data from entry: %v", updatedCredentialAzureServicePrincipalEntry) + } + + updatedAzureServicePrincipalData.ClientSecret = updatedAzureServicePrincipalData.ClientSecret + "-updated" + updatedCredentialAzureServicePrincipalEntry.Data = updatedAzureServicePrincipalData + + updatedCredentialAzureServicePrincipalEntry, err = testClient.Entries.Credential.Update(updatedCredentialAzureServicePrincipalEntry) + if err != nil { + t.Fatalf("Failed to update AzureServicePrincipal entry: %v", err) + } + + // Credential/ConnectionString + updatedCredentialConnectionStringEntry := *testCredentialConnectionStringEntry + updatedCredentialConnectionStringEntry.Name = updatedCredentialConnectionStringEntry.Name + "Updated" + updatedCredentialConnectionStringEntry.Path = updatedCredentialConnectionStringEntry.Path + "\\updated" + updatedCredentialConnectionStringEntry.Description = updatedCredentialConnectionStringEntry.Description + " updated" + updatedCredentialConnectionStringEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags + + updatedConnectionStringData, ok := updatedCredentialConnectionStringEntry.GetCredentialConnectionStringData() + if !ok { + t.Fatalf("Failed to get credential ConnectionString data from entry: %v", updatedCredentialConnectionStringEntry) + } + + updatedConnectionStringData.ConnectionString = updatedConnectionStringData.ConnectionString + "MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + updatedCredentialConnectionStringEntry.Data = updatedConnectionStringData + + updatedCredentialConnectionStringEntry, err = testClient.Entries.Credential.Update(updatedCredentialConnectionStringEntry) + if err != nil { + t.Fatalf("Failed to update ConnectionString entry: %v", err) + } + + // Credential/Default + updatedCredentialDefaultEntry := *testCredentialDefaultEntry + updatedCredentialDefaultEntry.Name = updatedCredentialDefaultEntry.Name + "Updated" + updatedCredentialDefaultEntry.Path = updatedCredentialDefaultEntry.Path + "\\updated" + updatedCredentialDefaultEntry.Description = updatedCredentialDefaultEntry.Description + " updated" + updatedCredentialDefaultEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags + + updatedDefaultData, ok := updatedCredentialDefaultEntry.GetCredentialDefaultData() + if !ok { + t.Fatalf("Failed to get credential default data from entry: %v", updatedCredentialDefaultEntry) + } + updatedDefaultData.Password = updatedDefaultData.Password + "-updated" + updatedCredentialDefaultEntry.Data = updatedDefaultData + + updatedCredentialDefaultEntry, err = testClient.Entries.Credential.Update(updatedCredentialDefaultEntry) + if err != nil { + t.Fatalf("Failed to update entry: %v", err) + } + + // Credential/PrivateKey + updatedCredentialPrivateKeyEntry := *testCredentialPrivateKeyEntry + updatedCredentialPrivateKeyEntry.Name = updatedCredentialPrivateKeyEntry.Name + "Updated" + updatedCredentialPrivateKeyEntry.Path = updatedCredentialPrivateKeyEntry.Path + "\\updated" + updatedCredentialPrivateKeyEntry.Description = updatedCredentialPrivateKeyEntry.Description + " updated" + updatedCredentialPrivateKeyEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags + + updatedPrivateKeyData, ok := updatedCredentialPrivateKeyEntry.GetCredentialPrivayeKey() + if !ok { + t.Fatalf("Failed to get credential access code data from entry: %v", updatedCredentialAccessCodeEntry) + } + updatedPrivateKeyData.Passphrase = updatedPrivateKeyData.Passphrase + "-updated" + updatedPrivateKeyData.OverridePassword = updatedPrivateKeyData.OverridePassword + "-updated" + updatedCredentialPrivateKeyEntry.Data = updatedPrivateKeyData + + updatedCredentialPrivateKeyEntry, err = testClient.Entries.Credential.Update(updatedCredentialPrivateKeyEntry) + if err != nil { + t.Fatalf("Failed to update entry: %v", err) + } +} + +func test_DeleteUserEntry(t *testing.T) { + // Credential/AccessCode + err := testClient.Entries.Credential.Delete(*testCredentialAccessCodeEntry) + if err != nil { + t.Fatalf("Failed to delete AccessCode entry: %v", err) + } + + _, err = testClient.Entries.Credential.Get(*testCredentialAccessCodeEntry) + if err == nil { + t.Fatalf("AccessCode entry still exists after deletion: %s", *testCredentialAccessCodeEntryID) + } + + // Credential/ApiKey + err = testClient.Entries.Credential.Delete(*testCredentialApiKeyEntry) + if err != nil { + t.Fatalf("Failed to delete ApiKey entry: %v", err) + } + + _, err = testClient.Entries.Credential.Get(*testCredentialApiKeyEntry) + if err == nil { + t.Fatalf("ApiKey entry still exists after deletion: %s", *testCredentialApiKeyEntryID) + } + + // Credential/AzureServicePrincipal + err = testClient.Entries.Credential.Delete(*testCredentialAzureServicePrincipalEntry) + if err != nil { + t.Fatalf("Failed to delete AzureServicePrincipal entry: %v", err) + } + + _, err = testClient.Entries.Credential.Get(*testCredentialAzureServicePrincipalEntry) + if err == nil { + t.Fatalf("AzureServicePrincipal entry still exists after deletion: %s", *testCredentialAzureServicePrincipalEntryID) + } + + // Credential/ConnectionString + err = testClient.Entries.Credential.Delete(*testCredentialConnectionStringEntry) + if err != nil { + t.Fatalf("Failed to delete ConnectionString entry: %v", err) + } + + _, err = testClient.Entries.Credential.Get(*testCredentialConnectionStringEntry) + if err == nil { + t.Fatalf("ConnectionString entry still exists after deletion: %s", *testCredentialConnectionStringEntryID) + } + + // Credential/Default + err = testClient.Entries.Credential.Delete(*testCredentialDefaultEntry) + if err != nil { + t.Fatalf("Failed to delete Default entry: %v", err) + } + + _, err = testClient.Entries.Credential.Get(*testCredentialDefaultEntry) + if err == nil { + t.Fatalf("Default entry still exists after deletion: %s", *testCredentialDefaultEntryID) + } + + // Credential/PrivateKey + err = testClient.Entries.Credential.Delete(*testCredentialPrivateKeyEntry) + if err != nil { + t.Fatalf("Failed to delete PrivateKey entry: %v", err) + } + + _, err = testClient.Entries.Credential.Get(*testCredentialPrivateKeyEntry) + if err == nil { + t.Fatalf("PrivateKey entry still exists after deletion: %s", *testCredentialPrivateKeyEntryID) + } +} diff --git a/entry_user_credentials.go b/entry_user_credentials.go deleted file mode 100644 index 3cf0dea..0000000 --- a/entry_user_credentials.go +++ /dev/null @@ -1,192 +0,0 @@ -package dvls - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" -) - -type EntryUserCredentialService service - -const ( - entryPublicEndpoint string = "/api/v1/vault/{vaultId}/entry/{id}" - EntryTypeCredential string = "Credential" - EntrySubTypeDefault string = "Default" -) - -// EntryUserCredential represents a DVLS entry/connection. -type EntryUserCredential struct { - ID string `json:"id,omitempty"` - VaultId string `json:"vaultId"` - EntryName string `json:"name"` - Description string `json:"description"` - Path string `json:"path"` - ModifiedOn *ServerTime `json:"modifiedOn,omitempty"` - ModifiedBy string `json:"modifiedBy,omitempty"` - CreatedOn *ServerTime `json:"createdOn,omitempty"` - CreatedBy string `json:"createdBy,omitempty"` - Type string `json:"type"` - SubType string `json:"subType"` - Tags []string `json:"tags,omitempty"` - - Credentials EntryCredentials `json:"data,omitempty"` -} - -// EntryCredentials represents an EntryUserCredential Credentials fields. -type EntryCredentials struct { - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` -} - -func entryReplacer(vaultId string, entryId string) string { - replacer := strings.NewReplacer("{vaultId}", vaultId, "{id}", entryId) - return replacer.Replace(entryPublicEndpoint) -} - -// validateEntry checks if an EntryUserCredential has the required fields and valid type/subtype. -func (c *EntryUserCredentialService) validateEntry(entry *EntryUserCredential) error { - if entry.VaultId == "" { - return fmt.Errorf("entry must have a VaultId") - } - - if entry.Type != EntryTypeCredential { - return fmt.Errorf("unsupported entry type (%s). Only %s is supported", entry.Type, EntryTypeCredential) - } - - if entry.SubType == "" { - entry.SubType = EntrySubTypeDefault - } else if entry.SubType != EntrySubTypeDefault { - return fmt.Errorf("unsupported entry subtype (%s). Only %s is supported", entry.SubType, EntrySubTypeDefault) - } - - return nil -} - -// Get returns a single EntryUserCredential specified by entryId. -func (c *EntryUserCredentialService) Get(vaultId string, entryId string) (EntryUserCredential, error) { - if entryId == "" || vaultId == "" { - return EntryUserCredential{}, fmt.Errorf("both entry ID and vault ID are required for deletion") - } - var entry EntryUserCredential - entryUri := entryReplacer(vaultId, entryId) - - reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to build entry url. error: %w", err) - } - - resp, err := c.client.Request(reqUrl, http.MethodGet, nil) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("error while fetching entry. error: %w", err) - } - - err = json.Unmarshal(resp.Response, &entry) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) - } - - entry.VaultId = vaultId - if entry.SubType == "" { - entry.SubType = EntrySubTypeDefault - } - - return entry, nil -} - -// New creates a new EntryUserCredential based on entry. -func (c *EntryUserCredentialService) New(entry EntryUserCredential) (EntryUserCredential, error) { - if err := c.validateEntry(&entry); err != nil { - return EntryUserCredential{}, err - } - - entry.ID = "" - - baseEntryEndpoint := strings.Replace(entryPublicEndpoint, "/{id}", "", 1) - entryUri := strings.Replace(baseEntryEndpoint, "{vaultId}", entry.VaultId, 1) - reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to build entry url. error: %w", err) - } - - entryJson, err := json.Marshal(entry) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to marshal body. error: %w", err) - } - - resp, err := c.client.Request(reqUrl, http.MethodPost, bytes.NewBuffer(entryJson)) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("error while creating entry. error: %w", err) - } - err = json.Unmarshal(resp.Response, &entry) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) - } - return entry, nil -} - -// Update updates an EntryUserCredential based on entry. -func (c *EntryUserCredentialService) Update(entry EntryUserCredential) (EntryUserCredential, error) { - if err := c.validateEntry(&entry); err != nil { - return EntryUserCredential{}, err - } - - if entry.ID == "" { - return EntryUserCredential{}, fmt.Errorf("entry ID is required for updates") - } - - originalEntry, err := c.Get(entry.VaultId, entry.ID) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to fetch original entry. error: %w", err) - } - - if originalEntry.SubType != entry.SubType { - return EntryUserCredential{}, fmt.Errorf("entry subType cannot be changed") - } - - entryUri := entryReplacer(entry.VaultId, entry.ID) - - reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to build entry url. error: %w", err) - } - - entryJson, err := json.Marshal(entry) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("failed to marshal body. error: %w", err) - } - - _, err = c.client.Request(reqUrl, http.MethodPut, bytes.NewBuffer(entryJson)) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("error while updating entry. error: %w", err) - } - - entry, err = c.Get(entry.VaultId, entry.ID) - if err != nil { - return EntryUserCredential{}, fmt.Errorf("update succeeded but failed to fetch updated entry: %w", err) - } - - return entry, nil -} - -// Delete deletes an entry based on entry. -func (c *EntryUserCredentialService) Delete(entry EntryUserCredential) error { - if entry.ID == "" || entry.VaultId == "" { - return fmt.Errorf("both entry ID and vault ID are required") - } - - entryUri := entryReplacer(entry.VaultId, entry.ID) - reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) - if err != nil { - return fmt.Errorf("failed to build delete entry url. error: %w", err) - } - - _, err = c.client.Request(reqUrl, http.MethodDelete, nil) - if err != nil { - return fmt.Errorf("error while deleting entry. error: %w", err) - } - - return nil -} diff --git a/entry_user_credentials_test.go b/entry_user_credentials_test.go deleted file mode 100644 index 840162d..0000000 --- a/entry_user_credentials_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package dvls - -import ( - "reflect" - "testing" -) - -var ( - testUserEntry = EntryUserCredential{ - ID: "", - VaultId: testVaultId, - EntryName: "TestGoDvlsSecret", - Description: "Test description", - Type: "Credential", - SubType: "Default", - Tags: []string{"testtag"}, - Credentials: EntryCredentials{ - Username: testEntryUsername, - Password: testEntryPassword, - }, - } -) - -const ( - testEntryUsername string = "Test" - testEntryPassword string = "TestPassword" -) - -func Test_EntryUserCredentials(t *testing.T) { - t.Run("NewEntry", test_NewUserEntry) - t.Run("GetEntry", test_GetUserEntry) - t.Run("UpdateEntry", test_UpdateUserEntry) - t.Run("DeleteEntry", test_DeleteUserEntry) -} - -// Allowing accurate comparison by ignoring fields that differ due to server assignment. -func NormalizeEntry(source, target *EntryUserCredential) { - target.ID = source.ID - target.ModifiedOn = source.ModifiedOn - target.ModifiedBy = source.ModifiedBy - target.CreatedOn = source.CreatedOn - target.CreatedBy = source.CreatedBy -} - -func test_NewUserEntry(t *testing.T) { - testUserEntry.VaultId = testVaultId - newEntry, err := testClient.Entries.UserCredential.New(testUserEntry) - if err != nil { - t.Fatalf("Failed to create new entry: %v", err) - } - - NormalizeEntry(&newEntry, &testUserEntry) - - if !reflect.DeepEqual(&newEntry, &testUserEntry) { - t.Fatalf("Entries differ.\nGot: %+v\nExpected: %+v", &newEntry, &testUserEntry) - } -} - -func test_GetUserEntry(t *testing.T) { - entry, err := testClient.Entries.UserCredential.Get(testVaultId, testUserEntry.ID) - if err != nil { - t.Fatalf("Failed to get entry: %v", err) - } - - NormalizeEntry(&entry, &testUserEntry) - - if !reflect.DeepEqual(&entry, &testUserEntry) { - t.Fatalf("Entries differ.\nGot: %+v\nExpected: %+v", &entry, &testUserEntry) - } -} - -func test_UpdateUserEntry(t *testing.T) { - testUpdatedEntry := testUserEntry - testUpdatedEntry.EntryName = "TestGoDvlsSecretUpdated" - testUpdatedEntry.Description = "Test description updated" - testUpdatedEntry.Credentials = EntryCredentials{ - Username: "TestK8sUpdatedUser", - Password: "TestK8sUpdatedPassword", - } - - updatedEntry, err := testClient.Entries.UserCredential.Update(testUpdatedEntry) - if err != nil { - t.Fatalf("Failed to update entry: %v", err) - } - NormalizeEntry(&updatedEntry, &testUpdatedEntry) - - if !reflect.DeepEqual(&updatedEntry, &testUpdatedEntry) { - t.Fatalf("Entries differ.\nGot: %+v\nExpected: %+v", &updatedEntry, &testUpdatedEntry) - } - testUserEntry = updatedEntry -} - -func test_DeleteUserEntry(t *testing.T) { - err := testClient.Entries.UserCredential.Delete(testUserEntry) - if err != nil { - t.Fatalf("Failed to delete entry: %v", err) - } - - // Verify it's gone by trying to retrieve it - _, err = testClient.Entries.UserCredential.Get(testVaultId, testUserEntry.ID) - if err == nil { - t.Fatalf("Entry still exists after deletion: %s", testUserEntry.ID) - } -} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..0efc665 --- /dev/null +++ b/utils.go @@ -0,0 +1,39 @@ +package dvls + +import ( + "strconv" + "strings" +) + +func keywordsToSlice(kw string) []string { + var spacedTag bool + tags := strings.FieldsFunc(string(kw), func(r rune) bool { + if r == '"' { + spacedTag = !spacedTag + } + return !spacedTag && r == ' ' + }) + for i, v := range tags { + unquotedTag, err := strconv.Unquote(v) + if err != nil { + continue + } + + tags[i] = unquotedTag + } + + return tags +} + +func sliceToKeywords(kw []string) string { + keywords := []string(kw) + for i, v := range keywords { + if strings.Contains(v, " ") { + kw[i] = "\"" + v + "\"" + } + } + + kString := strings.Join(keywords, " ") + + return kString +} diff --git a/vaults_test.go b/vaults_test.go index 30efa70..5a7f7be 100644 --- a/vaults_test.go +++ b/vaults_test.go @@ -10,13 +10,13 @@ const testNewVaultId string = "eabd3646-acf8-44a4-9ba0-991df147c209" var testNewVaultPassword string = "5w:mr6kPj" var testVault Vault = Vault{ - Name: "go-dvls tests", + Name: "go-dvls", Description: "Test Vault", } var testNewVault Vault = Vault{ ID: testNewVaultId, - Name: "go-dvls tests new", + Name: "go-dvls new", Description: "Test", }