From e479946180afe8f78a0604f5a9a98c44baaaa70d Mon Sep 17 00:00:00 2001 From: Dimitar Yanakiev <31411471+dkyanakiev@users.noreply.github.com> Date: Sun, 28 Apr 2024 23:56:16 +0300 Subject: [PATCH] Feature/metadata view (#17) * Hotfix for popups; metadata view * Fixing metadata view * Cleanup --- CHANGELOG.md | 10 ++ internal/models/models.go | 22 ++++ internal/state/state.go | 1 + internal/vault/secret.go | 38 ++++++- internal/watcher/secretobj.go | 13 ++- internal/watcher/watcher.go | 3 +- tui/component/commands.go | 6 +- tui/component/components.go | 2 + tui/component/secret_obj_table.go | 169 ++++++++++++++++++++++-------- tui/primitives/table.go | 8 ++ tui/view/mounts.go | 2 +- tui/view/namespace.go | 4 +- tui/view/policy.go | 2 +- tui/view/policyacl.go | 2 +- tui/view/secretobj.go | 11 +- tui/view/secrets.go | 2 +- 16 files changed, 231 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fdd3a..d5bf617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.1.8] - 2024-04-28 + +## Fixed + +-- Fixing issue with popups not being focused and requiring selection with mouse + +## Added + +-- Adding metadata view on secret objects + ## [0.1.7] - 2024-04-24 ## Added diff --git a/internal/models/models.go b/internal/models/models.go index ed3397b..6329e62 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -209,3 +209,25 @@ type Namespace struct { Name string Description string } + +type MetaResponse struct { + Data Metadata `json:"data"` +} + +type Metadata struct { + CasRequired bool `json:"cas_required"` + CreatedTime string `json:"created_time"` + CurrentVersion int `json:"current_version"` + DeleteVersionAfter string `json:"delete_version_after"` + MaxVersions int `json:"max_versions"` + OldestVersion int `json:"oldest_version"` + UpdatedTime string `json:"updated_time"` + CustomMetadata map[string]interface{} `json:"custom_metadata"` + Versions map[string]Version `json:"versions"` +} + +type Version struct { + CreatedTime string `json:"created_time"` + DeletionTime string `json:"deletion_time"` + Destroyed bool `json:"destroyed"` +} diff --git a/internal/state/state.go b/internal/state/state.go index 95e398b..f24afa5 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -23,6 +23,7 @@ type State struct { SelectedObject string SelectedPolicyName string SelectedSecret *api.Secret + SelectedSecretMeta *models.Metadata PolicyList []string PolicyACL string NewSecretName string diff --git a/internal/vault/secret.go b/internal/vault/secret.go index 321207a..8fe602b 100644 --- a/internal/vault/secret.go +++ b/internal/vault/secret.go @@ -2,6 +2,7 @@ package vault import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -74,23 +75,52 @@ func (v *Vault) ListNestedSecrets(mount, path string) ([]models.SecretPath, erro return secretPaths, nil } -func (v *Vault) GetSecretInfo(mount, path string) (*api.Secret, error) { +func (v *Vault) GetSecretData(mount, path string) (*api.Secret, error) { secretPath := fmt.Sprintf("%s/data/%s", mount, path) secretPath = sanitizePath(secretPath) secretData, err := v.vault.Logical().Read(secretPath) if err != nil { v.Logger.Err(err).Msgf("failed to read secret: %s", err) - return nil, errors.New(fmt.Sprintf("Failed to read secret: %v", err)) + return nil, fmt.Errorf("failed to read secret: %v", err) } if secretData == nil { v.Logger.Err(err).Msgf("no data found at %s", secretPath) - return nil, errors.New(fmt.Sprintf("No data found at %s", secretPath)) + return nil, fmt.Errorf("no data found at %s", secretPath) } - //TODO: Add logging + return secretData, nil } +func (v *Vault) GetSecretMetadata(mount, path string) (*models.Metadata, error) { + secretPath := fmt.Sprintf("%s/metadata/%s", mount, path) + secretPath = sanitizePath(secretPath) + var metadata models.Metadata + secretData, err := v.vault.Logical().Read(secretPath) + if err != nil { + v.Logger.Debug().Msgf("failed to read secret metadata: %s", err) + return nil, fmt.Errorf("failed to read secret metadata: %v", err) + } + + if secretData == nil { + v.Logger.Debug().Msgf("no metadata found at %s", secretPath) + return nil, fmt.Errorf("no metadata found at %s", secretPath) + } + + jsonData, err := json.Marshal(secretData.Data) + if err != nil { + v.Logger.Err(err).Msgf("failed to marshal secret data: %s", err) + } + // Convert JSON to Metadata + err = json.Unmarshal(jsonData, &metadata) + if err != nil { + v.Logger.Err(err).Msgf("failed to unmarshal secret data: %s", err) + } + + v.Logger.Debug().Msgf("Metadata: %v", metadata.CustomMetadata) + return &metadata, nil +} + func (v *Vault) UpdateSecretObjectKV2(mount string, path string, patch bool, data map[string]interface{}) error { ctx := context.Background() diff --git a/internal/watcher/secretobj.go b/internal/watcher/secretobj.go index bc6e146..c0d9b78 100644 --- a/internal/watcher/secretobj.go +++ b/internal/watcher/secretobj.go @@ -32,12 +32,19 @@ func (w *Watcher) updateSecretState(selectedMount, selectedPath string) { w.logger.Debug().Msgf("Enterprise version detected, setting namespace to %v", w.state.SelectedNamespace) w.vault.SetNamespace(w.state.SelectedNamespace) } - secret, err := w.vault.GetSecretInfo(selectedMount, selectedPath) + secret, err := w.vault.GetSecretData(selectedMount, selectedPath) if err != nil { - w.NotifyHandler(models.HandleError, err.Error()) - return + w.NotifyHandler(models.HandleInfo, err.Error()) + } + metadata, err2 := w.vault.GetSecretMetadata(selectedMount, selectedPath) + if err2 != nil { + w.NotifyHandler(models.HandleInfo, err2.Error()) + } + if err != nil && err2 != nil { + w.NotifyHandler(models.HandleError, "Unable to return secret data or metadata") } w.state.SelectedSecret = secret + w.state.SelectedSecretMeta = metadata } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2b826df..602adb2 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -25,7 +25,8 @@ type Vault interface { ListNestedSecrets(string, string) ([]models.SecretPath, error) SetNamespace(string) ListNamespaces() ([]string, error) - GetSecretInfo(string, string) (*api.Secret, error) + GetSecretData(string, string) (*api.Secret, error) + GetSecretMetadata(string, string) (*models.Metadata, error) //GetPolicy(string) (string, error) //ListPolicies() ([]string, error) } diff --git a/tui/component/commands.go b/tui/component/commands.go index 4697026..260ba05 100644 --- a/tui/component/commands.go +++ b/tui/component/commands.go @@ -43,6 +43,7 @@ var ( SecretObjectCommands = []string{ fmt.Sprintf("\n%s Secret Commands:", styles.HighlightSecondaryTag), fmt.Sprintf("%sh%s toggle display for secrets", styles.HighlightPrimaryTag, styles.StandardColorTag), + fmt.Sprintf("%st%s toggle display for metadata info", styles.HighlightPrimaryTag, styles.StandardColorTag), fmt.Sprintf("%sc%s copy secret to clipboard", styles.HighlightPrimaryTag, styles.StandardColorTag), fmt.Sprintf("%sj%s toggle json view for secret", styles.HighlightPrimaryTag, styles.StandardColorTag), fmt.Sprintf("%sP%s to PATCH secret", styles.HighlightPrimaryTag, styles.StandardColorTag), @@ -84,7 +85,6 @@ func NewCommands() *Commands { func (c *Commands) Update(commands []string) { c.Props.ViewCommands = commands - c.updateText() } @@ -101,6 +101,10 @@ func (c *Commands) Render() error { func (c *Commands) updateText() { commands := append(c.Props.MainCommands, c.Props.ViewCommands...) + // Easy way to handle long list of commands for views + if len(c.Props.ViewCommands) > 6 { + commands = c.Props.ViewCommands + } cmds := strings.Join(commands, "\n") c.TextView.SetText(cmds) } diff --git a/tui/component/components.go b/tui/component/components.go index 2608eaf..3a16c58 100644 --- a/tui/component/components.go +++ b/tui/component/components.go @@ -32,6 +32,8 @@ type Table interface { SetSelectedFunc(fn func(row, column int)) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) ScrollToTop() *tview.Table + SetSelectedStyle(style tcell.Style) + SetSelectable(rows, columns bool) } //go:generate counterfeiter . TextView diff --git a/tui/component/secret_obj_table.go b/tui/component/secret_obj_table.go index 95c3e3d..e00f97a 100644 --- a/tui/component/secret_obj_table.go +++ b/tui/component/secret_obj_table.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "sort" + "strconv" + "time" "github.com/dkyanakiev/vaulty/internal/models" primitive "github.com/dkyanakiev/vaulty/tui/primitives" @@ -31,15 +33,18 @@ var ( type SelectSecretPathFunc func(jsonPath string) type SecretObjTable struct { - Table Table - TextView TextView - TextArea TextArea - Props *SecretObjTableProps - Logger *zerolog.Logger - ShowJson bool - Editable bool - CursorPosition int - slot *tview.Flex + Table Table + MetadataTable Table + CustomMetadataTable Table + TextView TextView + TextArea TextArea + Props *SecretObjTableProps + Logger *zerolog.Logger + ShowJson bool + ShowMetadata bool + Editable bool + CursorPosition int + slot *tview.Flex } type SecretObjTableProps struct { @@ -53,6 +58,7 @@ type SecretObjTableProps struct { Namespace string Data *api.Secret + Metadata *models.Metadata UpdatedData map[string]interface{} ObscureSecrets bool Update string @@ -61,19 +67,26 @@ type SecretObjTableProps struct { func NewSecretObjTable() *SecretObjTable { t := primitive.NewTable() + mt := primitive.NewTable() + cmtt := primitive.NewTable() tv := primitive.NewTextView(1) tv.SetTextAlign(tview.AlignLeft) tv.SetBorderColor(styles.TcellColorStandard) ta := primitive.NewTextArea() + mt.SetSelectable(false, false) + cmtt.SetSelectable(false, false) jt := &SecretObjTable{ - Table: t, - TextView: tv, - TextArea: ta, - Props: &SecretObjTableProps{}, - ShowJson: false, - Editable: false, - slot: tview.NewFlex(), + Table: t, + MetadataTable: mt, + CustomMetadataTable: cmtt, + TextView: tv, + TextArea: ta, + Props: &SecretObjTableProps{}, + ShowJson: false, + ShowMetadata: false, + Editable: false, + slot: tview.NewFlex(), } //TODO: Revisit jt.slot.AddItem(jt.TextView.Primitive(), 0, 1, false) @@ -87,34 +100,49 @@ func (s *SecretObjTable) Bind(slot *tview.Flex) { func (s *SecretObjTable) reset() { s.slot.Clear() s.Table.Clear() + s.MetadataTable.Clear() + s.CustomMetadataTable.Clear() s.TextView.Clear() } func (s *SecretObjTable) ToggleView() { s.slot.Clear() - if !s.Editable { - if s.Props.JsonOnly { - s.slot.AddItem(s.TextView.Primitive(), 0, 1, true) - s.renderJson() - } else { - - if s.ShowJson { + if !s.ShowMetadata { + if !s.Editable { + if s.Props.JsonOnly { s.slot.AddItem(s.TextView.Primitive(), 0, 1, true) s.renderJson() } else { - s.slot.AddItem(s.Table.Primitive(), 0, 1, true) - s.renderRows() + if s.ShowJson { + s.slot.AddItem(s.TextView.Primitive(), 0, 1, true) + s.renderJson() + } else { + s.slot.AddItem(s.Table.Primitive(), 0, 1, true) + s.renderRows() + } } - } - } else { - if !s.Props.MissingSecret { - s.Props.UpdatedData = s.Props.Data.Data["data"].(map[string]interface{}) } else { - s.Props.UpdatedData = make(map[string]interface{}) + if !s.Props.MissingSecret { + s.Props.UpdatedData = s.Props.Data.Data["data"].(map[string]interface{}) + } else { + s.Props.UpdatedData = make(map[string]interface{}) + } + s.TextView.SetText(s.TextArea.GetText()) + s.slot.AddItem(s.TextArea.Primitive(), 0, 1, true) + s.renderEditArea() } - s.TextView.SetText(s.TextArea.GetText()) - s.slot.AddItem(s.TextArea.Primitive(), 0, 1, true) - s.renderEditArea() + } +} + +func (s *SecretObjTable) ToggleMetaView() { + s.Logger.Debug().Msgf("ShowMetadata: %v", s.ShowMetadata) + if s.ShowMetadata { + s.slot.AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(s.MetadataTable.Primitive(), 10, 1, false). + AddItem(s.CustomMetadataTable.Primitive(), 0, 1, false), 0, 2, false) + s.renderMetadata() + } else { + s.Render() } } @@ -131,13 +159,35 @@ func (s *SecretObjTable) GetIDForSelection() (string, string) { } func (s *SecretObjTable) Render() error { - s.Props.MissingSecret = false - s.Props.JsonOnly = false - s.reset() - s.Table.SetTitle("%s %s", SecretObjTableTitle, s.Props.SelectedPath) - s.validationLogic() + if !s.ShowMetadata { + s.Props.MissingSecret = false + s.Props.JsonOnly = false + s.reset() + s.Table.SetTitle("%s %s", SecretObjTableTitle, s.Props.SelectedPath) + s.validationLogic() + + if s.Props.MissingSecret { + s.Props.HandleNoResources( + "%sno Secret Object data available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", + styles.HighlightPrimaryTag, + styles.HighlightSecondaryTag, + ) + return nil + } + + s.Table.SetSelectedFunc(s.pathSelected) + s.Table.RenderHeader(SecretObjTableHeaderJobs) + + if !s.Props.MissingSecret { + s.ToggleView() + } + + } + return nil +} - if s.Props.MissingSecret { +func (s *SecretObjTable) renderMetadata() error { + if s.Props.Metadata == nil { s.Props.HandleNoResources( "%sno Secret Object data available\n¯%s\\_( ͡• ͜ʖ ͡•)_/¯", styles.HighlightPrimaryTag, @@ -146,12 +196,30 @@ func (s *SecretObjTable) Render() error { return nil } - s.Table.SetSelectedFunc(s.pathSelected) - s.Table.RenderHeader(SecretObjTableHeaderJobs) - - if !s.Props.MissingSecret { - s.ToggleView() + s.MetadataTable.SetTitle("Metadata") + s.CustomMetadataTable.SetTitle("Custom Metadata") + s.MetadataTable.RenderRow([]string{"Created Time", ConvertTimeFormat(s.Props.Metadata.CreatedTime)}, 0, tcell.ColorYellow) + s.MetadataTable.RenderRow([]string{"Update Time", ConvertTimeFormat(s.Props.Metadata.UpdatedTime)}, 1, tcell.ColorYellow) + s.MetadataTable.RenderRow([]string{"Current Version", strconv.Itoa(s.Props.Metadata.CurrentVersion)}, 2, tcell.ColorYellow) + s.MetadataTable.RenderRow([]string{"Oldest Version", strconv.Itoa(s.Props.Metadata.CurrentVersion)}, 3, tcell.ColorYellow) + s.MetadataTable.RenderRow([]string{"Delete after version", s.Props.Metadata.DeleteVersionAfter}, 4, tcell.ColorYellow) + s.MetadataTable.RenderRow([]string{"Cas Required", strconv.FormatBool(s.Props.Metadata.CasRequired)}, 5, tcell.ColorYellow) + + i := 0 + for k, v := range s.Props.Metadata.CustomMetadata { + value, ok := v.(string) + if !ok { + // handle the case where v is not a string + continue + } + row := []string{ + k, + value, + } + s.CustomMetadataTable.RenderRow(row, i, tcell.ColorYellow) + i++ } + return nil } @@ -285,3 +353,16 @@ func (s *SecretObjTable) validationLogic() { s.Props.MissingSecret = true } } + +func ConvertTimeFormat(input string) string { + // Parse the input string into a time.Time value + t, err := time.Parse(time.RFC3339Nano, input) + if err != nil { + return input + } + + // Format the time.Time value into a human-friendly string + output := t.Format("Monday, 02-Jan-06 15:04:05 MST") + + return output +} diff --git a/tui/primitives/table.go b/tui/primitives/table.go index 4e40927..3ce08a7 100644 --- a/tui/primitives/table.go +++ b/tui/primitives/table.go @@ -81,3 +81,11 @@ func (t *Table) ScrollToTop() *tview.Table { t.primitive.ScrollToBeginning() return t.primitive } + +func (t *Table) SetSelectedStyle(style tcell.Style) { + t.tviewTable.SetSelectedStyle(style) +} + +func (t *Table) SetSelectable(rows, columns bool) { + t.primitive.SetSelectable(rows, columns) +} diff --git a/tui/view/mounts.go b/tui/view/mounts.go index 837d709..5a67f0f 100644 --- a/tui/view/mounts.go +++ b/tui/view/mounts.go @@ -11,6 +11,7 @@ func (v *View) Mounts() { v.logger.Debug().Msg("Mounts view") v.viewSwitch() v.Layout.Body.SetTitle("Secret Mounts") + v.Layout.Container.SetFocus(v.components.MountsTable.Table.Primitive()) v.Layout.Container.SetInputCapture(v.InputMounts) v.components.Commands.Update(component.MountsCommands) v.state.Elements.TableMain = v.components.MountsTable.Table.Primitive().(*tview.Table) @@ -37,7 +38,6 @@ func (v *View) Mounts() { // v.addToHistory(v.state.SelectedNamespace, "mounts", func() { // v.Mounts() // }) - v.Layout.Container.SetFocus(v.components.MountsTable.Table.Primitive()) } func (v *View) parseMounts(data []*models.MountOutput) []*models.MountOutput { diff --git a/tui/view/namespace.go b/tui/view/namespace.go index 513ee8d..ce5b575 100644 --- a/tui/view/namespace.go +++ b/tui/view/namespace.go @@ -9,9 +9,10 @@ import ( ) func (v *View) Namespaces() { - v.logger.Debug().Msg("view: Namespaces") v.viewSwitch() + v.logger.Debug().Msg("view: Namespaces") v.Layout.Body.SetTitle("Vault Nmespaces") + v.Layout.Container.SetFocus(v.components.NamespaceTable.Table.Primitive()) v.state.Elements.TableMain = v.components.NamespaceTable.Table.Primitive().(*tview.Table) v.components.NamespaceTable.Logger = v.logger @@ -42,7 +43,6 @@ func (v *View) Namespaces() { // }) // v.addToHistory(v.state.SelectedNamespace, models.TopicNamespace, v.Namespaces) - v.Layout.Container.SetFocus(v.components.NamespaceTable.Table.Primitive()) } func (v *View) inputNamespaces(event *tcell.EventKey) *tcell.EventKey { diff --git a/tui/view/policy.go b/tui/view/policy.go index 255f6a9..03bc107 100644 --- a/tui/view/policy.go +++ b/tui/view/policy.go @@ -12,6 +12,7 @@ func (v *View) VPolicy() { v.viewSwitch() v.Layout.Body.Clear() v.Layout.Body.SetTitle("Vault Policies") + v.Layout.Container.SetFocus(v.components.PolicyTable.Table.Primitive()) v.Layout.Container.SetInputCapture(v.InputVaultPolicy) v.components.Commands.Update(component.PolicyCommands) search := v.components.Search @@ -36,7 +37,6 @@ func (v *View) VPolicy() { update() v.state.Elements.TableMain = v.components.PolicyTable.Table.Primitive().(*tview.Table) - v.Layout.Container.SetFocus(v.components.PolicyTable.Table.Primitive()) } func (v *View) inputPolicy(event *tcell.EventKey) *tcell.EventKey { diff --git a/tui/view/policyacl.go b/tui/view/policyacl.go index c45fae2..8247154 100644 --- a/tui/view/policyacl.go +++ b/tui/view/policyacl.go @@ -10,6 +10,7 @@ func (v *View) PolicyACL(policyName string) { v.viewSwitch() v.Layout.Body.SetTitle(policyName) + v.Layout.Container.SetFocus(v.components.PolicyAclTable.TextView.Primitive()) v.components.PolicyAclTable.TextView.Clear().ScrollToBeginning() v.components.Commands.Update(component.PolicyACLCommands) v.Layout.Container.SetInputCapture(v.inputPolicyACL) @@ -26,7 +27,6 @@ func (v *View) PolicyACL(policyName string) { update() v.state.Elements.TextMain = v.components.PolicyAclTable.TextView.Primitive().(*tview.TextView) - v.Layout.Container.SetFocus(v.components.PolicyAclTable.TextView.Primitive()) } diff --git a/tui/view/secretobj.go b/tui/view/secretobj.go index 6e33698..5023ccb 100644 --- a/tui/view/secretobj.go +++ b/tui/view/secretobj.go @@ -13,6 +13,7 @@ func (v *View) SecretObject(mount, path string) { v.viewSwitch() v.Layout.Body.SetTitle("Secret object") v.Layout.Container.SetInputCapture(v.InputSecret) + v.Layout.Container.SetFocus(v.components.SecretObjTable.Table.Primitive()) v.components.Commands.Update(component.SecretObjectCommands) v.logger.Debug().Msgf("Selected mount is: %v", mount) @@ -28,6 +29,7 @@ func (v *View) SecretObject(mount, path string) { if !v.components.SecretObjTable.Editable { v.components.SecretObjTable.Render() v.components.SecretObjTable.Props.Data = v.state.SelectedSecret + v.components.SecretObjTable.Props.Metadata = v.state.SelectedSecretMeta v.Draw() } } @@ -36,11 +38,6 @@ func (v *View) SecretObject(mount, path string) { update() v.state.Elements.TableMain = v.components.SecretObjTable.Table.Primitive().(*tview.Table) - v.Layout.Container.SetFocus(v.components.SecretObjTable.Table.Primitive()) - - // v.addToHistory(v.state.SelectedNamespace, "secret", func() { - // v.SecretObject(mount, path) - // }) } @@ -73,6 +70,10 @@ func (v *View) inputSecret(event *tcell.EventKey) *tcell.EventKey { return nil case 'b': v.goBack() + case 't': + v.components.SecretObjTable.ShowMetadata = !v.components.SecretObjTable.ShowMetadata + v.logger.Debug().Msgf("Show metadata: %v", v.state.SelectedSecretMeta.UpdatedTime) + v.components.SecretObjTable.ToggleMetaView() case 'j': v.components.SecretObjTable.ShowJson = !v.components.SecretObjTable.ShowJson v.components.SecretObjTable.ToggleView() diff --git a/tui/view/secrets.go b/tui/view/secrets.go index ce89413..6ae70aa 100644 --- a/tui/view/secrets.go +++ b/tui/view/secrets.go @@ -20,6 +20,7 @@ func (v *View) Secrets(path string, secretBool string) { v.viewSwitch() v.Layout.Body.Clear() v.Layout.Body.SetTitle(fmt.Sprintf("Secrets: %s", path)) + v.Layout.Container.SetFocus(v.components.SecretsTable.Table.Primitive()) v.Layout.Container.SetInputCapture(v.InputSecrets) v.components.Commands.Update(component.SecretsCommands) v.logger.Debug().Msgf("Selected path for secret is: %v", path) @@ -56,7 +57,6 @@ func (v *View) Secrets(path string, secretBool string) { update() v.state.Elements.TableMain = v.components.SecretsTable.Table.Primitive().(*tview.Table) - v.Layout.Container.SetFocus(v.components.SecretsTable.Table.Primitive()) }