Skip to content

Fix role-based authorization in BFF with role-to-scope mapping support - #3089

Merged
Thushani-Jayasekera merged 6 commits into
wso2:mainfrom
Thushani-Jayasekera:final-test
Aug 3, 2026
Merged

Fix role-based authorization in BFF with role-to-scope mapping support#3089
Thushani-Jayasekera merged 6 commits into
wso2:mainfrom
Thushani-Jayasekera:final-test

Conversation

@Thushani-Jayasekera

@Thushani-Jayasekera Thushani-Jayasekera commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

Logging into the AI Workspace against Microsoft Entra ID succeeded, but every operation
in the UI appeared blocked
. /api/session reported:

{ "user": { "scopes": ["access"], "role": "", ... } }

Three independent defects combined to produce that.

1. The BFF read scopes from the wrong place under role authorization. When the
Platform API runs [auth.authorization] mode = "role", a caller's effective scopes are
not in their token — the server derives them per request by expanding the roles claim
through role-to-scope-mapping.yaml (resolvePlatformRoles). The BFF instead read the
token's scope/scp claim verbatim. An Entra token carries no ap:* scope at all — its
scp holds only the application's own API scope (access) — so the SPA, which gates
every action on the scopes /api/session reports, hid everything the Platform API would
in fact have authorized.

2. An array-valued roles claim was silently dropped. UserFromClaims read roles via
a string type assertion. Entra emits roles: ["ap_admin"], so the assertion failed and
user.role came back empty — for any IDP using the array form.

3. GET /me/api-keys was unreachable. It accepts only ap:api_key:read or
ap:api_key:all:manage, and the default OIDC scope set requested neither, so listing
your own API keys always returned 403. Found by the new coverage test, not by inspection.

What changed

BFF derives effective scopes the same way the Platform API does

New [ai_workspace.auth.authorization] table, mirroring
[platform_api.auth.authorization] key for key:

[ai_workspace.auth.authorization]
mode                  = "role"
role_to_scope_mapping = "/etc/ai-workspace/role-to-scope-mapping.yaml"

In role mode the BFF expands the roles claim through the grant table and reports the
result, so /api/session now returns the real permission list:

{ "user": { "scopes": ["ap:organization:manage", "ap:project:manage", ...] } }

internal/session/role_scope_map.go loads the table — the same file, in the same shape,
that the Platform API reads, and that api-portal already reads through its own
roleScopeMap.js. Both services must mount the same file, or the UI's view of what a
role grants drifts from what is enforced; docker-compose.yaml now mounts it into the
ai-workspace service alongside platform-api.

The Platform API remains the only enforcement point. This expansion exists for
display and UI gating.

Fail-closed configuration

  • An unrecognized mode fails startup rather than falling through to reading the scope
    claim — the failure mode there is a UI in which nothing is permitted and no error
    saying why.
  • mode = "role" without role_to_scope_mapping fails startup: the expansion could only
    ever yield zero scopes.
  • A grant table that cannot be read, parses as invalid YAML, has an entry without a
    name, or declares a role twice fails startup. Duplicate roles are rejected rather
    than last-wins, since one entry would otherwise be silently inert depending on file
    order.
  • Role mode does not fall back to the scope claim when an expansion comes back empty.
    A role the operator never mapped granting nothing is a real deny-by-default outcome and
    matches what the Platform API decides for the same token; falling back would show
    actions as available that then 403.
  • The mapping path rejects .. on the raw input, before normalization (filepath.Clean
    would collapse traversal into a path that passes a later check), plus null bytes, a
    directory, and anything over 1 MiB.

Claim reading

strSliceClaim reads a claim that may be a single string, a space-delimited string, or
an array — Asgardeo sends roles as a string, Entra ID as an array.

Default OIDC scopes

Added ap:api_key:read (defect 3). Deliberately not the ap:api_key:all:manage
alternative: that is the cross-user ownership override (GO-AUTH-019) and must be
requested explicitly by a deployment that wants it, never granted to every session.

The full granular set is otherwise unchanged. A trim to :manage/:read per resource
was considered and rejected: an IDP grants the intersection of what is requested and what
the user is entitled to, so trimming would silently strip a least-privilege user's grant
— a user holding only ap:rest_api:create would lose it.

