Skip to content

docs(security): OIDC trusted publishing (v5.3.0), ops-table anchor fixes, badge guidance - #637

Merged
dawsontoth merged 5 commits into
mainfrom
claude/oidc-trusted-publishing-docs
Aug 25, 2026
Merged

docs(security): OIDC trusted publishing (v5.3.0), ops-table anchor fixes, badge guidance#637
dawsontoth merged 5 commits into
mainfrom
claude/oidc-trusted-publishing-docs

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Extracts from #599 the content whose upstream is settled, now that its gate has gone away rather than landed.

harper#1849 is closed. So are #2135 and #2301, which were stacked on it — #2135's base was claude/deploy-component-two-phase-94969a, not main. None of revert_component, two_phase, activateStagedApplication, deployComponentTwoPhase or stagingRetention exists anywhere in main, and harper is at v5.2.5. The two-phase deploy docs are therefore waiting on work that was abandoned, not delayed, and there is no reason for the rest of #599 to wait with them.

What this carries

OIDC trusted publishingharper#2173, merged 2026-08-21, in no tag yet. Unlike the two-phase work this is merged, so the API is fixed and only the release is outstanding; badged v5.3.0, confirmed by the 5-3-0.ts upgrade directive on main.

Ops-table anchors — a pre-existing docs bug: six user/role rows and seven SSH rows pointed at #certificate-management, which documents neither.

AGENTS.md badge guidance — derive the version from core's git tags, not a feature branch's package.json.

For the human reviewer

  1. Should merged-but-unreleased OIDC publish now? reference/ is the live site, so merging this documents a feature nobody can use until 5.3.0 ships. I judged it worth it because the API is settled — the risk that made docs(deploy): two-phase deploy, revert, by-reference deploys, and OIDC trusted publishing #599 dangerous was #1849 changing under the docs, and that risk is absent here. The alternative is holding this until 5.3.0 cuts. This is the call I'd most like ruled on.
  2. The operations scope is documented with a gap that is someone else's bug. It narrows the minted token on the Operations API and SQL paths but not on an application's REST/GraphQL resources, so it bounds what a CI credential can administer, not what data it can reach. harper#2201 tracks that. Documenting it as-is means the docs describe a partial control; softening it would misrepresent what the flag does.
  3. The ops-table anchor fix rides along. It has no upstream dependency and shouldn't have been behind a feature branch at all — but it does mean this PR spans two unrelated subjects. Splitting again is cheap if you'd rather.
  4. drop_oidc_trust does not revoke issued tokens, and the docs say so with the remediation (deactivate or re-role the user). That is a real operational sharp edge rather than a doc nicety; worth confirming the guidance matches what you'd want an operator doing during an incident.

Deliberately left on #599

The deployment status groupings look like generic tracking corrections — list_deployments and get_deployment are shipped in main — but they enumerate staged, staging, activating, reverting, extracting and loading, while main's code defines only pending, installing, replicating, restarting, success, failed and rolled_back. Extracting them would publish statuses that do not exist. Same for the payload/staging retention text, which mixes a shipped bound with an unshipped one.

Verification

Docs-only; per AGENTS.md the build is the end-to-end check. npm run format:check clean, npm run build succeeds with the two anchors that pre-exist on main and no others.

Claims verified against harper feat/oidc-trusted-publishing at its merge state, not the PR description — which had drifted, describing an issuer|jti replay key and an unimplemented audit-log path, both since changed:

Claim Source
Policy shape, super_user enforcement, replace-not-merge, user existence check security/authn/oidc/trustPolicyOperations.ts
Three structural requirements; job_workflow_ref excluded from the ref gate; pull_request_target veto providers/githubActions.ts
Generic issuers must pin sub providers/generic.ts
One-hour token, single-use via SHA-256 fingerprint, one rejection message, audit stream tokenExchange.ts
operations scope enforced at verifyPerms/verifyPermsAST, not the resource path security/authn/oidc/types.ts
Exchange ranks below configured credentials bin/workloadIdentity.ts, bin/cliOperations.ts
Ops-table anchors resolve npm run build

Description drafted by Claude Code (Opus 5).

Extracts from #599 everything whose upstream is settled, now that harper#1849
has been **closed** — with #2135 and #2301, which were stacked on it — leaving no
two-phase surface in main at all.

**OIDC trusted publishing** (harper#2173, merged 2026-08-21, awaiting v5.3.0).
Unlike the two-phase work this is merged, so the API is fixed and only the release
is pending:

- `add_oidc_trust` / `list_oidc_trust` / `drop_oidc_trust` (super_user) and the
  unauthenticated `exchange_oidc_token`, with the replace-not-merge semantics.
- The GitHub Actions policy-specificity rules as a table — pin the repository, pin
  the workflow, gate the ref — with what each leaves open unsatisfied, since the
  ref gate is stricter than npm's model and is the rule most likely to refuse a
  real workflow.
- The `operations` scope, including that it covers the Operations API and SQL but
  **not** an application's REST/GraphQL path, so it bounds what CI can administer
  rather than what data it reaches (harper#2201 tracks that gap).
- Single-use tokens with an honest account of the cross-node replay race; one
  rejection message for every failure with the reason in the `oidc-trust` log;
  `drop_oidc_trust` not revoking already-issued tokens.
- The CLI exchange as precedence entry 7 — ranked below every configured
  credential, so adding `id-token: write` to a workflow that still sets a token
  secret does not silently change identity.

**Ops-table anchors.** Six user/role rows and seven SSH rows pointed at
`#certificate-management`, which documents neither. Repointed to `#users--roles`
and `#components`; the five genuine certificate rows are untouched. A pre-existing
docs bug with no upstream dependency, which is why it should not have been sitting
behind a feature branch.

**`AGENTS.md`** gains the rule that produced most of this stack's version churn:
derive a badge from core's git tags, not from a feature branch's `package.json`,
and re-check every refresh pass.

Deliberately left on #599: the deployment status groupings. They read as generic
tracking corrections, but they enumerate `staged`, `staging`, `activating`,
`reverting`, `extracting` and `loading` — and main's code defines only `pending`,
`installing`, `replicating`, `restarting`, `success`, `failed` and `rolled_back`.
Extracting them would document statuses that do not exist.
dawsontoth added a commit that referenced this pull request Aug 25, 2026
harper#1849 is closed, and so are #2135 and #2301 which were stacked on it —
#2135's base was `claude/deploy-component-two-phase-94969a`, not `main`. None of
`revert_component`, `two_phase`, `activateStagedApplication`,
`deployComponentTwoPhase` or `stagingRetention` exists in `main`.

So this PR's subject is not merely unmerged, it is abandoned in that form. There
was no reason for the content whose upstream *is* settled to wait with it.
Extracted to #637:

- **OIDC trusted publishing** — harper#2173 is merged (2026-08-21), awaiting
  v5.3.0. The API is fixed, which is the difference that matters: the risk here
  was never "unreleased", it was "changing underneath the docs".
- **The ops-table anchor fix** — 13 rows pointing at `#certificate-management`,
  which documents neither user/role nor SSH operations. A pre-existing docs bug
  with no upstream dependency, which should never have been behind a feature
  branch.
- **The `AGENTS.md` badge guidance.**

What stays is two-phase deploy, `revert_component`, deploy modes, activation
failures, the deployment status groupings, retention, and the 5.3 Deployment
release notes.

Kept here deliberately, despite looking extractable: the status groupings and
retention text. `list_deployments` and `get_deployment` are shipped, so the
corrections read as generic — but they enumerate `staged`, `staging`,
`activating`, `reverting`, `extracting` and `loading`, while `main` defines only
`pending`, `installing`, `replicating`, `restarting`, `success`, `failed` and
`rolled_back`. Extracting them would publish statuses that do not exist. Same for
retention, which mixes the shipped payload bound with the unshipped staging one.
@github-actions
github-actions Bot temporarily deployed to pr-637 August 25, 2026 16:32 Inactive
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Your preview deployment is ready!

🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-637

This preview will update automatically when you push new commits.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces comprehensive documentation for OIDC Trusted Publishing (Workload Identity) in version 5.3.0, detailing how CI runners can authenticate without stored credentials using trust policies. It updates CLI authentication precedence, adds API reference documentation for new OIDC operations (such as add_oidc_trust and exchange_oidc_token), categorizes CLI commands, and adds release notes. The review feedback focuses on minor readability and grammatical enhancements, such as adding commas and splitting a run-on sentence to highlight a security warning regarding loopback targets.

Comment thread reference/cli/authentication.md Outdated
Comment thread reference/operations-api/operations.md Outdated
Comment thread reference/operations-api/operations.md
Comment thread release-notes/v5-lincoln/5.3.md Outdated
Four from gemini on #637, all correct.

Three are commas after introductory phrases ("In a reusable workflow,", "if
minting then fails,", "In the CLI,").

The fourth is worth more than a comma: the sentence about a rejected OIDC
exchange ran two consequences together with a semicolon, so the loopback case —
where a credential-less request is authorized as superuser and the operation
succeeds with no identity check at all — was buried mid-sentence behind the
payload-credential case. Now its own sentence.
Comment thread reference/cli/authentication.md
Comment thread reference/cli/authentication.md Outdated
Comment thread reference/cli/authentication.md Outdated
Comment thread reference/operations-api/operations.md Outdated
Comment thread reference/operations-api/operations.md
Comment thread release-notes/v5-lincoln/5.3.md Outdated
Comment thread reference/operations-api/operations.md
@github-actions
github-actions Bot temporarily deployed to pr-637 August 25, 2026 16:37 Inactive
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Your preview deployment is ready!

🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-637

This preview will update automatically when you push new commits.

…kthrough

Three review comments from @dawsontoth.

**The YAML block is now labelled GitHub Actions.** `permissions: id-token: write`
is Actions-specific syntax, not a portable convention, and the page presented it
as though it were generic. It now says so, notes that other CI systems express
the same idea differently, and that Harper detects Actions only — with an
unrecognized runtime falling through rather than erroring.

**Cut the combination-case analysis.** Two paragraphs explained what happens when
OIDC is configured *alongside* a refresh token or payload credentials, including
which one wins and the one case where the exchange takes over. Accurate, but the
wrong thing to hand a first-time reader: it teaches the mixed configuration
instead of the good one. Replaced with what someone setting this up needs — with
a trust policy you need nothing else, remove a token if you have one, and a
pointer to Authentication Precedence for anyone genuinely in a mixed setup.

The rejected-exchange paragraph got the same treatment: it keeps the point that
matters (a rejection does not reliably halt the command) and drops the
payload-credential branching that only applies off the happy path.

**Added a CLI walkthrough for `add_oidc_trust`** — `harper login` once, then run
the operation against the cluster, then `list_oidc_trust` to confirm. Includes the
detail that makes it work in a shell: `claims` must be quoted as one argument
because the CLI parses each value as JSON, which is what produces the nested
object the operation wants. Verified against `buildRequest` in
`bin/cliOperations.ts`, where every value except `ref` is JSON-parsed. Also gives
the explicit `target=` + `auth_username=` form for anyone who would rather not
store a login token.
@dawsontoth
dawsontoth marked this pull request as ready for review August 25, 2026 16:41
@dawsontoth
dawsontoth requested a review from a team as a code owner August 25, 2026 16:41
@github-actions
github-actions Bot temporarily deployed to pr-637 August 25, 2026 16:42 Inactive
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Your preview deployment is ready!

🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-637

This preview will update automatically when you push new commits.

Comment thread reference/operations-api/operations.md
Comment thread reference/cli/authentication.md
Comment thread reference/cli/authentication.md
Comment thread release-notes/v5-lincoln/5.3.md
Comment thread reference/operations-api/operations.md Outdated
Per @cb1kenobi: "every rejection returns the same message" was an overclaim.
`exchangeOidcToken` validates the request shape with Joi and throws
ClientError(validation.message) before entering the try block that produces the
uniform rejectToken 401s, so a missing or oversized `token` field fails with its
own message.

Now scoped to a well-formed token, with a note that the schema rejection reveals
nothing about a policy — which is the property the uniformity exists to protect.
@github-actions
github-actions Bot temporarily deployed to pr-637 August 25, 2026 17:30 Inactive
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Your preview deployment is ready!

🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-637

This preview will update automatically when you push new commits.

The guidance this PR adds is what produced a false Critical on this PR: a review
reported git tag --contains returning v5.2.5 for the OIDC merge commit, when four
checks against a tag object verified identical to origin say it is not contained
there at all.

So the check needs the step that distinguishes those cases. Fetch tags, then
confirm git rev-parse <tag> matches git ls-remote for the same name — --contains
is only as good as the local tag it compares against, and a divergent tag of the
same name is confidently wrong in either direction.

Also records the better decisive check for a disputed badge: whether the
feature files exist at the tag (git ls-tree / git show <tag>:<file>). That cannot
be confounded by ancestry or by which tag a clone holds, and it is what settled
this one.
@github-actions
github-actions Bot temporarily deployed to pr-637 August 25, 2026 17:34 Inactive
@github-actions

Copy link
Copy Markdown

🚀 Preview Deployment

Your preview deployment is ready!

🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-637

This preview will update automatically when you push new commits.

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great stuff!
🤖 Reviewed with Codex

"audience": "https://my-instance.harperdb.io:9925/",
"user": "ci-deploy",
"operations": ["deploy_component", "get_deployment", "restart_service"],
"claims": { "repository_id": "67890", "environment": "production" }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This example cannot pass GitHub Actions policy validation: it pins the repository and gates the ref through environment, but it does not pin a workflow. Please add one of workflow_ref, workflow_path, job_workflow_ref, or job_workflow_path to claims so the copyable add_oidc_trust example succeeds.

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.

Correct, and this is the one that stings — fixed in a7c885ac by adding workflow_ref to that example.

The page states the three structural requirements (pin the repository, pin the workflow, gate the ref) a few paragraphs above, and I still wrote a copyable example violating one of them. assertPolicyIsSpecific would have refused it. The lesson I am taking: check examples against the rules the same page states, not against what I meant them to show — I was focused on demonstrating operations and stopped thinking about claims.

🤖 Addressed by Claude Code

| ------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `id` | **Required.** Policy identifier, 1–128 characters of letters, numbers, `_`, `-`, and `.`. |
| `issuer` | **Required.** The token issuer (`iss`) this policy trusts. |
| `audience` | **Required.** The audience the token must be addressed to. Should identify **this instance**; enforced for GitHub Actions. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please document that every HTTP(S) audience—not only GitHub Actions audiences—must include an explicit port and trailing slash. For example, a generic issuer policy using the plausible https://my-instance.example.com value is rejected at write time; the accepted form is https://my-instance.example.com:9925/. This also qualifies the later statement that generic audiences are not checked for specificity.

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.

Verified and documented in a7c885ac. assertAudienceIsCanonical is general rather than GitHub-specific: any http(s) audience needs an explicit port and a trailing slash, so https://my-instance.example.com is rejected at write time and https://my-instance.example.com:9925/ is the accepted form. Non-http(s) audiences (an api:// identifier, a bare GUID) pass untouched.

Documented ahead of the specificity discussion with the reason, since that is what makes it stick: the audience is matched byte-for-byte at verification and the CLI asks for its token against the normalized target, which supplies both the port and the slash — so a policy missing them could never authenticate rather than merely being untidy.

And you are right that it qualifies the later line. "For an issuer with no registered profile the audience is not checked for specificity" now says explicitly that the canonical-form rule still applies — as written it implied generic audiences were unvalidated, which is wrong in the way most likely to waste someone an afternoon.

🤖 Addressed by Claude Code

5. `HARPER_CLI_OPERATION_TOKEN` and `HARPER_CLI_REFRESH_TOKEN` environment variables, or their legacy `CLI_TARGET_` equivalents — see [Token credentials for CI/CD](#token-credentials-for-cicd)
6. A token saved by `harper login`
7. `username=` and `password=` operation parameters (legacy fallback)
7. A [workload identity token](#workload-identity-oidc) exchanged with the runtime's OIDC provider (v5.3.0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we also update the recommendation table immediately below this list? It still tells every CI/CD pipeline to use HARPER_CLI_REFRESH_TOKEN, which conflicts with this new no-stored-credential happy path and steers supported runners toward a secret they need to rotate. Consider recommending workload identity when available, with a refresh token as the fallback.

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.

Yes — fixed in a7c885ac. The row now reads "Workload identity where the runtime supports it; otherwise HARPER_CLI_REFRESH_TOKEN".

Worth saying how it got that way, since it is a process failure rather than a judgment one. I had made exactly this edit on the earlier branch. When the OIDC content was extracted onto this one I re-applied the precedence entry and the Workload identity section but not the table row, so the page ended up recommending a rotatable secret three paragraphs above telling readers they need no stored credential. Re-applying a section by hand loses the edits that were scattered outside it.

🤖 Addressed by Claude Code

{ "operation": "list_oidc_trust" }
```

Returns `{ "policies": [ ... ] }`. Each entry carries `id`, `issuer`, `audience`, `claims`, `user`, `operations` (`null` when unscoped), `enabled`, `description`, `updated_by`, and timestamps.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The response can also include invalid_reason when a stored policy is malformed or when its user was later deleted or deactivated. Please document this conditional field; it is especially important because list_oidc_trust is the prescribed diagnostic path for an opaque exchange rejection.

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.

Documented in a7c885ac. Confirmed in trustPolicyOperations.ts: invalid_reason is set when the named user does not exist or is inactive, and included in the returned record when present.

Your framing is why it got more than a table row. The docs send readers to list_oidc_trust precisely because the exchange refuses to say what failed — so a conditional field that explains "this policy cannot authenticate anyone" is the highest-value thing on that response, not a footnote. It now says to check it first, and why: a policy you expected to match that carries an invalid_reason is the fastest answer available when the server will not give you one.

🤖 Addressed by Claude Code

@dawsontoth
dawsontoth merged commit 1fd0faa into main Aug 25, 2026
7 checks passed
dawsontoth added a commit that referenced this pull request Aug 25, 2026
Four from gemini on #637, all correct.

Three are commas after introductory phrases ("In a reusable workflow,", "if
minting then fails,", "In the CLI,").

The fourth is worth more than a comma: the sentence about a rejected OIDC
exchange ran two consequences together with a semicolon, so the loopback case —
where a credential-less request is authorized as superuser and the operation
succeeds with no identity check at all — was buried mid-sentence behind the
payload-credential case. Now its own sentence.
@dawsontoth
dawsontoth deleted the claude/oidc-trusted-publishing-docs branch August 25, 2026 21:53
@github-actions

Copy link
Copy Markdown

🧹 Preview Cleanup

The preview deployment for this PR has been removed.

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.

4 participants