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/principalcome 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 perGitlab::ConanToken, TTL 2h) minted at config-page sign-in by the newuser_delegationendpoint. 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 isforge_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_idclaims 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.