Worth recording, since it is easy to assume otherwise: :manage is not hierarchical.
scopeSatisfies("ap:gateway:manage", "ap:gateway:token:read") is false. Parent
:manage works only where an operation's own OpenAPI security block lists it as an
alternative — per-endpoint enumeration, not scope expansion. The event-gateway plugin's
ap:websub_api:deployment:manage and ap:webbroker_api:api_key:manage are not covered
by their parents at all.

Platform API

keyPlatformRoles renamed to keyRoles with context value "roles", matching the
default claim name. No behaviour change.

Tests

  • role_scope_map_test.go — loading, deduplication, and every rejection path (duplicate
    role, missing name, absent roles list, malformed YAML, traversal, null byte, missing
    file, directory). Also loads the shipped grant table, so it cannot drift into a
    shape this loader rejects.
  • claims_test.go — role-mode expansion for the exact Entra token shape; that the
    token's own access scope does not leak through; multiple roles unioning; an unmapped
    role granting nothing; scope mode unaffected; roles as a string.
  • oidc_scopes_test.go — parses both OpenAPI specs and asserts the default scope set
    satisfies all 148 scoped operations; that the ownership override is absent; that
    offline_access is present; and that representative granular scopes are still
    requested, so a future trim cannot silently break least-privilege users. This test is
    what caught defect 3.
  • config_test.go — scope mode is the default, role mode parses, role mode without a
    mapping fails, an invalid mode fails, and the mapping path never reaches the browser.

Documentation

New portals/ai-workspace/production/ENTRA_ID_SETUP.md — the Entra ID counterpart to the
existing Asgardeo guide (production/README.md). It covers app registration, and the
three ways Entra differs from Asgardeo materially enough to change the setup:

Asgardeo Entra ID
Custom ap:* scopes Registered in the IDP, requested at login Not possible — App Roles + role mode instead
Access token audience The client itself Microsoft Graph unless you Expose an API
Token version One format v1 and v2 with different issuers — must be pinned to v2

Also documented, each with cause and fix, from bringing a real tenant up:

  • AADSTS90015: Requested query string is too long — the default ap:* scope set is
    ~5 KB encoded, past Entra's authorize-URL limit. Entra deployments must override
    [auth.oidc] scope with their own resource scope; the shared default is not the thing
    to change.
  • AADSTS500011: resource principal not foundExpose an API not completed.
  • v1-vs-v2 issuer mismatch — requestedAccessTokenVersion left unset, so tokens carry
    iss: https://sts.windows.net/<tid>/ and every call fails the Platform API's issuer
    check while login itself succeeds.
  • /api/session 401 in a redirect loop — the callback failed and the SPA re-triggered
    login, masking the real error; the reason is logged at debug, not info.
  • The UI cannot auto-provision the organization with an Entra token. Auto-provisioning
    is gated on an org_handle claim (AppShellContext.tsx), which Entra does not emit, so
    POST /organizations never fires and the fallback path dead-ends on "Organization not
    found. Please contact your administrator."
    The guide documents the manual curl; see
    Follow-ups.
  • Bootstrap requires ap_admin — no shipped role grants ap:organization:create, and the
    pre-registration FOREIGN KEY constraint failed warning names the raw tenant ID rather
    than the actual cause.
  • Migrating from file-based auth: an organization created in file mode has an empty
    idp_organization_ref_uuid, which the lookup never matches, so it is invisible to
    IDP-mode auth.

Compatibility

Default mode = "scope" preserves current behaviour exactly — an operator who never adds
the new table sees no change. Scope-mode deployments (Asgardeo, WSO2 IS) are unaffected
apart from the added ap:api_key:read.

Verification

go build ./... and go test ./... pass for both the BFF and platform-api.

Not yet verified end to end against a live Entra tenant. After deploying, confirm the
expansion with:

curl -k https://<host>/api/session -b "_ai_workspace_session=<cookie>" | jq '.user.scopes'

["access"] means the BFF is still in scope mode; [] means the token's roles are not in
the grant table.

