# Use case: database credential rotation

Now that we know how to create custom workflow step script plugin in the hello world tutorial, let's walk through a real world use case: rotation database credentials. Credential rotation has traditionally been a complicated, thankless administrative chore. It's the type of work that might be labeled toil and exactly the kind of work that Rundeck has been designed to streamline.

The end result of this tutorial as well as a Docker environment to run it in can be found on GitHub

# The problem

What's difficult about database credential rotation?

  • It involves multiple steps across multiple nodes in your production cluster.
  • It's necessary to handle secrets in the form of the new credentials you're creating, plus credentials of a super user that can create the new login.
  • Manually run steps are error prone and mistakes can potentially cause a site-wide outage if your app can't authenticate with the database anymore.

# Addressing the problem with Rundeck

By creating a custom script plugin to interact with the database, its downstream clients and the Rundeck Key Storage, we can automate the creation and deployment of the new credentials as well as safely handle the decomissioning of the old credentials.

# Secure secrets handling

Since the Key Storage stores the credentials, the administrator triggering the credential rotation doesn't ever need to see the actual credentials of the database super user or the newly generated database user.

# Critical logic is tested ahead of time

The logic for creating the database login is encapsulated in a script that has been tested prior to actually needing to rotate the credentials, so we avoid putting a human in the stressful, error-prone situation of having to figure out correct syntax to run on a live system on the fly.

Similarly, the app restart logic is encapsulated as well, with health check logic as an extra safety measure to halt the process in case something has gone wrong before applying the change to the whole cluster.

# Ease of use encourages proactive security

Lastly, by creating a single button solution to rotate database credentials, we're much more likely to rotate our credentials on a regular basis to mitigate potential security risks, rather than only after a security incident has already occured.

# Automating credential rotation with Rundeck

In order to automate the credential rotation, we need to automate the individual steps, then orchestrate the steps in a single process. The steps we want to automate are:

  • Creating a new database login
  • Updating the application configuration to use the new database login
  • Restarting the applications to apply the configuration change
  • Deleting the old database login

# Restarting the apps

We can start with restarting the applications because that's something we can test on its own before proceeding. We'll create a new plugin metadata file rundeck-plugins/db-creds/plugin.yaml:

name: Database credential management
version: 1
rundeckPluginVersion: 1.2
author: Carlo Cabanilla
date: 2018-07-20
url: http://rundeck.org/
providers:
  - name: RestartApp
    service: RemoteScriptNodeStep
    plugin-type: script
    script-interpreter: /bin/bash
    script-file: restart.sh
    script-args: ${config.process} ${config.health_url}
    config:
      - type: String
        name: process
        title: process
        description: the process to restart
      - type: String
        name: health_url
        title: health_url
        description: the http endpoint to poll to check that it's healthy
        default: http://localhost:8080

Our restart script takes 2 parameters: the name of the process on the node, and a url that we can poll to check that it's healthy. The implementation of the restart script is specific to the Docker playground environment so we'll leave out the details, but from a high level it:

  • Kills the process and lets the supervising parent process restart it
  • Polls the health check url until it returns healthy or times out
  • Exits with an error code if it times out to let the calling process know whether to continue or not with downstream steps

In order to be able to run the restart, we create a job that specifies the nodes to run it on and the input parameters. rundeck-project/jobs/RestartApp.yaml:

- name: RestartApp
  uuid: RestartApp
  nodefilters:
    filter: web_.*
  sequence:
    commands:
      - configuration:
          health_url: http://localhost:8080
          process: python3
        nodeStep: true
        type: RestartApp
    keepgoing: false
    strategy: node-first

We specify a uuid here so that we know how to refer to it from other jobs, otherwise Rundeck will assign a random uuid.

We can test the restart using our playground Docker environment:

make rd-run-job JOB=RestartApp

# Found matching job: RestartApp RestartApp
# Execution started: [5] RestartApp /RestartApp <http://127.0.0.1:4440/project/hello-project/execution/show/5>
Restarting python3 app
Waiting for app to stop
Waiting for app to start and return 200
Done
Restarting python3 app
Waiting for app to stop
Waiting for app to start and return 200
Done

