cyberark/secrets-provider-for-k8s

View on GitHub
pkg/secrets/pushtofile/secret_group.go

Summary

Maintainability
A
1 hr
Test Coverage
A
93%
package pushtofile

import (
    "fmt"
    "io"
    "os"
    "path"
    "path/filepath"
    "sort"
    "strings"

    "github.com/cyberark/conjur-authn-k8s-client/pkg/log"
    "github.com/cyberark/secrets-provider-for-k8s/pkg/log/messages"
)

const secretGroupPrefix = "conjur.org/conjur-secrets."
const secretGroupPolicyPathPrefix = "conjur.org/conjur-secrets-policy-path."
const secretGroupFileTemplatePrefix = "conjur.org/secret-file-template."
const secretGroupFilePathPrefix = "conjur.org/secret-file-path."
const secretGroupFileFormatPrefix = "conjur.org/secret-file-format."
const secretGroupFilePermissionsPrefix = "conjur.org/secret-file-permissions."

const defaultFilePermissions os.FileMode = 0644
const maxFilenameLen = 255

// Config is used during SecretGroup creation, and contains default values for
// secret file and template file base paths, along with mockable functions for
// reading template files.
type Config struct {
    secretsBasePath   string
    templatesBasePath string
    openReadCloser    openReadCloserFunc
    pullFromReader    pullFromReaderFunc
}

// SecretGroup incorporates all of the information about a secret group
// that has been parsed from that secret group's Annotations.
type SecretGroup struct {
    Name             string
    FilePath         string
    FileTemplate     string
    FileFormat       string
    PolicyPathPrefix string
    FilePermissions  os.FileMode
    SecretSpecs      []SecretSpec
}

// ResolvedSecretSpecs resolves all of the secret paths for a secret
// group by prepending each path with that group's policy path prefix.
// Updates the Path of each SecretSpec in the field SecretSpecs.
func resolvedSecretSpecs(policyPathPrefix string, secretSpecs []SecretSpec) []SecretSpec {
    if len(policyPathPrefix) != 0 {
        for i := range secretSpecs {
            secretSpecs[i].Path = strings.TrimSuffix(policyPathPrefix, "/") +
                "/" + strings.TrimPrefix(secretSpecs[i].Path, "/")
        }
    }

    return secretSpecs
}

// PushToFile uses the configuration on a secret group to inject secrets into a template
// and write the result to a file.
func (sg *SecretGroup) PushToFile(secrets []*Secret) (bool, error) {
    return sg.pushToFileWithDeps(openFileAsWriteCloser, pushToWriter, secrets)
}

func (sg *SecretGroup) pushToFileWithDeps(
    depOpenWriteCloser openWriteCloserFunc,
    depPushToWriter pushToWriterFunc,
    secrets []*Secret,
) (updated bool, err error) {
    // Make sure all the secret specs are accounted for
    if err = validateSecretsAgainstSpecs(secrets, sg.SecretSpecs); err != nil {
        return false, err
    }

    // Determine file template from
    // 1. File template
    // 2. File format
    // 3. Secret specs (user to validate file template)
    fileTemplate, err := maybeFileTemplateFromFormat(
        sg.FileTemplate,
        sg.FileFormat,
        sg.SecretSpecs,
    )
    if err != nil {
        return false, err
    }

    //// Open and push to file
    wc, err := depOpenWriteCloser(sg.FilePath, sg.FilePermissions)
    if err != nil {
        return false, err
    }
    defer func() {
        _ = wc.Close()
    }()

    maskError := fmt.Errorf("failed to execute template, with secret values, on push to file for secret group %q", sg.Name)
    defer func() {
        if r := recover(); r != nil {
            err = maskError
        }
    }()
    updated, err = depPushToWriter(
        wc,
        sg.Name,
        fileTemplate,
        secrets,
    )
    if err != nil {
        err = maskError
    }
    return updated, err
}