Follow-ups (not in this PR)

  1. No IDP that omits org_handle can onboard through the UI. That is most of them,
    Entra included. The fallback branch in AppShellContext.tsx should provision from the
    token's organization id rather than dead-ending on an error.
  2. The membership-heal warning misreports its cause. ListOrganizationsForUser calls
    AddMembership with an unresolved organization claim, surfacing a FK error instead of
    "the organization claim does not resolve". OrganizationResolverMiddleware knows
    resolution failed but does not record it; a resolved flag in the context would let
    the heal skip and report accurately.
  3. No role grants ap:organization:create, so ap_admin's blanket :manage is the
    only bootstrap path.
  4. session.idle_timeout is inert. It is parsed and validated but nothing calls
    Store.Touch, so there is no sliding idle window — an ASVS V3.3.2 gap. Either wire it
    up or remove the key.
  5. refreshLocks is per-process, so the single-flight guarantee around refresh-token
    rotation disappears with more than one BFF replica.

…support

- Introduced AuthorizationConfig to manage authorization modes (scope/role) in AuthConfig.
- Updated claim mapping to handle role-based scope expansion.
- Added role-to-scope mapping functionality to align with platform API.
- Enhanced tests to validate role mode behavior and scope expansion.
- Updated configuration template to include role-to-scope mapping settings.
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The AI Workspace BFF now supports scope- and role-based authorization. It validates and loads role mappings at startup, then derives effective scopes from JWT roles. Platform middleware uses the renamed keyRoles context key.

Changes

Authorization flow

