From 5e701a3d6b8bd4f866d1913e68b5ce8b7c54b265 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 10 Jul 2026 20:16:53 +0000 Subject: [PATCH] docs(jira): document scoped API token support (authType, gateway routing, limitations) --- CLAUDE.md | 4 ++++ docs/architecture/06-integration-layer.md | 2 ++ docs/architecture/08-config-credentials.md | 25 ++++++++++++++++++-- docs/getting-started.md | 23 ++++++++++++++++++ src/integrations/README.md | 27 ++++++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a034bb9d..9de7ed7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,10 @@ Optional: **Project credentials (GitHub tokens, Trello/JIRA/Linear keys, LLM API keys) live in the `project_credentials` table.** The DB is the **sole source of truth** — there is no env var fallback for project-scoped secrets. +## JIRA scoped tokens + +JIRA supports classic site tokens **and** Atlassian API tokens with scopes. The optional `authType` field on the JIRA integration config (`'basic' | 'scoped'`, default `'basic'`) is a **non-secret connection setting** (mirrors `baseUrl`, not a credential role) that selects the REST v3 host — **both modes authenticate with HTTP Basic (`email:api_token`)**, so `authType` picks the host, not the auth scheme. Every REST v3 call site routes through the shared resolver `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`): `basic`/absent keeps the tenant **site URL**; `scoped` routes through the Atlassian **gateway** `https://api.atlassian.com/ex/jira/{cloudId}`, where `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) and cached per `baseUrl`. The worker carries the mode across process boundaries via `CASCADE_JIRA_AUTH_TYPE`. **Required scopes:** read/write Jira work, plus `manage:jira-webhook` (or granular `write:webhook:jira` + `read:field:jira` + `read:project:jira`) for programmatic `/rest/api/3/webhook` management — a scoped token lacking them gets `401`/`403`, and operators should register the webhook manually. **Known limitation:** ack reactions are unavailable under scoped tokens (`/rest/reactions/1.0/` is not exposed on the gateway), so the reaction degrades to a skipped no-op; `accessible-resources` is intentionally not used for cloudId (it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens). + ## Git hooks Lefthook runs pre-commit (lint, typecheck) and pre-push (unit + integration tests) hooks automatically. Pre-push auto-starts an ephemeral Postgres via `npm run test:db:up` — Docker must be running. diff --git a/docs/architecture/06-integration-layer.md b/docs/architecture/06-integration-layer.md index 45a80542..3988693f 100644 --- a/docs/architecture/06-integration-layer.md +++ b/docs/architecture/06-integration-layer.md @@ -122,6 +122,8 @@ Each provider declares its credential roles — the mapping from logical role na - ADF (Atlassian Document Format) ↔ markdown conversion (`src/pm/jira/adf.ts`) - Status transitions via JIRA transition ID lookup - Issue key extraction via regex: `[A-Z][A-Z0-9]+-\d+` +- **Auth mode** — the optional `authType` config field (`'basic' | 'scoped'`) is a non-secret connection setting (mirrors `baseUrl`, not a credential role) that selects the REST v3 host. Both modes use HTTP Basic (`email:api_token`); absent ⇒ `'basic'`. +- **Host resolution** — every REST v3 call site routes through the shared `resolveJiraApiBaseUrl(creds)` resolver (`src/jira/api-host.ts`): `basic` keeps the tenant site URL; `scoped` routes through the Atlassian gateway `https://api.atlassian.com/ex/jira/{cloudId}` (cloudId resolved from `/_edge/tenant_info`). Scoped tokens have known limits — see [`08-config-credentials.md`](./08-config-credentials.md#jira-authentication-modes-scoped-tokens). ### Linear (`src/integrations/pm/linear/`, `src/pm/linear/`, `src/linear/`) diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index 6c91204b..33880311 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -221,8 +221,10 @@ await withTrelloCredentials({ apiKey, token }, async () => { }); // JIRA -await withJiraCredentials({ email, apiToken, baseUrl }, async () => { - // All JIRA API calls use these credentials +await withJiraCredentials({ email, apiToken, baseUrl, authType }, async () => { + // All JIRA API calls use these credentials. + // `authType` ('basic' | 'scoped', optional) selects the REST v3 host + // via the shared resolveJiraApiBaseUrl() resolver — see below. }); // Linear @@ -231,6 +233,25 @@ await withLinearCredentials({ apiKey }, async () => { }); ``` +### JIRA authentication modes (scoped tokens) + +`src/jira/api-host.ts`, `src/jira/authType.ts` + +JIRA supports classic unscoped site tokens **and** Atlassian API tokens with scopes. The mode is selected by the optional `authType` field on the JIRA integration config (`project_integrations.config`) — a non-secret connection setting that mirrors `baseUrl`, **not** a credential role. Values: `'basic'` (or absent) and `'scoped'`. Both modes authenticate with **HTTP Basic** (`email:api_token`); `authType` selects the REST v3 *host*, not the auth scheme. + +Every REST v3 call site routes through one shared resolver, `resolveJiraApiBaseUrl(creds)` — the JIRA analogue of the shared auth-header helper: + +| `authType` | REST v3 host | Notes | +|---|---|---| +| `basic` / absent | tenant **site URL** (`creds.baseUrl`, e.g. `https://acme.atlassian.net`) | Classic behavior, unchanged. Every pre-existing config maps here. | +| `scoped` | Atlassian **gateway** (`https://api.atlassian.com/ex/jira/{cloudId}`) | `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) with the same Basic scoped token, cached per `baseUrl`. Direct site REST v3 calls can fail under scoped tokens, so the gateway is the supported path. | + +The worker/CLI credential scope carries the mode across process boundaries via the `CASCADE_JIRA_AUTH_TYPE` env var (injected by `secretBuilder.augmentProjectSecrets`); `normalizeJiraAuthType` maps absent/unknown values back to `'basic'` so existing projects keep working. `accessible-resources` is intentionally **not** used to discover `cloudId` — it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens. + +**Required scopes.** Read/write Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`. A scoped token without webhook scopes (or a non-app caller) gets `401`/`403` from `/rest/api/3/webhook`; the wizard then surfaces an actionable message pointing at manual webhook registration. + +**Known limitation — ack reactions.** The "eyes" acknowledgment reaction uses Jira's internal `/rest/reactions/1.0/` API, which lives only on the tenant site URL and is not confirmed on the scoped gateway. Under `scoped` auth the reaction degrades quietly (one log line, then skip) — it is best-effort and never fails a run. Comments, status transitions, and label writes are unaffected. + ## Credential Encryption `src/db/crypto.ts` diff --git a/docs/getting-started.md b/docs/getting-started.md index 4de027ac..84b301a7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -264,6 +264,29 @@ node bin/cascade.js projects integration-set my-project \ --config '{"baseUrl":"https://yourorg.atlassian.net","projectKey":"PROJ","statuses":{"todo":"To Do","inProgress":"In Progress","inReview":"In Review"}}' ``` +#### Scoped API tokens (`authType`) + +Cascade also supports Atlassian **API tokens with scopes** ("scoped" tokens) alongside classic unscoped site tokens. Opt in with the `authType` field on the integration config: + +```bash +node bin/cascade.js projects integration-set my-project \ + --category pm --provider jira \ + --config '{"baseUrl":"https://yourorg.atlassian.net","projectKey":"PROJ","authType":"scoped","statuses":{"todo":"To Do","inProgress":"In Progress","inReview":"In Review"}}' +``` + +`authType` is a non-secret connection setting (like `baseUrl`), **not** a credential — the stored `JIRA_EMAIL` / `JIRA_API_TOKEN` are unchanged. Both modes authenticate with **HTTP Basic** (`email:api_token`), so `authType` selects the request *host*, not the auth scheme: + +- `authType: "basic"` (or omitted — the historical default) routes REST v3 calls to your tenant **site URL** (`https://yourorg.atlassian.net`). Every existing config keeps working untouched. +- `authType: "scoped"` routes REST v3 calls through the Atlassian **gateway** (`https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3/...`). Cascade resolves `cloudId` automatically from `https://yourorg.atlassian.net/_edge/tenant_info` using the same Basic scoped token and caches it per site URL. Direct site REST v3 calls can fail under a scoped token, so the gateway is the supported path for scoped tokens. + +**Required scopes.** Grant the token read/write access to Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or the granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`. + +**Known limitations under scoped tokens:** + +- **Ack reactions are unavailable.** The "eyes" acknowledgment reaction uses Jira's internal `/rest/reactions/1.0/` API, which is not exposed through the scoped gateway. Under `scoped` auth Cascade logs one line and skips the reaction — it is cosmetic and never fails a run. Comments, status transitions, and label writes are unaffected. +- **Webhook auto-registration may be rejected.** Atlassian can restrict programmatic `/rest/api/3/webhook` registration to app callers and requires webhook scopes, so a scoped token may get `401`/`403`. If it does, register the webhook manually in Jira (**System → WebHooks**) pointing at `https://your-router-host/jira/webhook`. +- **`accessible-resources` is not used** to discover `cloudId` — that endpoint is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens, so Cascade uses `/_edge/tenant_info` instead. + ### Linear 1. Generate a **Personal API key** from https://linear.app/settings/api diff --git a/src/integrations/README.md b/src/integrations/README.md index 6e825b2f..441c7641 100644 --- a/src/integrations/README.md +++ b/src/integrations/README.md @@ -112,6 +112,33 @@ Single-source-of-truth utilities live in `src/integrations/pm/_shared/`: - **`label-id-resolver.ts`** — `resolveLabelId(slot, mapping, ctx)` validates UUIDs before passing labelIds to APIs that require them (Linear). Returns `null` and logs a warn for misconfigurations. - **`project-id-extractor.ts`** — `extractProjectIdFromJobViaRegistry(jobData)` iterates the registry. Used by `src/router/worker-env.ts` before its (now-minimal) legacy branches. +JIRA has one additional shared host resolver outside `_shared/` — `resolveJiraApiBaseUrl` (`src/jira/api-host.ts`), the JIRA analogue of the shared auth-header helper. See the next section. + +--- + +## JIRA authentication modes (scoped tokens) + +JIRA supports classic unscoped **site tokens** and Atlassian **API tokens with scopes** ("scoped" tokens). The mode is chosen by the optional `authType` field on the JIRA integration config (`jiraConfigSchema` in `src/integrations/pm/jira/config-schema.ts`): `'basic'` (or absent) and `'scoped'`. + +- **`authType` is a non-secret connection setting**, not a credential role — it mirrors `baseUrl` and lives in `project_integrations.config`, never `project_credentials`. Absent ⇒ `'basic'`, so every pre-existing JIRA config stays valid untouched (`normalizeJiraAuthType` maps absent/unknown values back to `'basic'`). +- **Both modes authenticate with HTTP Basic** (`email:api_token`) — confirmed live in MNG-1735. `authType` selects the REST v3 *host*, not a Basic-vs-Bearer scheme. There is no scoped-Bearer branch. + +### `resolveJiraApiBaseUrl` — the shared host-resolution contract + +Every JIRA REST v3 call site must route through the single shared resolver `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`) so scoped tokens hit the Atlassian gateway consistently and no divergent host-selection copies exist. This is the JIRA analogue of the `auth-headers.ts` provenance rule: + +| `authType` | Resolved REST v3 base | Detail | +|---|---|---| +| `basic` / absent | tenant **site URL** (`creds.baseUrl`) | Classic site-token behavior, unchanged. | +| `scoped` | Atlassian **gateway** `https://api.atlassian.com/ex/jira/{cloudId}` | `cloudId` resolved from `${baseUrl}/_edge/tenant_info` (**always** the site URL, never the gateway) with the same Basic scoped token, cached per `baseUrl` via `jiraClient.getCloudId`. | + +The in-worker `jiraClient` (`src/jira/client.ts`), the router-side `JiraPlatformClient` (`src/router/platformClients/jira.ts`), the webhook-management router (`src/api/routers/webhooks/jira.ts`), and the wizard's discovery factory (`jiraManifest.createDiscoveryProvider` / `configToCredentials`) all resolve the effective base this way. The worker/CLI credential scope carries the mode across process boundaries via the `CASCADE_JIRA_AUTH_TYPE` env var (injected by `secretBuilder.augmentProjectSecrets`). `accessible-resources` is intentionally **not** used as a cloudId fallback — it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens (MNG-1735). + +### Required scopes and known limitations + +- **Scopes.** Read/write Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management (`/rest/api/3/webhook`) additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`. A scoped token without those scopes (or a non-app caller) gets `401`/`403`; `jiraCreateWebhook` surfaces an actionable message pointing at manual registration (Jira **System → WebHooks**) rather than a raw status dump. +- **Ack reactions are unavailable under scoped tokens.** The "eyes" reaction uses the internal `/rest/reactions/1.0/` API, which is not confirmed on the scoped gateway (the gateway probe returned `401 scope does not match`; MNG-1735). Under `scoped` auth both `jiraClient.addCommentReaction` and `JiraPlatformClient.postReaction` degrade quietly (one log line, then skip). The reaction is cosmetic and must never fail a run; comments, transitions, and label writes are unaffected. + --- ## Registration at startup