# Modifying the app config

Now we can create a step to update the configured database login on the application nodes. In your production environment you might use a configuration management tool like Ansible or Chef to accomplish this but here we use a simple Python script. We can add this to the providers key of rundeck-plugins/db-creds/plugin.yaml:

- name: UpdateDBCredentials
  service: RemoteScriptNodeStep
  plugin-type: script
  script-interpreter: /usr/local/bin/python3
  script-file: change_password.py
  script-args: /etc/web.yaml ${config.user} ${config.password}
  config:
    - type: String
      name: user
      title: user
      description: "db user"
    - type: String
      name: password
      title: password
      description: "db password"
      renderingOptions:
        valueConversion: "STORAGE_PATH_AUTOMATIC_READ"

Note the valueConversion: "STORAGE_PATH_AUTOMATIC_READ" setting to able to refer to a path in Key Storage for the database password.

Corresponding job config rundeck-project/jobs/UpdateAppConfig.yaml:

- name: UpdateAppConfig
  uuid: UpdateAppConfig
  nodefilters:
    filter: web_.*
  options:
    - label: Database user
      name: dbuser
      required: true
  scheduleEnabled: true
  sequence:
    commands:
      - nodeStep: true
        configuration:
          user: ${option.dbuser}
          password: keys/projects/hello-project/db/${option.dbuser}
        type: UpdateDBCredentials
    keepgoing: false
    strategy: node-first

In the job, we expose an option to the job user to specify the database user to set the config to. We use that value as part of the Key Storage path to look up the password. By using a naming convention for the key path, we hide the details of the Key Storage setup from the job user.

We can test it with:

make rd-run-job JOB=UpdateAppConfig JOB_OPTIONS='-dbuser web2'

# Found matching job: UpdateAppConfig UpdateAppConfig
# Execution started: [6] UpdateAppConfig /UpdateAppConfig <http://127.0.0.1:4440/project/hello-project/execution/show/6>
Updating /etc/web.yaml with db user credentials: web2
Done
Updating /etc/web.yaml with db user credentials: web2
Done

# Creating the new db login

Creating a new database login requires a super user database login plus a user and password for the new login. Add this to the providers key of rundeck-plugins/db-creds/plugin.yaml:

- name: CreateDBUser
  service: RemoteScriptNodeStep
  plugin-type: script
  script-interpreter: /bin/bash
  script-file: create-db-user.sh
  script-args: ${config.master_db_user} ${config.master_db_password} ${config.new_user} ${config.new_password} ${config.role}
  config:
    - type: String
      name: master_db_user
      title: master_db_user
      description: "master db user"
      default: master1
    - type: String
      name: master_db_password
      title: master_db_password
      description: "master db user password"
      default: keys/projects/hello-project/db/master1
      renderingOptions:
        valueConversion: "STORAGE_PATH_AUTOMATIC_READ"
    - type: String
      name: new_user
      title: new_user
      description: "New db user"
    - type: String
      name: new_password
      title: new_password
      description: "New db password"
      renderingOptions:
        valueConversion: "STORAGE_PATH_AUTOMATIC_READ"
    - type: String
      name: role
      title: role
      description: "Database role to grant the new user"

Corresponding job config rundeck-project/jobs/CreateDbUser.yaml:

- name: CreateDbUser
  uuid: CreateDbUser
  nodefilters:
    filter: web_1
  options:
    - label: Master db user version
      name: master_user_version
      required: false
      value: "1"
    - label: Web db user version
      name: web_user_version
      required: true
  sequence:
    commands:
      - nodeStep: true
        configuration:
          master_db_user: master${option.master_user_version}
          master_db_password: keys/projects/hello-project/db/master${option.master_user_version}
          new_user: web${option.web_user_version}
          new_password: keys/projects/hello-project/db/web${option.web_user_version}
          role: web
        type: CreateDBUser
    keepgoing: false
    strategy: node-first
make rd-run-job JOB=CreateDbUser JOB_OPTIONS='-web_user_version 2'

