Skip to content

Security & Permissions

This document covers the Forge sandboxing model, the lens-level permission system, Jira permission integration, and security boundaries in the Foundation/Lens app.

Foundation runs entirely on Atlassian Forge, which provides a sandboxed execution environment:

  • Isolated runtime: Backend code runs in Forge’s managed Node.js 22.x container. There is no direct access to servers, file systems, or network resources outside of Forge APIs.
  • No self-managed infrastructure: No servers, databases, or storage to secure. Forge SQL and KVS are managed by Atlassian.
  • Scoped API access: The app can only access Jira APIs within its declared permission scopes.
  • Automatic authentication: User identity (context.accountId) is provided by the Forge platform, not managed by the app. No session tokens, cookies, or auth flows to implement.
ConstraintLimit
Standard function timeout25 seconds
Async event timeout900 seconds (15 minutes)
Frontend response payload5 MB
Forge SQL storage1 GiB per installation
Forge SQL DML150 operations/second
Invocations1,200/user/min, 5,000/install/min

Foundation implements a two-layer permission model: lens-level permissions (app-managed) and issue-level permissions (Jira-managed).

Each lens has an owner and optional permission grants. Permissions are stored in the lens_permissions Forge SQL table.

The edit_generators level was collapsed into edit in schema v30, leaving three explicit levels:

LevelCapabilitiesNotes
ownerFull control (implicit)Creator of the lens; cannot be revoked
controlGrant/revoke permissions, delete lens, all belowHighest explicit grant
editEdit hierarchy, inline edit issues, move nodes, create/edit/delete generators, all belowStandard editor
viewRead-only access to the lensLowest explicit grant

Permissions cascade upward: a user with edit implicitly has view. A user with control has all lower permissions.

grantee_typeDescription
userSpecific Atlassian account ID
groupJira group name
roleJira project role
everyoneAll users on the Jira site (grantee_id is NULL)

The lens_permissions table enforces a UNIQUE constraint on (lens_id, grantee_type, grantee_id) to prevent duplicate grants.

  1. Resolver receives a request with context.accountId
  2. permission-service.ts calls checkPermission(lensId, accountId, requiredLevel)
  3. Checks if user is the lens owner (implicit control)
  4. Queries lens_permissions for explicit grants
  5. Resolves group/role membership if applicable
  6. Returns the user’s effective permission level

Users can only see issues they have Jira BROWSE permission for, regardless of their lens-level access.

  1. When loading a lens view, the backend fetches the hierarchy tree from Forge SQL.
  2. Before returning rows to the frontend, it filters by Jira BROWSE permission.
  3. BROWSE permission is checked via batch JQL query: issues the user can’t see are excluded from results.
  4. Results are cached in Forge KVS for 30 minutes per project per user to avoid redundant API calls.

If a parent node is hidden (user lacks BROWSE on the parent issue), all children are also hidden. This prevents information leakage through the tree hierarchy structure.

If the Jira API is unreachable during permission checks, the app assumes all issues are visible. This is a deliberate design choice:

  • Rationale: A Jira API outage should not block all users from accessing the app. The risk of briefly showing issues a user shouldn’t see is lower than the cost of a complete app outage.
  • Mitigation: BROWSE permission checks are retried before falling back. The 30-minute KVS cache means recent permission decisions are honored even during brief outages.

The manifest declares these Jira API scopes:

ScopePurpose
read:jira-workRead issues, projects, boards, sprints
write:jira-workUpdate issues (inline editing, status transitions)
read:jira-userRead user profiles (assignee picker, user search)
read:sprint:jira-softwareRead sprint data for sprint dropdown
read:board-scope:jira-softwareRead board scope for project analysis
read:project:jiraRead project metadata
storage:appAccess Forge SQL and KVS

The manifest declares:

permissions:
content:
styles:
- 'unsafe-inline'

This is required for:

  • Atlassian Design System CSS custom properties (var(--ds-*))
  • AG Grid dynamic inline styles (cell formatting, density, conditional formatting)

