1
0
mirror of https://github.com/SAP/jenkins-library.git synced 2024-12-12 10:55:20 +02:00

ADO - Vault Secret Rotation (#3084)

* Implemented vault secret rotation for ADO

* Added tests

* Fixed issues
This commit is contained in:
Siarhei Pazdniakou 2021-09-08 17:48:12 +03:00 committed by GitHub
parent 3921c563c9
commit d8d533b154
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 2096 additions and 3 deletions

View File

@ -2,11 +2,13 @@ package cmd
import (
"context"
"fmt"
"net/http"
"time"
"github.com/hashicorp/vault/api"
"github.com/SAP/jenkins-library/pkg/ado"
"github.com/SAP/jenkins-library/pkg/jenkins"
"github.com/SAP/jenkins-library/pkg/vault"
@ -93,6 +95,7 @@ func runVaultRotateSecretID(utils vaultRotateSecretIDUtils) error {
if err = utils.UpdateSecretInStore(config, newSecretID); err != nil {
log.Entry().WithError(err).Warnf("Could not write secret back to secret store %s", config.SecretStore)
return err
}
log.Entry().Infof("Secret has been successfully updated in secret store %s", config.SecretStore)
return nil
@ -111,6 +114,25 @@ func writeVaultSecretIDToStore(config *vaultRotateSecretIdOptions, secretID stri
credManager := jenkins.NewCredentialsManager(instance)
credential := jenkins.StringCredentials{ID: config.VaultAppRoleSecretTokenCredentialsID, Secret: secretID}
return jenkins.UpdateCredential(ctx, credManager, config.JenkinsCredentialDomain, credential)
case "ado":
adoBuildClient, err := ado.NewBuildClient(config.AdoOrganization, config.AdoPersonalAccessToken, config.AdoProject, config.AdoPipelineID)
if err != nil {
log.Entry().Warn("Could not write secret ID back to Azure DevOps")
return err
}
variables := []ado.Variable{
{
Name: config.VaultAppRoleSecretTokenCredentialsID,
Value: secretID,
IsSecret: true,
},
}
if err := adoBuildClient.UpdateVariables(variables); err != nil {
log.Entry().Warn("Could not write secret ID back to Azure DevOps")
return err
}
default:
return fmt.Errorf("error: invalid secret store: %s", config.SecretStore)
}
return nil
}

View File

@ -24,6 +24,10 @@ type vaultRotateSecretIdOptions struct {
VaultServerURL string `json:"vaultServerUrl,omitempty"`
VaultNamespace string `json:"vaultNamespace,omitempty"`
DaysBeforeExpiry int `json:"daysBeforeExpiry,omitempty"`
AdoOrganization string `json:"adoOrganization,omitempty"`
AdoPersonalAccessToken string `json:"adoPersonalAccessToken,omitempty"`
AdoProject string `json:"adoProject,omitempty"`
AdoPipelineID int `json:"adoPipelineId,omitempty"`
}
// VaultRotateSecretIdCommand Rotate vault AppRole Secret ID
@ -58,6 +62,7 @@ func VaultRotateSecretIdCommand() *cobra.Command {
log.RegisterSecret(stepConfig.JenkinsURL)
log.RegisterSecret(stepConfig.JenkinsUsername)
log.RegisterSecret(stepConfig.JenkinsToken)
log.RegisterSecret(stepConfig.AdoPersonalAccessToken)
if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 {
sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID)
@ -109,10 +114,14 @@ func addVaultRotateSecretIdFlags(cmd *cobra.Command, stepConfig *vaultRotateSecr
cmd.Flags().StringVar(&stepConfig.JenkinsCredentialDomain, "jenkinsCredentialDomain", `_`, "The jenkins credential domain which should be used")
cmd.Flags().StringVar(&stepConfig.JenkinsUsername, "jenkinsUsername", os.Getenv("PIPER_jenkinsUsername"), "The jenkins username")
cmd.Flags().StringVar(&stepConfig.JenkinsToken, "jenkinsToken", os.Getenv("PIPER_jenkinsToken"), "The jenkins token")
cmd.Flags().StringVar(&stepConfig.VaultAppRoleSecretTokenCredentialsID, "vaultAppRoleSecretTokenCredentialsId", os.Getenv("PIPER_vaultAppRoleSecretTokenCredentialsId"), "The Jenkins credential ID for the Vault AppRole Secret ID credential")
cmd.Flags().StringVar(&stepConfig.VaultAppRoleSecretTokenCredentialsID, "vaultAppRoleSecretTokenCredentialsId", os.Getenv("PIPER_vaultAppRoleSecretTokenCredentialsId"), "The Jenkins credential ID or Azure DevOps variable name for the Vault AppRole Secret ID credential")
cmd.Flags().StringVar(&stepConfig.VaultServerURL, "vaultServerUrl", os.Getenv("PIPER_vaultServerUrl"), "The URL for the Vault server to use")
cmd.Flags().StringVar(&stepConfig.VaultNamespace, "vaultNamespace", os.Getenv("PIPER_vaultNamespace"), "The vault namespace that should be used (optional)")
cmd.Flags().IntVar(&stepConfig.DaysBeforeExpiry, "daysBeforeExpiry", 15, "The amount of days before expiry until the secret ID gets rotated")
cmd.Flags().StringVar(&stepConfig.AdoOrganization, "adoOrganization", os.Getenv("PIPER_adoOrganization"), "The Azure DevOps organization name")
cmd.Flags().StringVar(&stepConfig.AdoPersonalAccessToken, "adoPersonalAccessToken", os.Getenv("PIPER_adoPersonalAccessToken"), "The Azure DevOps personal access token")
cmd.Flags().StringVar(&stepConfig.AdoProject, "adoProject", os.Getenv("PIPER_adoProject"), "The Azure DevOps project ID. Project name also can be used")
cmd.Flags().IntVar(&stepConfig.AdoPipelineID, "adoPipelineId", 0, "The Azure DevOps pipeline ID. Also called as definition ID")
cmd.MarkFlagRequired("vaultAppRoleSecretTokenCredentialsId")
cmd.MarkFlagRequired("vaultServerUrl")
@ -228,6 +237,48 @@ func vaultRotateSecretIdMetadata() config.StepData {
Aliases: []config.Alias{},
Default: 15,
},
{
Name: "adoOrganization",
ResourceRef: []config.ResourceReference{},
Scope: []string{"GENERAL", "PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_adoOrganization"),
},
{
Name: "adoPersonalAccessToken",
ResourceRef: []config.ResourceReference{
{
Name: "",
Paths: []string{"$(vaultPath)/jenkins", "$(vaultBasePath)/$(vaultPipelineName)/jenkins", "$(vaultBasePath)/GROUP-SECRETS/jenkins"},
Type: "vaultSecret",
},
},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_adoPersonalAccessToken"),
},
{
Name: "adoProject",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "string",
Mandatory: false,
Aliases: []config.Alias{},
Default: os.Getenv("PIPER_adoProject"),
},
{
Name: "adoPipelineId",
ResourceRef: []config.ResourceReference{},
Scope: []string{"PARAMETERS", "STAGES", "STEPS"},
Type: "int",
Mandatory: false,
Aliases: []config.Alias{},
Default: 0,
},
},
},
},

2
go.mod
View File

@ -27,6 +27,7 @@ require (
github.com/google/go-containerregistry v0.1.3
github.com/google/go-github/v32 v32.1.0
github.com/google/uuid v1.1.2
github.com/gorilla/websocket v1.4.2 // indirect
github.com/hashicorp/go-retryablehttp v0.6.7
github.com/hashicorp/vault v1.7.2
github.com/hashicorp/vault/api v1.1.0
@ -38,6 +39,7 @@ require (
github.com/magicsong/color-glog v0.0.1 // indirect
github.com/magicsong/sonargo v0.0.1
github.com/mailru/easyjson v0.7.6 // indirect
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5
github.com/motemen/go-nuts v0.0.0-20200601065735-3df31f16cb2f
github.com/piper-validation/fortify-client-go v0.0.0-20210114140201-1261216783c6
github.com/pkg/errors v0.9.1

5
go.sum
View File

@ -731,8 +731,9 @@ github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYb
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotestyourself/gotestyourself v2.2.0+incompatible h1:AQwinXlbQR2HvPjQZOmDhRqsv5mZf+Jb1RnSLxcqZcI=
github.com/gotestyourself/gotestyourself v2.2.0+incompatible/go.mod h1:zZKM6oeNM8k+FRljX1mnzVYeS8wiGgQyvST1/GafPbY=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
@ -1131,6 +1132,8 @@ github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1
github.com/michaelklishin/rabbit-hole v0.0.0-20191008194146-93d9988f0cd5 h1:uA3b4GgZMZxAJsTkd+CVQ85b7KBlD7HLpd/FfTNlGN0=
github.com/michaelklishin/rabbit-hole v0.0.0-20191008194146-93d9988f0cd5/go.mod h1:+pmbihVqjC3GPdfWv1V2TnRSuVvwrWLKfEP/MZVB/Wc=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5 h1:YH424zrwLTlyHSH/GzLMJeu5zhYVZSx5RQxGKm1h96s=
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5/go.mod h1:PoGiBqKSQK1vIfQ+yVaFcGjDySHvym6FM1cNYnwzbrY=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.40 h1:pyyPFfGMnciYUk/mXpKkVmeMQjfXqt3FAJ2hy7tPiLA=

109
pkg/ado/ado.go Normal file
View File

@ -0,0 +1,109 @@
package ado
import (
"context"
"fmt"
"github.com/microsoft/azure-devops-go-api/azuredevops"
"github.com/microsoft/azure-devops-go-api/azuredevops/build"
"github.com/pkg/errors"
)
const azureUrl = "https://dev.azure.com"
type BuildClient interface {
UpdateVariables(variables []Variable) error
}
type BuildClientImpl struct {
ctx context.Context
buildClient build.Client
project string
pipelineID int
}
type Variable struct {
Name string
Value string
IsSecret bool
AllowOverride bool
}
//UpdateVariables updates variables in build definition or creates them if they are missing
func (bc *BuildClientImpl) UpdateVariables(variables []Variable) error {
if len(variables) == 0 {
return errors.New("error: slice variables must not be empty")
}
getDefinitionArgs := build.GetDefinitionArgs{
Project: &bc.project,
DefinitionId: &bc.pipelineID,
}
// Get a build definition
buildDefinition, err := bc.buildClient.GetDefinition(bc.ctx, getDefinitionArgs)
if err != nil {
return errors.Wrapf(err, "error: get definition failed")
}
buildDefinitionVars := map[string]build.BuildDefinitionVariable{}
if buildDefinition.Variables != nil {
buildDefinitionVars = *buildDefinition.Variables
}
for _, variable := range variables {
buildDefinitionVars[variable.Name] = build.BuildDefinitionVariable{
Value: &variable.Value,
IsSecret: &variable.IsSecret,
AllowOverride: &variable.AllowOverride,
}
}
buildDefinition.Variables = &buildDefinitionVars
updateDefinitionArgs := build.UpdateDefinitionArgs{
Definition: buildDefinition,
Project: &bc.project,
DefinitionId: &bc.pipelineID,
}
_, err = bc.buildClient.UpdateDefinition(bc.ctx, updateDefinitionArgs)
if err != nil {
return errors.Wrapf(err, "error: update definition failed")
}
return nil
}
//NewBuildClient Create a client to interact with the Build area
func NewBuildClient(organization string, personalAccessToken string, project string, pipelineID int) (BuildClient, error) {
if organization == "" {
return nil, errors.New("error: organization must not be empty")
}
if personalAccessToken == "" {
return nil, errors.New("error: personal access token must not be empty")
}
if project == "" {
return nil, errors.New("error: project must not be empty")
}
organizationUrl := fmt.Sprintf("%s/%s", azureUrl, organization)
// Create a connection to your organization
connection := azuredevops.NewPatConnection(organizationUrl, personalAccessToken)
ctx := context.Background()
// Create a client to interact with the Core area
buildClient, err := build.NewClient(ctx, connection)
if err != nil {
return nil, err
}
buildClientImpl := &BuildClientImpl{
ctx: ctx,
buildClient: buildClient,
project: project,
pipelineID: pipelineID,
}
return buildClientImpl, nil
}

100
pkg/ado/ado_test.go Normal file
View File

@ -0,0 +1,100 @@
package ado
import (
"context"
"errors"
"testing"
"github.com/SAP/jenkins-library/pkg/ado/mocks"
"github.com/microsoft/azure-devops-go-api/azuredevops/build"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestUpdateVariables(t *testing.T) {
t.Parallel()
ctx := context.Background()
const secretName = "test-secret"
const secretValue = "secret-value"
const projectID = "some-id"
const pipelineID = 1
testErr := errors.New("error")
tests := []struct {
name string
variables []Variable
getDefinitionError error
updateDefinitionError error
isErrorExpected bool
errorStr string
}{
{
name: "Test update secret - successful",
variables: []Variable{{Name: secretName, Value: secretValue, IsSecret: true}},
getDefinitionError: nil,
updateDefinitionError: nil,
isErrorExpected: false,
},
{
name: "Failed get definition",
variables: []Variable{{Name: secretName, Value: secretValue, IsSecret: true}},
getDefinitionError: testErr,
updateDefinitionError: nil,
isErrorExpected: true,
errorStr: "get definition failed",
},
{
name: "Failed update definition",
variables: []Variable{{Name: secretName, Value: secretValue, IsSecret: true}},
getDefinitionError: nil,
updateDefinitionError: testErr,
isErrorExpected: true,
errorStr: "update definition failed",
},
{
name: "Slice variables is empty",
variables: []Variable{},
getDefinitionError: nil,
updateDefinitionError: nil,
isErrorExpected: true,
errorStr: "slice variables must not be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buildClientMock := &mocks.Client{}
buildClientMock.On("GetDefinition", ctx, mock.Anything).Return(
func(ctx context.Context, getDefinitionArgs build.GetDefinitionArgs) *build.BuildDefinition {
return &build.BuildDefinition{}
},
func(ctx context.Context, getDefinitionArgs build.GetDefinitionArgs) error {
return tt.getDefinitionError
},
)
buildClientMock.On("UpdateDefinition", ctx, mock.Anything).Return(
func(ctx context.Context, updateDefinitionArgs build.UpdateDefinitionArgs) *build.BuildDefinition {
return &build.BuildDefinition{}
},
func(ctx context.Context, updateDefinitionArgs build.UpdateDefinitionArgs) error {
return tt.updateDefinitionError
},
)
buildClientImpl := BuildClientImpl{
ctx: ctx,
buildClient: buildClientMock,
project: projectID,
pipelineID: pipelineID,
}
err := buildClientImpl.UpdateVariables(tt.variables)
if tt.isErrorExpected {
assert.Contains(t, err.Error(), tt.errorStr)
} else {
assert.NoError(t, err)
}
})
}
}

1769
pkg/ado/mocks/Client.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@ spec:
default: "jenkins"
possibleValues:
- jenkins
- ado
- name: jenkinsUrl
type: string
description: "The jenkins url"
@ -73,7 +74,7 @@ spec:
- $(vaultBasePath)/GROUP-SECRETS/jenkins
- name: vaultAppRoleSecretTokenCredentialsId
type: string
description: The Jenkins credential ID for the Vault AppRole Secret ID credential
description: The Jenkins credential ID or Azure DevOps variable name for the Vault AppRole Secret ID credential
scope:
- GENERAL
- PARAMETERS
@ -105,3 +106,39 @@ spec:
- STAGES
- STEPS
default: 15
- name: adoOrganization
type: string
scope:
- GENERAL
- PARAMETERS
- STAGES
- STEPS
description: The Azure DevOps organization name
- name: adoPersonalAccessToken
type: string
scope:
- PARAMETERS
- STAGES
- STEPS
description: The Azure DevOps personal access token
secret: true
resourceRef:
- type: vaultSecret
paths:
- $(vaultPath)/jenkins
- $(vaultBasePath)/$(vaultPipelineName)/jenkins
- $(vaultBasePath)/GROUP-SECRETS/jenkins
- name: adoProject
type: string
scope:
- PARAMETERS
- STAGES
- STEPS
description: The Azure DevOps project ID. Project name also can be used
- name: adoPipelineId
type: int
scope:
- PARAMETERS
- STAGES
- STEPS
description: The Azure DevOps pipeline ID. Also called as definition ID