v2/tools/generator/internal/codegen/pipeline/add_secrets.go (228 lines of code) (raw):

/* * Copyright (c) Microsoft Corporation. * Licensed under the MIT license. */ package pipeline import ( "context" "regexp" "strings" "github.com/rotisserie/eris" kerrors "k8s.io/apimachinery/pkg/util/errors" "github.com/Azure/azure-service-operator/v2/tools/generator/internal/astmodel" "github.com/Azure/azure-service-operator/v2/tools/generator/internal/config" ) // AddSecretsStageID is the unique identifier for this pipeline stage const AddSecretsStageID = "addSecrets" // AddSecrets replaces properties flagged as secret with genruntime.SecretReference func AddSecrets(config *config.Configuration) *Stage { stage := NewStage( AddSecretsStageID, "Replace properties flagged as secret with genruntime.SecretReference", func(ctx context.Context, state *State) (*State, error) { types, err := applyConfigSecretOverrides(config, state.Definitions()) if err != nil { return nil, eris.Wrap(err, "applying config secret overrides") } updatedSpecs, err := transformSpecSecrets(types) if err != nil { return nil, eris.Wrap(err, "transforming spec secrets") } updatedStatuses, err := removeStatusSecrets(types) if err != nil { return nil, eris.Wrap(err, "removing status secrets") } return state.WithOverlaidDefinitions(astmodel.TypesDisjointUnion(updatedSpecs, updatedStatuses)), nil }) stage.RequiresPostrequisiteStages(CreateARMTypesStageID) return stage } func applyConfigSecretOverrides( config *config.Configuration, definitions astmodel.TypeDefinitionSet, ) (astmodel.TypeDefinitionSet, error) { result := make(astmodel.TypeDefinitionSet) applyConfigSecrets := func( _ *astmodel.TypeVisitor[astmodel.InternalTypeName], it *astmodel.ObjectType, ctx astmodel.InternalTypeName, ) (astmodel.Type, error) { strippedTypeName := ctx.WithName(strings.TrimSuffix(ctx.Name(), astmodel.StatusSuffix)) for _, prop := range it.Properties().Copy() { maybeSecret := mightBeSecretProperty(prop, definitions) isSecret, isSecretConfigured := config.ObjectModelConfiguration.IsSecret.Lookup(ctx, prop.PropertyName()) if ctx.IsStatus() && !isSecretConfigured { isSecret, isSecretConfigured = config.ObjectModelConfiguration.IsSecret.Lookup(strippedTypeName, prop.PropertyName()) } // If it's not a secret, but it looks like a secret, and we don't have any configuration to tell us for // sure, request configuration so we know for sure. if !prop.IsSecret() && maybeSecret && !isSecretConfigured { // Property might be a secret, but isn't already configured as one, // and we don't have config to tell us for sure return nil, eris.Errorf( "property %s might be a secret and must be configured with $isSecret", prop.PropertyName()) } if isSecretConfigured { propWithSecret := prop.WithIsSecret(isSecret) it = it.WithProperty(propWithSecret) } } return it, nil } visitor := astmodel.TypeVisitorBuilder[astmodel.InternalTypeName]{ VisitObjectType: applyConfigSecrets, }.Build() var errs []error for _, def := range definitions { if def.Name().IsARMType() { // No need to process ARM types continue } updatedDef, err := visitor.VisitDefinition(def, def.Name()) if err != nil { errs = append(errs, eris.Wrapf(err, "visiting type %q", def.Name())) continue } result.Add(updatedDef) } if len(errs) > 0 { return nil, eris.Wrap( kerrors.NewAggregate(errs), "encountered errors while applying config secrets") } // Verify that all 'isSecret' modifiers are consumed before returning the result err := config.ObjectModelConfiguration.IsSecret.VerifyConsumed() if err != nil { return nil, eris.Wrap( err, "Found unused $isSecret configurations; these need to be fixed or removed.") } return result, nil } // mightBeSecret returns true if the given name might represent a secret that shouldn't be present // in plain text in the resource. This is a heuristic used to require the presence of $isSecret // configuration so that we know for sure. // property is the property to check // definitions is the set of all definitions so we can look up a typename func mightBeSecretProperty( prop *astmodel.PropertyDefinition, definitions astmodel.TypeDefinitionSet, ) bool { // Only properties that are strings can be secrets propertyType := prop.PropertyType() // Look through a reference if we have one if tn, ok := astmodel.AsInternalTypeName(propertyType); ok { if def, ok := definitions[tn]; ok { propertyType = def.Type() } } // If not a string type, can't be a secret pt, ok := astmodel.AsPrimitiveType(propertyType) if !ok || pt != astmodel.StringType { return false } // If the property name matches a detector, that tells us // whether to expect it's a secret or not propertyName := string(prop.PropertyName()) for _, detector := range secretDetectors { if detector.regex.MatchString(propertyName) { return detector.isSecret } } return false } // Rules for detecting potentially secret properties. // These are processed in order, with the first matching rule being used. var secretDetectors = []struct { regex regexp.Regexp // Regular expression to match isSecret bool // Whether to treat the property as a secret }{ { // Look for the word `password` in any position regex: *regexp.MustCompile(`(?i)password`), isSecret: true, }, { // Look for the word `token` in any position regex: *regexp.MustCompile(`(?i)token`), isSecret: true, }, { // a PublicKey is not a secret regex: *regexp.MustCompile(`(?i)publickey`), isSecret: false, }, { // KeyData is always a secret regex: *regexp.MustCompile(`(?i)keydata`), isSecret: true, }, { // URLs and URIs are not secrets regex: *regexp.MustCompile(`(?i)url|uri`), isSecret: false, }, { // IDs, Identifiers, and Names are not secrets, they're used to look them up // (must match at the end) regex: *regexp.MustCompile(`(?i)(id|identifier|Identity|name)$`), isSecret: false, }, } func transformSpecSecrets(definitions astmodel.TypeDefinitionSet) (astmodel.TypeDefinitionSet, error) { specVisitor := astmodel.TypeVisitorBuilder[any]{ VisitObjectType: transformSecretProperties, }.Build() specTypes, err := astmodel.FindSpecConnectedDefinitions(definitions) if err != nil { return nil, eris.Wrap(err, "couldn't find all spec definitions") } result := make(astmodel.TypeDefinitionSet) for _, def := range specTypes { updatedDef, err := specVisitor.VisitDefinition(def, nil) if err != nil { return nil, eris.Wrapf(err, "visiting type %q", def.Name()) } result.Add(updatedDef) } return result, nil } func removeStatusSecrets(definitions astmodel.TypeDefinitionSet) (astmodel.TypeDefinitionSet, error) { specVisitor := astmodel.TypeVisitorBuilder[any]{ VisitObjectType: removeSecretProperties, }.Build() statusTypes, err := astmodel.FindStatusConnectedDefinitions(definitions) if err != nil { return nil, eris.Wrap(err, "couldn't find all status definitions") } result := make(astmodel.TypeDefinitionSet) for _, def := range statusTypes { updatedDef, err := specVisitor.VisitDefinition(def, nil) if err != nil { return nil, eris.Wrapf(err, "visiting type %q", def.Name()) } result.Add(updatedDef) } return result, nil } func isTypeSecretReferenceCandidate(t astmodel.Type) bool { isStringOrOptionalString := astmodel.TypeEquals(astmodel.Unwrap(t), astmodel.StringType) isStringSlice := isTypeSecretSliceCandidate(t) isStringMap := isTypeSecretMapCandidate(t) return isStringOrOptionalString || isStringSlice || isStringMap } func isTypeSecretSliceCandidate(t astmodel.Type) bool { return astmodel.TypeEquals(t, astmodel.NewArrayType(astmodel.StringType)) } func isTypeSecretMapCandidate(t astmodel.Type) bool { return astmodel.TypeEquals(t, astmodel.MapOfStringStringType) } func removeSecretProperties(_ *astmodel.TypeVisitor[any], it *astmodel.ObjectType, _ any) (astmodel.Type, error) { for _, prop := range it.Properties().Copy() { if prop.IsSecret() { propType := prop.PropertyType() // We only remove pure secret references here. For the case of secret maps, different services seem to treat them // differently. Some services (such as Microsoft.KubernetesConfiguration/extensions) will return the keys of the map // but not the values. Other services (such as APIM) will return certain keys and values that it knows are non-secret, but // redact the ones that are secret. Since it's hard to know statically what will be returned for any given service, we // default to having the map[string]string on the Status type and letting the service return what it wants. if isTypeSecretMapCandidate(propType) { it = it.WithProperty(prop.WithIsSecret(false)) continue } if !isTypeSecretReferenceCandidate(propType) { return nil, eris.Errorf("expected property %q to be a string, optional string, map[string]string, or []string, but was: %q", prop.PropertyName(), astmodel.DebugDescription(propType)) } it = it.WithoutProperty(prop.PropertyName()) } } return it, nil } func transformSecretProperties(_ *astmodel.TypeVisitor[any], it *astmodel.ObjectType, _ any) (astmodel.Type, error) { for _, prop := range it.Properties().Copy() { if prop.IsSecret() { propType := prop.PropertyType() if !isTypeSecretReferenceCandidate(propType) { return nil, eris.Errorf("expected property %q to be a string, optional string, map[string]string, or []string, but was: %T", prop.PropertyName(), astmodel.DebugDescription(propType)) } var newType astmodel.Type if isTypeSecretSliceCandidate(prop.PropertyType()) { newType = astmodel.NewArrayType(astmodel.SecretReferenceType) } else if isTypeSecretMapCandidate(prop.PropertyType()) { newType = astmodel.OptionalSecretMapReferenceType } else if _, ok := astmodel.AsOptionalType(prop.PropertyType()); ok { newType = astmodel.NewOptionalType(astmodel.SecretReferenceType) } else { newType = astmodel.SecretReferenceType } updatedProp := prop.WithType(newType) it = it.WithProperty(updatedProp) } } return it, nil }