Skip to content

feat(config): add optional system keyring credential storage#135

Merged
aarondpn merged 8 commits into
mainfrom
feat/keyring-credential-store
Jul 19, 2026
Merged

feat(config): add optional system keyring credential storage#135
aarondpn merged 8 commits into
mainfrom
feat/keyring-credential-store

Conversation

@aarondpn

Copy link
Copy Markdown
Owner

Summary

Adds optional, opt-in OS keyring storage for user credentials via zalando/go-keyring, so API keys and passwords no longer have to live in plaintext in ~/.redmine-cli.yaml. The plaintext file remains the default; nothing changes for existing users unless they opt in.

How it works

  • A profile opts in with a new credential_store: keyring marker. Its secret is written to the OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Manager) and stripped from the YAML file on save.
  • New internal/secrets package provides a small Store interface over go-keyring, so the backend is swappable and testable (MockInit).
  • auth login gains a --keyring flag plus a TTY-gated "Store credentials in system keyring?" prompt (shown only when a usable backend is detected). auth logout removes the profile's keyring entries.

Backward compatibility & CI safety (the design constraints)

  • Resolution order: CLI flag -> environment variable -> keyring -> plaintext file field. Env vars and flags are resolved before the keyring is ever consulted, so CI setting REDMINE_API_KEY never touches the keyring.
  • Graceful degradation: any keyring failure (headless, no D-Bus, locked keychain) is logged and never aborts or hangs the CLI.
  • Escape hatch: REDMINE_NO_KEYRING=1 hard-disables all keyring reads/writes.
  • Existing plaintext profiles load and save exactly as before and never consult the keyring (enforced by a test that fails on any unexpected keyring access).

Scope

Applies to api_key and password only. mcp.auth_token is intentionally left in file/env (different lifecycle).

Tests

New coverage in internal/secrets and internal/config: keyring round-trip, ErrNotFound handling, env-var-wins-over-keyring, env-wins-over-failing-keyring (core CI path), legacy-never-consults-keyring, no-plaintext-written-when-opted-in, headless-returns-clear-error, DeleteProfile cleanup, and --api-key flag bypassing the keyring. Full go build / go vet / go test ./... / gofmt pass. Docs (English + zh-cn) validated with astro build.

Notes for reviewers

  • The keyring lookup key is service="redmine-cli", user="<profile>:<field>". These strings are an implicit upgrade-compatibility contract: they must stay stable so credentials survive binary upgrades.

aarondpn added 8 commits July 19, 2026 11:41
Add opt-in OS keyring storage for API keys and passwords via
zalando/go-keyring, keeping the plaintext config file the default.

A profile opts in with credential_store: keyring; the secret is then
written to the OS keyring (macOS Keychain, Linux Secret Service, Windows
Credential Manager) and stripped from the YAML file. Credentials resolve
in order CLI flag, environment variable, keyring, plaintext file, so env
vars and flags always win and are resolved before the keyring is ever
consulted. Any keyring failure degrades gracefully and never aborts, and
REDMINE_NO_KEYRING=1 hard-disables all keyring access, keeping CI and
headless usage working unchanged.

Existing plaintext profiles are unaffected and never consult the keyring.

auth login gains a --keyring flag and a TTY-gated prompt; auth logout
removes keyring entries. Docs updated in English and zh-cn.
Address three keyring credential-store issues:

- Key keyring entries by an opaque per-profile keyring_id instead of the
  profile name. Two --config files with a same-named profile no longer
  collide and clobber each other's credential, and the id is stable
  across profile renames. The id is generated on first keyring save,
  inherited on re-login, and persisted in the config file.

- auth logout no longer reports success when the keyring rejects
  deletion. DeleteProfile removes the stored secret before touching the
  config and returns an actionable error on a real backend failure, so
  the profile stays intact and logout is retryable.

- Switching a profile from keyring back to the plaintext file now
  removes its stored secret instead of orphaning it in the keyring.
…secrets

- Delete a profile's keyring secret only after the config change is
  durably committed. Previously DeleteProfile and the keyring-to-plaintext
  transition removed the stored secret first, so a failing os.Remove or
  SaveProfiles left a profile pointing at a destroyed credential. Config
  now commits first; a keyring cleanup failure is surfaced but never
  destroys a still-referenced secret.

- Run the missing-keyring-secret check regardless of the no-active-profile
  path. CLI overrides are already applied at that point, so a valid
  --api-key still passes, but supplying only --server against an
  unreachable keyring now returns a clear error instead of a misleading
  downstream 401.
- Commit config file before writing secrets to the keyring so a backend
  failure never orphans a secret under an unrecorded keyring id; the
  retry reuses the persisted id.
- Keep existing keyring profiles on the keyring during re-login: inherit
  the choice without a TTY and fail clearly when the backend is unusable
  instead of silently downgrading to plaintext.
- Remove the stale counterpart secret when a login switches auth methods.
- Stop suggesting REDMINE_NO_KEYRING=1 as a standalone fix in the
  missing-secret error message.
- Roll the config file back to its previous contents when persisting
  secrets to the keyring fails, so a failed plaintext-to-keyring
  conversion keeps the working file credential instead of leaving a
  profile with no credential anywhere.
- Delete keyring secrets before committing profile deletion or a
  plaintext switch. Since Delete maps not-found to nil this order is
  retryable in both failure modes and can no longer drop the only
  reference to the opaque keyring id, permanently orphaning the secret.
…on skipped cleanup

Switching keyring to plaintext snapshots the old secret and restores it
if the config write fails, so no state is left without a working,
retryable credential. When REDMINE_NO_KEYRING skips cleanup on logout or
plaintext switch, a warning tells the user where the credential remains
instead of silently orphaning it.
@aarondpn
aarondpn merged commit 3c1bb2e into main Jul 19, 2026
6 checks passed
@aarondpn
aarondpn deleted the feat/keyring-credential-store branch July 19, 2026 11:09
@Primexz

Primexz commented Jul 19, 2026

Copy link
Copy Markdown

o_O

aarondpn added a commit that referenced this pull request Jul 20, 2026
…only) (#137)

## Summary

Adds an opt-in **read-only mode** to the CLI, complementing the
read-only gating that already exists in the MCP layer. When enabled, any
mutating HTTP request is refused before it is sent, so the tool can be
pointed at a production Redmine for safe browsing/scripting.

## How it works

- Enforced at the **HTTP transport** (`readOnlyTransport` RoundTripper):
only `GET`/`HEAD`/`OPTIONS` pass; anything else returns `ErrReadOnly`
without hitting the network. A single choke point means new commands are
covered automatically.
- Three ways to enable, precedence **flag > env > config**:
  - `--read-only` global flag
  - `REDMINE_READ_ONLY` environment variable
  - per-profile `read_only:` in the config file
- **Backward compatible**: default off; no behavior change for existing
users.

## Tests

- `internal/api/read_only_test.go` — transport refuses mutating methods,
allows reads.
- `internal/cmdutil/factory_test.go` — flag/env/config precedence.
- `internal/config/config_test.go` — `REDMINE_READ_ONLY` override.

`go build`, `go vet`, `gofmt`, and full `go test ./...` all pass on top
of current `main` (including #134/#135).

## Notes

The docs site (`docs/`) is not updated in this PR — happy to add a page
if you'd like it.

---------

Co-authored-by: Aaron Döppner <58708656+aarondpn@users.noreply.github.com>
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.

2 participants