feat: persist index credentials in the OS keyring (sysand auth login/logout/status/whoami)#453
feat: persist index credentials in the OS keyring (sysand auth login/logout/status/whoami)#453consideRatio wants to merge 27 commits into
Conversation
|
Doing some UX testing, dumping notes here. (Transcripts below refreshed to match the gutter-aligned output that landed after the original capture.)
Testing with staging.sysand.com$ sysand auth login https://staging.sysand.com
Logging in to index `https://staging.sysand.com/`
Enter token for `https://staging.sysand.com/`:
error: credential for `https://staging.sysand.com/` was rejected by the index API (`v1/whoami`) and accepted by no surface; nothing was stored
note: pass `-v`/`--verbose` to output additional logs
$ sysand auth login https://staging.sysand.com
Logging in to index `https://staging.sysand.com/`
Enter token for `https://staging.sysand.com/`:
Stored credential for `https://staging.sysand.com/` (validated (api))
Covers https://staging.sysand.com/**
$ sysand auth login https://staging.sysand.com
Logging in to index `https://staging.sysand.com/`
Enter token for `https://staging.sysand.com/`:
Replacing existing credential for `https://staging.sysand.com/`
Stored credential for `https://staging.sysand.com/` (validated (api))
Covers https://staging.sysand.com/**
$ sysand auth status
Stored https://staging.sysand.com/ validated (api)
patterns: https://staging.sysand.com/**
subject: user admin
token prefix: sysand_u_53a8fea5
expires: 2026-10-17 11:27:09 UTC (expires in 89 days)
$ sysand build
Building kpar `/home/erik/dev/sensmetry/sysand-test-repo/output/proj0-4.0.21.kpar`
updating file metadata (1/1)
Including readme from `/home/erik/dev/sensmetry/sysand-test-repo/README.md`
Including license from `/home/erik/dev/sensmetry/sysand-test-repo/LICENSES/MIT.txt`
$ sysand publish --index https://staging.sysand.com
warning: KPAR does not contain a changelog file CHANGELOG.md;
it is recommended to provide it to inform users of
the changes between versions
Publishing admin/proj0 v4.0.21 to https://staging.sysand.com/
Published new release successfully
$ sysand auth logout https://staging.sysand.com
Logging out from index `https://staging.sysand.com/`
Removed stored credential for `https://staging.sysand.com/`
$ sysand auth status
No credentials configured (no stored logins, no `SYSAND_CRED_*` variables).Testing with private index on GitHub$ sysand auth login "https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/"
Logging in to index `https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/`
Enter token for `https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/`:
Stored credential for `https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/` (validated (read))
Covers https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/**
$ sysand info --iri "pkg:sysand/example-publisher-1/example-project-1" --index "https://raw.githubusercontent.com/consideratio/priv-index-example-github/refs/heads/index/"
warning: Direct or transitive usages of SysML v2/KerML standard library packages are
ignored by default. If you want to process them, pass `--include-std` flag
Name: example-project-1
Publisher: Example Publisher 1
Version: 0.0.1
License: MIT
No usages.Testing with private index on GitLab$ sysand auth login "https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index"
Logging in to index `https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index`
Enter token for `https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index`:
Stored credential for `https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index` (validated (read))
Covers https://gitlab.com/api/v4/projects/84113019/repository/files/**
$ sysand auth status
Stored https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index validated (read)
patterns: https://gitlab.com/api/v4/projects/84113019/repository/files/**
$ sysand info --iri "pkg:sysand/example-publisher-1/example-project-1" --index "https://gitlab.com/api/v4/projects/84113019/repository/files/{path}/raw?ref=index"
warning: Direct or transitive usages of SysML v2/KerML standard library packages are
ignored by default. If you want to process them, pass `--include-std` flag
Name: example-project-1
Publisher: Example Publisher 1
Version: 0.0.1
License: MIT
No usages. |
b7b0928 to
b3a984c
Compare
a94ecaf to
99a0bd1
Compare
99a0bd1 to
bb9a413
Compare
18c55d5 to
3dcef4b
Compare
| - **P2 - API presence is read from discovery (consumed, not enforced | ||
| here).** This plan treats an index as having an API iff its discovery | ||
| document advertises `api_root`, and derives globs accordingly. It does | ||
| **not** change the runtime default (today a plain-URL index still defaults | ||
| `api_root` to its root). Enforcing "`api_root` required" at the protocol | ||
| level, and dropping that default, is a **separate, decoupled change** | ||
| (§12), out of this plan's phases. |
There was a problem hiding this comment.
Why require api_root to be explicitly advertised to treat it as such?
There was a problem hiding this comment.
Without doing so, you could end up not knowing if you failed to access an API or there was no API if the discovery/index was public but not the API, as error codes cant be trusted to distinguish that.
However, I dislike the state of things now with this PR, as we havnt forced sysand publish to require api_root to be explicitly declared.
Wdyt? Make sysand publish require it?
An index must have a file serving index root, but not an API, so it would be good to have the discovery doc (or missing of one) to indicate it - for example with an explicit api_root
There was a problem hiding this comment.
Not reflected at great length, but asked ai to make such requirement #478 preliminary
Squashed import of the reviewed plan from the design/credential-storage-plan branch so the implementation branch is self-contained; see sensmetry#437 for the discussion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Rename StandardHTTPAuthentication::try_into_publish_bearer_auth_map to publish_bearer_auth_map and take &self, cloning the bearer tokens into the returned GlobMap instead of consuming the policy to move them. The upcoming lazy keyring auth layer is not Clone, so publish can no longer use Arc::unwrap_or_clone to consume the policy; the secret clones are accepted as the cost of that layer (design/credential-storage.md, section 9). No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…ture Implement phase 1 storage from design/credential-storage.md (sections 9 and 14), the store only, nothing consuming it yet. Unconditional in sysand-core: - CredentialRecord / CredentialBlob types with a versioned JSON codec; unknown fields are tolerated on read and round-tripped on rewrite, and a parse failure or unknown version fails closed with a dedicated "credential store unreadable" error, never treated as empty - normalize_index_key: index-URL key normalization via the url crate (trailing slash, lowercased host, http(s) only) - the CredentialStore trait plus an in-memory implementation for tests Behind the new non-default keyring cargo feature: - OsKeyringBackend: one keyring v3 entry (service "sysand", account "credentials") holding the whole blob, with the error taxonomy split into backend-absent (callers may fall back to env) and backend-denied (callers must surface) - LockedBlobStore: cross-process advisory file locking via fd-lock with a bounded wait, a per-user lock path (XDG runtime/state dir or %LOCALAPPDATA%, home fallback), and 0600/0700 modes on unix - Windows CRED_MAX_CREDENTIAL_BLOB_SIZE gate measured in UTF-16 bytes, with keyring's TooLong mapped to the same friendly error - deleting the entry when the last record is removed, keeping the cheap no-entry fast path for logged-out users The blob handling is generic over a BlobBackend trait so the locking, fail-closed, and size-limit logic is tested headlessly without an OS keyring. The keyring crate uses its vendored dbus so Linux builds need no system libdbus headers; the feature stays off for the js/wasm binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Restructure publish bearer selection from one flat glob map into a source-tagged PublishBearerSources struct (env map plus keyring map), per design/credential-storage.md sections 7 and 8. Selection tries env first: a single env match wins, an ambiguous env match errors without consulting the keyring, and only when env has no match at all is the keyring source tried under the same single-match rule. No match in any source keeps the existing NoPublishBearer error, whose SYSAND_CRED_* hint stays truthful while `sysand auth login` does not exist yet. The keyring map is constructed but always empty for now, so behavior is unchanged today (env remains the only source); a later phase populates it from stored logins. The keyring-side constructor is crate-private on purpose so the eager two-map shape is not a public API commitment (the stored-login source must stay lazily read). AmbiguousPublishBearer now carries the ambiguous source and renders a per-source remediation hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Implement the deferred/cached auth policy from design/credential-storage.md (sections 9 and 14): stored logins are now consulted at request time, with the credential blob read at most once per process and only when a credential might actually be needed. New in sysand-core (behind the existing networking feature): - CredentialStoreAuthentication<Inner, S>, a combinator generic over the CredentialStore trait. The inner env policy (SYSAND_CRED_*) runs first; only a 4xx triggers the store read. Deliberately not a stock SequenceAuthentication: the lower arm there cannot see the higher arm's response, so a routine resolve-path 404 would be re-requested identically on every call. This combinator passes the initial response down and returns it untouched when no stored record matches the URL, so no-match costs no extra request. - A matching record sends a forced bearer retry (v1 stores bearer credentials only). Several matching patterns are a real ambiguity only between distinct tokens (one login stores several globs); genuine ambiguity warns and tries all matches in order, mirroring RestrictAuthentication. - The read is cached in a tokio OnceCell so concurrent first requests (the resolve path fans out) share one keychain touch, and it runs under spawn_blocking because the keyring crate is synchronous and a locked Secret Service can block on an unlock prompt. A synchronous accessor sharing the same cache serves publish, which resolves credentials outside the runtime. - Store errors degrade to no-credentials for the process: an absent backend is a quiet debug, a locked/denied backend warns once naming the unlock and SYSAND_CRED_* remediations. An invalid stored URL pattern skips only itself, not every login. Publish keyring source: resolve_publish_bearer now takes the eager env bearer map plus a lazy stored-credential provider, invoked only when no env bearer matches the upload URL, replacing the eager two-map PublishBearerSources from the source-precedence refactor. Env-match, env-ambiguity, and trusted-publishing paths never read the store. The sysand CLI enables core's keyring feature and composes the policy as CliAuthPolicy (env policy over KeyringCredentialStore::open_default, degrading with a warning if the store cannot be opened). Bindings are unchanged: the layer is generic over the store trait, core still compiles with networking on and keyring off, and the wasm/minimal configs stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
First slice of the sysand auth command family (design/credential-storage.md sections 4, 9, 14); login follows separately and slots into the same subcommand tree. Core (commands/auth.rs, gated filesystem+networking like publish): do_auth_logout and do_auth_status orchestration, generic over CredentialStore. Library calls never prompt and never print; env credentials are passed in as EnvCredentialEntry values, and the status view reports per-record key, globs, expires_at (with an expired flag computed in core), and which SYSAND_CRED_* entries may shadow the record. StoredCredentialStatus is the extension point for the subject/prefix fields the validation work will add. CLI: a `sysand auth` subcommand with `status` and `logout [index-url]`. Bare logout resolves the target through the default-index chain (--default-index / SYSAND_DEFAULT_INDEX, then a default = true configured index, else the built-in default index URL; more than one distinct default asks for an explicit URL) via a shared resolve_default_index helper that login will reuse, and echoes the resolved index on stdout so --quiet cannot hide it. Missing credential, non-HTTP(S) target, and unparseable URLs are clear non-zero errors. Status renders one unified view (stored entries with the exact logout key form, env entries as label + pattern, never a secret), degrades to an env-only view with a note when no keyring backend is usable, and surfaces a locked or denied keyring with the unlock and SYSAND_CRED_* hints. Auth commands dispatch before the eager env-policy build so `auth status` stays usable for diagnosing malformed SYSAND_CRED_* groups. Tests: core coverage over InMemoryCredentialStore (logout removal, normalization, error taxonomy, status assembly, shadow detection, clock-based expiry) and CLI tests for parsing, the default-index resolution branches, and status rendering, all constructed to never require or touch a real OS keyring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Store a bearer credential for an index (design/credential-storage.md sections 4, 8, 9, 14), without validation probes; those arrive with the upcoming --validation flag, and do_auth_login leaves an explicit slot for them between glob derivation and the store write. Core (do_auth_login, generic over CredentialStore, never prompts): - Discovery is fetched best-effort with the unauthenticated policy to resolve index_root/api_root for glob scoping; when it cannot be read (network failure, or 401 on a private index) the credential falls back to the URL-derived glob with a notice. - Escaped glob derivation: the primary glob anchors on the normalized user-supplied URL (globset::escape(root) + "**"); the resolved index_root and api_root add globs only when not already covered (Case B), keeping the set minimal. A templated index_root anchors on its literal prefix cut back to the last "/" boundary and reparsed as a URL, and is skipped with a notice when no anchor at least as deep as scheme://authority/ exists (a shallower anchor could match other hosts). Tests pin the section 8 coverage guarantee (discovery document, index.json, and upload URL each match) and IPv6-literal escaping. - Replacing an existing login is reported through a notice before the write; an absent keyring backend is an outcome carrying the derived globs so the CLI can print the exact SYSAND_CRED_* lines to set instead (secret always as a literal <token> placeholder). - URL-template targets are rejected up front for both login and logout (normalization would silently mangle the braces); SYSAND_CRED_* stays the authentication path for templated indexes. CLI: - sysand auth login [INDEX_URL] [--token-stdin], with the default-index chain of logout and the resolved index echoed before any secret entry. - The token comes from a hidden prompt (rpassword) or --token-stdin (trimming exactly one trailing newline), never an inline argument; a non-TTY stdin without --token-stdin fails fast, and an empty token is an error. Success wording says "stored, not validated". - New debug-only test seam (SYSAND_TEST_CREDENTIAL_STORE) swapping the OS keyring for a file-backed or simulated-absent blob store, so CLI integration tests never touch a real keyring; release builds refuse the variable rather than silently using the keyring. The auth commands and the lazy auth policy now share this store construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Implement design/credential-storage.md section 5 for `auth login`:
- `--validation true|false` (default true), mapped as `Option<bool>`
through core so bindings later get a clean optional keyword.
`--validation false` preserves the previous store-without-probing
behavior exactly.
- Probe mechanism, discovery-first: the read surface
(`index_root/index.json`) gets an unauthenticated baseline and a
forced-bearer retry only on a baseline 4xx; the API surface
(`api_root/v1/whoami`) is forced-only and probed only when discovery
explicitly advertised `api_root` (`ResolvedEndpoints` gains
`api_root_advertised`, never set by the plain-URL runtime default).
Probes use a dedicated no-redirect, timeout-bounded client; a
redirected probe counts as not tested with a warning naming the
target.
- Refusal rule: store when any exercised surface accepted (warning per
rejected or unreachable surface); refuse, without touching an
existing stored login, only when at least one exercised surface
rejected and none accepted; store as "stored, not validated" when
nothing exercised the credential. Output always scopes the claim
("validated (read)", "validated (api)", "validated (read, api)").
- A `WWW-Authenticate: Basic` challenge on a rejected read surface
routes the user to `SYSAND_CRED_<X>_BASIC_USER` / `_BASIC_PASS`.
- `CredentialRecord` persists whoami identity (`subject`,
`token_name`, `token_prefix`, `expires_at`; blob version stays 1,
pre-existing blobs still parse), and `auth status` shows subject,
token prefix, and "expires in N days" / "expired".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Task B8 of the credential-storage plan (design/credential-storage.md sections 5, 7, 9): - 429 is never a probe verdict: a rate-limited unauth baseline or forced retry during `sysand auth login` validation counts the surface as not tested (new ProbeRateLimited notice) instead of rejected, so throttling can no longer false-refuse a valid token. A 429 baseline also sends no forced retry. Recorded in the section 5 refusal rule. - Reactive expiry hint: when a stored credential's forced retry still ends in any 4xx and the record carries a past expires_at, the lazy layer warns once per record per process to re-run `sysand auth login`. - Source-named publish auth failures: an upload 401/403 now states whether the selected bearer came from `SYSAND_CRED_<LABEL>` (with the unset/rotate remediation, since env shadows stored logins) or from the stored login for an index key (re-login remediation); a 403 also points at `sysand auth status`. Provenance is threaded via SelectedPublishBearer and the new EnvBearerAuth/StoredBearerAuth map values. - Fail fast on expiry: publish stops before uploading when the selected stored bearer's known expires_at is clearly past (60 s skew margin); env bearers carry no expiry and always proceed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Follow the CLI's house convention for disabling a default behavior (--no-lock, --no-deps, ...): the login flag becomes the presence flag --no-validation instead of the value-taking --validation true|false, which would have been the only boolean flag in the CLI taking a value. The flag spelling and the library parameter are independent: core's do_auth_login keeps validation: Option<bool> (None = validate), the CLI maps --no-validation to Some(false), and future language bindings still get a clean optional keyword. The plan's section 5 rationale is updated accordingly, both the house-style flag and the bindings ergonomics are kept. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
A templated index location (for example the GitLab repository files API, https://gitlab.com/api/v4/projects/<id>/repository/files/{path}/raw?ref=<branch>) was rejected as a sysand auth target, leaving SYSAND_CRED_* environment variables as the only authentication path for such indexes. Accept it: - The storage key is the template text with its literal-prefix anchor normalized through url::Url serialization (lowercased scheme and host, default port stripped, IDN punycoded); the placeholder, suffix, and the prefix text past its last slash stay verbatim. The form is idempotent, so logout and status agree with login across spellings of the anchored part. - The primary credential glob anchors on the template's literal prefix, cut back to the last slash and clamped to at least scheme://authority/, reusing the discovery-advertised templated index_root rules (design section 8). A template with no safe anchor is rejected before the secret prompt (AuthCommandError::TemplateWithoutAnchor), since no credential scope could be derived for it. - Validation is unchanged: the read probe goes through the template- resolved index.json URL, and the API surface is probed only when a discovery document explicitly advertised api_root (never for a template without one). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Style sysand auth status through the existing house tokens (sysand_core::style) and anstream, which strips styling on non-terminal stdout and under NO_COLOR: source tags in the header style, keys and env labels as literals, (expired) in red, near expiry (7 days or less) and shadowed-by warnings in yellow, and the backend-unavailable note with a styled note: prefix. The layout and plain-text content are unchanged, so piped output and the existing CLI test assertions stay identical. Add sysand auth whoami [INDEX_URL], a query-only live identity check against api_root/v1/whoami. The target resolves like login/logout (explicit URL or the default-index chain) and is echoed; only an index whose discovery configuration explicitly advertises api_root can be asked, and the read surface is never probed. Credential selection mirrors the runtime and publish source precedence: a SYSAND_CRED_* bearer matching the whoami URL wins over a stored login, candidates carrying one token collapse to one credential, and the output names the source used, since the right remediation for a rejected credential depends on where it came from. One forced-bearer GET is sent with the no-redirect probe client: 200 renders the subject and token details and exits 0, 401 reports the rejection with a source-tailored hint, and redirects, rate limiting, other statuses, and network errors report the API as unreachable, all nonzero. The command never writes the credential store; cached identity fields are deliberately not refreshed. Unlike login, the discovery fetch runs with the regular read policy (built tolerantly, so malformed SYSAND_CRED_* groups cannot break a diagnostic command), because a private index may gate its discovery document. Core logic lives in do_auth_whoami, generic over CredentialStore and never printing; the CLI wrapper renders. Covered by core unit tests for the credential selection and seam-plus-mockito CLI tests for the 200, 401, env-precedence, no-credential, unadvertised-api, and non-HTTP paths. Also record sysand auth whoami in the design section 4 command table and the target-defaulting principle in section 7: irreversible remote effects require an explicit target (sysand publish --index stays required), while reversible local effects may default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Restyle every user-facing line of sysand auth login, logout, status, and whoami to the cargo-style convention used by the rest of the CLI: a header-styled leading word right-aligned in a 12-column gutter, with continuation sublines indented to the gutter. Status keeps its B10 hierarchy (Stored/Env tags with label: value sublines) while whoami puts each identity field's label in the gutter, a deliberate split recorded in the renderer's doc comment. Expiry timestamps drop chrono's sub-second noise via a shared formatting helper. Every line keeps its stdout/stderr channel; the login, logout, and whoami echoes stay println so --quiet cannot hide them, and the alignment spaces are part of plain piped output, which the CLI tests now assert with leading-space predicates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Status output suppression: a credential source with nothing to show is omitted instead of announced with a negative; when neither stored logins nor SYSAND_CRED_* variables exist, a single combined line prints. The no-usable-keyring note still prints whenever the backend is unusable. Stored entries are separated by a blank line when there is more than one, and the sublabels (patterns:, subject:, token prefix:, expires:) render dim through a new house style token; values stay plain, and shadowed by: stays warn-styled. Plain piped output is unchanged apart from these content changes. CredentialRecord gains an optional validated field: the surfaces that exercised and accepted the credential at login, written by do_auth_login from the probe outcome and serialized compactly as ["read","api"] strings (absent for --no-validation or nothing-exercised logins; plain strings so a surface name written by a newer sysand does not fail the whole blob closed). The blob version stays 1 and pre-existing blobs parse with the claim absent. auth status shows the claim on each entry's key line: validated (read) and so on dimmed, or a warn-styled not validated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Annotate the auth status entries (stored and env) that apply to the default index with a dim '(default index)' marker, answering which credential bare commands will actually use. Resolution reuses the login/logout default-index chain, including a new --default-index arg on status, but is lenient: an ambiguous chain prints a note and marks nothing, and an unusable default silently marks nothing; status never errors over the chain. A stored entry is marked on normalized-key equality with the default's key or when one of its globs matches the default index root URL (a template default anchors on its literal prefix); an env entry when its pattern matches that root. Patterns compile leniently, like shadow detection. The test harness now strips an ambient SYSAND_DEFAULT_INDEX so a developer's override cannot inject markers or notes into asserted output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
sysand auth login fetched discovery unauthenticated only, so a fully private index (S4: private read surface and API) answered 401 (or, in GitLab's style, 404) and login never learned api_root: no glob coverage for a disjoint API host, no whoami probe, and at most a validated (read) claim. Discovery for a login is now an unauthenticated baseline retried once with the in-hand secret as a forced bearer on any 4xx answer, mirroring the probe mechanism: a forced 200 with a valid document is used exactly like a public discovery success; a forced 404 is the authoritative "no document" answer and reconstructs the flat topology without a warning; everything else (other 4xx, 429, redirect, 5xx, network failure) falls back to the URL-derived glob with the existing notice, and the probes still deliver the credential verdict. The forced discovery fetch never counts toward the validated claim: the index.json probe stays the sole read verdict. The validation opt-out is removed entirely (no --no-validation flag, no validation parameter on do_auth_login): login has exactly one behavior. Offline degrades gracefully through the refusal rule (nothing exercised means "stored, not validated" with warnings, and an unreachable discovery baseline gets no forced retry, so no secret is transmitted), false refusals are engineered out (429 and redirects are never verdicts, acceptance by any exercised surface wins), and SYSAND_CRED_* remains the escape hatch. The store is read before any network so an absent keyring backend is detected before the secret could be spent on a credentialed request; that path keeps discovery strictly unauthenticated. In discovery.rs the fetch is restructured (fetch_index_config_strict surfaces an absent document as its 404 status; resolve_index_config interprets a document for the forced-retry path); public callers are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
…ands The flag existed as the clap carrier for SYSAND_DEFAULT_INDEX (global resolution options do not propagate to sibling subcommands), but as a user-facing option it only duplicated the positional index argument. resolve_default_index now reads the environment variable directly; the chain (env var, config default = true, built-in) is otherwise unchanged, including comma-delimited lists and the ambiguity error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
When publish finds no matching bearer for the upload URL, the error now suggests `sysand auth login <index-url>` alongside the `SYSAND_CRED_*` environment fallback. The message predated the `auth login` command, so it only mentioned the environment path; the docs already describe the login hint, so this aligns the client with the documented behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
The pre-upload expiry stop refused to even attempt an upload when a stored token was more than 60 seconds past its `expires_at`. Real client-clock skew is often minutes, not seconds, so a clock running a few minutes fast would false-trip the check and refuse a token the server would still accept. The stop is only an optimization (avoid uploading a large archive with a known-dead token); the server's 401 is the real authority. So the margin should err toward attempting: widen it to one hour, past which a clock is wrong enough that a request would likely fail regardless. Raised in the plan review on sensmetry#437. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Address questions from the plan review (sensmetry#437): state that the blob `version` gates the format and fails closed on an unrecognized version (the migration hook); spell out the conflict model (no record-level merge; the file lock serializes writers, last writer wins, and field round-tripping preserves a concurrent newer binary's additions); note the lock coordinates sysand processes with each other only, not against other applications; and describe the expiry skew margin as a generous hour that errs toward attempting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
On the platform blob cap (Windows ~2.5 KB), `sysand auth login` now points at how to fix it: run `sysand auth status` to see stored logins, `sysand auth logout <index>` to remove one, and it lists any already-expired logins as the obvious drop candidates. A first login whose single token overflows says to use a smaller token rather than suggesting a nonexistent login to remove. The message is built in the CLI, not core: the core store reports only the condition (it must not name CLI commands). Raised in the plan review on sensmetry#437. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Core (`sysand-core`) error messages described conditions but also named `sysand auth login` / `sysand auth status`, coupling the library to the CLI's command vocabulary (wrong for the bindings and any other frontend). The library now states the condition (and the `SYSAND_CRED_*` fallback, which is core's own env mechanism, not CLI vocabulary); the CLI adds the `sysand auth` remediation from the error's fields, so user-facing output is unchanged. Relocated to the CLI: - `NoWhoamiCredential` (whoami handler). - `NoPublishBearer`, `StoredCredentialExpired`, and the provenance-aware `PublishAuthFailed` (publish handler via `publish_error_with_hint`): a stored credential is refreshed by re-login, an env credential is not, and a 403 points at `sysand auth status` for the subject. Neutral-reworded in core, where relocation is impractical: the reactive stored-bearer expiry warning is a `log::warn!` deep in the request path the CLI never sees, so it now says "re-authenticate to store a fresh credential" instead of naming a command. Raised in the plan review on sensmetry#437. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Core states conditions (and may reference `SYSAND_CRED_*`, its own env mechanism) but does not name `sysand <command>` subcommands; the frontend adds command hints. Documents the rule the auth error-message refactor established so it does not silently drift back. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Safe fixes from a multi-lens review of the branch:
- security: give `ForceBearerAuth` a hand-written redacting `Debug`. It is
the single secret-bearing leaf, so redacting it stops any accidental
`{:?}` on a composed policy from leaking `SYSAND_CRED_*` bearer tokens
(latent: no such formatting exists today).
- api hygiene: narrow the keyring service/account constants and the
Windows blob-size helpers to `pub(crate)`, and gate the size helpers to
`#[cfg(any(windows, test))]` since only the Windows path and the
size-logic tests use them.
- simplify: extract the duplicated `v1/whoami` body parse into one
`parse_whoami_identity` helper; collapse the single-match and ambiguous
stored-bearer retry arms (the single case is the same path with an empty
remainder loop), preserving the distinct log line.
- ux: do not render "expires in 0 days" for a token with under a day left
("expires in less than a day"); align the no-keyring status note with the
login/logout phrasing.
- docs/comments: sync the design doc's reactive-expiry quote with the
command-name-free wording the code now emits; correct the
`stored_bearer_map_blocking` comment to state the real invariant and the
latent multi-thread hazard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN
Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Decisions taken on the review's judgment calls: - terminology: standardize the user-facing noun on "credential" (a UX-research pass found comparable CLIs, gh/docker/gcloud/npm/aws, all call the stored thing a credential and use "login" only as a verb). Sweep "stored login(s)" -> "stored credential(s)" in output, errors, and the design doc; the `Stored`/`Env` tags disambiguate from `SYSAND_CRED_*`. Verbs and command names (`sysand auth login`) are untouched. - `auth login` output: emit the "Covers" result line via `log::info!` like "Stored", so `--quiet` suppresses the whole confirmation as a unit (matching `publish`'s "Published") instead of leaving an orphan "Covers" on stdout. - security: gate the debug-only test-seam backends (the plaintext file / absent variants) behind `#[cfg(debug_assertions)]` so the cleartext path is physically absent from release binaries, not merely refused at runtime; the loud refusal stays for a release binary that sees the var. - api hygiene: make the test-only `InMemoryCredentialStore` `#[cfg(test)]` so the double never ships in the public API (kept in place, shared by several core test modules). - ux: a whoami failure that got a server response (403, redirect, 429) no longer reads "could not reach the index API"; it now says "could not get an identity from the index API", which fits both transport failures and server-answered non-identities. - testing: the Windows blob-size cap enforcement (`store_blob` -> `upsert`) is Windows-only in production, so give the store an injectable size limit and add a test that drives the `BlobTooLarge` wiring on any platform. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
- Align the glob-list label: `auth status` now says "covers:" like `auth login`'s "Covers", instead of "patterns:". - The non-HTTP index error now names a next step: "(use an https:// index URL)". - Finish the "credential" terminology sweep in internal doc-comments (the earlier sweep covered user-facing output and the design doc). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
Making `InMemoryCredentialStore` `#[cfg(test)]` removed the only non-keyring caller of `upsert_record` / `remove_record`, so the `sysand-core --no-default-features --features std` clippy lane (no keyring, no test) saw them as dead code and failed under `--deny warnings`. Gate both to `#[cfg(any(feature = "keyring", test))]`, the configs where the keyring store or the in-memory test store actually call them. Local prek runs one clippy config; CI runs the full feature matrix, which is what caught this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN Signed-off-by: Erik Sundell <erik.sundell+2025@sensmetry.com>
3f4805e to
745df25
Compare
Non-AI writeup - I just wanted to see what came out of following the planned feature, and enable live testing a UX with it.
It was mostly fable 5 doing the work, fallback to opus 8 some times i figure.
Implements credential storage and the
sysand authcommand family, v1 of the design settled in #437 (the reviewed plan lives in this comment and is imported asdesign/credential-storage.mdin this branch's first commit).A note on how this PR came to be: it is a one-shot, agent-driven implementation of that plan. The thinking work happened in the plan itself (several adversarial review passes, documented in the issue); the code was then produced in one pass of small reviewed increments, each verified on the integrated branch. If the direction is wrong, discarding this costs tokens, not accumulated human design work, so please review the plan first and treat the code as its consequence. Draft until a maintainer wants to take it forward.
The server-side counterpart (the
v1/whoamiendpoint) is already deployed on sysand.com (a validating login against it probesv1/whoami).What this delivers
Store an index credential once with
sysand auth login, andsysandreuses it across runs on Windows, macOS, and Linux, with the secret held by the OS keyring, never a plaintext file. Bearer-only v1;SYSAND_CRED_*env vars keep working unchanged and take precedence (CI overrides an interactive login).Command surface: auth login / logout / status
sysand auth login [INDEX_URL] [--token-stdin]SYSAND_DEFAULT_INDEX, configdefault = true, elsehttps://sysand.com); more than one configured default errors and asks for an explicit URL. The resolved index is always echoed before any secret entry. There is deliberately no per-subcommand--default-indexflag: it would only duplicate the positional argument.Enter token for ...:, rpassword) or--token-stdin(trims exactly one trailing newline); never an inline argument. No TTY and no--token-stdinfails fast instead of hanging. Empty token errors.SYSAND_CRED_*lines with the secret as a literal<token>placeholder (never echoed; asserted by test).sysand auth logout [INDEX_URL]: removes a stored login (same default-index resolution, echo, and clear missing-entry error).sysand auth status: one unified view of everything sysand will authenticate with. Stored entries (key in the exactauth logout <key>form, covered globs, subject and token prefix when a validating login ran,expires in N days/ expired,SYSAND_CRED_*shadowing) and env entries, tagged by source; each stored entry shows its persisted validation claim (validated (read/api)or a warn-stylednot validated); absence negatives print only when nothing is configured at all; entries covering the resolved default index carry a dim(default index)marker. Never shows secrets. Output is styled with the CLI's existing anstyle tokens and aligned to the same 12-column gutter as the rest of the CLI (plain when piped or underNO_COLOR).sysand auth whoami [INDEX_URL]: query-only live identity check againstv1/whoami, using the same credential selection as the runtime (env over stored, and the output names which source was used). Exit 0 only on an accepted credential; rejected/unreachable/rate-limited get distinct nonzero messages. Never writes the store. Errors clearly when the index advertises no API.https://gitlab.com/api/v4/projects/<id>/repository/files/{path}/raw?ref=index) are accepted as login/logout/whoami targets: the credential is scoped to the template's literal prefix, and template keys are normalized so login/logout/status round-trip.Storage: single keyring blob, fail-closed, locked
sysand, accountcredentials) holding a versioned JSON blob of records{key, globs, scheme, secret, expires_at?, subject?, token_name?, token_prefix?}.serde(flatten)), a parse failure surfaces "credential store unreadable" instead of silently clobbering stored credentials. A pre-existing older blob is proven to still parse.fd-lock) at a per-user path, bounded wait; never an existence-based lock file.SYSAND_CRED_*fallback named).CredentialStoretrait are unconditional core; the OS-keyring backend sits behind a new non-defaultkeyringfeature (enabled by the CLI; the js/wasm binding stays green, verified by wasmcargo check). dbus is vendored so clippy--all-featuresworks on runners without libdbus headers.Consumption: lazy, no extra requests, env precedence
CredentialStoreAuthenticationcombinator wraps the existing env policy: runs it first, returns non-4xx untouched, and only on an auth-relevant 4xx reads the credential blob (once per process, cached viaOnceCell;spawn_blockingfor the sync keyring call) and issues a forced bearer retry when a stored glob matches.SYSAND_CRED_*> stored login > unauthenticated, end to end.expires_atis past, a hint suggests re-runningsysand auth login(any 4xx, since some hosts answer 404 on bad auth); at most once per record per process.Login validation (always on)
sysand-index-config.jsononce, both for glob scoping and to learnapi_root. The API surface is probed only when discovery explicitly advertisedapi_root(a newapi_root_advertisedflag distinguishes it from the plain-URL runtime default), so static indexes are never phantom-probed.v1/whoamiis forced-only. Probes never follow redirects (a redirected probe counts as not tested, naming the target). A 429 is never a verdict (rate-limited probes count as not tested; this was a spec bug found during implementation and fixed in both code and plan).validated (read),validated (api),validated (read, api), orstored, not validated.WWW-Authenticate: Basicsays the index uses username/password auth and routes toSYSAND_CRED_<X>_BASIC_USER/_BASIC_PASS.subject, token name/prefix, andexpires_atinto the record forauth status.SYSAND_CRED_*remains the escape hatch for pathological middleboxes.api_rootlearned, disjoint roots covered, and can validate asvalidated (read, api); a forced 404 is treated as an authoritative "no document".Glob scoping
globset::escape(<normalized root>)+**), so URLs containing glob metacharacters, including IPv6 literals likehttps://[::1]:8000/, cannot silently fail to match themselves (tested).index_root/api_rootonly when not already prefix-covered; disjoint hosts get their own escaped glob. Tests assert the discovery document URL,index.jsonURL, and upload URL each match the derived set.index_roots and typed login targets, are anchored at their literal prefix (clamped to at leastscheme://authority/; a template with no safe anchor is rejected with a pointer toSYSAND_CRED_*).Publish integration
SYSAND_CRED_<LABEL>" vs "from your stored login for<key>") with matching remediation; a 403 additionally points atsysand auth status(catches a project token scoped to a different project).expires_at(60 s skew margin) stops publish before the archive upload; the server's 401 remains the authority.Protocol spec and server counterpart
design/index-api-protocol.mdgains a Token Identity section specifyingGET v1/whoamiunder the resolvedapi_root: bearer auth, 200 body (subject.typeuser/project/oidc with per-typesubject.namesemantics,token.namenull for exchanged OIDC tokens, non-secrettoken.prefix,expires_atwith Z suffix), 401 with unspecified body, MUST NOT consume single-use credentials, MAY rate limit.api/v1/whoami, reusing the existing bearer resolution across all three token types). It is already deployed on sysand.com, satisfying the ordering requirement that whoami be live before this client (a validating login against an official index without it would refuse valid tokens on a 404).Testing and verification
keyringfeature enabled) and 164 sysand CLI tests, all green on the final branch;cargo check -p sysand-core -F networking(keyring off) and wasmcargo checkgreen;prek run -aclean; every commit DCO-signed.SYSAND_TEST_CREDENTIAL_STOREseam selects a file-backed or absent backend (hard-errors in release builds), enabling end-to-end login/status/logout tests including a PTY-driven hidden-prompt test and a no-secret-leak assertion on the no-keyring path.Deferred and follow-ups
Deferred by design (plan section 10):
auth set/auth unset, basic auth via--username,--pattern, longest-prefix glob tie-breaking, and P2 protocol enforcement (a separate breaking change). Follow-ups noted during implementation: exposingdo_auth_*through the py/java bindings, templated login targets, a CI test lane with-F keyring, and cargo-deny verification of the new deps (keyring, fd-lock, believed MIT/Apache-compatible).docs.sysand.com documentation. The user-facing docs for this feature are written and reviewed (a multi-lens review pass, fixes applied) on the
docs/sysand-authbranch in the sysand-index repo, and are live on the staging docs preview. That branch is 16 pages: the client authentication explanation / how-to / reference, theauth login/logout/status/whoamicommand pages, publish and configuration cross-links, and the index-side API-token pages. It is deliberately gated on this PR shipping: docs.sysand.com deploys frommain, and the docs policy forbids documenting functionality that has not been released, so the branch merges to main (and goes live) only once the release carrying this change is out.🤖 Generated with Claude Code
https://claude.ai/code/session_01EfyrkJkeo3mRngVoppJjUN