diff --git a/keepass/export.go b/keepass/export.go index 6468f4b..fca11da 100644 --- a/keepass/export.go +++ b/keepass/export.go @@ -147,71 +147,65 @@ func getKeepassEntry(client *api.Client, resource api.Resource, secret api.Secre gokeepasslib.ValueData{Key: "Notes", Value: gokeepasslib.V{Content: desc}}, ) - if resource.ResourceType.Slug == "password-description-totp" || resource.ResourceType.Slug == "totp" || resource.ResourceType.Slug == "v5-default-with-totp" || resource.ResourceType.Slug == "v5-totp-standalone" { - var totpData api.SecretDataTOTP - + // Extract TOTP data using generic map-based approach + if rType.HasSecretField("totp") { rawSecretData, err := client.DecryptMessage(resource.Secrets[0].Data) if err != nil { return nil, fmt.Errorf("decrypting Secret Data: %w", err) } - switch resource.ResourceType.Slug { - case "password-description-totp": - var secretData api.SecretDataTypePasswordDescriptionTOTP - err = json.Unmarshal([]byte(rawSecretData), &secretData) - if err != nil { - return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err) + var secretMap map[string]any + err = json.Unmarshal([]byte(rawSecretData), &secretMap) + if err != nil { + return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err) + } + + if totpRaw, ok := secretMap["totp"].(map[string]any); ok { + totpData := api.SecretDataTOTP{} + if s, ok := totpRaw["secret_key"].(string); ok { + totpData.SecretKey = s } - totpData = secretData.TOTP - case "totp": - var secretData api.SecretDataTypeTOTP - err = json.Unmarshal([]byte(rawSecretData), &secretData) - if err != nil { - return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err) + if s, ok := totpRaw["algorithm"].(string); ok { + totpData.Algorithm = s } - totpData = secretData.TOTP - case "v5-default-with-totp": - var secretData api.SecretDataTypeV5DefaultWithTOTP - err = json.Unmarshal([]byte(rawSecretData), &secretData) - if err != nil { - return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err) + if d, ok := totpRaw["digits"].(float64); ok { + totpData.Digits = int(d) } - totpData = secretData.TOTP - case "v5-totp-standalone": - var secretData api.SecretDataTypeV5TOTPStandalone - err = json.Unmarshal([]byte(rawSecretData), &secretData) - if err != nil { - return nil, fmt.Errorf("parsing Decrypted Secret Data: %w", err) + if p, ok := totpRaw["period"].(float64); ok { + totpData.Period = int(p) } - totpData = secretData.TOTP - } - v := url.Values{} - v.Set("secret", totpData.SecretKey) - v.Set("period", strconv.FormatUint(uint64(totpData.Period), 10)) - v.Set("algorithm", totpData.Algorithm) - v.Set("digits", fmt.Sprint(totpData.Digits)) + // Skip TOTP entry if secret_key is missing — can't build a valid OTP URI + if totpData.SecretKey == "" { + return &entry, nil + } - issuer := uri - if uri == "" { - issuer = name + v := url.Values{} + v.Set("secret", totpData.SecretKey) + v.Set("period", strconv.FormatUint(uint64(totpData.Period), 10)) + v.Set("algorithm", totpData.Algorithm) + v.Set("digits", fmt.Sprint(totpData.Digits)) - } - v.Set("issuer", issuer) + issuer := uri + if uri == "" { + issuer = name + } + v.Set("issuer", issuer) - accountName := username - if username == "" { - accountName = name - } + accountName := username + if username == "" { + accountName = name + } - u := url.URL{ - Scheme: "otpauth", - Host: "totp", - Path: "/" + issuer + ":" + accountName, - RawQuery: encodeQuery(v), - } + u := url.URL{ + Scheme: "otpauth", + Host: "totp", + Path: "/" + issuer + ":" + accountName, + RawQuery: encodeQuery(v), + } - entry.Values = append(entry.Values, gokeepasslib.ValueData{Key: "otp", Value: gokeepasslib.V{Content: u.String(), Protected: w.NewBoolWrapper(true)}}) + entry.Values = append(entry.Values, gokeepasslib.ValueData{Key: "otp", Value: gokeepasslib.V{Content: u.String(), Protected: w.NewBoolWrapper(true)}}) + } } return &entry, nil diff --git a/resource/create.go b/resource/create.go index 2b00df7..f1a4834 100644 --- a/resource/create.go +++ b/resource/create.go @@ -3,8 +3,10 @@ package resource import ( "encoding/json" "fmt" + "strings" "github.com/passbolt/go-passbolt-cli/util" + "github.com/passbolt/go-passbolt/api" "github.com/passbolt/go-passbolt/helper" "github.com/spf13/cobra" ) @@ -25,15 +27,12 @@ func init() { ResourceCreateCmd.Flags().StringP("description", "d", "", "Resource Description") ResourceCreateCmd.Flags().StringP("folderParentID", "f", "", "Folder in which to create the Resource") ResourceCreateCmd.Flags().String("expiry", "", "Expiry as RFC3339 (e.g. 2025-12-31T23:59:59Z) or Go duration (e.g. 48h, 30m)") - ResourceCreateCmd.MarkFlagRequired("name") - ResourceCreateCmd.MarkFlagRequired("password") + ResourceCreateCmd.Flags().String("type", "", "Resource type slug (e.g. v5-default, password-and-description, v5-custom-fields)") + ResourceCreateCmd.Flags().StringArray("field", []string{}, "Metadata field as key=value (repeatable; JSON values like [\"a\"] are parsed automatically)") + ResourceCreateCmd.Flags().StringArray("secret-field", []string{}, "Secret field as key=value (repeatable; JSON values are parsed automatically)") } func ResourceCreate(cmd *cobra.Command, args []string) error { - folderParentID, err := cmd.Flags().GetString("folderParentID") - if err != nil { - return err - } name, err := cmd.Flags().GetString("name") if err != nil { return err @@ -54,17 +53,33 @@ func ResourceCreate(cmd *cobra.Command, args []string) error { if err != nil { return err } - + folderParentID, err := cmd.Flags().GetString("folderParentID") + if err != nil { + return err + } expiry, err := cmd.Flags().GetString("expiry") if err != nil { return err } - + resourceType, err := cmd.Flags().GetString("type") + if err != nil { + return err + } + fields, err := cmd.Flags().GetStringArray("field") + if err != nil { + return err + } + secretFields, err := cmd.Flags().GetStringArray("secret-field") + if err != nil { + return err + } jsonOutput, err := cmd.Flags().GetBool("json") if err != nil { return err } + useGeneric := resourceType != "" || len(fields) > 0 || len(secretFields) > 0 + ctx, cancel := util.GetContext() defer cancel() @@ -75,21 +90,72 @@ func ResourceCreate(cmd *cobra.Command, args []string) error { defer util.SaveSessionKeysAndLogout(ctx, client) cmd.SilenceUsage = true - id, err := helper.CreateResource( - ctx, - client, - folderParentID, - name, - username, - uri, - password, - description, - ) + var id string + + if useGeneric { + // Generic path: use CreateResourceGeneric with field maps + metadataFields := map[string]any{} + secretFieldsMap := map[string]any{} + + // Map standard flags to field maps + if name != "" { + metadataFields["name"] = name + } + if username != "" { + metadataFields["username"] = username + } + if uri != "" { + metadataFields["uri"] = uri + } + if description != "" { + metadataFields["description"] = description + } + if password != "" { + secretFieldsMap["password"] = password + } + + // Parse --field flags + for _, f := range fields { + k, v, err := parseKeyValue(f) + if err != nil { + return fmt.Errorf("invalid --field: %w", err) + } + metadataFields[k] = v + } + + // Parse --secret-field flags + for _, f := range secretFields { + k, v, err := parseKeyValue(f) + if err != nil { + return fmt.Errorf("invalid --secret-field: %w", err) + } + secretFieldsMap[k] = v + } + + if resourceType == "" { + if client.MetadataTypeSettings().DefaultResourceType == api.PassboltAPIVersionTypeV5 { + resourceType = "v5-default" + } else { + resourceType = "password-and-description" + } + } + + id, err = helper.CreateResourceGeneric(ctx, client, resourceType, folderParentID, metadataFields, secretFieldsMap) + } else { + // Legacy path: use standard CreateResource + if name == "" { + return fmt.Errorf("required flag \"name\" not set") + } + if password == "" { + return fmt.Errorf("required flag \"password\" not set") + } + id, err = helper.CreateResource(ctx, client, folderParentID, name, username, uri, password, description) + } + if err != nil { - return fmt.Errorf("creating Resource: %w", err) + return fmt.Errorf("creating resource: %w", err) } - // TODO, Should be done by go-passbolt when the "new" Resource API is done if expiry != "" { if err := SetResourceExpiry(ctx, client, id, expiry); err != nil { return err @@ -97,13 +163,9 @@ func ResourceCreate(cmd *cobra.Command, args []string) error { } if jsonOutput { - jsonID, err := json.MarshalIndent( - map[string]string{"id": id}, - "", - " ", - ) + jsonID, err := json.MarshalIndent(map[string]string{"id": id}, "", " ") if err != nil { - return fmt.Errorf("marshaling Json: %w", err) + return fmt.Errorf("marshaling json: %w", err) } fmt.Println(string(jsonID)) } else { @@ -111,3 +173,24 @@ func ResourceCreate(cmd *cobra.Command, args []string) error { } return nil } + +// parseKeyValue parses a "key=value" string. If the value looks like JSON +// (starts with [ or {), it is decoded into the appropriate Go type so that +// it is serialized correctly when marshaled back to JSON. +func parseKeyValue(s string) (string, any, error) { + parts := strings.SplitN(s, "=", 2) + if len(parts) != 2 { + return "", nil, fmt.Errorf("expected key=value, got %q", s) + } + key := parts[0] + val := parts[1] + + trimmed := strings.TrimSpace(val) + if strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "{") { + var parsed any + if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil { + return key, parsed, nil + } + } + return key, val, nil +} diff --git a/resource/filter.go b/resource/filter.go index dc2e4bc..7327a67 100644 --- a/resource/filter.go +++ b/resource/filter.go @@ -19,6 +19,8 @@ var CelEnvOptions = []cel.EnvOption{ cel.Variable("Description", cel.StringType), cel.Variable("CreatedTimestamp", cel.TimestampType), cel.Variable("ModifiedTimestamp", cel.TimestampType), + cel.Variable("Metadata", cel.MapType(cel.StringType, cel.DynType)), + cel.Variable("Secret", cel.MapType(cel.StringType, cel.DynType)), } // filterDecryptedResources filters already-decrypted resources by evaluating a CEL expression. @@ -34,6 +36,16 @@ func filterDecryptedResources(resources []decryptedResource, celCmd string, ctx filtered := []decryptedResource{} for _, d := range resources { + // Build metadata and secret maps for CEL, defaulting to empty maps + metadata := d.metadataFields + if metadata == nil { + metadata = map[string]any{} + } + secret := d.secretFields + if secret == nil { + secret = map[string]any{} + } + val, _, err := (*program).ContextEval(ctx, map[string]any{ "ID": d.resource.ID, "FolderParentID": d.resource.FolderParentID, @@ -44,6 +56,8 @@ func filterDecryptedResources(resources []decryptedResource, celCmd string, ctx "Description": d.description, "CreatedTimestamp": d.resource.Created.Time, "ModifiedTimestamp": d.resource.Modified.Time, + "Metadata": metadata, + "Secret": secret, }) if err != nil { diff --git a/resource/get.go b/resource/get.go index 60eed8e..8eb42b7 100644 --- a/resource/get.go +++ b/resource/get.go @@ -63,24 +63,42 @@ func ResourceGet(cmd *cobra.Command, args []string) error { defer util.SaveSessionKeysAndLogout(ctx, client) cmd.SilenceUsage = true - folderParentID, name, username, uri, password, description, err := helper.GetResource( - ctx, - client, - id, - ) + resource, err := client.GetResource(ctx, id) if err != nil { - return fmt.Errorf("getting Resource: %w", err) + return fmt.Errorf("getting resource: %w", err) + } + rType, err := client.GetResourceType(ctx, resource.ResourceTypeID) + if err != nil { + return fmt.Errorf("getting resource type: %w", err) + } + secret, err := client.GetSecret(ctx, resource.ID) + if err != nil { + return fmt.Errorf("getting secret: %w", err) + } + + folderParentID, name, username, uri, password, description, metadata, secretFields, err := + helper.GetResourceFieldMaps(client, *resource, *secret, *rType, true) + if err != nil { + return fmt.Errorf("decrypting resource: %w", err) } if jsonOutput { - jsonResource, err := json.MarshalIndent(ResourceJSONOutput{ + output := ResourceJSONOutput{ FolderParentID: &folderParentID, Name: &name, Username: &username, URI: &uri, Password: &password, Description: &description, - }, "", " ") + } + if len(metadata) > 0 { + output.Metadata = metadata + } + if len(secretFields) > 0 { + output.Secret = secretFields + } + + jsonResource, err := json.MarshalIndent(output, "", " ") if err != nil { return err } @@ -92,6 +110,15 @@ func ResourceGet(cmd *cobra.Command, args []string) error { fmt.Printf("URI: %v\n", shellescape.StripUnsafe(uri)) fmt.Printf("Password: %v\n", shellescape.StripUnsafe(password)) fmt.Printf("Description: %v\n", shellescape.StripUnsafe(description)) + + for k, v := range metadata { + switch k { + case "name", "username", "uri", "uris", "description", "object_type", "resource_type_id": + continue + default: + fmt.Printf("%s: %v\n", k, shellescape.StripUnsafe(fmt.Sprint(v))) + } + } } return nil } diff --git a/resource/json.go b/resource/json.go index f34c574..ceca8b8 100644 --- a/resource/json.go +++ b/resource/json.go @@ -3,13 +3,15 @@ package resource import "time" type ResourceJSONOutput struct { - ID *string `json:"id,omitempty"` - FolderParentID *string `json:"folder_parent_id,omitempty"` - Name *string `json:"name,omitempty"` - Username *string `json:"username,omitempty"` - URI *string `json:"uri,omitempty"` - Password *string `json:"password,omitempty"` - Description *string `json:"description,omitempty"` - CreatedTimestamp *time.Time `json:"created_timestamp,omitempty"` - ModifiedTimestamp *time.Time `json:"modified_timestamp,omitempty"` + ID *string `json:"id,omitempty"` + FolderParentID *string `json:"folder_parent_id,omitempty"` + Name *string `json:"name,omitempty"` + Username *string `json:"username,omitempty"` + URI *string `json:"uri,omitempty"` + Password *string `json:"password,omitempty"` + Description *string `json:"description,omitempty"` + CreatedTimestamp *time.Time `json:"created_timestamp,omitempty"` + ModifiedTimestamp *time.Time `json:"modified_timestamp,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Secret map[string]any `json:"secret,omitempty"` } diff --git a/resource/list.go b/resource/list.go index 676df87..98ea712 100644 --- a/resource/list.go +++ b/resource/list.go @@ -21,14 +21,16 @@ import ( // decryptedResource holds the result of decrypting a single resource type decryptedResource struct { - index int - resource api.Resource - name string - username string - uri string - password string - description string - err error + index int + resource api.Resource + name string + username string + uri string + password string + description string + metadataFields map[string]any + secretFields map[string]any + err error } var defaultTableColumns = []string{"ID", "FolderParentID", "Name", "Username", "URI"} @@ -78,9 +80,9 @@ func ResourceList(cmd *cobra.Command, args []string) error { } } - // Check if CEL filter references Password or Description + // Check if CEL filter references Password, Description, or Secret if !needSecrets && config.celFilter != "" { - refsSecrets, err := util.CELExpressionReferencesFields(config.celFilter, []string{"Password", "Description"}, CelEnvOptions...) + refsSecrets, err := util.CELExpressionReferencesFields(config.celFilter, []string{"Password", "Description", "Secret"}, CelEnvOptions...) if err != nil { return fmt.Errorf("parsing filter: %w", err) } @@ -186,6 +188,12 @@ func decryptResourcesParallel(ctx context.Context, client *api.Client, resources uri: resource.URI, password: "", description: resource.Description, + metadataFields: map[string]any{ + "name": resource.Name, + "username": resource.Username, + "uri": resource.URI, + "description": resource.Description, + }, } continue } @@ -196,7 +204,7 @@ func decryptResourcesParallel(ctx context.Context, client *api.Client, resources secret = resource.Secrets[0] } - _, name, username, uri, pass, desc, err := helper.GetResourceFromDataWithOptions( + _, name, username, uri, pass, desc, metaFields, secFields, err := helper.GetResourceFieldMaps( client, resource, secret, @@ -204,14 +212,16 @@ func decryptResourcesParallel(ctx context.Context, client *api.Client, resources needSecrets, ) results <- decryptedResource{ - index: idx, - resource: resource, - name: name, - username: username, - uri: uri, - password: pass, - description: desc, - err: err, + index: idx, + resource: resource, + name: name, + username: username, + uri: uri, + password: pass, + description: desc, + metadataFields: metaFields, + secretFields: secFields, + err: err, } } }() @@ -284,7 +294,7 @@ func printJSONResources( uri := d.uri pass := d.password desc := d.description - outputResources[i] = ResourceJSONOutput{ + output := ResourceJSONOutput{ ID: &d.resource.ID, FolderParentID: &d.resource.FolderParentID, Name: &name, @@ -295,6 +305,13 @@ func printJSONResources( CreatedTimestamp: &d.resource.Created.Time, ModifiedTimestamp: &d.resource.Modified.Time, } + if len(d.metadataFields) > 0 { + output.Metadata = d.metadataFields + } + if len(d.secretFields) > 0 { + output.Secret = d.secretFields + } + outputResources[i] = output } if isColumnsChanged { diff --git a/resource/update.go b/resource/update.go index 0afff1e..5826240 100644 --- a/resource/update.go +++ b/resource/update.go @@ -24,6 +24,8 @@ func init() { ResourceUpdateCmd.Flags().StringP("password", "p", "", "Resource Password") ResourceUpdateCmd.Flags().StringP("description", "d", "", "Resource Description") ResourceUpdateCmd.Flags().String("expiry", "", "Expiry as RFC3339 (e.g. 2025-12-31T23:59:59Z), duration (e.g. 7d, 12h), or 'none' to clear") + ResourceUpdateCmd.Flags().StringArray("field", []string{}, "Metadata field as key=value (repeatable; JSON values like [\"a\"] are parsed automatically)") + ResourceUpdateCmd.Flags().StringArray("secret-field", []string{}, "Secret field as key=value (repeatable; JSON values are parsed automatically)") ResourceUpdateCmd.MarkFlagRequired("id") } @@ -52,11 +54,20 @@ func ResourceUpdate(cmd *cobra.Command, args []string) error { if err != nil { return err } - expiry, err := cmd.Flags().GetString("expiry") if err != nil { return err } + fields, err := cmd.Flags().GetStringArray("field") + if err != nil { + return err + } + secretFields, err := cmd.Flags().GetStringArray("secret-field") + if err != nil { + return err + } + + useGeneric := len(fields) > 0 || len(secretFields) > 0 ctx, cancel := util.GetContext() defer cancel() @@ -68,18 +79,49 @@ func ResourceUpdate(cmd *cobra.Command, args []string) error { defer util.SaveSessionKeysAndLogout(ctx, client) cmd.SilenceUsage = true - err = helper.UpdateResource( - ctx, - client, - id, - name, - username, - uri, - password, - description, - ) + if useGeneric { + // Generic path: use UpdateResourceGeneric with field maps + metadataUpdates := map[string]any{} + secretUpdates := map[string]any{} + + if name != "" { + metadataUpdates["name"] = name + } + if username != "" { + metadataUpdates["username"] = username + } + if uri != "" { + metadataUpdates["uri"] = uri + } + if description != "" { + metadataUpdates["description"] = description + } + if password != "" { + secretUpdates["password"] = password + } + + for _, f := range fields { + k, v, parseErr := parseKeyValue(f) + if parseErr != nil { + return fmt.Errorf("invalid --field: %w", parseErr) + } + metadataUpdates[k] = v + } + for _, f := range secretFields { + k, v, parseErr := parseKeyValue(f) + if parseErr != nil { + return fmt.Errorf("invalid --secret-field: %w", parseErr) + } + secretUpdates[k] = v + } + + err = helper.UpdateResourceGeneric(ctx, client, id, metadataUpdates, secretUpdates) + } else { + err = helper.UpdateResource(ctx, client, id, name, username, uri, password, description) + } + if err != nil { - return fmt.Errorf("updating Resource: %w", err) + return fmt.Errorf("updating resource: %w", err) } if expiry != "" {