Skip to content

Support fetching tokens from an external command - #469

Open
ammachado wants to merge 7 commits into
psss:mainfrom
ammachado:did-pwmgr
Open

Support fetching tokens from an external command#469
ammachado wants to merge 7 commits into
psss:mainfrom
ammachado:did-pwmgr

Conversation

@ammachado

@ammachado ammachado commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add token_command as a third token source alongside token / token_file, so plugins can pull secrets from password managers such as BitWarden (bw get password did-jira) or 1Password (op read op://Personal/Jira/token). The command is parsed with shlex (no shell) and its stripped stdout is used as the token; failures raise ConfigError. Results are memoized per process via functools.lru_cache so multiple config sections sharing a command only invoke the external tool once.
  • Precedence when more than one of token, token_file, token_command is set in a section: token > token_file > token_command, matching the existing token vs. token_file precedence — lower-precedence keys are silently ignored, same as before.
  • Updated the jira, confluence, github, and gitlab plugin docstrings to document token_command and the precedence rule. Other plugins automatically gain the feature through the shared did.base.get_token helper.

Test plan

  • pytest tests/unit/test_base.py::TestGetToken — 13/13 passing (5 new tests for the command source, precedence, failure modes, and memoization)
  • Full pytest tests/unit -n auto clean except for pre-existing failures unrelated to token handling (nitrate/psycopg2 build needs pg_config locally; one redmine live-data test)
  • CI runs the full suite with .[all] installed
  • Manual smoke: configure a plugin with token_command = printf %s ... and confirm the report runs

🤖 Generated with Claude Code

@ammachado
ammachado marked this pull request as ready for review June 1, 2026 15:46
@ammachado

Copy link
Copy Markdown
Contributor Author

Note on integration test count updates (tests/github/issues.sh, tests/github/pulls.sh)

@psss, while investigating CI failures in Testing Farm, I found that two items are no longer being returned by the GitHub Search API for the 2022 date range:

  • teemtee/fmf#149 — missing from all "issues created/closed" queries
  • teemtee/try#002 — missing from all "pull requests created" queries

Both items are still visible by direct URL, so they have not been deleted. The root cause is GitHub Search API inconsistency: the index is eventually consistent and does not guarantee that all matching items are returned, especially for older closed issues/PRs. I confirmed the failure also reproduces on main, so this is not a regression introduced by this PR. The same behavior is also observed in PRs #468 and #470.

As a short-term fix I updated the expected counts and dropped the specific rlAssertGrep assertions for the two missing items.

I'd like your advice on how to handle this long-term. A few options I can think of:

  1. Keep exact counts (current approach) — simple to read, but requires manual updates whenever the Search index drifts. Will happen again.
  2. Drop count assertions entirely — only assert that specific known items are present/absent. More resilient to index drift, but loses the sanity check that ensures we're not silently fetching zero results.
  3. Use a lower-bound check — e.g. assert the count is >= N rather than == N. Beakerlib doesn't support this natively but it could be done with a small shell snippet.
  4. Pin the test to a narrower, stable date range where the result set is small enough that index drift is unlikely to drop items.

What is the preferred approach for this project?

@psss

psss commented Jul 10, 2026

Copy link
Copy Markdown
Owner

@ammachado, thanks for looking into the test issues. Yes, it really seems that some of the expected issues are not included in the search results anymore. Interestingly, for pull requests your changes seem not needed anymore. Something weird is happening on the GitHub side. Here's a slightly modified version of your adjustments:

@psss psss left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks much for implementing this! Looks good, just two minor comments. Also the following statement from the pull request description is not valid, right?

  • Breaking change: setting more than one of token, token_file, token_command in a section is now a hard ConfigError. Previously the lower-precedence keys were silently ignored.

Comment thread CLAUDE.md Outdated
Comment thread did/plugins/jira.py
ammachado added a commit to ammachado/did that referenced this pull request Jul 14, 2026
…re widely

- Remove CLAUDE.md from this branch per review request; the repo
  guidance content is fine but belongs in its own PR.
- Document token_command (alongside token/token_file) in the github
  and gitlab plugin docstrings, matching the jira/confluence notes.
psss pushed a commit to ammachado/did that referenced this pull request Jul 14, 2026
…re widely

- Remove CLAUDE.md from this branch per review request; the repo
  guidance content is fine but belongs in its own PR.
- Document token_command (alongside token/token_file) in the github
  and gitlab plugin docstrings, matching the jira/confluence notes.
@psss psss added the base label Jul 14, 2026
@psss psss added this to the 0.24 milestone Jul 14, 2026
psss pushed a commit to ammachado/did that referenced this pull request Jul 14, 2026
…re widely

- Remove CLAUDE.md from this branch per review request; the repo
  guidance content is fine but belongs in its own PR.
- Document token_command (alongside token/token_file) in the github
  and gitlab plugin docstrings, matching the jira/confluence notes.

@psss psss left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the changes. Added one more missing pre-commit dependency in 4cd37b4. Should be good to go.

@lukaszachy lukaszachy 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.

LGTM, finally a safer way to store tokens

@kwk kwk 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.

I do like the idea of using Bitwarden. But I'm sorry but I have a few questions and annotations.

Comment thread did/plugins/gitlab.py
type = gitlab
url = https://gitlab.com/
token = <authentication-token>
token_file = <authentication-token-file>

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.

Suggested change
token_file = <authentication-token-file>
token_command = <command-to-fetch-token>

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.

Since both, token and token_file are already listed, I think we should add the token_command as well.

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 in 1f3a204.

Comment thread did/plugins/jira.py
type = jira
url = https://issues.redhat.com/
auth_type = token
token_file = ~/.did/jira_api_token

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.

Suggested change
token_file = ~/.did/jira_api_token
token_command = <command-to-fetch-token>

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.

Same here.

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 in 1f3a204.

Comment thread did/plugins/jira.py Outdated
Shell-style command line whose stdout is used as the token, e.g.
``bw get password did-jira`` or
``op read op://Personal/Jira/token``. The command is parsed with
``shlex`` and executed without a shell.

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.

I'm a bit confused what "without a shell" means here. Will there be no environment variables available when the command is run? The reason I ask is that for bitwarden you need the BW_SESSION env var to be set.

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.

Fair point, the wording was misleading. "Without a shell" only meant that the argv is built with shlex.split() and handed straight to subprocess.run() instead of going through /bin/sh. The environment is untouched: subprocess.run() inherits os.environ by default, so BW_SESSION (and OP_SERVICE_ACCOUNT_TOKEN, GNUPGHOME, etc.) are visible to the command.

What you do not get is shell expansion: no pipes, no redirects, no $VAR substitution, no globbing. So bw get password did-jira works, but bw get password did-jira | tr -d '\\n' would not. sh -c '...' still works if someone really wants a pipeline.

Documented that explicitly in 1f3a204, both in the jira plugin docstring and in the _run_token_command docstring.

Comment thread did/base.py
log.info("Using login alias '%s' for '%s'", login, stats)


@lru_cache(maxsize=None)

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.

I wonder if it is such a good idea to cache passwords?

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.

Worth spelling out, since "cache" is a loaded word for secrets.

This is an lru_cache on a module-level function, so it lives entirely in the process heap and dies with it. Nothing is written to disk, no temp file, no keyring, no cross-invocation state. did is a short-lived CLI, so "lifetime of the process" is the duration of one report.

It also does not widen exposure: get_token() returns the token to its caller, which hands it to the plugin, which keeps it on the session object for the whole run. The secret is already resident in memory for at least as long as the cache holds it. Dropping the lru_cache would not remove the token from memory, it would just make us shell out to the password manager once per config section.

That last part is the actual motivation. A config with [jira], [gitlab] and [github] all pointing at the same token_command would otherwise trigger three separate bw get/op read calls, each with its own network round trip and, for some setups, its own biometric or PIN prompt. Failures are deliberately not cached, so a transient error can be retried.

Documented the reasoning in 1f3a204. If you would still rather not keep it, the alternative I would suggest is caching per Config instance instead of per process, but that is a larger change and I do not think it buys real security.

Comment thread did/base.py
except subprocess.CalledProcessError as exc:
raise ConfigError(
f"Token command failed (exit {exc.returncode}): "
f"{exc.stderr.strip()}") from exc

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.

I think it does make sense to show the actual command in all cases FileNotFoundError, TimeoutExpired, CalledProcessError.

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.

Agreed, fixed in 1f3a204. The command line is now in all three messages:

Token command not found: bw: bw get password did-jira
Token command timed out after 30s: bw get password did-jira
Token command failed (exit 1): bw get password did-jira: You are not logged in.

FileNotFoundError keeps exc.filename as well, since that names the specific binary that is missing, which is not obvious when the command is something like sh -c '...'.

Comment thread tests/unit/test_base.py Outdated
""" Tests for the `get_token` function """

def setUp(self) -> None:
# Clear the per-process token-command cache so memoized results

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.

Typo?

Suggested change
# Clear the per-process token-command cache so memoized results
# Clear the per-process token-command cache so memorized results

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.

Not a typo, "memoized" is the standard term for what functools.lru_cache does, and codespell in the pre-commit config does not flag it. But it clearly read as one, which is reason enough, so I reworded it to "cached results" in 1f3a204. Same meaning, no double-take.

Comment thread tests/unit/test_utils.py
res = did.utils.color(
"text", text_color=None, background=None, light=False, enabled=True)
assert res == "\033[0mtext\033[1;m"
assert res == "\033[0mtext\033[0m"

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.

Same here. Why make this change in this PR?

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.

These three assertions are the direct consequence of the color() change in did/utils.py, answered in detail there: #469 (comment)

Short version: the reset sequence emitted by color() changed from \033[1;m to \033[0m, so the expected strings had to follow or the tests fail. They are not an independent change, and if the utils.py change gets split out into its own PR these three go with it.

Comment thread tests/unit/test_utils.py
res = did.utils.color(
"text", text_color="red", background=None, light=False, enabled=True)
assert res == "\033[0;31mtext\033[1;m"
assert res == "\033[0;31mtext\033[0m"

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.

see above

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.

Same as above: follows the color() reset change. #469 (comment)

Comment thread tests/unit/test_utils.py
res = did.utils.color(
"text", text_color="red", background=None, light=True, enabled=True)
assert res == "\033[1;31mtext\033[1;m"
assert res == "\033[1;31mtext\033[0m"

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.

see above

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.

Same as above: follows the color() reset change. #469 (comment)

Comment thread .pre-commit-config.yaml
Comment on lines +69 to +85
additional_dependencies:
[
bodhi-client,
feedparser,
google-api-python-client,
gssapi,
koji,
nitrate,
oauth2client,
pytest,
python-bugzilla,
python-dateutil,
requests,
requests-gssapi,
setuptools,
tenacity,
]

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.

Why do we need those dependencies added in this PR?

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.

The pylint hook was failing with import-error across the plugin modules. import-error is not in the disable= list in .pylintrc, and pre-commit runs each hook in its own isolated virtualenv, so the init-hook sys.path extension only ever sees the hook's own venv. Without these, pylint cannot resolve feedparser, koji, nitrate, bodhi-client and friends, and reports an error per plugin.

This is not a new pattern, it is the same list the mypy hook right below already carries:

  - repo: https://github.com/pre-commit/mirrors-mypy
    hooks:
      - id: mypy
        additional_dependencies: [
            bodhi-client,
            feedparser,
            google-api-python-client,
            ...

The pylint hook was simply missing the equivalent. The difference in the two lists is that pylint needs the real packages where mypy wants the stubs, hence requests and python-dateutil instead of types-requests and types-python-dateutil.

Fair to ask why it landed here rather than separately, and the honest answer is that it surfaced while getting the full pre-commit suite green for this branch. Worth noting @psss reviewed it and then added setuptools to the same list himself in 4cd37b4, so the list as it stands is partly his. I can still split it into a standalone PR if you would rather keep this one narrow.

ammachado added a commit to ammachado/did that referenced this pull request Sep 6, 2026
- List `token_command` in the gitlab and jira config examples, next
  to the `token` and `token_file` entries that were already there
- Clarify that the token command inherits did's environment (so
  `BW_SESSION` and friends work) and only loses shell expansion
- Include the command line in the `FileNotFoundError` and
  `CalledProcessError` messages, matching the timeout message
- Document why caching the token for the process lifetime does not
  widen exposure of the secret
- Reword a test comment that read as a typo

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ammachado and others added 7 commits September 6, 2026 12:24
Add `token_command` as a third token source alongside `token` and
`token_file`, so secrets can be pulled from password managers such as
BitWarden (`bw get password ...`) or 1Password (`op read op://...`).
The command is parsed with shlex (no shell) and its stdout is used as
the token; failures raise `ConfigError`. Results are memoized per
process so multiple sections sharing a command only invoke the tool
once.

Setting more than one of `token`, `token_file`, `token_command` is now
a hard `ConfigError` (previously the lower-precedence keys were
silently ignored).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Fix terminal color reset sequence (\033[1;m -> \033[0m) so
  background color does not bleed into subsequent output
- Extract the human-readable 'message' field from GitHub API JSON
  error responses instead of printing the raw JSON blob
- Drop the Future object reference from ReportError log lines so
  only the error text is shown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirror the dependency list from the mypy hook so pylint can resolve
all project imports, substituting requests/python-dateutil for their
type-stub equivalents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Adriano Machado <60320+ammachado@users.noreply.github.com>
Update expected ANSI escape sequences from \033[1;m to \033[0m
following the fix in cbef398.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

rh-pre-commit.version: 2.4.0
rh-pre-commit.check-secrets: ENABLED
…re widely

- Remove CLAUDE.md from this branch per review request; the repo
  guidance content is fine but belongs in its own PR.
- Document token_command (alongside token/token_file) in the github
  and gitlab plugin docstrings, matching the jira/confluence notes.
- List `token_command` in the gitlab and jira config examples, next
  to the `token` and `token_file` entries that were already there
- Clarify that the token command inherits did's environment (so
  `BW_SESSION` and friends work) and only loses shell expansion
- Include the command line in the `FileNotFoundError` and
  `CalledProcessError` messages, matching the timeout message
- Document why caching the token for the process lifetime does not
  widen exposure of the secret
- Reword a test comment that read as a typo

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ammachado

Copy link
Copy Markdown
Contributor Author

@kwk, all fourteen threads from your review are answered, and the branch is rebased on current main. Could you take another look when you have a moment?

Changed in 179036d:

  • token_command added to the gitlab and jira config examples, next to the token / token_file entries that were already listed
  • Clarified "without a shell": the command inherits did's environment, so BW_SESSION and friends are visible. Only shell expansion is missing (no pipes, redirects, $VAR, globs). Documented in both the jira plugin docstring and _run_token_command
  • The command line is now included in all three error paths, not just the timeout one
  • Documented why caching the token for the process lifetime does not widen exposure of the secret
  • Reworded the "memoized" comment that read as a typo

Answered without a code change, reasoning in each thread:

  • Optional[str] stays, since setup.py still declares Python 3.9 and PEP 604 is 3.10+. from __future__ import annotations would let us switch, but repo-wide rather than in this PR
  • did/stats.py: the old line printed a Future repr, and a failing token_command surfaces through exactly that line
  • did/utils.py colour reset and the three test assertions that follow from it: I overstated this as a bug fix in the commit message, it is a cleanup. Corrected in the thread
  • .pre-commit-config.yaml: pylint was hitting import-error, mirroring the list the mypy hook already carries

I offered in several threads to split the colour-reset and pre-commit changes into separate PRs if you would still prefer this one narrower. Happy to do that, just say which.

One note on CI: the four Fedora testing-farm jobs are red, but not because of this branch. pagure.io's REST API is currently returning 404 for every path, including https://pagure.io/api/0/version, while the web UI is up. That fails the five tests/unit/plugins/test_pagure.py tests. The same five fail on unmodified main, and this PR touches no pagure code. The CentOS Stream jobs pass because they do not run the unit plan.

@ammachado
ammachado requested a review from kwk September 6, 2026 16:55
@kwk

kwk commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Okay, let me make one thing clear, I like the feature. But I also like Big Buts (the song is cool as well but written differently).

Can we please get a policy for AI contributions? I simply do not buy that my last review was answered by a human. And I refuse to work this way.

I'm getting really angry about such comments. Not because they are incorrect or inaccurate but because I feel like someone is operating a machine instead of thinking for him or herself. This is disrespectful.

@psss please look at the responses and tell me this was written by a person.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants