diff --git a/app/nexus/internal/route/base/route.go b/app/nexus/internal/route/base/route.go index a4a1d61..76800ba 100644 --- a/app/nexus/internal/route/base/route.go +++ b/app/nexus/internal/route/base/route.go @@ -53,6 +53,8 @@ func Route( return policy.RouteDeletePolicy case a == net.ActionNexusList && p == net.SpikeNexusUrlPolicy: return policy.RouteListPolicies + case a == net.ActionNexusGet && p == net.SpikeNexusUrlSecretsMetadata: + return secret.RouteGetSecretMetadata default: return net.Fallback } diff --git a/app/nexus/internal/route/store/secret/get.go b/app/nexus/internal/route/store/secret/get.go index 07a0210..5a42f64 100644 --- a/app/nexus/internal/route/store/secret/get.go +++ b/app/nexus/internal/route/store/secret/get.go @@ -146,3 +146,148 @@ func RouteGetSecret( log.Log().Info("routeGetSecret", "msg", data.ErrSuccess) return nil } + +// RouteGetSecretMetadata handles requests to retrieve a secret metadata at a specific path +// and version. +// +// This endpoint requires a valid admin JWT token for authentication. The +// function retrieves a secret based on the provided path and optional version +// number. If no version is specified, the latest version is returned. +// +// The function follows these steps: +// 1. Validates the JWT token +// 2. Validates and unmarshal the request body +// 3. Attempts to retrieve the secret metadata +// 4. Returns the secret metadata or an appropriate error response +// +// Parameters: +// - w: http.ResponseWriter to write the HTTP response +// - r: *http.Request containing the incoming HTTP request +// - audit: *log.AuditEntry for logging audit information +// +// Returns: +// - error: if an error occurs during request processing. +// +// Request body format: +// +// { +// "path": string, // Path to the secret +// "version": int // Optional: specific version to retrieve +// } +// +// Response format on success (200 OK): +// +// "versions": { // map[int]SecretMetadataVersionResponse +// +// "version": { // SecretMetadataVersionResponse object +// "createdTime": "", // time.Time +// "version": 0, // int +// "deletedTime": null // *time.Time (pointer, can be null) +// } +// }, +// +// "metadata": { // SecretRawMetadataResponse object +// +// "currentVersion": 0, // int +// "oldestVersion": 0, // int +// "createdTime": "", // time.Time +// "updatedTime": "", // time.Time +// "maxVersions": 0 // int +// }, +// +// "err": null // ErrorCode +// +// Error responses: +// - 401 Unauthorized: Invalid or missing JWT token +// - 400 Bad Request: Invalid request body +// - 404 Not Found: Secret doesn't exist at specified path/version +// +// All operations are logged using structured logging. +func RouteGetSecretMetadata( + w http.ResponseWriter, r *http.Request, audit *log.AuditEntry, +) error { + log.Log().Info("routeGetSecretMetadata", "method", r.Method, "path", r.URL.Path, + "query", r.URL.RawQuery) + audit.Action = log.AuditRead + + requestBody := net.ReadRequestBody(w, r) + if requestBody == nil { + return errors.New("failed to read request body") + } + + request := net.HandleRequest[ + reqres.SecretReadRequest, reqres.SecretReadResponse]( + requestBody, w, + reqres.SecretReadResponse{Err: data.ErrBadInput}, + ) + if request == nil { + return errors.New("failed to parse request body") + } + + path := request.Path + version := request.Version + + rawSecret, err := state.GetRawSecret(path, version) + if err != nil { + return handleError(err, w) + } + + response := rawSecretResponseMapper(rawSecret) + responseBody := net.MarshalBody(response, w) + + if responseBody == nil { + return errors.New("failed to marshal response body") + } + + net.Respond(http.StatusOK, responseBody, w) + log.Log().Info("routeGetSecret", "msg", "OK") + return nil +} + +func rawSecretResponseMapper(rawSecret *store.Secret) reqres.SecretMetadataResponse { + versions := make(map[int]reqres.SecretMetadataVersionResponse) + for versionNum, version := range rawSecret.Versions { + versions[versionNum] = reqres.SecretMetadataVersionResponse{ + CreatedTime: version.CreatedTime, + Version: version.Version, + DeletedTime: version.DeletedTime, + } + } + + metadata := reqres.SecretRawMetadataResponse{ + CurrentVersion: rawSecret.Metadata.CurrentVersion, + OldestVersion: rawSecret.Metadata.OldestVersion, + CreatedTime: rawSecret.Metadata.CreatedTime, + UpdatedTime: rawSecret.Metadata.UpdatedTime, + MaxVersions: rawSecret.Metadata.MaxVersions, + } + + return reqres.SecretMetadataResponse{ + Versions: versions, + Metadata: metadata, + } +} + +func handleError(err error, w http.ResponseWriter) error { + if errors.Is(err, store.ErrSecretNotFound) { + log.Log().Info("routeGetSecret", "msg", "Secret not found") + + res := reqres.SecretReadResponse{Err: data.ErrNotFound} + responseBody := net.MarshalBody(res, w) + if responseBody == nil { + return errors.New("failed to marshal response body") + } + net.Respond(http.StatusNotFound, responseBody, w) + return nil + } + + log.Log().Info("routeGetSecret", "msg", "Failed to retrieve secret", "err", err) + responseBody := net.MarshalBody(reqres.SecretReadResponse{ + Err: "Internal server error"}, w, + ) + if responseBody == nil { + return errors.New("failed to marshal response body") + } + net.Respond(http.StatusInternalServerError, responseBody, w) + return err +} diff --git a/app/nexus/internal/state/base/secret.go b/app/nexus/internal/state/base/secret.go index e1b27b1..6c416e4 100644 --- a/app/nexus/internal/state/base/secret.go +++ b/app/nexus/internal/state/base/secret.go @@ -4,7 +4,10 @@ package base -import "github.com/spiffe/spike/app/nexus/internal/state/persist" +import ( + "github.com/spiffe/spike/app/nexus/internal/state/persist" + "github.com/spiffe/spike/pkg/store" +) // UpsertSecret stores or updates a secret at the specified path with the // provided values. It provides thread-safe access to the underlying key-value @@ -106,3 +109,38 @@ func GetSecret(path string, version int) (map[string]string, error) { return cachedSecret.Versions[version].Data, nil } + +// GetRawSecret retrieves a secret with metadata from the specified path and version. +// It provides thread-safe read access to the secret store. +// +// Parameters: +// - path: The location of the secret to retrieve +// - version: The specific version of the secret to fetch +// +// Returns: +// - *store.Secret: The secret type +// - bool: Whether the secret was found +func GetRawSecret(path string, version int) (*store.Secret, error) { + kvMu.RLock() + secret, err := kv.GetRawSecret(path) + kvMu.RUnlock() + + if err == nil { + return secret, nil + } + + cachedSecret := persist.ReadSecret(path, version) + if cachedSecret == nil { + return nil, err + } + + if version == 0 { + version = cachedSecret.Metadata.CurrentVersion + } + + kvMu.Lock() + kv.Put(path, cachedSecret.Versions[version].Data) + kvMu.Unlock() + + return cachedSecret, nil +} diff --git a/app/spike/internal/cmd/secret/delete.go b/app/spike/internal/cmd/secret/delete.go index 0b9885b..493df77 100644 --- a/app/spike/internal/cmd/secret/delete.go +++ b/app/spike/internal/cmd/secret/delete.go @@ -36,9 +36,9 @@ import ( // // Example Usage: // -// spike delete secret/apocalyptica # Deletes current version -// spike delete secret/apocalyptica -v 1,2,3 # Deletes specific versions -// spike delete secret/apocalyptica -v 0,1,2 # Deletes current version plus 1,2 +// spike secret delete secret/apocalyptica # Deletes current version +// spike secret delete secret/apocalyptica -v 1,2,3 # Deletes specific versions +// spike secret delete secret/apocalyptica -v 0,1,2 # Deletes current version plus 1,2 // // The command performs trust to ensure: // - Exactly one path argument is provided @@ -54,9 +54,9 @@ Version 0 refers to the current/latest version. If no version is specified, defaults to deleting the current version. Examples: - spike delete secret/apocalyptica # Deletes current version - spike delete secret/apocalyptica -v 1,2,3 # Deletes specific versions - spike delete secret/apocalyptica -v 0,1,2 # Deletes current version plus versions 1 and 2`, + spike secret delete secret/apocalyptica # Deletes current version + spike secret delete secret/apocalyptica -v 1,2,3 # Deletes specific versions + spike secret delete secret/apocalyptica -v 0,1,2 # Deletes current version plus versions 1 and 2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { state, err := spike.CheckInitState(source) diff --git a/app/spike/internal/cmd/secret/get.go b/app/spike/internal/cmd/secret/get.go index d6cb17c..89b79e7 100644 --- a/app/spike/internal/cmd/secret/get.go +++ b/app/spike/internal/cmd/secret/get.go @@ -6,6 +6,9 @@ package secret import ( "fmt" + "github.com/spiffe/spike-sdk-go/api/entity/v1/reqres" + "strings" + "time" "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" @@ -82,3 +85,116 @@ func newSecretGetCommand(source *workloadapi.X509Source) *cobra.Command { return getCmd } + +// newSecretMetadataGetCommand creates and returns a new cobra.Command for retrieving +// secrets. It configures a command that fetches and displays secret data from a +// specified path. +// +// Parameters: +// - source: X.509 source for workload API authentication +// +// The command accepts a single argument: +// - path: Location of the secret to retrieve +// +// Flags: +// - --version, -v (int): Specific version of the secret to retrieve +// (default 0) where 0 represents the current version +// +// Returns: +// - *cobra.Command: Configured get command +// +// The command will: +// 1. Verify SPIKE initialization status via admin token +// 2. Retrieve the secret metadata from the specified path and version +// 3. Display all metadata fields and secret versions +// +// Error cases: +// - SPIKE not initialized: Prompts user to run 'spike init' +// - Secret not found: Displays appropriate message +// - Read errors: Displays error message +func newSecretMetadataGetCommand(source *workloadapi.X509Source) *cobra.Command { + getMetadataCmd := &cobra.Command{ + Use: "metadata", + Short: "Manage secrets", + } + var getCmd = &cobra.Command{ + Use: "get ", + Short: "Get secrets metadata from the specified path", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + state, err := spike.CheckInitState(source) + if err != nil { + fmt.Println("Failed to check initialization state:") + fmt.Println(err.Error()) + return + } + + if state == data.NotInitialized { + fmt.Println("Please initialize SPIKE first by running 'spike init'.") + return + } + + path := args[0] + version, _ := cmd.Flags().GetInt("version") + + secret, err := spike.GetSecretMetadata(source, path, version) + if err != nil { + fmt.Println("Error reading secret:", err.Error()) + return + } + + if secret == nil { + fmt.Println("Secret not found.") + return + } + + printSecretResponse(secret) + }, + } + + getCmd.Flags().IntP("version", "v", 0, "Specific version to retrieve") + + getMetadataCmd.AddCommand(getCmd) + return getMetadataCmd +} + +// printSecretResponse prints secret metadata +func printSecretResponse(response *reqres.SecretMetadataResponse) { + printSeparator := func() { + fmt.Println(strings.Repeat("-", 50)) + } + + formatTime := func(t time.Time) string { + return t.Format("2006-01-02 15:04:05 MST") + } + + if response.Metadata != (reqres.SecretRawMetadataResponse{}) { + fmt.Println("\nMetadata:") + printSeparator() + fmt.Printf("Current Version : %d\n", response.Metadata.CurrentVersion) + fmt.Printf("Oldest Version : %d\n", response.Metadata.OldestVersion) + fmt.Printf("Created Time : %s\n", formatTime(response.Metadata.CreatedTime)) + fmt.Printf("Last Updated : %s\n", formatTime(response.Metadata.UpdatedTime)) + fmt.Printf("Max Versions : %d\n", response.Metadata.MaxVersions) + printSeparator() + } + + if len(response.Versions) > 0 { + fmt.Println("\nSecret Versions:") + printSeparator() + + for version, versionData := range response.Versions { + fmt.Printf("Version %d:\n", version) + fmt.Printf(" Created: %s\n", formatTime(versionData.CreatedTime)) + if versionData.DeletedTime != nil { + fmt.Printf(" Deleted: %s\n", formatTime(*versionData.DeletedTime)) + } + printSeparator() + } + } + + if response.Err != "" { + fmt.Printf("\nError: %s\n", response.Err) + printSeparator() + } +} diff --git a/app/spike/internal/cmd/secret/list.go b/app/spike/internal/cmd/secret/list.go index 607fd4d..13d4e03 100644 --- a/app/spike/internal/cmd/secret/list.go +++ b/app/spike/internal/cmd/secret/list.go @@ -6,7 +6,6 @@ package secret import ( "fmt" - "github.com/spf13/cobra" "github.com/spiffe/go-spiffe/v2/workloadapi" spike "github.com/spiffe/spike-sdk-go/api" diff --git a/app/spike/internal/cmd/secret/new.go b/app/spike/internal/cmd/secret/new.go index 2245910..2d0f82c 100644 --- a/app/spike/internal/cmd/secret/new.go +++ b/app/spike/internal/cmd/secret/new.go @@ -17,6 +17,7 @@ func NewSecretCommand(source *workloadapi.X509Source) *cobra.Command { cmd.AddCommand(newSecretUndeleteCommand(source)) cmd.AddCommand(newSecretListCommand(source)) cmd.AddCommand(newSecretGetCommand(source)) + cmd.AddCommand(newSecretMetadataGetCommand(source)) cmd.AddCommand(newSecretPutCommand(source)) return cmd diff --git a/app/spike/internal/cmd/secret/put.go b/app/spike/internal/cmd/secret/put.go index 5eb6885..785fd10 100644 --- a/app/spike/internal/cmd/secret/put.go +++ b/app/spike/internal/cmd/secret/put.go @@ -30,8 +30,8 @@ import ( // // Example Usage: // -// spike put secret/myapp username=admin password=secret -// spike put secret/config host=localhost port=8080 +// spike secret put secret/myapp username=admin password=secret +// spike secret put secret/config host=localhost port=8080 // // The command will: // 1. Verify SPIKE initialization status via admin token diff --git a/app/spike/internal/cmd/secret/undelete.go b/app/spike/internal/cmd/secret/undelete.go index 6c41fda..feb22ec 100644 --- a/app/spike/internal/cmd/secret/undelete.go +++ b/app/spike/internal/cmd/secret/undelete.go @@ -36,9 +36,9 @@ import ( // // Example Usage: // -// spike undelete secret/ella # Restores current version -// spike undelete secret/ella -v 1,2,3 # Restores specific versions -// spike undelete secret/ella -v 0,1,2 # Restores current version plus 1,2 +// spike secret undelete secret/ella # Restores current version +// spike secret undelete secret/ella -v 1,2,3 # Restores specific versions +// spike secret undelete secret/ella -v 0,1,2 # Restores current version plus 1,2 // // The command performs trust to ensure: // - Exactly one path argument is provided @@ -57,9 +57,9 @@ Version 0 refers to the current/latest version. If no version is specified, defaults to undeleting the current version. Examples: - spike undelete secret/ella # Undeletes current version - spike undelete secret/ella -v 1,2,3 # Undeletes specific versions - spike undelete secret/ella -v 0,1,2 # Undeletes current version plus versions 1 and 2`, + spike secret undelete secret/ella # Undeletes current version + spike secret undelete secret/ella -v 1,2,3 # Undeletes specific versions + spike secret undelete secret/ella -v 0,1,2 # Undeletes current version plus versions 1 and 2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { state, err := spike.CheckInitState(source) diff --git a/docs/quickstart.md b/docs/quickstart.md index 3649fc8..014628c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -244,16 +244,16 @@ Let test **SPIKE** by creating a secret spike init # Register a secret: -spike put /secrets/db-secret username=postgres password=postgres +spike secret put /secrets/db-secret username=postgres password=postgres -spike get /secrets/db/secret +spike secret get /secrets/db-secret # Wil return: # username=postgres # password=postgres -spike delete /secrets/db-secret # Deleting the current secret +spike secret delete /secrets/db-secret # Deleting the current secret -spike get /secrets/db-secret +spike secret get /secrets/db-secret # WIll be empty. ``` diff --git a/internal/net/action.go b/internal/net/action.go index 519f3ef..5af8ee7 100644 --- a/internal/net/action.go +++ b/internal/net/action.go @@ -7,7 +7,6 @@ package net type SpikeNexusApiAction string const KeyApiAction = "action" - const ActionNexusCheck SpikeNexusApiAction = "check" const ActionNexusGet SpikeNexusApiAction = "get" const ActionNexusDelete SpikeNexusApiAction = "delete" diff --git a/internal/net/url.go b/internal/net/url.go index 80105f9..326af8c 100644 --- a/internal/net/url.go +++ b/internal/net/url.go @@ -7,9 +7,8 @@ package net type ApiUrl string const SpikeNexusUrlSecrets ApiUrl = "/v1/store/secrets" +const SpikeNexusUrlSecretsMetadata ApiUrl = "/v1/store/secrets/metadata" const SpikeNexusUrlLogin ApiUrl = "/v1/auth/login" const SpikeNexusUrlInit ApiUrl = "/v1/auth/initialization" - const SpikeNexusUrlPolicy ApiUrl = "/v1/acl/policy" - const SpikeKeeperUrlKeep ApiUrl = "/v1/store/keep"