Layer / File(s) Summary
Authorization configuration and scope coverage
portals/ai-workspace/bff/internal/config/*, portals/ai-workspace/configs/config-template.toml, portals/ai-workspace/src/config.env.ts, portals/ai-workspace/bff/go.mod
Adds authorization modes, role-mapping configuration, validation, default scopes, deployment configuration, and OIDC scope coverage tests.
Role-to-scope mapping
portals/ai-workspace/bff/internal/session/role_scope_map*
Loads and validates YAML mappings, normalizes scopes, and expands multiple roles in first-seen order.
Claim mapping and startup wiring
portals/ai-workspace/bff/internal/server/server.go, portals/ai-workspace/bff/internal/session/claims*
Loads role mappings during startup and derives user scopes from roles in role mode while preserving scope mode behavior.
Deployment and platform role context
portals/ai-workspace/docker-compose.yaml, platform-api/internal/middleware/*
Mounts the mapping file, retains the HTTPS health check, and replaces keyPlatformRoles with keyRoles.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IDP
  participant BFF
  participant RoleScopeMap
  participant PlatformAPI
  BFF->>RoleScopeMap: Load role-to-scope mapping
  IDP->>BFF: Send JWT roles
  BFF->>RoleScopeMap: Expand roles into scopes
  RoleScopeMap-->>BFF: Return effective scopes
  BFF->>PlatformAPI: Send authorized request
Loading

Possibly related PRs

Suggested reviewers: anugayan, krishanx92, renuka-fernando

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the problem, implementation, tests, documentation, compatibility, and verification, but omits several required template sections. Add explicit User stories, Security checks, Samples, Related PRs, and Test environment sections, or mark them N/A with appropriate details.
Docstring Coverage ⚠️ Warning Docstring coverage is 68.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: role-based authorization with role-to-scope mapping support in the BFF.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain modules listed in go.work or their selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
portals/ai-workspace/bff/internal/config/config.go (1)

312-327: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize Authorization.Mode case before validating it.

normalize() lowercases c.Auth.Mode before validate() compares it. c.Auth.Authorization.Mode gets no such treatment, so validate() rejects a functionally valid value such as "Role" or "SCOPE" with a generic invalid-mode error. Apply the same normalization used for Auth.Mode.

🐛 Proposed fix
 func (c *Config) normalize() {
 	c.Logging.Level = strings.ToLower(c.Logging.Level)
 	c.Logging.Format = strings.ToLower(c.Logging.Format)
 	c.Auth.Mode = strings.ToLower(c.Auth.Mode)
+	c.Auth.Authorization.Mode = strings.ToLower(c.Auth.Authorization.Mode)

Also applies to: 338-344

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@portals/ai-workspace/bff/internal/config/config.go` around lines 312 - 327,
Update Config.normalize to lowercase c.Auth.Authorization.Mode alongside
c.Auth.Mode before validation. Preserve the existing authorization configuration
and validation flow so case-insensitive values such as “Role” and “SCOPE” are
normalized before validate compares them.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@portals/ai-workspace/bff/internal/session/role_scope_map.go`:
- Around line 80-93: Update the mapping-loading function around the existing
os.Stat and os.ReadFile calls to open the cleaned path once, validate the opened
descriptor’s FileInfo with Mode().IsRegular(), and reject non-regular files.
Read through io.LimitReader with a limit of maxMappingBytes+1, reject data
exceeding maxMappingBytes, then parse the bounded content; remove the separate
pre-validation and path-based read so replacements and FIFOs cannot bypass the
size or regular-file checks.

---

Outside diff comments:
In `@portals/ai-workspace/bff/internal/config/config.go`:
- Around line 312-327: Update Config.normalize to lowercase
c.Auth.Authorization.Mode alongside c.Auth.Mode before validation. Preserve the
existing authorization configuration and validation flow so case-insensitive
values such as “Role” and “SCOPE” are normalized before validate compares them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9aabc9c2-b59a-46a2-a460-2c92a15fa8d4

📥 Commits

Reviewing files that changed from the base of the PR and between 397df10 and f1b3462.

📒 Files selected for processing (13)
  • platform-api/internal/middleware/auth.go
  • platform-api/internal/middleware/scope_enforcer_test.go
  • portals/ai-workspace/bff/internal/config/config.go
  • portals/ai-workspace/bff/internal/config/config_test.go
  • portals/ai-workspace/bff/internal/config/default_config.go
  • portals/ai-workspace/bff/internal/config/oidc_scopes_test.go
  • portals/ai-workspace/bff/internal/server/server.go
  • portals/ai-workspace/bff/internal/session/claims.go
  • portals/ai-workspace/bff/internal/session/claims_test.go
  • portals/ai-workspace/bff/internal/session/role_scope_map.go
  • portals/ai-workspace/bff/internal/session/role_scope_map_test.go
  • portals/ai-workspace/configs/config-template.toml
  • portals/ai-workspace/docker-compose.yaml

Comment thread portals/ai-workspace/bff/internal/session/role_scope_map.go Outdated
@Thushani-Jayasekera Thushani-Jayasekera changed the title Implement role-based authorization in BFF with role-to-scope mapping support Fix role-based authorization in BFF with role-to-scope mapping support Aug 3, 2026
- Updated LoadRoleScopeMap function to open the file and validate its type using os.Open and f.Stat.
- Changed error message for non-regular files to be more specific.
- Enhanced test case to reflect the updated error message for directory paths.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 3, 2026
…n mode

- Updated the normalize function to apply case-folding to the Auth.Authorization.Mode field.
- Improved comments for clarity on the normalization process and its implications.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@portals/ai-workspace/configs/config-template.toml`:
- Line 284: Update the explicit scope value in the template’s scope
configuration to include the standalone ap:api_key:read permission, and extend
the existing OIDC scope test covering this template to assert that the
permission is requested.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3d6572bc-2a26-4dde-b8ab-378a0fa6a867

📥 Commits

Reviewing files that changed from the base of the PR and between f1b3462 and 2f22378.

⛔ Files ignored due to path filters (1)
  • portals/ai-workspace/bff/go.sum is excluded by !**/*.sum
📒 Files selected for processing (8)
  • portals/ai-workspace/bff/go.mod
  • portals/ai-workspace/bff/internal/config/config.go
  • portals/ai-workspace/bff/internal/config/oidc_scopes_test.go
  • portals/ai-workspace/bff/internal/session/role_scope_map.go
  • portals/ai-workspace/bff/internal/session/role_scope_map_test.go
  • portals/ai-workspace/configs/config-template.toml
  • portals/ai-workspace/docker-compose.yaml
  • portals/ai-workspace/src/config.env.ts
💤 Files with no reviewable changes (1)
  • portals/ai-workspace/src/config.env.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • portals/ai-workspace/bff/internal/session/role_scope_map.go
  • portals/ai-workspace/docker-compose.yaml
  • portals/ai-workspace/bff/internal/session/role_scope_map_test.go
  • portals/ai-workspace/bff/internal/config/oidc_scopes_test.go

Comment thread portals/ai-workspace/configs/config-template.toml
@Thushani-Jayasekera
Thushani-Jayasekera merged commit a3f69a2 into wso2:main Aug 3, 2026
10 of 11 checks passed
@Thushani-Jayasekera

Copy link
Copy Markdown
Contributor Author

issue: #3093

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants