Skip to content

chore: Add tokengrant for JWT permission context (no-changelog)#28295

Merged
phyllis-noester merged 2 commits intomasterfrom
iam-467-2b3-tokengrant-type-and-scope-enforcement-changes
Apr 10, 2026
Merged

chore: Add tokengrant for JWT permission context (no-changelog)#28295
phyllis-noester merged 2 commits intomasterfrom
iam-467-2b3-tokengrant-type-and-scope-enforcement-changes

Conversation

@phyllis-noester
Copy link
Copy Markdown
Contributor

@phyllis-noester phyllis-noester commented Apr 10, 2026

IAM-467: TokenGrant type and scope enforcement changes

What was done

1. Added the TokenGrant interface (packages/@n8n/db/src/entities/types-db.ts)

A new interface attached to AuthenticatedRequest that carries the permission context from a scoped JWT:

interface TokenGrant {
  roles?: string[];     // role URNs — for audit logging only
  scopes: string[];     // concrete scopes resolved from roles
  actor?: { userId: string }; // delegation context
}

roles is optional on TokenGrant. The resource field from the ticket spec was
deliberately omitted — resource constraint enforcement is out of scope for this slice.

2. Rewired ApiKeyAuthStrategy to populate req.tokenGrant

The strategy now queries ApiKeyRepository directly (instead of UserRepository via
relation join), and after successful auth sets:

req.tokenGrant = { scopes: apiKeyRecord.scopes ?? [] };

Scopes are resolved once at auth time and stored on the request, rather than
re-checking the raw API key in the scope middleware.

3. Replaced apiKeyHasScope with publicApiScope in global.middleware.ts

The old getApiKeyScopeMiddleware re-fetched the API key from the DB on every scope
check — a redundant DB hit after auth. The new makeScopeEnforcementMiddleware simply
reads req.tokenGrant.scopes. If no tokenGrant is present, it returns 403 immediately.

4. Removed getApiKeyScopeMiddleware from PublicApiKeyService — dead code once
scope enforcement moved to reading from the request.


Design decisions

Decision Rationale
Scopes resolved once at auth time Avoids a second DB round-trip per endpoint. The auth strategy is the single place where the API key record is loaded, so that's where scopes should be extracted.
roles preserved but not enforced Roles are available for downstream audit logging per the spec, but enforcement uses only the already-resolved scopes array — avoids re-doing role→scope resolution on every request.
resource field omitted Resource constraint enforcement is a separate concern, not part of this slice. The interface is open to adding it later.
403 when tokenGrant absent Treats absence of a token grant as an authorisation failure, not an authentication failure. If future non-API-key auth paths need to flow through publicApiScope, this will need revisiting.
No changes to session/RBAC auth paths tokenGrant is optional on AuthenticatedRequest; existing session-based routes that don't go through publicApiScope are entirely unaffected.
Query through ApiKeyRepository directly The old strategy joined through UserRepository to reach the API key. Reversing the join (ApiKey → User) gives direct access to apiKeyRecord.scopes without an extra query.

Related Linear tickets, Github issues, and Community forum posts

[

](https://linear.app/n8n/issue/IAM-467/2b3-tokengrant-type-and-scope-enforcement-changes)

Manual API testing

Three curl calls against http://localhost:5678 to verify the full auth path end-to-end.

Replace <API_KEY> with a valid scoped JWT API key.

1. GET /api/v1/workflows — scope gate + role-based result filtering

Exercises publicApiScope('workflow:list') (reads req.tokenGrant.scopes) and the role branch in the handler (req.user.role.slug at workflows.handler.ts:158). If the role relation was not loaded by the new ApiKeyRepository join, this call would 500.

curl -s -X GET 'http://localhost:5678/api/v1/workflows' \
  -H 'X-N8N-API-KEY: <API_KEY>' \
  | jq '{count: .count, firstWorkflow: .data[0].name}'

Expected: { "count": N, "firstWorkflow": "..." }

2. POST /api/v1/users — user creation through apiKeyHasScopeWithGlobalScopeFallback

Exercises the scope path that now also reads from req.tokenGrant rather than re-fetching the API key from the DB.

curl -s -X POST 'http://localhost:5678/api/v1/users' \
  -H 'X-N8N-API-KEY: <API_KEY>' \
  -H 'Content-Type: application/json' \
  -d '[{"email": "test-api-user@example.com", "role": "global:member"}]' \
  | jq '.'

Expected: array with the created user, or { "message": "User already exists" } on re-run.

3. GET /api/v1/credentials — direct req.user.role.slug check in handler body

At credentials.handler.ts:185 the handler reads req.user.role.slug to decide whether to return all credentials or only the caller's own. This is the most targeted test that relations: { user: { role: true } } in the new ApiKeyRepository query actually populates the role.

curl -s -X GET 'http://localhost:5678/api/v1/credentials' \
  -H 'X-N8N-API-KEY: <API_KEY>' \
  | jq '{count: .count, role_check: "passed - role was loaded"}'

Expected: { "count": N, "role_check": "passed - role was loaded" } — a 500 here means req.user.role was null.

Review / Merge checklist

  • I have seen this code, I have run this code, and I take responsibility for this code.
  • PR title and summary are descriptive. (conventions)
  • Docs updated or follow-up ticket created.
  • Tests included.
  • PR Labeled with Backport to Beta, Backport to Stable, or Backport to v1 (if the PR is an urgent fix that needs to be backported)

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 10, 2026

Codecov Report

❌ Patch coverage is 91.66667% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/cli/src/services/api-key-auth.strategy.ts 71.42% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 issues found across 17 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts:29">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, these workflow routes must enforce `tokenGrant.resource` as well as `tokenGrant.scopes`. Switching them to `publicApiScope()` introduces a resource-level authorization bypass: a token scoped to project X can still operate on workflows from another project if the backing user has access.</violation>
</file>

<file name="packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts">

<violation number="1" location="packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts:111">
P1: According to linked Linear issue IAM-467, missing `tokenGrant` should preserve the previous scope-check path instead of returning `403` immediately.</violation>

<violation number="2" location="packages/cli/src/public-api/v1/shared/middlewares/global.middleware.ts:116">
P0: Custom agent: **Security Review**

Security Review / Authorization & Access Control: according to linked Linear issue IAM-467, token grants with a `resource` constraint must also validate the requested resource. This middleware only checks `tokenGrant.scopes`, so a token scoped to project X can still access project Y.</violation>
</file>

<file name="packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts:116">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, these credential write routes must enforce `tokenGrant.resource` as well as `tokenGrant.scopes`. Using `publicApiScope` here only checks scopes, so a JWT scoped to project X can still update, move, or delete a credential in project Y if `req.user` has broader access.</violation>

<violation number="2" location="packages/cli/src/public-api/v1/handlers/credentials/credentials.handler.ts:161">
P1: According to linked Linear issue IAM-467, this route still ignores token `resource` constraints, so a project-scoped grant can move credentials across projects as long as the backing user has access.</violation>
</file>

<file name="packages/cli/src/services/api-key-auth.strategy.ts">

<violation number="1" location="packages/cli/src/services/api-key-auth.strategy.ts:46">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, this scopes-only token grant strips the JWT `resource` constraint, so `publicApiScope` cannot enforce project/resource boundaries. Populate `req.tokenGrant` from the verified JWT claims, including `resource`, instead of replacing it with only persisted scopes.</violation>
</file>

<file name="packages/cli/src/public-api/v1/handlers/community-packages/community-packages.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/community-packages/community-packages.handler.ts:17">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, token-grant enforcement must reject requests whose target does not match `tokenGrant.resource`. These routes now use `publicApiScope`, but that middleware only checks scopes, so a resource-constrained JWT can still perform global community-package operations.</violation>
</file>

<file name="packages/cli/src/public-api/v1/__tests__/global.middleware.test.ts">

<violation number="1" location="packages/cli/src/public-api/v1/__tests__/global.middleware.test.ts:40">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, token grants must enforce their `resource` constraint as well as scopes. This change only validates `tokenGrant.scopes`, so a token scoped to project X can still be used against project Y.</violation>
</file>

<file name="packages/@n8n/db/src/entities/types-db.ts">

<violation number="1" location="packages/@n8n/db/src/entities/types-db.ts:421">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, `TokenGrant` must carry an optional `resource` constraint. Omitting it here removes the request-level field needed to enforce project/resource boundaries for scoped tokens.</violation>
</file>

<file name="packages/cli/src/public-api/v1/handlers/insights/insights.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/insights/insights.handler.ts:36">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, token grants on project-scoped routes must reject requests whose target project does not match the token resource. This endpoint now uses `publicApiScope('insights:read')`, but that middleware only checks `tokenGrant.scopes` and this handler still forwards any `query.data.projectId`, so a token scoped to project X can read project Y insights.</violation>
</file>

<file name="packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts:72">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, these data-table routes must enforce `tokenGrant.resource` as well as `tokenGrant.scopes`. Swapping to `publicApiScope()` here authorizes the token's scope but still lets requests act on any project the underlying user can access, so a token constrained to project X can list/create/read/update/delete data tables in project Y.</violation>
</file>

<file name="packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts:69">
P0: Custom agent: **Security Review**

According to linked Linear issue IAM-467, this route must enforce `tokenGrant.resource` on token-grant requests. Replacing `apiKeyHasScope` with `publicApiScope` here enables scope-only checks on a resource-scoped data-table endpoint, so a token for project X can authorize row access in project Y.</violation>
</file>

<file name="packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts">

<violation number="1" location="packages/cli/src/public-api/v1/handlers/executions/executions.handler.ts:37">
P1: According to linked Linear issue IAM-467, these execution routes must reject token grants constrained to a different resource, but `publicApiScope()` only checks `tokenGrant.scopes`. A token scoped to project X can still reach execution endpoints for project Y if the user otherwise has access.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client
    participant Auth as ApiKeyAuthStrategy
    participant DB as ApiKeyRepository
    participant MW as publicApiScope Middleware
    participant Handler as API Route Handler

    Note over Client,Handler: Public API Request Flow (Scoped JWT/API Key)

    Client->>Auth: Request with x-n8n-api-key
    
    Auth->>DB: CHANGED: findOne() via ApiKeyRepository (Direct lookup)
    DB-->>Auth: apiKeyRecord (includes User and Scopes)
    
    alt API Key invalid or User disabled
        Auth-->>Client: 401 Unauthorized / 403 Forbidden
    else Valid Credentials
        Auth->>Auth: NEW: Extract scopes from record
        Auth->>Auth: NEW: Attach tokenGrant to Request object
        Note right of Auth: req.tokenGrant = { scopes: [...] }
        Auth-->>MW: next()
    end

    Note over MW: Enforcement Phase

    alt NEW: req.tokenGrant is missing
        MW-->>Client: 403 Forbidden
    else tokenGrant present
        MW->>MW: CHANGED: Check req.tokenGrant.scopes (No DB hit)
        
        alt Required scope missing
            MW-->>Client: 403 Forbidden
        else Required scope present
            MW-->>Handler: next()
            Handler-->>Client: 200 OK + Data
        end
    end
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

@n8n-assistant n8n-assistant bot added core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team labels Apr 10, 2026
@phyllis-noester phyllis-noester requested review from a team, BGZStephen, afitzek, cstuncsik and guillaumejacquart and removed request for a team April 10, 2026 09:09
if (typeof providedApiKey !== 'string' || !providedApiKey) return null;

const user = await this.userRepository.findOne({
where: {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this performance improvement e(not doing two separate calls) it worth it. In theory, it should result in the same result and I tested the behavior but also not worth it to break the public API for people in case there is an edge case I didn't consider

Copy link
Copy Markdown
Contributor

@afitzek afitzek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, and tested manually 🚀

@phyllis-noester phyllis-noester added this pull request to the merge queue Apr 10, 2026
Merged via the queue into master with commit 99e5f15 Apr 10, 2026
144 of 151 checks passed
@phyllis-noester phyllis-noester deleted the iam-467-2b3-tokengrant-type-and-scope-enforcement-changes branch April 10, 2026 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Enhancement outside /nodes-base and /editor-ui n8n team Authored by the n8n team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants