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 }