# Found matching job: CreateDbUser CreateDbUser
# Execution started: [7] CreateDbUser /CreateDbUser <http://127.0.0.1:4440/project/hello-project/execution/show/7>
GRANT ROLE

# Deleting the old db login

Updating rundeck-plugins/db-creds-plugin/plugin.yaml:

- name: DeleteDBUser
  service: RemoteScriptNodeStep
  plugin-type: script
  script-interpreter: /bin/bash
  script-file: delete-db-user.sh
  script-args: ${config.master_db_user} ${config.master_db_password} ${config.user}
  config:
    - type: String
      name: master_db_user
      title: master_db_user
      description: "master db user"
      default: master1
    - type: String
      name: master_db_password
      title: master_db_password
      description: "master db user password"
      default: keys/projects/hello-project/db/master1
      renderingOptions:
        valueConversion: "STORAGE_PATH_AUTOMATIC_READ"
    - type: String
      name: user
      title: user
      description: "db user to delete"

And the corresponding job rundeck-project/jobs/DeleteDbUser.yaml:

- name: DeleteDbUser
  uuid: DeleteDbUser
  nodefilters:
    filter: web_1
  options:
    - label: Master db user version
      name: master_user_version
      required: false
      value: "1"
    - label: Web db user version
      name: web_user_version
      required: true
  sequence:
    commands:
      - nodeStep: true
        configuration:
          master_db_user: master${option.master_user_version}
          master_db_password: keys/projects/hello-project/db/master${option.master_user_version}
          user: web${option.web_user_version}
        type: DeleteDBUser
make rd-run-job JOB=DeleteDbUser JOB_OPTIONS='-web_user_version 1'

# Found matching job: DeleteDbUser DeleteDbUser
# Execution started: [9] DeleteDbUser /DeleteDbUser <http://127.0.0.1:4440/project/hello-project/execution/show/9>
DROP ROLE

# Tying it all together

To tie all the steps together into a single job, we can use job reference steps. rundeck-project/jobs/RotateDbCredentials.yaml:

- name: RotateDbCredentials
  uuid: RotateDbCredentials
  options:
  - label: master_user_version
    name: master_user_version
    value: '1'
  - label: web_user_version
    name: web_user_version
    required: true
  - label: prev_web_user_version
    name: prev_web_user_version
    required: true
  sequence:
    commands:
    - jobref:
        name: CreateDbUser
        uuid: CreateDbUser
        nodeStep: 'true'
        importOptions: true
    - jobref:
        name: UpdateAppConfig
        uuid: UpdateAppConfig
        nodeStep: 'true'
        args: -dbuser web${option.web_user_version}
    - jobref:
        name: RestartApp
        name: RestartApp
        nodeStep: 'true'
    - jobref:
        name: DeleteDbUser
        uuid: DeleteDbUser
        nodeStep: 'true'
        args: -master_user_version ${option.master_user_version} -web_user_version ${option.prev_web_user_version}
    keepgoing: false
    strategy: sequential

We'll need a new user and password, which we can create in the Key Storage:

echo 'An0th3r!S3cr3t' > rundeck-project/key-storage/keys/projects/hello-project/db/web3

Then running the job should push the new key. We need to specify both the new version and the previous version that needs deleting.

make rd-run-job JOB=RotateDbCredentials JOB_OPTIONS='-web_user_version 3 -prev_web_user_version 2'

# Created: keys/projects/hello-project/db/web3 [password]
# Found matching job: RotateDbCredentials RotateDbCredentials
# Execution started: [7] RotateDbCredentials /RotateDbCredentials <http://127.0.0.1:4440/project/hello-project/execution/show/7>
GRANT ROLE
Updating /etc/web.yaml with db user credentials: web3
Done
Updating /etc/web.yaml with db user credentials: web3
Done
Restarting python3 app
Waiting for app to stop
Waiting for app to start and return 200
Done
Restarting python3 app
Waiting for app to stop
Waiting for app to start and return 200
Done
DROP ROLE

And there you have it: a single command to rotate your database credentials!