func (sg *SecretGroup) absoluteFilePath(secretsBasePath string) (string, error) {
    groupName := sg.Name
    filePath := sg.FilePath
    fileTemplate := sg.FileTemplate
    fileExt := sg.FileFormat

    // filePath must be relative
    if path.IsAbs(filePath) {
        return "", fmt.Errorf(
            "provided filepath %q for secret group %q is absolute, requires relative path",
            filePath, groupName,
        )
    }

    pathContainsFilename := !strings.HasSuffix(filePath, "/") && len(filePath) > 0

    if !pathContainsFilename {
        if len(fileTemplate) > 0 {
            // Template filename defaults to "{groupName}.out"
            fileExt = "out"
        }

        // For all other formats, the filename defaults to "{groupName}.{fileFormat}"
        filePath = path.Join(
            filePath,
            fmt.Sprintf("%s.%s", groupName, fileExt),
        )
        log.Info(messages.CSPFK017I, groupName)
    }

    absoluteFilePath := path.Join(secretsBasePath, filePath)

    // filePath must be relative to secrets base path. This protects against relative paths
    // that, by using the double-dot path segment, resolve to a path that is not relative
    // to the base path.
    if !strings.HasPrefix(absoluteFilePath, secretsBasePath) {
        return "", fmt.Errorf(
            "provided filepath %q for secret group %q must be relative to secrets base path",
            filePath, groupName,
        )
    }

    // Filename cannot be longer than allowed by the filesystem
    _, filename := path.Split(absoluteFilePath)
    if len(filename) > maxFilenameLen {
        return "", fmt.Errorf(
            "filename %q for provided filepath for secret group %q must not be longer than %d characters",
            filename,
            groupName,
            maxFilenameLen,
        )
    }

    return absoluteFilePath, nil
}

func (sg *SecretGroup) validate() []error {
    groupName := sg.Name
    fileFormat := sg.FileFormat
    fileTemplate := sg.FileTemplate
    secretSpecs := sg.SecretSpecs

    if errors := validateSecretPathsAndContents(secretSpecs, groupName); len(errors) > 0 {
        return errors
    }

    if len(fileFormat) > 0 && fileFormat != "template" {
        _, err := FileTemplateForFormat(fileFormat, secretSpecs)
        if err != nil {
            return []error{
                fmt.Errorf(
                    "unable to process group %q into file format %q: %s",
                    groupName,
                    fileFormat,
                    err,
                ),
            }
        }
    }

    // First-pass at provided template rendering with dummy secret values
    // This first-pass is limited for templates that branch conditionally on secret values
    // Relying logically on specific secret values should be avoided
    if len(fileTemplate) > 0 {
        dummySecrets := []*Secret{}
        for _, secretSpec := range secretSpecs {
            dummySecrets = append(dummySecrets, &Secret{Alias: secretSpec.Alias, Value: "REDACTED"})
        }

        _, err := pushToWriter(io.Discard, groupName, fileTemplate, dummySecrets)
        if err != nil {
            return []error{fmt.Errorf(
                `unable to use file template for secret group %q: %s`,
                groupName,
                err,
            )}
        }
    }

    return nil
}

func validateSecretsAgainstSpecs(
    secrets []*Secret,
    specs []SecretSpec,
) error {
    // If in "Fetch All" mode, then the number of specs will always be 1
    // while the number of secrets will be variable. Skip this validation.
    if len(specs) == 1 && specs[0].Path == "*" {
        return nil
    }

    if len(secrets) != len(specs) {
        return fmt.Errorf(
            "number of secrets (%d) does not match number of secret specs (%d)",
            len(secrets),
            len(specs),
        )
    }

    // Secrets should match SecretSpecs
    var aliasInSecrets = map[string]struct{}{}
    for _, secret := range secrets {
        aliasInSecrets[secret.Alias] = struct{}{}
    }

    var missingAliases []string
    for _, spec := range specs {
        if _, ok := aliasInSecrets[spec.Alias]; !ok {
            missingAliases = append(missingAliases, spec.Alias)
        }
    }

    // Sort strings to ensure deterministic behavior of the method
    sort.Strings(missingAliases)

    if len(missingAliases) > 0 {
        return fmt.Errorf("some secret specs are not present in secrets %q", strings.Join(missingAliases, ""))
    }

    return nil
}

