github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/pkg/project/secrets.go (about)

     1  package project
     2  
     3  import (
     4  	"errors"
     5  	"strings"
     6  
     7  	"github.com/ActiveState/cli/internal/condition"
     8  	"github.com/ActiveState/cli/pkg/platform/authentication"
     9  
    10  	"github.com/ActiveState/cli/internal/access"
    11  	"github.com/ActiveState/cli/internal/keypairs"
    12  	"github.com/ActiveState/cli/internal/locale"
    13  	"github.com/ActiveState/cli/internal/prompt"
    14  	"github.com/ActiveState/cli/internal/secrets"
    15  	"github.com/ActiveState/cli/pkg/platform/api/mono/mono_models"
    16  	secretsapi "github.com/ActiveState/cli/pkg/platform/api/secrets"
    17  	secretsModels "github.com/ActiveState/cli/pkg/platform/api/secrets/secrets_models"
    18  	"github.com/ActiveState/cli/pkg/platform/model"
    19  	"github.com/ActiveState/cli/pkg/projectfile"
    20  )
    21  
    22  // UserCategory is the string used when referencing user secrets (eg. $secrets.user.foo)
    23  const UserCategory = "user"
    24  
    25  // ProjectCategory is the string used when referencing project secrets (eg. $secrets.project.foo)
    26  const ProjectCategory = "project"
    27  
    28  // SecretAccess is used to track secrets that were requested
    29  type SecretAccess struct {
    30  	IsUser bool
    31  	Name   string
    32  }
    33  
    34  // SecretExpander takes care of expanding secrets
    35  type SecretExpander struct {
    36  	secretsClient   *secretsapi.Client
    37  	keypair         keypairs.Keypair
    38  	organization    *mono_models.Organization
    39  	remoteProject   *mono_models.Project
    40  	projectFile     *projectfile.Project
    41  	project         *Project
    42  	prompt          prompt.Prompter
    43  	cfg             keypairs.Configurable
    44  	auth            *authentication.Auth
    45  	secrets         []*secretsModels.UserSecret
    46  	secretsAccessed []*SecretAccess
    47  	cachedSecrets   map[string]string
    48  }
    49  
    50  // NewSecretExpander returns a new instance of SecretExpander
    51  func NewSecretExpander(secretsClient *secretsapi.Client, prj *Project, prompt prompt.Prompter, cfg keypairs.Configurable, auth *authentication.Auth) *SecretExpander {
    52  	return &SecretExpander{
    53  		secretsClient: secretsClient,
    54  		cachedSecrets: map[string]string{},
    55  		project:       prj,
    56  		prompt:        prompt,
    57  		cfg:           cfg,
    58  		auth:          auth,
    59  	}
    60  }
    61  
    62  // NewSecretQuietExpander creates an Expander which can retrieve and decrypt stored user secrets.
    63  func NewSecretQuietExpander(secretsClient *secretsapi.Client, cfg keypairs.Configurable, auth *authentication.Auth) ExpanderFunc {
    64  	secretsExpander := NewSecretExpander(secretsClient, nil, nil, cfg, auth)
    65  	return secretsExpander.Expand
    66  }
    67  
    68  // NewSecretPromptingExpander creates an Expander which can retrieve and decrypt stored user secrets. Additionally,
    69  // it will prompt the user to provide a value for a secret -- in the event none is found -- and save the new
    70  // value with the secrets service.
    71  func NewSecretPromptingExpander(secretsClient *secretsapi.Client, prompt prompt.Prompter, cfg keypairs.Configurable, auth *authentication.Auth) ExpanderFunc {
    72  	secretsExpander := NewSecretExpander(secretsClient, nil, prompt, cfg, auth)
    73  	return secretsExpander.ExpandWithPrompt
    74  }
    75  
    76  // KeyPair acts as a caching layer for secrets.LoadKeypairFromConfigDir, and ensures that we have a projectfile
    77  func (e *SecretExpander) KeyPair() (keypairs.Keypair, error) {
    78  	if e.projectFile == nil {
    79  		return nil, locale.NewError("secrets_err_expand_noproject")
    80  	}
    81  
    82  	if !e.auth.Authenticated() {
    83  		return nil, locale.NewInputError("secrets_err_not_authenticated")
    84  	}
    85  
    86  	var err error
    87  	if e.keypair == nil {
    88  		e.keypair, err = secrets.LoadKeypairFromConfigDir(e.cfg)
    89  		if err != nil {
    90  			return nil, err
    91  		}
    92  	}
    93  
    94  	return e.keypair, nil
    95  }
    96  
    97  // Organization acts as a caching layer, and ensures that we have a projectfile
    98  func (e *SecretExpander) Organization() (*mono_models.Organization, error) {
    99  	if e.project == nil {
   100  		return nil, locale.NewError("secrets_err_expand_noproject")
   101  	}
   102  	var err error
   103  	if e.organization == nil {
   104  		e.organization, err = model.FetchOrgByURLName(e.project.Owner(), e.auth)
   105  		if err != nil {
   106  			return nil, err
   107  		}
   108  	}
   109  
   110  	return e.organization, nil
   111  }
   112  
   113  // Project acts as a caching layer, and ensures that we have a projectfile
   114  func (e *SecretExpander) Project() (*mono_models.Project, error) {
   115  	if e.project == nil {
   116  		return nil, locale.NewError("secrets_err_expand_noproject")
   117  	}
   118  	var err error
   119  	if e.remoteProject == nil {
   120  		e.remoteProject, err = model.LegacyFetchProjectByName(e.project.Owner(), e.project.Name())
   121  		if err != nil {
   122  			return nil, err
   123  		}
   124  	}
   125  
   126  	return e.remoteProject, nil
   127  }
   128  
   129  // Secrets acts as a caching layer, and ensures that we have a projectfile
   130  func (e *SecretExpander) Secrets() ([]*secretsModels.UserSecret, error) {
   131  	if e.secrets == nil {
   132  		org, err := e.Organization()
   133  		if err != nil {
   134  			return nil, err
   135  		}
   136  
   137  		e.secrets, err = secretsapi.FetchAll(e.secretsClient, org)
   138  		if err != nil {
   139  			return nil, err
   140  		}
   141  	}
   142  
   143  	return e.secrets, nil
   144  }
   145  
   146  // FetchSecret retrieves the given secret
   147  func (e *SecretExpander) FetchSecret(name string, isUser bool) (string, error) {
   148  	keypair, err := e.KeyPair()
   149  	if err != nil {
   150  		return "", nil
   151  	}
   152  
   153  	userSecret, err := e.FindSecret(name, isUser)
   154  	if err != nil {
   155  		return "", err
   156  	}
   157  	if userSecret == nil {
   158  		return "", locale.WrapInputError(ErrSecretNotFound, "secrets_expand_err_not_found", "", name)
   159  	}
   160  
   161  	decrBytes, err := keypair.DecodeAndDecrypt(*userSecret.Value)
   162  	if err != nil {
   163  		return "", err
   164  	}
   165  
   166  	return string(decrBytes), nil
   167  }
   168  
   169  // FetchDefinition retrieves the definition associated with a secret
   170  func (e *SecretExpander) FetchDefinition(name string, isUser bool) (*secretsModels.SecretDefinition, error) {
   171  	defs, err := secretsapi.FetchDefinitions(e.secretsClient, e.remoteProject.ProjectID)
   172  	if err != nil {
   173  		return nil, err
   174  	}
   175  
   176  	scope := secretsapi.ScopeUser
   177  	if !isUser {
   178  		scope = secretsapi.ScopeProject
   179  	}
   180  
   181  	for _, def := range defs {
   182  		if name == *def.Name && string(scope) == *def.Scope {
   183  			return def, nil
   184  		}
   185  	}
   186  
   187  	return nil, nil
   188  }
   189  
   190  // FindSecret will find the secret appropriate for the current project
   191  func (e *SecretExpander) FindSecret(name string, isUser bool) (*secretsModels.UserSecret, error) {
   192  	owner := e.project.Owner()
   193  	allowed, err := access.Secrets(owner, e.auth)
   194  	if err != nil {
   195  		return nil, err
   196  	}
   197  	if !allowed {
   198  		return nil, locale.NewInputError("secrets_expand_err_no_access", "", owner)
   199  	}
   200  
   201  	secrets, err := e.Secrets()
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  
   206  	project, err := e.Project()
   207  	if err != nil {
   208  		return nil, err
   209  	}
   210  
   211  	e.secretsAccessed = append(e.secretsAccessed, &SecretAccess{isUser, name})
   212  
   213  	projectID := project.ProjectID.String()
   214  	variableRequiresUser := isUser
   215  	variableRequiresProject := true
   216  
   217  	for _, userSecret := range secrets {
   218  		secretProjectID := userSecret.ProjectID.String()
   219  		secretRequiresUser := userSecret.IsUser != nil && *userSecret.IsUser
   220  		secretRequiresProject := secretProjectID != ""
   221  
   222  		nameMatches := strings.EqualFold(*userSecret.Name, name)
   223  		projectMatches := (!variableRequiresProject || secretProjectID == projectID)
   224  
   225  		// shareMatches and storeMatches show a detachment from the data due to the secrets-svc api needing a refactor
   226  		// to match the new data structure. Story: https://www.pivotaltracker.com/story/show/166272717
   227  		shareMatches := variableRequiresUser == secretRequiresUser
   228  		storeMatches := variableRequiresProject == secretRequiresProject
   229  
   230  		if nameMatches && projectMatches && shareMatches && storeMatches {
   231  			return userSecret, nil
   232  		}
   233  	}
   234  
   235  	return nil, nil
   236  }
   237  
   238  // SecretsAccessed returns all secrets that were accessed since initialization
   239  func (e *SecretExpander) SecretsAccessed() []*SecretAccess {
   240  	return e.secretsAccessed
   241  }
   242  
   243  // SecretFunc defines what our expander functions will be returning
   244  type SecretFunc func(name string, project *Project) (string, error)
   245  
   246  var ErrSecretNotFound = errors.New("secret not found")
   247  
   248  // Expand will expand a variable to a secret value, if no secret exists it will return an empty string
   249  func (e *SecretExpander) Expand(_ string, category string, name string, isFunction bool, ctx *Expansion) (string, error) {
   250  	if !condition.OptInUnstable(e.cfg) {
   251  		return "", locale.NewError("secrets_unstable_warning")
   252  	}
   253  
   254  	isUser := category == UserCategory
   255  
   256  	if e.project == nil {
   257  		e.project = ctx.Project
   258  	}
   259  	if e.projectFile == nil {
   260  		e.projectFile = ctx.Project.Source()
   261  	}
   262  
   263  	keypair, err := e.KeyPair()
   264  	if err != nil {
   265  		return "", err
   266  	}
   267  
   268  	if knownValue, exists := e.cachedSecrets[category+name]; exists {
   269  		return knownValue, nil
   270  	}
   271  
   272  	userSecret, err := e.FindSecret(name, isUser)
   273  	if err != nil {
   274  		return "", err
   275  	}
   276  
   277  	if userSecret == nil {
   278  		return "", locale.WrapInputError(ErrSecretNotFound, "secrets_expand_err_not_found", "Unable to obtain value for secret: `{{.V0}}.`", name)
   279  	}
   280  
   281  	decrBytes, err := keypair.DecodeAndDecrypt(*userSecret.Value)
   282  	if err != nil {
   283  		return "", err
   284  	}
   285  
   286  	secretValue := string(decrBytes)
   287  	e.cachedSecrets[category+name] = secretValue
   288  	return secretValue, nil
   289  }
   290  
   291  // ExpandWithPrompt will expand a variable to a secret value, if no secret exists the user will be prompted
   292  func (e *SecretExpander) ExpandWithPrompt(_ string, category string, name string, isFunction bool, ctx *Expansion) (string, error) {
   293  	if !condition.OptInUnstable(e.cfg) {
   294  		return "", locale.NewError("secrets_unstable_warning")
   295  	}
   296  
   297  	isUser := category == UserCategory
   298  
   299  	if knownValue, exists := e.cachedSecrets[category+name]; exists {
   300  		return knownValue, nil
   301  	}
   302  
   303  	if e.project == nil {
   304  		e.project = ctx.Project
   305  	}
   306  	if e.projectFile == nil {
   307  		e.projectFile = ctx.Project.Source()
   308  	}
   309  
   310  	keypair, err := e.KeyPair()
   311  	if err != nil {
   312  		return "", err
   313  	}
   314  
   315  	value, err := e.FetchSecret(name, isUser)
   316  	if err != nil && !errors.Is(err, ErrSecretNotFound) {
   317  		return "", err
   318  	}
   319  
   320  	if err == nil {
   321  		return value, nil
   322  	}
   323  
   324  	def, err := e.FetchDefinition(name, isUser)
   325  	if err != nil {
   326  		return "", err
   327  	}
   328  
   329  	scope := string(secretsapi.ScopeUser)
   330  	if !isUser {
   331  		scope = string(secretsapi.ScopeProject)
   332  	}
   333  	description := locale.T("secret_no_description")
   334  	if def != nil && def.Description != "" {
   335  		description = def.Description
   336  	}
   337  
   338  	ctx.Project.Outputer.Notice(locale.Tr("secret_value_prompt_summary", name, description, scope, locale.T("secret_prompt_"+scope)))
   339  	if value, err = e.prompt.InputSecret(locale.Tl("secret_expand", "Secret Expansion"), locale.Tr("secret_value_prompt", name)); err != nil {
   340  		return "", locale.NewInputError("secrets_err_value_prompt", "The provided secret value is invalid.")
   341  	}
   342  
   343  	pj, err := e.Project()
   344  	if err != nil {
   345  		return "", err
   346  	}
   347  	org, err := e.Organization()
   348  	if err != nil {
   349  		return "", err
   350  	}
   351  
   352  	err = secrets.Save(e.secretsClient, keypair, org, pj, isUser, name, value, e.auth)
   353  
   354  	if err != nil {
   355  		return "", err
   356  	}
   357  
   358  	// Cache it so we're not repeatedly prompting for the same secret
   359  	e.cachedSecrets[category+name] = value
   360  
   361  	return value, nil
   362  }