How to add secrets to a charm

See also: Secret events

This feature is scheduled for release in ops 2.0, and is only available on juju 3.0-beta5 or greater.

As you might have heard, juju v.3.0 is adding a new feature we’ve all been waiting for: secrets. Secrets allow charms to securely exchange data via a juju controller-mediated channel. In this document we will guide you through how to use the new secrets API as exposed by the Charmed Operator Framework ops.

We are going to assume:

  • surface knowledge of the ops library
  • familiarity with secrets terminology and concepts (see Secret events)

Contents:

Setup

Let us assume that you have two charms that need to exchange a secret. For example, a webserver and a database. To access the database, the webserver needs a username/password pair. Without secrets, charmers were forced to exchange the credentials in clear text via relation data, or manually add a layer of (presumably public key) encryption to avoid broadcasting that data (because, remember, anyone with a juju CLI and access to the controller can juju show-unit).

In secrets terminology, we call the database the owner of a secret, and the webserver its consumer.

Starting from the owner, let’s see what changes are necessary to the codebase to switch from this relation-data credentials exchange mechanism to one backed by the new juju secrets API.

Owner: Add a secret

Presumably, the owner code (before using secrets) looked something like this:

class MyDatabaseCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.database_relation_joined, 
                               self._on_database_relation_joined)

    ...  # other methods and event handlers
   
    def _on_database_relation_joined(self, event: RelationJoinedEvent):
        event.relation.data[self.app]['username'] = 'admin' 
        event.relation.data[self.app]['password'] = 'admin'  # don't do this at home   

Using the new secrets API, this can be rewritten as:

class MyDatabaseCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.database_relation_joined,
                               self._on_database_relation_joined)

    ...  # other methods and event handlers

    def _on_database_relation_joined(self, event: RelationJoinedEvent):
        secret = self.app.add_secret({
            'username': 'admin',
            'password': 'admin'})
        event.relation.data[self.app]['secret-id'] = secret.id
        secret.grant(event.relation.app, relation=event.relation)

Note that:

  • we call add_secret on self.app: Application. That is because we wish the secret to be owned by this application, not by this unit. If we wanted to create a secret owned by the unit, we’d have called self.unit.add_secret instead.
  • the only data shared in plain text is the secret ID. The secret ID can be publicly shared. Juju will ensure that only remote apps/units to which the secret has explicitly been granted by the owner will be able to fetch the actual secret payload from that ID.
  • the secret needs to be granted to a remote entity (app or unit), and that always goes via a relation instance. By passing a relation to grant(), we are explicitly declaring the scope of the secret – its lifetime will be bound to that of this relation instance.

Consumer: Get a secret

Before secrets, the consumer code could have looked something like this:

class MyWebserverCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.database_relation_changed,
                               self._on_database_relation_changed)

    ...  # other methods and event handlers

    def _on_database_relation_changed(self, event: RelationChangedEvent):
        username = event.relation.data[event.app]['username']
        password = event.relation.data[event.app]['password']
        self._configure_db_credentials(username, password)

With the secrets API, the code would become:

class MyWebserverCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.database_relation_changed,
                               self._on_database_relation_changed)

    ...  # other methods and event handlers

    def _on_database_relation_changed(self, event: RelationChangedEvent):
        secret_id = event.relation.data[event.app]['secret-id']
        secret = self.model.get_secret(id=secret_id)
        self._configure_db_credentials(secret.get('username'),
                                       secret.get('password'))

Note that:

  • the consumer charm gets a secret via the model (not its app/unit). Because it’s the owner who decides who the secret is granted to, the scoping/ownership of a secret is not a consumer concern. The consumer code can rightfully assume that, so long as a secret ID is shared with it, the owner has taken care to grant and scope the secret in such a way that the consumer has the rights to inspect its contents.
  • the charm first gets the secret object from the model, then gets the individual secret payload components by key.
  • the secret object itself does no caching at all, to avoid storing the secret payload in the clear. All secrets calls are directly forwarded to juju.

Manage secret revisions

If your application never needs to rotate secrets, then this would be enough. However, typically you want to rotate a secret periodically to contain the damage from a leak, or to avoid giving crackers too much time to break the encryption.

Creating new secret revisions is an owner’s concern. First we will look at how to create a new revision (regardless of when a charm decides to do so) and how to consume it. Then, we will look at the built-in mechanisms to facilitate periodic secret rotation.

Owner: Create a new revision

To create a new revision, the owner simply needs to call secret.set and pass the new payload.

class MyDatabaseCharm(CharmBase):

    ... # as before

    def _rotate_webserver_secret(self, secret):
        secret.set({
            'username': secret.get('username'),  # keep the same username
            'password': _generate_new_secure_password(),  # something stronger than 'admin'
        })

This will inform juju that a new revision is available, and juju will inform all units tracking older revisions that a new one is available, by means of a secret-changed hook.

Consumer: Update to a new revision

To update to a new revision, the webserver charm will typically subscribe to the secret-changed event like so:

class MyWebserverCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.secret_changed,
                               self._on_secret_changed)

    ...  # as before

    def _on_secret_changed(self, event):
        event.secret.update()
        self._configure_db_credentials(event.secret.get('username'),
                                       event.secret.get('password'))

Peek at a secret’s payload

Sometimes, before reconfiguring to use a new credential revision, the charm may want to peek at its contents (e.g. to ensure that they are valid). To do so:

    def _on_secret_changed(self, event):
        if not self._check_password(event.secret.get('password', peek=True)):
            logger.warning('invalid credentials! not updating to new revision')
            return 

        event.secret.update()
        ...

Label secrets

Sometimes a charm will consume multiple secrets. In the secret-changed event handler above, you might ask yourself: How do I know which secret has changed? The answer lies with secret labels. Let’s go through the following code:

class MyWebserverCharm(CharmBase):

    ...  # as before

    def _on_database_relation_changed(self, event: RelationChangedEvent):
        secret_id = event.relation.data[event.app]['secret-id']
        secret = self.model.get_secret(id=secret_id, label='database-secret')
        self._configure_db_credentials(secret.get('username'),
                                       secret.get('password'))

    def _on_secret_changed(self, event):
        if event.secret.label == 'database-secret':
            event.secret.update()
            self._configure_db_credentials(event.secret.get('username'),
                                           event.secret.get('password'))
        elif event.secret.label == 'my-other-secret':
            self._handle_other_secret_changed(event.secret)
        else: 
            ...

When the webserver charm adds the secret, it can specify a label for it. That label is locally unique: if you attempt to add two secrets (from the same application, whether it’s the consumer side or the owner one) and give them the same label, you’ll get a ModelError.

Whenever a charm receives an event concerning a secret for which it has set a label, the label will be present on the secret object exposed by the Operator Framework.

The owner of the secret can do the same. At secret-add time, it can specify a label for the newly created secret:

class MyDatabaseCharm(CharmBase):

    ...  # as before

    def _on_database_relation_joined(self, event: RelationJoinedEvent):
        secret = self.app.add_secret({
            'username': 'admin',
            'password': 'admin'},
        label='secret-for-webserver-app')
        event.relation.data[event.unit]['secret-id'] = secret.id
        secret.grant(event.relation.app)

If a secret has been labelled in this way, the charm can retrieve a reference to the secret object at any time from the label itself. This way, a charm can perform any secret management operation even if all it knows is just the label alone: all the secret ID is used for is to exchange a reference to the secret between applications. Within a single application, all you need is the secret label.

So, having labelled the secret on creation, the Database charm could add a new revision simply by:

    def _rotate_webserver_secret(self):
        secret = self.model.get_secret(label='secret-for-webserver-app')
        secret.set(...)  # pass a new revision payload, as before

Owner: Manage rotation

We have seen how an owner can create a new revision and how a consumer can update to it (or peek at it). What remains to be seen is: when does the owner decide to rotate a secret?

Juju exposes two separate mechanisms for owner charms to rotate a secret: rotation and expiration. The consumer-side mechanism for updating/peeking new revisions does not change, so this section will only discuss owner code.

A charm can configure a secret, at creation time, to have either:

  • a rotation policy (e.g. weekly, monthly, daily…)
  • an expiration date (e.g. in two months from now).

The code is self-explanatory:

class MyDatabaseCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.secret_rotate,
                               self._on_secret_rotate)

    ...  # as before

    def _on_database_relation_joined(self, event: RelationJoinedEvent):
        secret = self.app.add_secret({
            'username': 'admin',
            'password': 'admin'},
            label='secret-for-webserver-app',
            rotate='daily')

    def _on_secret_rotate(self, event: SecretRotateEvent):
        # this will be called once per day.
        if event.secret.label == 'secret-for-webserver-app':
            self._rotate_webserver_secret(event.secret)

Or:

class MyDatabaseCharm(CharmBase):
    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.secret_expired,
                               self._on_secret_expired)

    ...  # as before

    def _on_database_relation_joined(self, event: RelationJoinedEvent):
        expiration_date = datetime.now() + datetime.timedelta(days=42)
        secret = self.app.add_secret({
            'username': 'admin',
            'password': 'admin'},
            label='secret-for-webserver-app',
            expire=expiration_date)

    def _on_secret_expired(self, event: SecretExpiredEvent):
        # this will be called only once, 42 days after the relation-joined event.
        if event.secret.label == 'secret-for-webserver-app':
            self._rotate_webserver_secret(event.secret)

Manage a secret’s end of life

No matter how well you keep them, secrets aren’t forever. If the relation holding the two charms together is removed, the owner charm might want to clean things up and remove the secret as well (the consumer won’t be able to access it anyway).

Also, suppose that the owner charm has some config variable that determines who is to be granted access to the db. If that were to change after the charm already has granted access to some remote entity, the database charm will need to revoke access.

Finally, if the owner rotates the charm and all consumers update to track the new (latest) revision, the old revision will become dead wood and can be pruned.

We show you how to handle all these scenarios below.

Remove a secret

To remove a secret (effectively destroying it for good), the owner needs to call secret.remove(). Therefore, regardless of the logic leading to the decision of when to remove a secret, the code will look like some variation of the following:

class MyDatabaseCharm(CharmBase):

    ...  # as before

    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.database_relation_broken,
                               self._on_database_relation_broken)

    def _on_database_relation_broken(self, event: RelationBrokenEvent):
        self._remove_webserver_secret()

    def _remove_webserver_secret(self):
        secret = self.model.get_secret(label='secret-for-webserver-app')
        secret.remove()

Starting from this moment, the consumer charm will get ModelError whenever it attempts to .get() the secret’s payload. In general, the presumption is that the consumer charm will take the absence of the relation as indication that the secret is gone as well, and so will not attempt to get it.

Prune a secret

Pruning a secret is a less drastic operation than removing it. It is rather more like removing a single revision of a secret. (In fact, that’s precisely what pruning means.)

Typically, the owner will prune a secret upon receiving a SecretRemove event, that is, when that specific revision is no longer tracked by any consumer. If that is not the case, and an owner were to prune a revision which still has some trackers, than all those secret consumers will start getting ModelError as if the secret had been removed.

Therefore, a typical implementation will look like:

class MyDatabaseCharm(CharmBase):

    ...  # as before

    def __init__(self, *args, **kwargs):
        ...  # other setup
        self.framework.observe(self.on.secret_remove,
                               self._on_secret_remove)

    def _on_secret_remove(self, event: SecretRemoveEvent):
        event.secret.prune()

Revoke a secret

For whatever reason, the owner of a secret can at any moment decide to revoke access to the secret to a remote entity. That is easily done by calling secret.revoke().

An example of usage might look like:

class MyDatabaseCharm(CharmBase):

    ...  # as before

    def _revoke_webserver_secret_access(self, unit_or_app, relation):
        secret = self.model.get_secret(label='secret-for-webserver-app')
        secret.revoke(unit_or_app, relation=relation)

Note:

  • We are abstracting here from the logic leading to the decision to revoke access to a specific remote unit or application.
  • Just like when the owner granted the secret, here too we need to pass a relation to the revoke() call, making it clear what scope this action is to be applied to. In fact, the owner could theoretically be granting the same secret to the same remote target via multiple scopes (i.e. multiple relations).

Conclusion

In this guide we have taken a tour of the new ops secrets API. Hopefully you will find that the API is intuitive and provides a clear wrapper around the new juju hook tools. If you find bugs, have suggestions, or want to contribute to the discussion, you can use the tracker on github.

Of course, we have also added facilities in the testing Harness to help charmers test their secrets. This is however a matter for another document.


Last updated 27 days ago.