func maybeFileTemplateFromFormat(
    fileTemplate string,
    fileFormat string,
    secretSpecs []SecretSpec,
) (string, error) {
    // Default to "yaml" file format
    if len(fileTemplate)+len(fileFormat) == 0 {
        fileFormat = "yaml"
    }

    // fileFormat is used to set fileTemplate when fileTemplate is not
    // already set
    if len(fileTemplate) == 0 {
        var err error

        fileTemplate, err = FileTemplateForFormat(
            fileFormat,
            secretSpecs,
        )
        if err != nil {
            return "", err
        }
    }

    return fileTemplate, nil
}

// NewSecretGroups creates a collection of secret groups from a map of annotations
func NewSecretGroups(
    secretsBasePath string,
    templatesBasePath string,
    annotations map[string]string,
) ([]*SecretGroup, []error) {
    c := Config{
        secretsBasePath:   secretsBasePath,
        templatesBasePath: templatesBasePath,
        openReadCloser:    openFileAsReadCloser,
        pullFromReader:    pullFromReader,
    }

    return newSecretGroupsWithDeps(annotations, c)
}

func newSecretGroupsWithDeps(annotations map[string]string, c Config) ([]*SecretGroup, []error) {
    var sgs []*SecretGroup

    var errors []error
    for key := range annotations {
        if strings.HasPrefix(key, secretGroupPrefix) {
            groupName := strings.TrimPrefix(key, secretGroupPrefix)
            sg, errs := newSecretGroup(groupName, annotations, c)
            if errs != nil {
                errors = append(errors, errs...)
                continue
            }
            sgs = append(sgs, sg)
        }
    }

    errors = append(errors, validateGroupFilePaths(sgs)...)

    if len(errors) > 0 {
        return nil, errors
    }

    // Sort secret groups for deterministic order based on group path
    sort.SliceStable(sgs, func(i, j int) bool {
        return sgs[i].Name < sgs[j].Name
    })

    return sgs, nil
}

func newSecretGroup(groupName string, annotations map[string]string, c Config) (*SecretGroup, []error) {
    groupSecrets := annotations[secretGroupPrefix+groupName]
    filePath := annotations[secretGroupFilePathPrefix+groupName]
    fileFormat := annotations[secretGroupFileFormatPrefix+groupName]

    policyPathPrefix := annotations[secretGroupPolicyPathPrefix+groupName]
    policyPathPrefix = strings.TrimPrefix(policyPathPrefix, "/")
    filePermissions := annotations[secretGroupFilePermissionsPrefix+groupName]

    var err error
    var fileTemplate string
    if fileFormat == "template" {
        fileTemplate, err = collectTemplate(groupName, annotations, c)
        if err != nil {
            return nil, []error{err}
        }
    }

    // Default to "yaml" file format
    if len(fileFormat) == 0 {
        fileFormat = "yaml"
    }

    fileMode, err := permStrToFileMode(filePermissions)
    if err != nil {
        err = fmt.Errorf(`unable to create fileMode from annotation "%s": %s`, secretGroupFilePermissionsPrefix, err)
        return nil, []error{err}
    }

    secretSpecs, err := NewSecretSpecs([]byte(groupSecrets))
    if err != nil {
        err = fmt.Errorf(`unable to create secret specs from annotation "%s": %s`, secretGroupPrefix+groupName, err)
        return nil, []error{err}
    }
    secretSpecs = resolvedSecretSpecs(policyPathPrefix, secretSpecs)

    sg := &SecretGroup{
        Name:             groupName,
        FilePath:         filePath,
        FileTemplate:     fileTemplate,
        FileFormat:       fileFormat,
        FilePermissions:  *fileMode,
        PolicyPathPrefix: policyPathPrefix,
        SecretSpecs:      secretSpecs,
    }

    errors := sg.validate()
    if len(errors) > 0 {
        return nil, errors
    }

    // Generate absolute file path
    sg.FilePath, err = sg.absoluteFilePath(c.secretsBasePath)
    if err != nil {
        return nil, []error{err}
    }

    return sg, nil
}

