Skip to content

fix: enforce relation databag read permissions on all access paths - #2738

Draft
tonyandrewmeyer wants to merge 4 commits into
canonical:mainfrom
tonyandrewmeyer:fix-relation-data-read-validation
Draft

fix: enforce relation databag read permissions on all access paths#2738
tonyandrewmeyer wants to merge 4 commits into
canonical:mainfrom
tonyandrewmeyer:fix-relation-data-read-validation

Conversation

@tonyandrewmeyer

@tonyandrewmeyer tonyandrewmeyer commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

A charm that touches its own application databag from a non-leader unit gets this, with nothing in it pointing at leadership:

ops.model.ModelError: ERROR permission denied (unauthorized access)

ops already has a much better error for exactly this case (ops/model.py:2049):

<unit> is not leader and cannot read its own application databag

However, the charm never sees it. RelationDataContent.__getitem__ calls _validate_read(), but _GenericLazyMapping.__contains__, __iter__ and __len__ go straight to _data_load() → the hook tool with no validation. update()'s change detection uses key not in self, so it took the unvalidated path and surfaced Juju's raw error.

This applies both with Relation.save() and also direct usage of update().

This PR makes two changes:

We now validate on every read path. _load() now calls _validate_read(), so no read can bypass the check. __getitem__ and __repr__ validate only when the data is already cached (_validate_cached_read), otherwise _load does it, and doing both would mean a redundant leadership check. That preserves today's behaviour for a unit that loses leadership after a read, at no extra cost. _validate_read() is almost always very quick (it's checking a few attributes, except in the case where a fresh leadership check is required).

We now check write permission before reading in update(). _validate_write ran after the diff loop, so a follower writing application data failed on an incidental read that change detection happens to need, rather than on the write it actually asked for. It now raises <unit> is not leader and cannot write application data.

A non-leader unit that touched its own application databag got a bare
`ModelError: ERROR permission denied (unauthorized access)` from Juju
instead of the `RelationDataAccessError` that ops already has for this
case, because only `__getitem__` called `_validate_read`. Read paths
that go via `_GenericLazyMapping` (`__contains__`, `__iter__`,
`__len__`) reached the hook tool unvalidated.

Validate in `_load` as well, so no read path can bypass the check.
`__getitem__` and `__repr__` now only re-validate when the data is
already cached, since otherwise `_load` does it; that keeps the current
behaviour for a unit that loses leadership after a read without adding a
redundant leadership check.

Also check write permission in `update()` before reading the current
content. Change detection reads the databag to skip no-op writes, and
that incidental read was what failed first, so a follower writing
application data got an error about a read it never asked for rather
than one naming the write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018mFLSaFNf7NmyUtrLjLp4q
Comment thread ops/model.py Outdated
Comment thread ops/model.py
Co-authored-by: Tony Meyer <tony.meyer@gmail.com>
@tonyandrewmeyer tonyandrewmeyer changed the title fix: validate relation data reads on every access path fix: enforce relation databag read permissions on all access paths Sep 9, 2026
Comment thread test/test_testing.py
Comment on lines +536 to +538
# The charm records every databag, including its own application one, so it
# needs to be the leader to read them all.
harness.set_leader(True)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because there's a behaviour change. You're allowed to access relation data outside of a hook in Harness without setting up things so that you should have access to it. This could break real tests. At minimum it would need a hyrum run, and I wonder that it's too risky to do at all in a minor / point release.

Comment thread ops/model.py
# Always check permissions, and do so before reading the current content, so
# that a unit that can't write gets an error naming the write, rather than one
# about the read that change detection happens to require.
self._validate_write(data)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a behaviour change in that if there was something that was removed by the change but has type problems (something we can't store, for example) it will break now and wouldn't have before. I think in this case that's probably ok, it seems a weird situation and is arguably buggy behaviour anyway. But it would be worth checking that nothing we know of does break because of this.

Comment on lines -132 to -133
# these probably fail at real runtime with a ModelError
# but pass here because the validation methods are only hooked up to get/set

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change here is exactly what this comment is about. Basically, the comment was acknowledging the buggy (or limited) behaviour and the test pinned that behaviour. The PR updates it so that we're doing the proper thing instead.

tonyandrewmeyer and others added 2 commits September 9, 2026 12:42
__repr__ guarded with _validate_cached_read(), which is deliberately a
no-op when the data hasn't been loaded yet. For an unloaded databag the
guard passed and the following super().__repr__() triggered _load() ->
_validate_read(), so RelationDataAccessError escaped instead of repr()
returning '<n/a>'.

Force the load inside the same try, so the read is validated exactly once
on either path (cached via _validate_cached_read, uncached via _load) and
the error is caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUVLD2hkQQdF5jjW12f3Hk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant