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
}