func collectTemplate(groupName string, annotations map[string]string, c Config) (string, error) {
    annotationTemplate := annotations[secretGroupFileTemplatePrefix+groupName]

    configmapTemplate, err := readTemplateFromFile(groupName, annotations, c)
    if os.IsNotExist(err) {
        return annotationTemplate, nil
    } else if err != nil {
        return "", fmt.Errorf("unable to read template file for secret group %q: %s", groupName, err)
    }

    if len(annotationTemplate) > 0 && len(configmapTemplate) > 0 {
        return "", fmt.Errorf("secret file template for group %q cannot be provided both by annotation and by ConfigMap", groupName)
    }

    if len(annotationTemplate)+len(configmapTemplate) == 0 {
        return "", fmt.Errorf("template required for secret group %q", groupName)
    }

    return configmapTemplate, nil
}

func readTemplateFromFile(
    groupName string,
    annotations map[string]string,
    c Config,

) (string, error) {
    templateFilepath := filepath.Join(c.templatesBasePath, groupName+".tpl")
    rc, err := c.openReadCloser(templateFilepath)
    if err != nil {
        return "", err
    }
    defer func() {
        _ = rc.Close()
    }()

    return c.pullFromReader(rc)
}

func permStrToFileMode(perms string) (*os.FileMode, error) {
    if perms == "" {
        fileMode := defaultFilePermissions
        return &fileMode, nil
    }

    invalidFormatErr := fmt.Errorf("Invalid permissions format: '%s'", perms)
    // Permissions string should be 9 digits or 10 digits with leading '-'
    switch len(perms) {
    case 9:
        break
    case 10:
        if perms[0] != '-' {
            return nil, invalidFormatErr
        }
        perms = perms[1:]
    default:
        return nil, invalidFormatErr
    }

    invalidPermissionsErr := fmt.Errorf("Invalid permissions: '%s', owner permissions must atleast have read and write (-rw-------)", perms)
    //User Group should atleast have read/write permissions
    if perms[0] != 'r' || perms[1] != 'w' {
        return nil, invalidPermissionsErr
    }

    validChars := "rwx"
    multipliers := []int{64, 8, 1}
    bitValues := []int{4, 2, 1}

    result := 0
    index := 0
    for group := 0; group < 3; group++ {
        for bit := 0; bit < 3; bit++ {
            switch perms[index] {
            case validChars[bit]:
                result += bitValues[bit] * multipliers[group]
            case '-':
                break
            default:
                return nil, invalidFormatErr
            }
            index++
        }
    }

    fileMode := os.FileMode(result)
    return &fileMode, nil
}

func validateGroupFilePaths(secretGroups []*SecretGroup) []error {
    // Iterate over the secret groups and group any that have the same file path
    groupFilePaths := make(map[string][]string)
    for _, sg := range secretGroups {
        if len(groupFilePaths[sg.FilePath]) == 0 {
            groupFilePaths[sg.FilePath] = []string{sg.Name}
            continue
        }

        groupFilePaths[sg.FilePath] = append(groupFilePaths[sg.FilePath], sg.Name)
    }

    // If any file paths are used in more than one group, log all the groups that share the path
    var errors []error
    for path, groupNames := range groupFilePaths {
        if len(groupNames) > 1 {
            errors = append(errors, fmt.Errorf(
                "duplicate filepath %q for groups: %q", path, strings.Join(groupNames, `, `),
            ))
        }
    }
    return errors
}