Create Forge installation on first namespace link

What

Create the JiraConnectInstallation at first namespace link instead of the Connect installed event, so the row lands on the correct organization (its Cells shard), resolved from the linked namespace.

The link is one app-context (invokeRemote) call that GitLab verifies end-to-end. Nothing unsigned is trusted:

  • installationId / cloud_id / apiBaseUrl / principal come from the FIT (RS256, verified against the Atlassian JWKS, aud = our app), not from spoofable headers.
  • The Jira site-admin check runs in GitLab, against Jira, using the app system token riding the same call (X-Forge-Oauth-System).
  • The GitLab user is bound via a short-lived GitLab-signed delegation token (Integrations::JiraForge::UserDelegationToken, HMAC per Gitlab::ConanToken, TTL 2h) minted at config-page sign-in by the new user_delegation endpoint. Forge never exposes the user's OAuth token to app code, so the app presents this instead and GitLab runs the namespace permission checks itself.
  • The apiBaseUrl + system token are stored at creation, so the installation is forge_direct? (dev-info sync works) immediately.

Flow

sequenceDiagram
  autonumber
  participant Admin as Jira admin (config page)
  participant App as Forge app (resolver)
  participant GL as GitLab
  participant Jira as Jira Cloud

  Note over Admin,GL: Sign-in (once per user, user-context)
  Admin->>App: Sign in to GitLab (OAuth consent)
  App->>GL: POST user_delegation (user OAuth Bearer)<br/>account_id + cloud_id
  GL-->>App: signed delegation token<br/>(user_id, account_id, cloud_id, TTL 2h)
  App->>App: store in Forge storage (per Jira account)

  Note over Admin,Jira: Link a group (ONE app-context call)
  Admin->>App: Link group (namespace_path)
  App->>GL: POST subscriptions via invokeRemote<br/>FIT (bearer) + X-Forge-Oauth-System<br/>+ delegation header + namespace_path
  GL->>GL: verify FIT (JWKS, aud)<br/>→ trusted installationId / cloud_id / apiBaseUrl / principal
  GL->>GL: verify delegation (signature, TTL,<br/>claims match FIT, user active)
  GL->>Jira: user_info(principal) with system token
  Jira-->>GL: site/org-admin? must be true
  GL->>GL: namespace permission checks (delegated user)<br/>find-or-create installation on namespace org<br/>store apiBaseUrl + system token (forge_direct?)
  GL-->>App: 201 + organization_id
  App->>App: persist organization_id (Cells routing)

Uninstall (pure-native cleanup)

A pure-native install fires no Connect uninstalled event, so the Forge app's avi:forge:uninstalled:app trigger calls the new FIT-authenticated DELETE /integrations/jira_forge/installation, which reuses JiraConnectInstallations::DestroyService. That destroys the installation and its subscriptions, and deactivates the linked jira_cloud_app integrations, exactly like the Connect uninstall path. For Connect-on-Forge installs both events may fire; whichever runs second finds nothing and no-ops.

sequenceDiagram
  autonumber
  participant Jira as Jira Cloud
  participant App as Forge app (lifecycle)
  participant GL as GitLab

  Jira->>App: avi:forge:uninstalled:app
  App->>GL: DELETE installation via invokeRemote (FIT)
  GL->>GL: destroy installation + subscriptions<br/>deactivate jira_cloud_app integrations
  GL-->>App: 200
  App->>App: clear Forge storage (org, base path)

Threat model (summary)

  • A direct caller without a valid FIT gets 401. The FIT proves the request came through our Forge app, and only Atlassian signs FITs for our app ARI.
  • The delegation token is scoped: its account_id/cloud_id claims must match the FIT of the call presenting it, so a leaked token is only usable from that user's own Jira account and site (where they are an admin anyway), and only for 2 hours. The delegated user must still be active, and all authorization (namespace permission, Jira admin) is evaluated live at link time.
  • The Jira-admin and namespace-permission checks are both server-side in GitLab; the app asserts nothing GitLab does not re-verify, except the mint-time scope claims (the honest-app-at-mint trust anchor, standard for Forge apps).

Why

The Connect installed event has no user/org context, so it creates the installation on the default organization, which is wrong for Cells. First link is the earliest point the correct organization is unambiguously known.

The installation is resolved by the FIT's installationId (forge_installation_xid, unique per app install); an upgraded Connect row is found by its site (cloud_id) and adopts the id on first contact.

Existing Connect installs get cloud_id backfilled by the re-fired Connect installed lifecycle on upgrade (not by re-linking), so they keep working.

Forge app side: gitlab-jira-forge!7.

Edited by Jorge Tomás

Merge request reports

Loading
Loading