Forge enforces script-src 'self' by default for Custom UI apps, preventing script injection. The unsafe-inline for styles only applies to CSS, not JavaScript.

Backend functions can only make HTTP requests to explicitly declared domains:

permissions:
external:
fetch:
backend:
- address: "*.ingest.sentry.io"
inScopeEUD: false
DomainPurposeEUD Data
*.ingest.sentry.ioError tracking (Sentry)No (inScopeEUD: false)

The inScopeEUD: false declaration indicates that no End User Data is transmitted to this endpoint, which is relevant for Marketplace data handling policies.

Foundation uses two modes for Jira API calls:

ModeWhen UsedPermission Model
asUser()Most operations (inline edit, transitions, user search)Runs with the current user’s Jira permissions
asApp()Admin-level operations, background tasks, event handlersRuns with the app’s system permissions
  • All asUser() calls are wrapped in try/catch blocks.
  • Raw API errors are never propagated to the frontend (prevents ECO-1297 false re-consent).
  • Controlled error objects are returned instead: { error: 'message' }.
  • Used only when asUser() cannot fulfill the operation (e.g., ECO-1222 ADMINISTER permission issue).
  • When using asApp(), the resolver performs explicit permission validation in application code before executing.

Jira API rate limits are a shared resource:

TierBudgetScope
Tier 165,000 points/hourGlobal pool across ALL app installations
Tier 2 (granted)100,000-500,000 points/hourPer-tenant

Mitigation strategies:

  • Product event triggers are free (no rate limit cost). The event-driven issue cache avoids most direct API calls.
  • KVS-backed rate limit counters (utils/rate-limiter.ts) track and throttle outbound API usage.
  • Generator execution (bulk JQL) runs as async events with 15-minute timeout, naturally spreading load.
  • Encryption at rest: Managed by Atlassian’s infrastructure.
  • Tenant isolation: Each Jira site installation has its own isolated Forge SQL database (1 GiB limit).
  • No cross-tenant access: One installation’s data is never accessible from another installation.
  • Per-installation isolation: Same as SQL — each installation has its own KVS namespace.
  • Sensitive variables: Environment variables (like SENTRY_DSN) are stored encrypted using forge variables set --encrypt.
  • User-scoped data: Announcement dismissals are stored per user (announcements_dismissed:${accountId}).
  • Cross-origin iframe: The Custom UI runs in a Forge-managed iframe with strict CSP. The frontend cannot access the parent page’s DOM or cookies.
  • No direct API calls: The frontend communicates with the backend exclusively through @forge/bridge invoke() calls. It cannot make direct HTTP requests to Jira APIs.
  • No secrets in frontend code: API keys, tokens, and credentials are never included in the frontend bundle. All external service communication goes through the backend.
  • Resolvers validate required payload fields before processing.
  • The "schema" key is never used in resolver payloads (ECO-1307 — Forge bridge rejects it).
  • Resolvers never return bare undefined (ECO-277 — would become {} on frontend).
// Standard pattern for resolver error handling
resolver.define('methodName', async ({ payload, context }) => {
try {
// Permission check first
await checkPermission(payload.lensId, context.accountId, 'edit');
// Business logic
const result = await someOperation(payload);
return { data: result };
} catch (error) {
// Never propagate raw errors -- return controlled response
return { error: error.message || 'Operation failed' };
}
});

Every resolver that modifies data follows this pattern:

  1. Extract accountId from context (Forge-provided, tamper-proof)
  2. Check lens-level permission via permission-service.ts
  3. If the operation touches Jira issues, verify BROWSE permission
  4. Execute the operation
  5. Return controlled response
BugRiskMitigation
ECO-1297401 from asUser() triggers confusing consent dialogAll asUser() calls wrapped in try/catch
ECO-1222ADMINISTER perm not recognized with asUser()Use asApp() with explicit permission validation
ECO-1356/1198Stale context could show wrong issue’s dataContext-key guards verify issue before rendering
ECO-277undefined -> {} could bypass “no data” checksAlways return explicit null or { data: null }
ECO-1175Missing permissions on install if Jira unavailableRe-installing fixes permissions