fix: enforce relation databag read permissions on all access paths - #2738
fix: enforce relation databag read permissions on all access paths#2738tonyandrewmeyer wants to merge 4 commits into
Conversation
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
Co-authored-by: Tony Meyer <tony.meyer@gmail.com>
| # 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) |
There was a problem hiding this comment.
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.
| # 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) |
There was a problem hiding this comment.
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.
| # these probably fail at real runtime with a ModelError | ||
| # but pass here because the validation methods are only hooked up to get/set |
There was a problem hiding this comment.
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.
__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
A charm that touches its own application databag from a non-leader unit gets this, with nothing in it pointing at leadership:
ops already has a much better error for exactly this case (
ops/model.py:2049):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 useskey 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 ofupdate().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_loaddoes 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_writeran 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.