Skip to content

fix: use cached leadership in security logging, renew lease on success - #2778

Merged
tonyandrewmeyer merged 6 commits into
canonical:mainfrom
cristiangirlea:fix/leadership-cache-security-log
Oct 5, 2026
Merged

tonyandrewmeyer merged 6 commits into
canonical:mainfrom
cristiangirlea:fix/leadership-cache-security-log

Conversation

@cristiangirlea

@cristiangirlea cristiangirlea commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Reporting a failed hook command as a security event called is_leader(), which could run the is-leader hook command while handling the original failure. If is-leader itself failed with an authorisation error and no leadership status was cached, this recursed until a RecursionError.

The security event now uses the leadership status that ops already has cached, through a new _cached_leadership() helper that is_leader() shares, so the lease rule is written once. This also covers failures in juju_log(). When there is no valid cached status, the event says "(leadership unknown)" rather than reading the same as a known non-leader.

is_leader() also stored the lease time before running is-leader, so a failed refresh kept the previous value with a fresh timestamp. The time is still taken before the query, and is now stored only once it succeeds.

No exception message changes: str(e) and args[0] of ModelError are still Juju's stderr.

Pass the existing leadership state in to the security logging, rather
than running is-leader while reporting a failed hook command.

Only store the refreshed leadership lease time when is-leader succeeds.

@james-garner-canonical james-garner-canonical left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, this looks good to me.

Comment thread ops/model.py Outdated
if self._is_recursive.get():
# Either `juju-log` hook command failed or there's a bug in ops.
return
is_leader = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
is_leader = None
# Save known leadership status at time of calling the hook command,
# for use in security event logging if the hook command fails.
is_leader = None

Comment thread ops/model.py Outdated
return
base_cmd = os.path.basename(cmd)
leadership = ' (as leader)' if self.is_leader() else ''
leadership = ' (as leader)' if is_leader else ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is a decent behaviour change -- we don't call is-leader on every authorization related error, which is a small win for performance, and arguably for correctness too on two fronts: 1) we avoid potentially failing in error handling on another hook command call, and 2) it's sensible to report the known leadership status at the time we called the hook command rather than afterwards.

Ops will have checked if we were the leader on events where we expect that to matter (so it will be cached in that case), and leadership doesn't matter for other permission errors (e.g. querying a secret or relation ID that we don't have access to).

WDYT @tonyandrewmeyer?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the lease has expired (or is_leader() wasn't called in this hook), is_leader is None and the log reads exactly the same as for a known non-leader. Since this is the security audit log, I think it would be better to distinguish those, maybe with something like (leadership unknown) for None?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added (leadership unknown) when there is no valid cached status.

Comment thread test/test_model.py
query = mock.Mock(side_effect=is_leader)
monkeypatch.setattr(hookcmds, 'is_leader', query)
assert backend.is_leader() is True
assert backend._leader_check_time == 100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
assert backend._leader_check_time == 100
assert query.call_count == 1
assert backend._leader_check_time == 100
assert backend._leader_check_time != now

Comment thread test/test_model.py
assert backend._leader_check_time == 100
now = 130
assert backend.is_leader() is True
assert query.call_count == 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
assert query.call_count == 1
assert query.call_count == 1
assert backend._leader_check_time == 100
assert backend._leader_check_time != now

Comment thread test/test_model.py
now = 131
assert backend.is_leader() is True
assert query.call_count == 2
assert backend._leader_check_time == 131

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
assert backend._leader_check_time == 131
assert backend._leader_check_time == 131
assert backend._leader_check_time != now

Comment thread test/test_model.py Outdated
'cached,age,as_leader',
[
(True, 0, True),
(True, 30, True),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is quite funny on our part really isn't it, it's just asking for a leadership change out from under you ...

Comment thread ops/model.py Outdated
Comment thread ops/model.py Outdated
Comment thread ops/model.py Outdated
Comment thread ops/model.py Outdated
Comment thread test/test_model.py Outdated
Comment thread test/test_model.py
Comment thread test/test_model.py Outdated
Comment thread test/test_model.py
Comment thread ops/model.py Outdated
return
base_cmd = os.path.basename(cmd)
leadership = ' (as leader)' if self.is_leader() else ''
leadership = ' (as leader)' if is_leader else ''

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the lease has expired (or is_leader() wasn't called in this hook), is_leader is None and the log reads exactly the same as for a known non-leader. Since this is the security audit log, I think it would be better to distinguish those, maybe with something like (leadership unknown) for None?

cristiangirlea and others added 2 commits September 30, 2026 12:08
Move the lease check into a _cached_leadership() helper shared by
is_leader() and _check_for_security_event(), so the two can't drift and
juju_log() failures are covered too. Report "(leadership unknown)" in
the security event when there is no valid cached status, and add a
test for the is-leader authz failure that used to recurse.
@cristiangirlea

Copy link
Copy Markdown
Contributor Author

Thanks Tony, all addressed in a53b128. One note on the history: my first cut of this PR had a shared helper like _cached_leadership(), but I trimmed it down to strictly the two changes you and James listed, so the helper went with it. Now that you've asked for it, it's back, and it also fixed the recursion case you spotted.

@tonyandrewmeyer tonyandrewmeyer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks! Could you please update the PR body? It gets used as the body of the squash commit, so it's important that it's up-to-date and accurate.

@cristiangirlea cristiangirlea changed the title fix: pass known leadership to security logging, renew lease on success fix: use cached leadership in security logging, renew lease on success Oct 1, 2026
@cristiangirlea

Copy link
Copy Markdown
Contributor Author

Updated the PR title and body to match the final change.

@tonyandrewmeyer
tonyandrewmeyer merged commit da64263 into canonical:main Oct 5, 2026
60 checks passed
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.

3 participants