diff --git a/AGENTS.md b/AGENTS.md index 3ecf2812..143bcf67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,8 @@ Prefer plain ASCII characters in Markdown unless a typographic character is genu - Tag minor-version availability inline: `` for new surface, `` for behavior changes to existing surface. - Derive the version from the core release the change ships in, stripping prerelease suffixes (`5.1.0-beta.1` → `v5.1.0`). +- **Determine that release from the core repo's git tags, not from the feature branch's `package.json`.** A branch reading `5.2.0-beta.3` says which release was open when the branch started, not which one the change lands in — if a release is cut before the feature merges, the badge is silently wrong. Check `git tag --sort=-creatordate` for the newest release, and `git tag --contains ` for whether the change is in one; a merged-but-untagged feature ships in the _next_ version, which may be a minor bump. Re-check on every refresh pass of a long-lived docs PR, because a release cut between passes invalidates a badge that was correct when written. +- **Fetch tags and confirm the tag object before trusting the answer.** `git tag --contains` is only as good as the local tag it compares against: a stale or divergent tag of the same name gives a confidently wrong answer in either direction. Run `git fetch --tags`, then check `git rev-parse ` against `git ls-remote --tags origin `. When a badge is disputed, the decisive check is not `--contains` at all but whether the feature's files exist at the tag — `git ls-tree -r --name-only | grep `, or `git show :` — since that cannot be confounded by ancestry or by which tag your clone happens to hold. - Each minor release gets a file under `release-notes//` (e.g. `release-notes/v5-lincoln/5.1.md`); the sidebar picks it up automatically. - Absolute links from `release-notes/` (or `learn/`) into current reference docs use the versioned path `/reference/v5/...` — the reference plugin maps the current version to the `v5` URL path. - When documenting a change from a core/pro PR, cross-link the feature PR and the docs PR in both descriptions. diff --git a/reference/cli/authentication.md b/reference/cli/authentication.md index f139f527..78c5d524 100644 --- a/reference/cli/authentication.md +++ b/reference/cli/authentication.md @@ -44,7 +44,8 @@ For remote Operations API commands, the CLI uses the first complete authenticati 4. Legacy `CLI_TARGET_USERNAME` and `CLI_TARGET_PASSWORD` environment variables 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) +8. `username=` and `password=` operation parameters (legacy fallback) :::tip **Configure one credential style per context, not two.** Precedence exists to resolve a conflict, but it resolves it silently, and the ways this page describes for authentication to go wrong all need two styles live at once: a payload `username=`/`password=` pair takes over when a token stops resolving, a blank token variable hands the run to whatever saved login the machine has. Pick one and leave the others unset: @@ -62,7 +63,7 @@ Credentials are resolved as a pair and are never combined across sources. An inc Entry 5 is not a two-step fallback the way 3 and 4 are: whichever token namespace is merely **set** claims the choice, so a blank `HARPER_CLI_REFRESH_TOKEN` shadows a complete `CLI_TARGET_REFRESH_TOKEN` instead of deferring to it — see below. -Entries 5 and 6 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts — as, by default, it trusts any loopback address, since `authentication.authorizeLocal` defaults to `true`. Token environment variables are deliberately ignored on the socket path — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. +Entries 5 through 7 authenticate with a bearer token and apply to **remote targets only**. A local operation goes over the domain socket, which the server already trusts — as, by default, it trusts any loopback address, since `authentication.authorizeLocal` defaults to `true`. Token environment variables are deliberately ignored on the socket path — a token minted for one instance would otherwise be attached to every local `harper` command in that shell and rejected. Note what that trust means on a self-hosted runner: an unset or blank `target` does not fail. The CLI falls back to the target saved by a previous `harper login` on that machine, and only if there is none does it go local — where it runs as superuser on the socket's ambient trust, with no credential checked at all. So a job that loses its `HARPER_CLI_TARGET` either deploys to whatever remote that runner last logged into, or to the runner's own node. Neither is an error, and the first is the wider blast radius. Before v5.2.0, `username=` and `password=` operation parameters took precedence over environment variables and saved login tokens. This could authenticate an operation as the wrong user when those fields were part of the operation payload, such as the user being created by `add_user`. @@ -255,6 +256,47 @@ Create a **dedicated CI user** and run `harper login --for-ci` as that user. Tha There is no operation that revokes a refresh token directly. To invalidate one, either run `harper login --for-ci` again as that user — minting overwrites the stored hash, so the previous token stops working — or deactivate the user with `alter_user`. `harper logout` is not a revocation: it deletes your local copy and leaves the server-side hash valid. ::: +##### Workload identity (OIDC) + + + +On a runner that can prove its own identity, the CLI needs **no stored credential at all**. It asks the runtime for an identity token addressed to your instance and trades it for a one-hour operation token. Nothing durable is stored in your CI provider, and there is no 30-day token to rotate. + +Configure the instance to trust the workflow once with [`add_oidc_trust`](../operations-api/operations.md#add_oidc_trust), then grant the token permission in the workflow. + +The block below is **GitHub Actions** syntax — `permissions: id-token: write` is how Actions specifically opts a job into requesting an identity token. Other CI systems expose the same idea differently, and Harper currently detects GitHub Actions only; an unrecognized runtime is not an error, the CLI simply falls through to its other credential sources. + +```yaml +# .github/workflows/deploy.yml +permissions: + id-token: write + contents: read +environment: production +steps: + - run: harper deploy by_ref=true restart=true replicated=true + env: + HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }} # a var, not a secret +``` + +`HARPER_CLI_TARGET` is the only variable the step needs, and it is not sensitive — hence `vars` rather than `secrets`. + +**With a trust policy in place you need nothing else** — no `HARPER_CLI_REFRESH_TOKEN`, no password, no credentials on the command. That is the point of it: the workflow holds no Harper secret at all. + +If your pipeline already sets a token or password, remove it. Leaving it in place is not harmful but it does keep winning — a configured credential outranks the exchange, deliberately, so that enabling this does not silently re-point an existing pipeline at a different user. See [Authentication Precedence](#authentication-precedence) if you need the full order for a mixed setup. + +The exchange is attempted only when the runtime actually offers an identity. GitHub Actions sets `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` together on a job that declares `id-token: write`; without both, the CLI falls through to its other credential sources rather than reporting a failure. An unrecognized runtime falls through the same way. + +On success the CLI prints, to stderr, which policy authenticated it and as whom: + +``` +Requesting a GitHub Actions identity token for https://my-instance.harperdb.io:9925/... +Authenticated as 'ci-deploy' via OIDC trust policy 'my-app-prod'. +``` + +If Harper rejects the token, the CLI reports it and carries on rather than aborting. On a workflow configured as above there is nothing left to try, so the operation itself fails with a 401 — but do not treat a rejected exchange as a guaranteed hard stop: if anything else on the runner can authenticate, the command proceeds under that identity instead. [Authentication Precedence](#authentication-precedence) covers those cases. + +The server deliberately does not report which check failed — see [`exchange_oidc_token`](../operations-api/operations.md#exchange_oidc_token) — so diagnose with `list_oidc_trust` and the instance's `oidc-trust` log. + #### Method 3: Dedicated Authentication Parameters diff --git a/reference/cli/operations-api-commands.md b/reference/cli/operations-api-commands.md index 23a7f5f7..8d128e8b 100644 --- a/reference/cli/operations-api-commands.md +++ b/reference/cli/operations-api-commands.md @@ -54,24 +54,24 @@ This is just a brief overview of all operations available as CLI commands. Revie | `sql` | Execute SQL queries | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 | | `csv_file_load` | Load data from CSV file | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 | | `csv_url_load` | Load data from CSV URL | [Data](../operations-api/operations.md#nosql-operations) | v4.3.0 | -| `list_users` | List all users | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `add_user` | Create a new user | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `alter_user` | Modify user properties | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `drop_user` | Delete a user | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `list_roles` | List all roles | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | -| `drop_role` | Delete a role | [Security](../operations-api/operations.md#certificate-management) | v4.3.0 | +| `list_users` | List all users | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `add_user` | Create a new user | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `alter_user` | Modify user properties | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `drop_user` | Delete a user | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `list_roles` | List all roles | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | +| `drop_role` | Delete a role | [Users & Roles](../operations-api/operations.md#users--roles) | v4.3.0 | | `create_csr` | Create certificate signing request | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `sign_certificate` | Sign a certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `list_certificates` | List SSL/TLS certificates | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `add_certificate` | Add SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | | `remove_certificate` | Remove SSL/TLS certificate | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `add_ssh_key` | Add SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `get_ssh_key` | Get SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.7.2 | -| `update_ssh_key` | Update SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `delete_ssh_key` | Delete SSH key | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `list_ssh_keys` | List all SSH keys | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `set_ssh_known_hosts` | Set SSH known hosts | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | -| `get_ssh_known_hosts` | Get SSH known hosts | [Security](../operations-api/operations.md#certificate-management) | v4.4.0 | +| `add_ssh_key` | Add SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `get_ssh_key` | Get SSH key | [Components](../operations-api/operations.md#components) | v4.7.2 | +| `update_ssh_key` | Update SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `delete_ssh_key` | Delete SSH key | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `list_ssh_keys` | List all SSH keys | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `set_ssh_known_hosts` | Set SSH known hosts | [Components](../operations-api/operations.md#components) | v4.4.0 | +| `get_ssh_known_hosts` | Get SSH known hosts | [Components](../operations-api/operations.md#components) | v4.4.0 | | `cluster_get_routes` | Get cluster routing information | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 | | `cluster_network` | Get cluster network status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 | | `cluster_status` | Get cluster status | [Clustering](../operations-api/operations.md#replication--clustering) | v4.3.0 | diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 9d282b3c..aea164d8 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -532,10 +532,14 @@ Operations for JWT token creation and refresh. Detailed documentation: [JWT Authentication](../security/jwt-authentication.md) -| Operation | Description | Role Required | -| ------------------------------ | ------------------------------------------------------- | ---------------------- | -| `create_authentication_tokens` | Creates an operation token and refresh token for a user | none (unauthenticated) | -| `refresh_operation_token` | Creates a new operation token from a refresh token | any | +| Operation | Description | Role Required | +| ------------------------------ | ---------------------------------------------------------- | ---------------------- | +| `create_authentication_tokens` | Creates an operation token and refresh token for a user | none (unauthenticated) | +| `refresh_operation_token` | Creates a new operation token from a refresh token | any | +| `exchange_oidc_token` | Trades a CI workload identity token for an operation token | none (unauthenticated) | +| `add_oidc_trust` | Creates or replaces an OIDC trust policy | super_user | +| `list_oidc_trust` | Lists all OIDC trust policies, including disabled ones | super_user | +| `drop_oidc_trust` | Deletes an OIDC trust policy | super_user | ### `create_authentication_tokens` @@ -560,6 +564,218 @@ Creates a new operation token from an existing refresh token. } ``` +### OIDC Trusted Publishing + + + +A CI runner can authenticate to Harper with **no stored credential**. It presents an identity token minted by its own provider; if that token verifies against a stored **trust policy**, Harper returns a one-hour operation token for the user the policy names. This is the same exchange npm, PyPI, and AWS STS `AssumeRoleWithWebIdentity` use. + +The alternative is a `HARPER_CLI_REFRESH_TOKEN` secret: a 30-day credential, one per user, that expires on a schedule nobody tracks. A trust policy replaces it with a rule you configure once, and revoke with `drop_oidc_trust`. + +```yaml +permissions: + id-token: write + contents: read +environment: production +steps: + - run: harper deploy by_ref=true + env: + HARPER_CLI_TARGET: ${{ vars.HARPER_CLI_TARGET }} # a var, not a secret +``` + +No secret at all — `HARPER_CLI_TARGET` is not sensitive. See [CLI Authentication](../cli/authentication.md#workload-identity-oidc) for the client half and where the exchange sits in credential precedence. + +Policies live in the replicated `system.hdb_oidc_trust` table, so configuring one on any node applies cluster-wide. + +#### `add_oidc_trust` + +Creates or replaces a trust policy. **super_user only** — a policy lets an external system authenticate as a Harper user, so granting one is equivalent to handing out a credential. + +```json +{ + "operation": "add_oidc_trust", + "id": "my-app-prod", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://my-instance.harperdb.io:9925/", + "user": "ci-deploy", + "claims": { + "repository_id": "67890", + "workflow_ref": "HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main", + "environment": "production" + } +} +``` + +| Parameter | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `claims` | **Required.** The claim constraints a token must satisfy. At least one, and specific enough for the issuer's profile. | +| `user` | **Required.** The Harper user a matching run authenticates as. Must already exist and be active. | +| `operations` | Narrow the minted token to these operations, 1–100 unique names. Omit for the user's full role — see below. | +| `enabled` | Defaults to `true`. A disabled policy is kept but never matched. | +| `description` | Optional free text, up to 1024 characters. | + +##### Setting a policy up from the CLI + +Every Operations API operation is available as a CLI command of the same name, which is usually the easiest way to configure a policy: log in once from your machine, then run the operation against the cluster. + +```sh +# 1. Authenticate to the cluster you are configuring (once, interactively) +harper login https://my-instance.harperdb.io:9925 + +# 2. Create the trust policy +harper add_oidc_trust \ + id=my-app-prod \ + issuer=https://token.actions.githubusercontent.com \ + audience=https://my-instance.harperdb.io:9925/ \ + user=ci-deploy \ + claims='{"repository_id":"67890","workflow_ref":"HarperFast/my-app/.github/workflows/deploy.yml@refs/heads/main","environment":"production"}' + +# 3. Confirm what the cluster now trusts +harper list_oidc_trust +``` + +`claims` is a JSON object, so quote it as a single shell argument — the CLI parses each value as JSON, which is what turns that string into the nested object the operation expects. `operations` works the same way if you scope the policy: `operations='["deploy_component","get_deployment"]'`. + +`harper login` stores a token for that target, so step 2 needs no credentials of its own. If you would rather not store one, pass the target and credentials explicitly instead: + +```sh +harper add_oidc_trust target=https://my-instance.harperdb.io:9925 auth_username=HDB_ADMIN auth_password="$ADMIN_PASSWORD" id=my-app-prod ... +``` + +Both routes need **super_user**, since that is what the trust-policy operations require. + +This **replaces** the policy rather than merging into it. A partial update is how an over-broad policy gets created by accident, and the point of `claims` is that every constraint in it was written deliberately. + +##### Narrowing what the token may do (`operations`) + +The user the policy names is the privilege boundary: a matching run gets that user's role. `operations` narrows it further, so one CI user can back several policies that each do less than the role allows: + +```json +{ + "operation": "add_oidc_trust", + "id": "my-app-prod", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "https://my-instance.harperdb.io:9925/", + "user": "ci-deploy", + "operations": ["deploy_component", "get_deployment", "restart_service"], + "claims": { "repository_id": "67890", "environment": "production" } +} +``` + +It is **narrowing only** — never widening. An operation the role forbids stays forbidden, so the scope cannot be used to grant something the user does not already have. Omit `operations` and the token carries the user's full role. + +Names are validated when the policy is written, against the same registry `add_role` and `alter_role` use, so a typo is rejected there rather than failing later inside CI with nothing to point at. One consequence: an operation a component registered at runtime with `server.registerOperation` is **not** recognized, because that registry is process-local, so a policy naming one is rejected. It fails closed — a rejected policy, never a widened one — and the same gap applies to `add_role` and `alter_role`. + +:::warning +**The scope covers the Operations API and SQL, not the application data path.** It is enforced at the `verifyPerms` / `verifyPermsAST` gate, so a scoped token still carries the role's full table-level CRUD when it reaches an application's REST or GraphQL resources, which authorize through `checkPermission` instead. + +So `operations` bounds what a CI credential can _administer_, not what data it can read or write. If that matters, point the policy's `user` at a role that is itself least-privilege for the data the token can reach, rather than relying on the scope alone. +::: + +A scoped token also cannot trade itself for a browser session: `create_authentication_tokens` with `purpose: "login"` is refused, because a session carries no operation scope and would silently restore the user's full role. + +`user` is resolved at write time. A policy naming a user that does not exist, or one that is inactive, is rejected — otherwise it would fail only at exchange time, inside CI, with nothing to point at. If the named user is a **super_user**, the policy is still created but the response carries a `warning`: any run matching it gains full administrative access. + +**Claim constraints** are exact string matches. A value may be a string, or an array of strings meaning any-of: + +```json +{ "claims": { "repository": "HarperFast/my-app", "ref": ["refs/heads/main", "refs/heads/release"] } } +``` + +A constrained claim that is **absent** from the token fails rather than passes, so a policy cannot be weakened by an issuer that stops emitting a claim. + +**The audience should identify this instance.** For GitHub Actions, Harper rejects the provider's shared default — anything shaped like `https://github.com/` — because that value is shared by every repository under the owner, so accepting it would make a token minted by any of them valid here. + +That check is a guard against the one known-dangerous value, not a proof of correctness: Harper does not compare the audience against its own identity, so an arbitrary or mistyped value is accepted at write time and instead fails to match at exchange time, when the CLI derives the audience from its target URL. Use the instance URL your CI targets. For an issuer with no registered profile the audience is not checked for specificity at all, and the required `sub` pin is what binds the policy to one principal. + +##### Policy specificity for GitHub Actions + +For `https://token.actions.githubusercontent.com`, a policy must satisfy all three of these, each closing a distinct way a policy can be accidentally broad: + +| Requirement | Satisfied by one of | Left open otherwise | +| ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | +| **Pin the repository** | `repository_id`, `repository` | Any repository | +| **Pin the workflow** | `workflow_ref`, `workflow_path`, `job_workflow_ref`, `job_workflow_path` | Any workflow in that repository | +| **Gate the ref** | `workflow_ref`, `ref`, `environment` | Any branch that can be pushed to the repository | + +The ref gate is the one worth understanding, and it is stricter than npm's model. Pinning repository and workflow without also pinning a ref is not safe: anyone who can push a branch can add the trusted workflow to that branch and mint a token. npm accepts that shape and relies on environment protection instead. + +Consequences worth planning around: + +- **`repository_id` is preferred over `repository`** because it is immutable — it survives a repository rename, and is immune to org-name recycling. +- **A tag-triggered release cannot pin `workflow_ref`**, since the tag is unknown when the policy is written. Pin `workflow_path` instead — Harper derives it from `workflow_ref` by removing the ref — and gate on `environment`. +- **`ref_type: tag` is deliberately not accepted as a ref gate.** Anyone with push access can create a tag. +- **`sub` is not accepted as a pin.** It varies by trigger, and its format changed for repositories created after 2026-07-15 (immutable subjects embed owner and repository ids), so a policy pinning it would have to handle two shapes indefinitely. +- **`job_workflow_ref` pins the workflow but does not gate the ref.** In a reusable workflow, it names the reusable workflow that ran, not the caller that invoked it, and its `@ref` suffix is that workflow's own branch — constant however it is called. Accepting it as a ref gate would admit any branch of any repository that references the reusable workflow. Pin the workflow with it if you like, then gate the ref with `workflow_ref`, `ref`, or `environment`. +- **`pull_request_target` runs are denied** unless the policy explicitly constrains `event_name`. Such a run executes the base repository's workflow, with its secrets, while a fork controls the checked-out code. A plain `pull_request` run from a fork cannot mint at all, since it gets no `id-token: write`. + +##### Other issuers + +An issuer with no registered profile gets a strict generic profile: the policy must pin **`sub`**. That is the one claim every OIDC issuer defines as identifying a single principal, and it makes workload identity work with no provider-specific code — a Kubernetes service-account token (`system:serviceaccount::`), a GCP service account, and a SPIFFE SVID all carry a stable canonical subject. + +GitHub Actions needs its own profile precisely because its `sub` is the one claim you should _not_ pin. + +#### `exchange_oidc_token` + +Trades an identity token for a Harper operation token. **Unauthenticated by design** — this operation _is_ the authentication, the way `create_authentication_tokens` is against a password. The CLI calls it for you; you would call it directly only from a client that mints its own requests. + +```json +{ + "operation": "exchange_oidc_token", + "token": "eyJhbGciOi..." +} +``` + +Response: + +```json +{ + "operation_token": "eyJhbGciOi...", + "expires_in": 3600, + "username": "ci-deploy", + "policy": "my-app-prod" +} +``` + +The operation token is valid for **one hour** — long enough to cover a slow deploy, short enough to bound the exposure if it leaks. That is a reduced window, not safety: within the hour it is a live credential carrying the policy's identity, so treat it like any other secret and keep it out of logs and step outputs. No refresh token is issued; a subsequent run performs a new exchange. + +:::note +**Every rejection of a well-formed token returns the same message.** The endpoint is unauthenticated, so a caller told which check failed could enumerate a policy one claim at a time. A malformed request — a missing `token`, or one over 8192 characters — is rejected by schema validation before any of that, with its own message; that reveals nothing about a policy. The specific reason is written to the `oidc-trust` logger, which is where to look when a workflow that should match does not. +::: + +**An identity token can be exchanged once.** Harper records a SHA-256 fingerprint of each spent token in `system.hdb_oidc_token_use`, expiring with the token itself, so the table stays proportional to in-flight tokens and never holds a credential. The record is written _before_ the operation token is minted: if minting then fails, the identity token is burned, costing a CI re-run, where the reverse order would leave a spendable token behind. + +That replay check is replicated, but replication is asynchronous, so two simultaneous replays against **different nodes** can both succeed. This is not a privilege escalation — whoever holds the token could obtain one operation token regardless — and what it does stop is the realistic case: a token that leaks after a legitimate run and is reused inside its window. + +Exchanges are recorded in the authentication audit stream alongside Basic, Bearer, and mTLS events, for failures as well as successes — a run repeatedly failing to authenticate is what an audit trail is for. Enable it with `logging.auditAuthEvents.logSuccessful` and `logging.auditAuthEvents.logFailed`. + +#### `list_oidc_trust` + +Lists every policy, **including disabled ones**, sorted by `id`. **super_user only** — the policy set names exactly which repository and workflow are worth compromising. + +```json +{ "operation": "list_oidc_trust" } +``` + +Returns `{ "policies": [ ... ] }`. Each entry carries `id`, `issuer`, `audience`, `claims`, `user`, `operations` (`null` when unscoped), `enabled`, `description`, `updated_by`, and timestamps. + +#### `drop_oidc_trust` + +Stops every workflow that matched the policy from exchanging again. **super_user only.** Fails with `404` if no policy has that `id`. + +```json +{ "operation": "drop_oidc_trust", "id": "my-app-prod" } +``` + +:::caution +**This does not revoke operation tokens already issued.** The minted token is a stateless JWT valid until its one-hour expiry, so a token obtained moments before the policy was dropped keeps authorizing for the rest of that hour. + +Dropping the policy is therefore containment against _future_ runs. If you are responding to a suspected compromise rather than doing routine cleanup, also deactivate or re-role the user the policy named (`alter_user`), which is what stops a token that is already in someone's hands. +::: + --- ## Components diff --git a/release-notes/v5-lincoln/5.3.md b/release-notes/v5-lincoln/5.3.md index e75d5505..c7928d4d 100644 --- a/release-notes/v5-lincoln/5.3.md +++ b/release-notes/v5-lincoln/5.3.md @@ -19,3 +19,15 @@ When `node.hostname` is unset, Harper resolves the identity to the first valid b Replication URLs now also bracket a bare IPv6 literal correctly (`::1` becomes `ws://[::1]:9933`), which the URL parser previously rejected. See [Configuration Options](/reference/v5/configuration/options#node). + +## Security + +### OIDC Trusted Publishing + +A CI runner can now authenticate to Harper with no stored credential. It presents an identity token minted by its own provider, and if that token verifies against a trust policy configured on the instance, Harper returns a one-hour operation token for the user the policy names — the same exchange npm, PyPI, and AWS STS `AssumeRoleWithWebIdentity` use. This replaces a 30-day refresh-token secret with a rule you configure once and revoke with `drop_oidc_trust`. + +Policies are managed with `add_oidc_trust`, `list_oidc_trust`, and `drop_oidc_trust` (all super_user), and live in the replicated `system.hdb_oidc_trust` table. A policy pins the issuer, an instance-specific audience, and a set of claim constraints. For GitHub Actions those constraints must pin the repository, pin the workflow, and gate the ref — a policy naming only a repository and workflow is refused, because anyone who can push a branch could otherwise add the trusted workflow to it and mint a token. Issuers with no registered profile must pin `sub`, which makes Kubernetes service accounts, GCP service accounts, and SPIFFE identities work with no provider-specific configuration. + +A policy can also carry an `operations` list, narrowing the minted token to a subset of its user's role on the Operations API and SQL paths — narrowing only, and not covering an application's REST/GraphQL data path, so the policy's user should still be a least-privilege role for the data it can reach. + +Identity tokens are single-use, though the replay record replicates asynchronously, so two simultaneous replays against different nodes can both succeed. Exchanges are recorded in the authentication audit stream when `logging.auditAuthEvents.logSuccessful` / `logFailed` is enabled — they are off by default, so turn them on before you need the trail. Every rejection returns the same message, with the specific reason written to the `oidc-trust` log. In the CLI, the exchange ranks below every configured credential, so enabling it does not silently re-point a pipeline that still has a token secret set. See [OIDC Trusted Publishing](/reference/v5/operations-api/operations#oidc-trusted-publishing) and [Workload identity](/reference/v5/cli/authentication#workload-identity-oidc).