Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion cmd/hub_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ Examples:
RunE: runSecretList,
}

// hubSecretUpdateCmd updates secret metadata without changing the value
var hubSecretUpdateCmd = &cobra.Command{
Use: "update KEY",
Short: "Update secret metadata",
Long: `Update secret metadata without re-entering the secret value.

Only the specified flags are updated; all other fields remain unchanged.
At least one metadata flag must be provided.

Examples:
scion hub secret update API_KEY --description "Production API key"
scion hub secret update API_KEY --injection-mode always
scion hub secret update API_KEY --type variable
scion hub secret update API_KEY --allow-progeny
scion hub secret update --project API_KEY --description "Project key"`,
Args: cobra.ExactArgs(1),
RunE: runSecretUpdate,
}

// hubSecretClearCmd clears a secret
var hubSecretClearCmd = &cobra.Command{
Use: "clear KEY",
Expand All @@ -168,12 +187,13 @@ func init() {
hubSecretCmd.AddCommand(hubSecretGetCmd)
hubSecretCmd.AddCommand(hubSecretListCmd)
hubSecretCmd.AddCommand(hubSecretClearCmd)
hubSecretCmd.AddCommand(hubSecretUpdateCmd)

// Add scope flags to all subcommands.
// --scope selects the scope level (hub, user). --project/--broker select their
// respective scopes and support both bare usage (infer from settings) and
// explicit name/ID via --project=<name|id>.
for _, cmd := range []*cobra.Command{hubSecretSetCmd, hubSecretGetCmd, hubSecretListCmd, hubSecretClearCmd} {
for _, cmd := range []*cobra.Command{hubSecretSetCmd, hubSecretGetCmd, hubSecretListCmd, hubSecretClearCmd, hubSecretUpdateCmd} {
cmd.Flags().StringVar(&secretScope, "scope", "", "Scope level: hub, user (default: user)")
cmd.Flags().StringVar(&secretProjectScope, "project", "", "Project scope (bare flag infers current project, or use --project=<name|id>)")
cmd.Flags().Lookup("project").NoOptDefVal = scopeInferSentinel
Expand All @@ -194,6 +214,13 @@ func init() {
hubSecretSetCmd.Flags().StringVar(&secretType, "type", "", "Secret type: environment (default), variable, file")
hubSecretSetCmd.Flags().StringVar(&secretTarget, "target", "", "Projection target (env var name, json key, or file path; defaults to KEY)")
hubSecretSetCmd.Flags().BoolVar(&secretAllowProgeny, "allow-progeny", false, "Allow creator's progeny agents to access this secret (user scope only)")

// Metadata flags for update command
hubSecretUpdateCmd.Flags().StringP("description", "d", "", "Description for the secret")
hubSecretUpdateCmd.Flags().String("injection-mode", "", "Injection mode: always or as_needed")
hubSecretUpdateCmd.Flags().String("type", "", "Secret type: environment, variable, file")
hubSecretUpdateCmd.Flags().String("target", "", "Projection target (env var name, json key, or file path)")
hubSecretUpdateCmd.Flags().Bool("allow-progeny", false, "Allow creator's progeny agents to access this secret (user scope only)")
}

// resolveSecretScope determines the scope and scopeID based on flags.
Expand Down Expand Up @@ -533,6 +560,108 @@ func runSecretList(cmd *cobra.Command, _ []string) error {
return nil
}

func runSecretUpdate(cmd *cobra.Command, args []string) error {
key := args[0]

// Validate key
if key == "" {
return fmt.Errorf("key cannot be empty")
}

// Check that at least one metadata flag is provided
descChanged := cmd.Flags().Changed("description")
injectionChanged := cmd.Flags().Changed("injection-mode")
typeChanged := cmd.Flags().Changed("type")
targetChanged := cmd.Flags().Changed("target")
progenyChanged := cmd.Flags().Changed("allow-progeny")

if !descChanged && !injectionChanged && !typeChanged && !targetChanged && !progenyChanged {
return fmt.Errorf("at least one metadata flag must be provided (--description, --injection-mode, --type, --target, --allow-progeny)")
}

// Validate injection-mode if provided
injectionMode, _ := cmd.Flags().GetString("injection-mode")
if injectionChanged && injectionMode != "always" && injectionMode != "as_needed" {
return fmt.Errorf("injection-mode must be \"always\" or \"as_needed\"")
}

// Validate type if provided
secretTypeVal, _ := cmd.Flags().GetString("type")
if typeChanged {
switch secretTypeVal {
case "environment", "variable", "file":
// valid
default:
return fmt.Errorf("type must be one of: environment, variable, file")
}
}

resolvedPath, _, err := config.ResolveProjectPath(projectPath)
if err != nil {
return fmt.Errorf("failed to resolve project path: %w", err)
}

settings, err := config.LoadSettings(resolvedPath)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}

client, err := getHubClient(settings)
if err != nil {
return err
}

scope, scopeID, err := resolveSecretScope(cmd, settings)
if err != nil {
return err
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

scopeID, err = resolveScopeID(ctx, client, scope, scopeID)
if err != nil {
return err
}

req := &hubclient.UpdateSecretMetaRequest{
Scope: scope,
ScopeID: scopeID,
}

if descChanged {
desc, _ := cmd.Flags().GetString("description")
req.Description = &desc
}
if injectionChanged {
req.InjectionMode = injectionMode
}
if typeChanged {
req.Type = secretTypeVal
}
if targetChanged {
target, _ := cmd.Flags().GetString("target")
req.Target = target
}
if progenyChanged {
progeny, _ := cmd.Flags().GetBool("allow-progeny")
req.AllowProgeny = &progeny
}

secret, err := client.Secrets().UpdateMeta(ctx, key, req)
if err != nil {
return fmt.Errorf("failed to update secret metadata: %w", err)
}

typeLabel := secret.SecretType
if typeLabel == "" {
typeLabel = "environment"
}

fmt.Printf("Updated secret metadata for %s (scope: %s, type: %s, version: %d)\n", key, scope, typeLabel, secret.Version)
return nil
}

func runSecretClear(cmd *cobra.Command, args []string) error {
key := args[0]

Expand Down
4 changes: 4 additions & 0 deletions cmd/server_foreground_pluginsecrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ func (f *fakeSecretBackend) GetMeta(context.Context, string, string, string) (*s
return nil, store.ErrNotFound
}

func (f *fakeSecretBackend) UpdateMeta(_ context.Context, _ *secret.UpdateMetaInput) (*secret.SecretMeta, error) {
return nil, nil
}

func (f *fakeSecretBackend) Resolve(context.Context, string, string, string, *secret.ResolveOpts) ([]secret.SecretWithValue, error) {
return nil, nil
}
Expand Down
154 changes: 152 additions & 2 deletions pkg/hub/handlers_env_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,16 @@ type SetSecretResponse struct {
Created bool `json:"created"`
}

// PatchSecretRequest is the request body for metadata-only secret updates (PATCH).
// Only non-null/non-empty fields are applied. The secret value is never modified.
type PatchSecretRequest struct {
Description *string `json:"description"` // null = no change, "" = clear
InjectionMode string `json:"injectionMode,omitempty"` // "" = no change
Type string `json:"type,omitempty"` // "" = no change
Target string `json:"target,omitempty"` // "" = no change
AllowProgeny *bool `json:"allowProgeny,omitempty"` // null = no change
}

// metaToStoreSecret converts a secret.SecretMeta to a store.Secret for API response compatibility.
func metaToStoreSecret(m secret.SecretMeta) store.Secret {
return store.Secret{
Expand Down Expand Up @@ -795,6 +805,8 @@ func (s *Server) handleSecretByKey(w http.ResponseWriter, r *http.Request) {
s.getSecret(w, r, key)
case http.MethodPut:
s.setSecret(w, r, key)
case http.MethodPatch:
s.patchSecret(w, r, key)
case http.MethodDelete:
s.deleteSecret(w, r, key)
default:
Expand Down Expand Up @@ -960,6 +972,138 @@ func (s *Server) setSecret(w http.ResponseWriter, r *http.Request, key string) {
})
}

// patchSecretValidateAndUpdate is the shared helper for all PATCH secret
// handlers. It decodes and validates the PatchSecretRequest, applies the
// metadata update, manages progeny-policy lifecycle, and writes the response.
func (s *Server) patchSecretValidateAndUpdate(w http.ResponseWriter, r *http.Request, key, scope, scopeID string) {
ctx := r.Context()

r.Body = http.MaxBytesReader(w, r.Body, 128*1024)

var req PatchSecretRequest
if err := readJSON(r, &req); err != nil {
BadRequest(w, "Invalid request body: "+err.Error())
return
}

// Validate type if provided
if req.Type != "" {
switch req.Type {
case store.SecretTypeEnvironment, store.SecretTypeVariable, store.SecretTypeFile:
// valid
default:
ValidationError(w, "type must be one of: environment, variable, file", map[string]interface{}{
"field": "type",
"value": req.Type,
})
return
}
}

// Validate injectionMode if provided
if req.InjectionMode != "" {
switch req.InjectionMode {
case store.InjectionModeAlways, store.InjectionModeAsNeeded:
// valid
default:
ValidationError(w, "injectionMode must be \"always\" or \"as_needed\"", map[string]interface{}{
"field": "injectionMode",
"value": req.InjectionMode,
"allowed": []string{"always", "as_needed"},
})
return
}
}

// Determine the effective secret type for target validation
effectiveType := req.Type
if effectiveType == "" && req.Target != "" {
// Fetch stored type to validate target against it
existing, err := s.secretBackend.GetMeta(ctx, key, scope, scopeID)
if err != nil {
writeErrorFromErr(w, err, "")
return
}
effectiveType = existing.SecretType
}

// Validate file-specific target constraints (including stored target when type changes to file)
effectiveTarget := req.Target
if effectiveType == store.SecretTypeFile && effectiveTarget == "" {
existing, err := s.secretBackend.GetMeta(ctx, key, scope, scopeID)
if err != nil {
writeErrorFromErr(w, err, "")
return
}
effectiveTarget = existing.Target
}
if effectiveTarget != "" && effectiveType == store.SecretTypeFile {
if strings.Contains(effectiveTarget, "..") {
BadRequest(w, "target path must not contain '..'")
return
}
if !strings.HasPrefix(effectiveTarget, "/") && !strings.HasPrefix(effectiveTarget, "~/") {
ValidationError(w, "file secret target must be an absolute path (or start with ~/)", map[string]interface{}{
"field": "target",
"value": effectiveTarget,
})
return
}
}

// allowProgeny is only valid on user-scoped secrets
if req.AllowProgeny != nil && *req.AllowProgeny && scope != store.ScopeUser {
ValidationError(w, "allowProgeny is only supported on user-scoped secrets", map[string]interface{}{
"field": "allowProgeny",
"scope": scope,
})
return
}

input := &secret.UpdateMetaInput{
Name: key,
Scope: scope,
ScopeID: scopeID,
Description: req.Description,
InjectionMode: req.InjectionMode,
SecretType: req.Type,
Target: req.Target,
AllowProgeny: req.AllowProgeny,
}

if userIdent := GetUserIdentityFromContext(ctx); userIdent != nil {
input.UpdatedBy = userIdent.ID()
}

meta, err := s.secretBackend.UpdateMeta(ctx, input)
if err != nil {
writeErrorFromErr(w, err, "")
return
}

// Manage implicit progeny policy lifecycle if AllowProgeny changed
if req.AllowProgeny != nil {
s.ensureProgenyPolicy(ctx, meta)
}

result := metaToStoreSecret(*meta)
writeJSON(w, http.StatusOK, result)
}

func (s *Server) patchSecret(w http.ResponseWriter, r *http.Request, key string) {
scope := r.URL.Query().Get("scope")
if scope == "" {
scope = store.ScopeUser
}

scopeID, ok := s.resolveEnvSecretAccess(w, r, scope, r.URL.Query().Get("scopeId"), true)
if !ok {
return
}

s.patchSecretValidateAndUpdate(w, r, key, scope, scopeID)
}

func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request, key string) {
ctx := r.Context()
query := r.URL.Query()
Expand Down Expand Up @@ -1774,7 +1918,7 @@ func (s *Server) handleProjectSecretByKey(w http.ResponseWriter, r *http.Request
}

// Authorize access
isWrite := r.Method == http.MethodPut || r.Method == http.MethodDelete
isWrite := r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete
identity := GetIdentityFromContext(ctx)
if identity == nil {
Unauthorized(w)
Expand Down Expand Up @@ -1899,6 +2043,9 @@ func (s *Server) handleProjectSecretByKey(w http.ResponseWriter, r *http.Request
result := metaToStoreSecret(*meta)
writeJSON(w, http.StatusOK, SetSecretResponse{Secret: &result, Created: created})

case http.MethodPatch:
s.patchSecretValidateAndUpdate(w, r, key, store.ScopeProject, projectID)

case http.MethodDelete:
if err := s.secretBackend.Delete(ctx, key, store.ScopeProject, projectID); err != nil {
writeErrorFromErr(w, err, "")
Expand Down Expand Up @@ -2454,7 +2601,7 @@ func (s *Server) handleBrokerSecretByKey(w http.ResponseWriter, r *http.Request,
}

// Authorize access: broker self-access or user CheckAccess
isWrite := r.Method == http.MethodPut || r.Method == http.MethodDelete
isWrite := r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete
if brokerIdent := GetBrokerIdentityFromContext(ctx); brokerIdent != nil && brokerIdent.BrokerID() == brokerID {
// Broker accessing its own secrets — allowed
} else {
Expand Down Expand Up @@ -2573,6 +2720,9 @@ func (s *Server) handleBrokerSecretByKey(w http.ResponseWriter, r *http.Request,
result := metaToStoreSecret(*meta)
writeJSON(w, http.StatusOK, SetSecretResponse{Secret: &result, Created: created})

case http.MethodPatch:
s.patchSecretValidateAndUpdate(w, r, key, store.ScopeRuntimeBroker, brokerID)

case http.MethodDelete:
if err := s.secretBackend.Delete(ctx, key, store.ScopeRuntimeBroker, brokerID); err != nil {
writeErrorFromErr(w, err, "")
Expand Down
Loading
Loading