diff --git a/kubernetes/helm/developer-portal-helm-chart/values-local.yaml b/kubernetes/helm/developer-portal-helm-chart/values-local.yaml index e5b0e4985..87afd81b9 100644 --- a/kubernetes/helm/developer-portal-helm-chart/values-local.yaml +++ b/kubernetes/helm/developer-portal-helm-chart/values-local.yaml @@ -26,5 +26,8 @@ developer-portal-ui: level: debug platformApi: insecure: true - security: - roleValidation: false + auth: + authorization: + # Relax page role-tier gating for local dev. REST scope enforcement + # (authorization.enabled) stays on — it is a separate switch. + pageRoleValidation: false diff --git a/kubernetes/helm/developer-portal-ui-helm-chart/templates/configmap.yaml b/kubernetes/helm/developer-portal-ui-helm-chart/templates/configmap.yaml index 967ae770c..affd500ca 100644 --- a/kubernetes/helm/developer-portal-ui-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/developer-portal-ui-helm-chart/templates/configmap.yaml @@ -75,13 +75,33 @@ data: [api_portal.auth] mode = {{ ternary "idp" "local" (ne $auth.idp.clientId "") | quote }} - role_validation = {{ $cfg.security.roleValidation }} [api_portal.auth.claim_mappings] organization = {{ $auth.claimMappings.organization | quote }} roles = {{ $auth.claimMappings.roles | quote }} groups = {{ $auth.claimMappings.groups | quote }} + {{- $authz := $auth.authorization }} + {{- if and (ne $authz.mode "scope") (ne $authz.mode "role") }} + {{- fail (printf "config.auth.authorization.mode must be \"scope\" or \"role\", got %q" $authz.mode) }} + {{- end }} + {{- if and (eq $authz.mode "role") (not $authz.roleToScopeMapping) }} + {{- fail "config.auth.authorization.mode is \"role\" but config.auth.authorization.roleToScopeMapping is empty — the portal would refuse to start" }} + {{- end }} + # Authorization — what a verified token may do. Rendered outside the idp block + # below because it applies in every auth mode: a token carries the same roles + # claim whether the portal verified it against a JWKS endpoint or against the + # Platform API's public key. + [api_portal.auth.authorization] + enabled = {{ $authz.enabled }} + mode = {{ $authz.mode | quote }} + role_to_scope_mapping = {{ $authz.roleToScopeMapping | quote }} + page_role_validation = {{ $authz.pageRoleValidation }} + + [api_portal.auth.authorization.portal_roles] + admin = {{ $authz.portalRoles.admin | quote }} + subscriber = {{ $authz.portalRoles.subscriber | quote }} + [api_portal.auth.local] platform_api_url = {{ $platformApiUrl | quote }} public_key_path = {{ $auth.publicKeyPath | quote }} @@ -108,11 +128,6 @@ data: org_callback = {{ $auth.idp.orgCallback }} silent_sso = {{ $auth.idp.silentSso }} token_refresh_timeout_ms = {{ $auth.idp.tokenRefreshTimeoutMs | int }} - - [api_portal.auth.idp.roles] - admin = {{ $auth.idp.roles.admin | quote }} - subscriber = {{ $auth.idp.roles.subscriber | quote }} - super_admin = {{ $auth.idp.roles.superAdmin | quote }} {{- end }} {{- $org := $cfg.organization }} diff --git a/kubernetes/helm/developer-portal-ui-helm-chart/values-local.yaml b/kubernetes/helm/developer-portal-ui-helm-chart/values-local.yaml index 77a9409fa..96274e568 100644 --- a/kubernetes/helm/developer-portal-ui-helm-chart/values-local.yaml +++ b/kubernetes/helm/developer-portal-ui-helm-chart/values-local.yaml @@ -15,6 +15,8 @@ config: level: debug platformApi: insecure: true - security: - # Relax role checks for local dev only (secure default is true). - roleValidation: false + auth: + authorization: + # Relax page role-tier gating for local dev. REST scope enforcement + # (authorization.enabled) stays on — it is a separate switch. + pageRoleValidation: false diff --git a/kubernetes/helm/developer-portal-ui-helm-chart/values.yaml b/kubernetes/helm/developer-portal-ui-helm-chart/values.yaml index b4a65eefc..4407ff246 100644 --- a/kubernetes/helm/developer-portal-ui-helm-chart/values.yaml +++ b/kubernetes/helm/developer-portal-ui-helm-chart/values.yaml @@ -89,10 +89,6 @@ config: # --- Security --- security: - # Enforce per-operation role checks on incoming tokens. Secure by default; - # override to false locally (values-local.yaml) only if a deployment - # genuinely cannot supply role claims yet. - roleValidation: true # A shared API key some server-to-server callers present in a header. serviceApiKey: enabled: true @@ -144,11 +140,36 @@ config: orgCallback: false # redirect to the org's own landing page after login silentSso: true tokenRefreshTimeoutMs: 10000 - # Map IDP role names to the portal's internal roles (idp mode). - roles: + + # --- Authorization: what a verified token may do --- + # Outside auth.local/auth.idp on purpose — a token carries the same roles claim + # whichever way the portal verified it, so these apply in every auth mode. + authorization: + # Enforce each REST operation's declared dp:* scopes. Secure by default; + # false lets any authenticated caller through (development only). + enabled: true + # "role" (the default) expands the token's roles claim through + # roleToScopeMapping. "scope" reads the token's own scope claim instead — use + # it when the issuer mints dp:* scopes directly (an Asgardeo tenant registered + # via production/scripts/register_asgardeo_scopes.sh, or the Platform API in + # local auth mode). + mode: role + # Path to the role-to-scope grant table inside the container. REQUIRED in role + # mode — the chart refuses to render without it, since the portal would refuse + # to start. Defaults to the table baked into the image (WORKDIR /app). To change + # what a role grants, mount your own copy from a ConfigMap and point this at it. + roleToScopeMapping: ./resources/role-to-scope-mapping.yaml + # Gate portal pages on the caller's role tier (portalRoles below). Distinct + # from `enabled`, which governs the REST API — one switch for both would mean + # turning page gating off also silently turned REST enforcement off. + # This replaced config.security.roleValidation. + pageRoleValidation: false + # Which role name in the token's roles claim grants each page tier. Was + # auth.idp.roles, despite being read in local auth mode too. There were three + # tiers; the superAdmin one gated pages this portal does not serve and is gone. + portalRoles: admin: admin subscriber: Internal/subscriber - superAdmin: superAdmin organization: handle: default # URL slug: /{handle}/views/{viewName}; required diff --git a/portals/api-portal/Makefile b/portals/api-portal/Makefile index 4d914245a..627507c46 100644 --- a/portals/api-portal/Makefile +++ b/portals/api-portal/Makefile @@ -262,6 +262,10 @@ dist: clean-dist ## Build standalone API Portal distribution zip @mkdir -p $(DIST_DIR)/configs @mkdir -p $(DIST_DIR)/resources/api-portal/db-scripts @cp -R database/* $(DIST_DIR)/resources/api-portal/db-scripts +# Both components ship a role-to-scope-mapping.yaml, so each goes under its own +# resources// dir (matching db-scripts) rather than colliding on one +# resources/role-to-scope-mapping.yaml. The in-container mount paths are unchanged. + @cp resources/role-to-scope-mapping.yaml $(DIST_DIR)/resources/api-portal/role-to-scope-mapping.yaml @mkdir -p $(DIST_DIR)/resources/platform-api/db-scripts @mkdir -p $(DIST_DIR)/resources/samples @cp -R samples/apis $(DIST_DIR)/resources/samples/ @@ -285,12 +289,12 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config-template.toml" \ > $(DIST_DIR)/configs/.pa-config-template.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/role-to-scope-mapping.yaml" \ - > $(DIST_DIR)/resources/role-to-scope-mapping.yaml + > $(DIST_DIR)/resources/platform-api/role-to-scope-mapping.yaml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-config.toml @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml - @cp ../../platform-api/resources/role-to-scope-mapping.yaml $(DIST_DIR)/resources/role-to-scope-mapping.yaml + @cp ../../platform-api/resources/role-to-scope-mapping.yaml $(DIST_DIR)/resources/platform-api/role-to-scope-mapping.yaml endif # Require a [platform_api] root table — pre-unified configs would merge into a broken file. @if ! grep -q '^\[platform_api' $(DIST_DIR)/configs/.pa-config.toml; then \ @@ -311,7 +315,8 @@ endif @rm -f $(DIST_DIR)/configs/.pa-config.toml $(DIST_DIR)/configs/.pa-config-template.toml $(DIST_DIR)/configs/.aiw-config-template.toml # Point the platform-api mount at the merged config so both containers share one file. @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ - -e 's#\.\./\.\./platform-api/resources/role-to-scope-mapping\.yaml:#./resources/role-to-scope-mapping.yaml:#' \ + -e 's#\.\./\.\./platform-api/resources/role-to-scope-mapping\.yaml:#./resources/platform-api/role-to-scope-mapping.yaml:#' \ + -e 's#\./resources/role-to-scope-mapping\.yaml:#./resources/api-portal/role-to-scope-mapping.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts diff --git a/portals/api-portal/README.md b/portals/api-portal/README.md index 889a88ee0..3674be82d 100644 --- a/portals/api-portal/README.md +++ b/portals/api-portal/README.md @@ -255,10 +255,19 @@ For quick exploration without an IdP, the portal delegates credential validation [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$..." # bcrypt hash — generate with: htpasswd -bnBC 12 "" | tr -d ':\n' -roles = ["ap_admin"] # grants dp:organization:manage, dp:api:manage, … — see role-to-scope-mapping.yaml +roles = ["ap_admin"] # grants dp:organization:manage, dp:api:manage, … — see platform-api's role-to-scope-mapping.yaml ``` -To change what a portal user may do, edit that role's entry in `role-to-scope-mapping.yaml` — or name a second role alongside it — rather than listing scopes on the user block. +To change what a portal user may do, edit that role's entry in [platform-api's `role-to-scope-mapping.yaml`](../../platform-api/resources/role-to-scope-mapping.yaml) — or name a second role alongside it — rather than listing scopes on the user block. + +Note there are two files with this name, read by different components in different modes: + +| File | Read by | When | +|---|---|---| +| [`platform-api/resources/role-to-scope-mapping.yaml`](../../platform-api/resources/role-to-scope-mapping.yaml) | Platform API | Local auth — expands a file user's `roles` into the `scope` claim of the token it issues (roles named `ap_*`) | +| [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml) | This portal | `auth.authorization.mode = "role"` — expands an incoming token's roles claim on every request (roles named `dp_admin`, `dp_subscriber`) | + +Local auth uses the first; an external IDP in role mode uses the second. See [Authorization](docs/administer/authentication.md#authorization). The portal config (or `APIP_AP_AUTH_LOCAL_*` env vars) must point to the Platform API. `config.toml`'s own defaults assume Docker Compose, where `platform-api` is a resolvable hostname on the compose network — `npm run start:local` already overrides `platform_api_url` to `https://localhost:9243` (the sidecar's port published to the host) and `tls_skip_verify = true` (self-signed cert), so no manual edit is needed for that flow: @@ -277,30 +286,45 @@ organization is refused. ### Environment variable overrides -Every config key can be overridden with an `APIP_AP_*` environment variable. You can place these in a `.env` file at the project root. +There is **no** automatic `APIP_AP_*` override layer. A variable takes effect only where +`configs/config.toml` explicitly references it with a `{{ env "NAME" "fallback" }}` token +— the same design platform-api uses (see `src/config/configLoader.js`). Setting a +variable no key references does nothing, silently. -**Convention:** -- Prefix: `APIP_AP_` -- `_` separates nesting levels (one token = one config object level) -- `__` represents a literal underscore within a key name -- Tokens are matched case-insensitively against config keys (matched against the camelCase struct produced from the TOML's snake_case keys) +Following `platform-api/config/config.toml`, tokens are used sparingly: a key gets one +only where something actually drives it — the Compose database overrides, +`npm run start:local`, `docker-entrypoint.sh`, or a secret. Everything else is a plain +literal, so the file states its own effective configuration. + +These are the variables the shipped `configs/config.toml` honours: | Env var | Config path | |---------|-------------| +| `APIP_AP_SERVER_PORT` | `config.server.port` | +| `APIP_AP_SERVER_HTTPS_ENABLED` | `config.server.https.enabled` | +| `APIP_AP_LOGGING_LEVEL` | `config.logging.level` | +| `APIP_AP_DATABASE_DRIVER` | `config.database.driver` | +| `APIP_AP_DATABASE_PATH` | `config.database.path` | | `APIP_AP_DATABASE_HOST` | `config.database.host` | | `APIP_AP_DATABASE_PORT` | `config.database.port` | -| `APIP_AP_SERVER_HTTPS_ENABLED` | `config.server.https.enabled` | -| `APIP_AP_IDP_CLIENTID` | `config.auth.idp.clientId` | -| `APIP_AP_IDP_ISSUER` | `config.auth.idp.issuer` | -| `APIP_AP_SERVER_PORT` | `config.server.port` | -| `APIP_AP_SERVER_BASE_URL` | `config.server.baseUrl` | -| `APIP_AP_DATABASE_SSL_MODE` | `config.database.sslMode` | - -`.env` example: +| `APIP_AP_DATABASE_NAME` | `config.database.name` | +| `APIP_AP_DATABASE_USER` | `config.database.user` | +| `APIP_AP_DATABASE_PASSWORD` | `config.database.password` | +| `APIP_AP_AUTH_LOCAL_PLATFORM_API_URL` | `config.auth.local.platformApiUrl` | +| `APIP_AP_AUTH_LOCAL_PUBLIC_KEY_PATH` | `config.auth.local.publicKeyPath` | +| `APIP_AP_AUTH_LOCAL_TLS_SKIP_VERIFY` | `config.auth.local.tlsSkipVerify` | +| `APIP_AP_ORGANIZATION_HANDLE` | `config.organization.handle` | +| `APIP_AP_ORGANIZATION_DISPLAY_NAME` | `config.organization.displayName` | + +To make any other key settable from the environment, add the token to `config.toml` +yourself. To change something without an environment variable — including the +`[api_portal.auth.authorization]` block and the IDP settings — edit `config.toml`, or +layer a thin overlay with a second `--config` flag. + +`.env` example (loaded from `api-platform.env` at the project root): ```dotenv APIP_AP_DATABASE_HOST=my-postgres-host APIP_AP_DATABASE_PASSWORD=my-secret-password -APIP_AP_IDP_CLIENTID=my-client-id ``` --- diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index 11b9cac46..93b29305b 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -101,14 +101,15 @@ value = "" # ============================================================================= # AUTHENTICATION # ============================================================================= -# A mode gate plus the two backends it selects between: local (the default) and -# idp. Configure the block matching your chosen mode; the other is ignored. +# HOW a token is verified: a mode gate plus the two backends it selects between, +# local (the default) and idp. Configure the block matching your chosen mode; the +# other is ignored. What a verified token may DO is authorization, configured in +# its own mode-independent section further below. [api_portal.auth] # "local" — username/password validated against the Platform API control plane # ([api_portal.auth.local] below). "idp" — external OIDC IDP (auth.idp below). mode = "local" # local | idp -role_validation = false # Enforce per-operation role validation # JWT claim name mappings — which token claim carries each field. # Dot-notation supported for nested claims (e.g. "realm_access.roles"). @@ -145,11 +146,64 @@ token_refresh_timeout_ms = 10000 silent_sso = true # Enable silent SSO org_callback = false # Redirect to the org's own landing page after login -# Maps IDP role names to API Portal's internal roles. -[api_portal.auth.idp.roles] -admin = "admin" -subscriber = "Internal/subscriber" -super_admin = "superAdmin" +# ============================================================================= +# AUTHORIZATION +# ============================================================================= +# What a VERIFIED token may do. Deliberately its own section, outside both +# auth.local and auth.idp: a token carries the same roles claim whether the portal +# verified it against a JWKS endpoint or against the Platform API's public key, so +# these settings apply in every auth mode. +# +# Retired keys (startup fails if either is still present, because an ignored key +# would silently apply the default instead of what the file says): +# auth.role_validation -> auth.authorization.page_role_validation +# auth.idp.roles -> auth.authorization.portal_roles + +[api_portal.auth.authorization] +# Master switch for REST API (/api/v0.9) authorization. false lets any +# authenticated caller satisfy every operation's declared scope list — an explicit +# development opt-out that logs a warning at startup. +enabled = true +# How a REST request's effective scopes are derived: +# "role" — (the DEFAULT) by expanding the token's roles claim +# (auth.claim_mappings.roles) through role_to_scope_mapping below. Works +# for every issuer: an external IDP emits the roles its estate is +# organized around and has no reason to mint dp:* scopes, and the +# Platform API mints its own ap_* role names, which the shipped table +# aliases. The scope claim is ignored entirely in this mode, so a caller +# cannot widen a role's grant by requesting extra scopes. +# "scope" — from the token's own scope claim. Use this when the issuer mints dp:* +# scopes directly: an Asgardeo tenant set up with +# production/scripts/register_asgardeo_scopes.sh (which registers all +# dp:* scopes and lets you attach them to an Asgardeo role), or the +# Platform API in local auth mode. +# Validated even when enabled = false, so a typo surfaces when it is written rather +# than when enforcement is switched back on. +mode = "role" # scope | role +# Path to the YAML role-to-scope grant table. REQUIRED when mode = "role", which is +# the default — hence a real default rather than an empty string. The shipped table is +# baked into the image at ./resources (and resolves from the project root for +# `npm start`); docker-compose.yaml overrides this to the mounted /etc/api-portal copy +# so operators can edit what a role grants without rebuilding. +# Loaded and validated at startup whenever it is set (regardless of mode) against the +# portal's OpenAPI spec: an undeclared dp:* scope fails startup rather than surfacing +# later as a role that logs in fine and is denied every request. +role_to_scope_mapping = "./resources/role-to-scope-mapping.yaml" +# Per-page role-tier gating: requires the caller's roles claim to name the tier a +# page demands (portal_roles below). Separate from `enabled` above, which governs +# REST scopes — one switch for both would mean turning page gating off also +# silently turned REST scope enforcement off. +page_role_validation = false + +# Which role name, as it appears in the token's roles claim, grants each of the +# portal's two page-access tiers. Was [api_portal.auth.idp.roles], despite being +# read in local auth mode too. (There was a third tier, super_admin; it gated pages +# this portal does not serve, so it guarded nothing and was removed.) Point these at your IDP's role names — or at the role +# names in role_to_scope_mapping (e.g. admin = "dp_admin") to drive page gating and +# REST authorization from the same roles. +[api_portal.auth.authorization.portal_roles] +admin = "ap_admin" +subscriber = "ap_subscriber" # ============================================================================= # PAGE ACCESS RULES diff --git a/portals/api-portal/configs/config.toml b/portals/api-portal/configs/config.toml index 4142a6651..c6a091400 100644 --- a/portals/api-portal/configs/config.toml +++ b/portals/api-portal/configs/config.toml @@ -40,16 +40,23 @@ # Partial substitution works too: 'foo-{{ env "X" }}' resolves to "foo-bar" if # X=bar. See src/config/configLoader.js for the full implementation. # -# This file's own fallbacks (the third arg to {{ env "NAME" "default" }} below) -# are intentionally wired for the Docker Compose topology (docker-compose.yaml), -# NOT for src/config/configDefaults.js's generic, dependency-free defaults — -# e.g. server.https.enabled defaults to true here (a real cert is bind-mounted at -# /etc/api-portal/tls) vs. false in configDefaults.js (no cert available for a -# bare `npm start`). configDefaults.js's DEFAULTS only take effect for keys this -# file doesn't list at all (idp, organization, uploads, etc.). A config file is -# always required, so DEFAULTS never drive the app on their own — they only fill -# the gaps for keys no --config file sets. See configDefaults.js's own comment -# for why its values stay generic. +# {{ env }} is used SPARINGLY, matching platform-api/config/config.toml: a key gets +# a token only where something actually drives it — the compose DB overrides +# (docker-compose.postgres.yaml / .sqlserver.yaml), `npm run start:local` +# (package.json), docker-entrypoint.sh, or a secret. Everything else is a plain +# literal, so this file states its own effective configuration instead of sending +# the reader hunting for a variable nobody sets. Adding a token "just in case" is +# what turned a readable file into 25 indirections; don't. +# +# Where a token IS present, its fallback (the third arg) is wired for the Docker +# Compose topology (docker-compose.yaml), NOT for src/config/configDefaults.js's +# generic, dependency-free defaults — e.g. server.https.enabled falls back to true +# here (a real cert is bind-mounted at /etc/api-portal/tls) vs. false in +# configDefaults.js (no cert available for a bare `npm start`). configDefaults.js's +# DEFAULTS only take effect for keys this file doesn't list at all (idp, uploads, +# etc.). A config file is always required, so DEFAULTS never drive the app on their +# own — they only fill the gaps for keys no --config file sets. See +# configDefaults.js's own comment for why its values stay generic. # # Running locally without Docker? Use `npm run start:local` instead of # `npm start` (package.json) — it overrides server.https.enabled and auth.local.* to @@ -63,7 +70,7 @@ port = '{{ env "APIP_AP_SERVER_PORT" "9543" }}' # embedded in a generated agent prompt (so they don't depend on the request's # Host header). Set this to the portal's externally-reachable origin behind a # proxy/LB. Empty falls back to the request origin. -base_url = '{{ env "APIP_AP_SERVER_BASE_URL" "https://localhost:9543" }}' +base_url = "https://localhost:9543" # Single listener on server.port; enabled toggles whether it terminates TLS. Set # enabled=false only when a trusted upstream (proxy/LB/ingress) terminates TLS — @@ -71,13 +78,13 @@ base_url = '{{ env "APIP_AP_SERVER_BASE_URL" "https://localhost:9543" }}' # at a real pair when enabled=true (no self-signed fallback). [api_portal.server.https] enabled = '{{ env "APIP_AP_SERVER_HTTPS_ENABLED" "true" }}' -cert_file = '{{ env "APIP_AP_SERVER_HTTPS_CERT_FILE" "/etc/api-portal/tls/cert.pem" }}' -key_file = '{{ env "APIP_AP_SERVER_HTTPS_KEY_FILE" "/etc/api-portal/tls/key.pem" }}' +cert_file = "/etc/api-portal/tls/cert.pem" +key_file = "/etc/api-portal/tls/key.pem" [api_portal.logging] level = '{{ env "APIP_AP_LOGGING_LEVEL" "info" }}' # debug | info | warn | error -format = '{{ env "APIP_AP_LOGGING_FORMAT" "text" }}' # text | json -console_only = '{{ env "APIP_AP_LOGGING_CONSOLE_ONLY" "true" }}' +format = "text" # text | json +console_only = true [api_portal.database] driver = '{{ env "APIP_AP_DATABASE_DRIVER" "sqlite" }}' # sqlite | postgres @@ -96,11 +103,11 @@ name = '{{ env "APIP_AP_DATABASE_NAME" "api_portal" }}' user = '{{ env "APIP_AP_DATABASE_USER" "postgres" }}' password = '{{ env "APIP_AP_DATABASE_PASSWORD" "" }}' # Connection pool — PostgreSQL / MSSQL only (pool_request_timeout_ms is MSSQL-only). -max_open_conns = '{{ env "APIP_AP_DATABASE_MAX_OPEN_CONNS" "50" }}' -min_open_conns = '{{ env "APIP_AP_DATABASE_MIN_OPEN_CONNS" "2" }}' -pool_idle_timeout_ms = '{{ env "APIP_AP_DATABASE_POOL_IDLE_TIMEOUT_MS" "10000" }}' -pool_connection_timeout_ms = '{{ env "APIP_AP_DATABASE_POOL_CONNECTION_TIMEOUT_MS" "30000" }}' -pool_request_timeout_ms = '{{ env "APIP_AP_DATABASE_POOL_REQUEST_TIMEOUT_MS" "30000" }}' +max_open_conns = 50 +min_open_conns = 2 +pool_idle_timeout_ms = 10000 +pool_connection_timeout_ms = 30000 +pool_request_timeout_ms = 30000 [api_portal.security] encryption_key = '{{ file "/etc/api-portal/keys/encryption.key" }}' @@ -118,6 +125,38 @@ platform_api_url = '{{ env "APIP_AP_AUTH_LOCAL_PLATFORM_API_URL" "https://platfo public_key_path = '{{ env "APIP_AP_AUTH_LOCAL_PUBLIC_KEY_PATH" "/etc/api-portal/keys/jwt_public.pem" }}' tls_skip_verify = '{{ env "APIP_AP_AUTH_LOCAL_TLS_SKIP_VERIFY" "true" }}' +# Authorization — what a verified token may do. Applies in every auth mode, which is +# why it is not nested under auth.local or auth.idp. Plain literals, matching how +# [platform_api.auth.authorization] is written: these are deployment policy an operator +# edits in place, not per-environment values or secrets, so they get no {{ env }} token. +[api_portal.auth.authorization] +# false lets any authenticated caller satisfy every operation's declared scope list. +enabled = true +# "role" expands the token's roles claim through the grant table below. "scope" reads +# the token's own scope claim instead — use it when the issuer mints dp:* scopes +# directly (an Asgardeo tenant registered via +# production/scripts/register_asgardeo_scopes.sh, or the Platform API in local mode). +mode = "role" +# The role-to-scope grant table: required in role mode, and validated against the +# portal's OpenAPI spec at startup. Edit it to change what each role grants — it is +# config, not part of the image. +# +# Relative, unlike platform-api's absolute /etc/platform-api path, so one literal works +# everywhere: the container resolves it against WORKDIR /app, where docker-compose.yaml +# mounts the host copy over the one baked into the image, and `npm run start:local` +# resolves it against the project root. +role_to_scope_mapping = "./resources/role-to-scope-mapping.yaml" +# Per-page role-tier gating (portal_roles below). Distinct from `enabled`, which +# governs REST scopes. +page_role_validation = true + +# Which role name in the token's roles claim grants each page-access tier. These name +# the grant table's roles, so page gating and REST authorization are driven by the same +# roles the Platform API and your IDP already emit. +[api_portal.auth.authorization.portal_roles] +admin = "ap_admin" +subscriber = "ap_subscriber" + # The single organization this instance serves — the {handle} segment of # /{handle}/views/{viewName}. Required; the portal refuses to start without it. # In auth.mode = "local" it must match the Platform API's diff --git a/portals/api-portal/distribution/README.md b/portals/api-portal/distribution/README.md index d356facfe..418a6436a 100644 --- a/portals/api-portal/distribution/README.md +++ b/portals/api-portal/distribution/README.md @@ -16,10 +16,11 @@ wso2apip-api-portal-/ │ ├── config.toml # Unified active config — [api_portal] + [platform_api] sections │ └── config-template.toml # Config reference — both active components, plus optional [ai_workspace] at the bottom └── resources/ - ├── role-to-scope-mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) ├── api-portal/ + │ ├── role-to-scope-mapping.yaml # API Portal role-to-scope mapping (dp:* scopes; used when auth.authorization.mode = "role") │ └── db-scripts/ # API Portal PostgreSQL schema (reference copy) ├── platform-api/ + │ ├── role-to-scope-mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) │ └── db-scripts/ # Platform API database schemas (reference copy) └── samples/ ├── apis/ # Sample REST/GraphQL/SOAP APIs @@ -124,6 +125,11 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[api_portal.auth].mode` | `local` (Platform API sidecar) or `idp` (external OIDC IDP via `[api_portal.auth.idp]`) | `local` | | `[api_portal.auth.local].platform_api_url` | Address of the Platform API local-auth sidecar | `https://platform-api:9243` | | `[api_portal.auth.local].public_key_path` | Path to the Platform API RS256 public key PEM used to verify login tokens | `/etc/api-portal/keys/jwt_public.pem` | +| `[api_portal.auth.authorization].enabled` | Enforce each REST operation's declared `dp:*` scopes. `false` lets any authenticated caller through — development only | `true` | +| `[api_portal.auth.authorization].mode` | `scope` reads the token's own scope claim; `role` expands its roles claim through the grant table instead (for an IDP that emits roles, not `dp:*` scopes) | `scope` | +| `[api_portal.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/api-portal/role-to-scope-mapping.yaml` — required in `role` mode; edit that file to change what a role grants | _(empty)_ | +| `[api_portal.auth.authorization].page_role_validation` | Gate portal pages on the caller's role tier (`portal_roles` below). Separate from `enabled`, which governs REST scopes | `false` | +| `[api_portal.auth.authorization.portal_roles]` | Which role name in the token's roles claim grants each page tier (`admin`, `super_admin`, `subscriber`) | `admin`, `superAdmin`, `Internal/subscriber` | | `[api_portal.organization].handle` | The single organization this instance serves, bootstrapped on first start. Required — the portal refuses to start without it | `default` | | `[api_portal.organization].display_name` | Display name applied when the organization is first seeded | `Default` | @@ -136,8 +142,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.database].driver` | `sqlite3` or `postgres` | `sqlite3` | | `[platform_api.auth.jwt].public_key_file` / `.private_key_file` | RS256 keypair — platform-api signs login JWTs with the private key; the portal verifies with the public one | _(from `setup.sh`)_ | | `[platform_api.auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode | disabled | -| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `roles` names one or more entries in `resources/role-to-scope-mapping.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | -| `[platform_api.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/role-to-scope-mapping.yaml` — edit that file to change what a role grants | `/etc/platform-api/role-to-scope-mapping.yaml` | +| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `roles` names one or more entries in `resources/platform-api/role-to-scope-mapping.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | +| `[platform_api.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/platform-api/role-to-scope-mapping.yaml` — edit that file to change what a role grants | `/etc/platform-api/role-to-scope-mapping.yaml` | See `configs/config-template.toml` for a fully-commented reference of every available setting across both active components (plus the optional `[ai_workspace]` section at the bottom). @@ -159,7 +165,8 @@ To delegate login to an external OIDC-compliant provider instead of file-based a 1. Register an OIDC application in your IDP with redirect URL `https:////callback`, and enable the **Authorization Code** grant. 2. In `configs/config.toml`, set `[api_portal.auth]` `mode = "idp"` and fill in the `[api_portal.auth.idp]` block — `client_id`, `client_secret`, `issuer`, `authorization_url`, `token_url`, `jwks_url`, `callback_url`, etc. -3. Adjust `[api_portal.auth.claim_mappings]` and `[api_portal.auth.idp.roles]` to match what your IDP puts in the issued token. +3. Adjust `[api_portal.auth.claim_mappings]` to match what your IDP puts in the issued token, and `[api_portal.auth.authorization.portal_roles]` to name the IDP roles that grant each portal tier. (`[api_portal.auth.idp.roles]` is retired — leaving it in place fails startup.) +4. To authorize the REST API from those same IDP roles rather than from `dp:*` scopes the IDP has no reason to mint, set `[api_portal.auth.authorization]` `mode = "role"` and point `role_to_scope_mapping` at the mounted `resources/api-portal/role-to-scope-mapping.yaml`. See `configs/config-template.toml` for the full, per-field reference. @@ -173,7 +180,7 @@ docker compose up -d --force-recreate ## Compose project name -`setup.sh` pins `COMPOSE_PROJECT_NAME=wso2apip-developer-portal--<6 hex>` in `.env` on its first run and never changes it. Compose prefixes this stack's containers, network, and volumes with it, so unpacking this zip again elsewhere on the host gets its own volumes instead of adopting this copy's APIs, applications, and users. Don't edit that line or delete `.env` — the data lives in `_api-portal-data` and `_platform-api-data`, and a different name starts the portal empty. `down` keeps those volumes; only `down -v` deletes them. To choose the name yourself — including adopting an earlier release's volumes, whose prefix `docker volume ls` shows — set it for the first run only: `COMPOSE_PROJECT_NAME= ./scripts/setup.sh` (PowerShell: `$env:COMPOSE_PROJECT_NAME = ''; .\scripts\setup.ps1`). It must match `^[a-z0-9][a-z0-9_-]*$`. Two portal stacks still can't run at once: both bind ports `9243` and `9543`. +`setup.sh` pins `COMPOSE_PROJECT_NAME=wso2apip-api-portal--<6 hex>` in `.env` on its first run and never changes it. Compose prefixes this stack's containers, network, and volumes with it, so unpacking this zip again elsewhere on the host gets its own volumes instead of adopting this copy's APIs, applications, and users. Don't edit that line or delete `.env` — the data lives in `_api-portal-data` and `_platform-api-data`, and a different name starts the portal empty. `down` keeps those volumes; only `down -v` deletes them. To choose the name yourself — including adopting an earlier release's volumes, whose prefix `docker volume ls` shows — set it for the first run only: `COMPOSE_PROJECT_NAME= ./scripts/setup.sh` (PowerShell: `$env:COMPOSE_PROJECT_NAME = ''; .\scripts\setup.ps1`). It must match `^[a-z0-9][a-z0-9_-]*$`. Two portal stacks still can't run at once: both bind ports `9243` and `9543`. ## Database diff --git a/portals/api-portal/docker-compose.yaml b/portals/api-portal/docker-compose.yaml index 0ddb456cf..28d9cd1f1 100644 --- a/portals/api-portal/docker-compose.yaml +++ b/portals/api-portal/docker-compose.yaml @@ -56,6 +56,12 @@ services: volumes: - ./configs/config.toml:/app/configs/config.toml:ro - api-portal-data:/app/data + # Role-to-scope grant table (auth.authorization.role_to_scope_mapping). Mounted + # OVER the copy baked into the image — config.toml names it by the one relative + # path that resolves both here (WORKDIR /app) and for `npm run start:local` — so + # editing this file on the host changes what a role grants after a restart, with + # no rebuild and no env-var indirection. + - ./resources/role-to-scope-mapping.yaml:/app/resources/role-to-scope-mapping.yaml:ro - ./resources/certificates:/etc/api-portal/tls:ro - ./resources/keys/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro - ./resources/keys/api-portal-encryption.key:/etc/api-portal/keys/encryption.key:ro diff --git a/portals/api-portal/docs/README.md b/portals/api-portal/docs/README.md index 824fa2dbc..415f78340 100644 --- a/portals/api-portal/docs/README.md +++ b/portals/api-portal/docs/README.md @@ -6,7 +6,7 @@ The API Portal is a self-hosted, multi-tenant web application where API publishe | Section | Audience | What you'll find | |---|---|---| -| [Introduction](introduction/what-is-developer-portal.md) | Everyone | Overview, quick start, and core concepts | +| [Introduction](introduction/what-is-api-portal.md) | Everyone | Overview, quick start, and core concepts | | [Administer](administer/manage-organizations.md) | Admins / Operators | The organization, views, subscription plans, gateway and key manager integration, theming, design mode, IDP authentication | | [Publish APIs](publish-apis/publishing-apis.md) | API Publishers / Admins | Registering APIs, uploading definitions and docs, managing API workflows | | [Discover APIs](discover-apis/search-apis.md) | Developers | Searching the API catalog, reading documentation, AI agent discovery | @@ -29,7 +29,7 @@ The API Portal is a self-hosted, multi-tenant web application where API publishe 2. [Get a Bearer Token via curl](administer/api-token-curl.md) **As a developer consuming APIs** -1. [What is the API Portal?](introduction/what-is-developer-portal.md) +1. [What is the API Portal?](introduction/what-is-api-portal.md) 2. [Core Concepts](introduction/concepts.md) 3. [Search APIs](discover-apis/search-apis.md) 4. [Subscribe to an API](consume-an-api/subscriptions.md) diff --git a/portals/api-portal/docs/administer/asgardeo-setup.md b/portals/api-portal/docs/administer/asgardeo-setup.md index 0831547f5..61a149c38 100644 --- a/portals/api-portal/docs/administer/asgardeo-setup.md +++ b/portals/api-portal/docs/administer/asgardeo-setup.md @@ -104,20 +104,20 @@ authorization_url = "https://api.asgardeo.io/t//oauth2/authorize" token_url = "https://api.asgardeo.io/t//oauth2/token" user_info_url = "https://api.asgardeo.io/t//oauth2/userinfo" client_id = "" -client_secret = "" # env: APIP_AP_IDP_CLIENTSECRET +client_secret = '{{ env "APIP_AP_AUTH_IDP_CLIENT_SECRET" }}' audience = "" # Asgardeo sets client_id as the aud claim callback_url = "https:///default/callback" logout_url = "https://api.asgardeo.io/t//oidc/logout" logout_redirect_uri = "https:///default" jwks_url = "https://api.asgardeo.io/t//oauth2/jwks" -scope = "openid profile email" # dp:* not needed — browser sessions are preauthorized +scope = "openid profile email" # dp:* not needed — see auth.authorization.mode in authentication.md -[idp.claims] -org_id = "org_name" # Asgardeo B2B: org_name matches ORGANIZATION_IDENTIFIER (sub-org display name) -role = "roles" +[api_portal.auth.claim_mappings] +organization = "org_name" # Asgardeo B2B: org_name matches ORGANIZATION_IDENTIFIER (sub-org display name) +roles = "roles" ``` -> **Note:** Set `client_secret` via the `APIP_AP_IDP_CLIENTSECRET` environment variable rather than in the config file. +> **Note:** Keep the client secret out of the config file — but note there is no automatic `APIP_AP_*` override layer, so the variable only works because the `client_secret` key above references it with a `{{ env }}` token. `APIP_AP_AUTH_IDP_CLIENT_SECRET` is the name the Helm chart wires; a hand-written `config.toml` may use any name it references. `'{{ file "/etc/api-portal/keys/idp-client-secret" }}'` avoids putting it in the environment at all. > **Callback URL:** A single `callback_url` is shared across all devportal organizations. After the callback, the portal uses the session's `returnTo` value to redirect the user to the correct org. Register only the URL you set in `callback_url` with Asgardeo. diff --git a/portals/api-portal/docs/administer/authentication.md b/portals/api-portal/docs/administer/authentication.md index a21845dbf..563acb422 100644 --- a/portals/api-portal/docs/administer/authentication.md +++ b/portals/api-portal/docs/administer/authentication.md @@ -23,40 +23,47 @@ The API Portal supports two authentication modes, controlled by `auth.mode` in ` ### `idp` fields -| Field (TOML) | Env var | Required | Description | -|-------|---------|----------|-------------| -| `name` | `APIP_AP_IDP_NAME` | No | Friendly name used in logs (default: `oauth2`) | -| `issuer` | `APIP_AP_IDP_ISSUER` | Yes | IDP token issuer URL — used for issuer claim verification | -| `authorization_url` | `APIP_AP_IDP_AUTHORIZATIONURL` | Yes | OAuth2 authorization endpoint | -| `token_url` | `APIP_AP_IDP_TOKENURL` | Yes | OAuth2 token endpoint | -| `user_info_url` | `APIP_AP_IDP_USERINFOURL` | No | OIDC userinfo endpoint | -| `client_id` | `APIP_AP_IDP_CLIENTID` | Yes | OAuth2 client ID | -| `client_secret` | `APIP_AP_IDP_CLIENTSECRET` | No* | Client secret for confidential clients (Traditional Web App, Keycloak). Leave empty for PKCE-only public clients. | -| `audience` | `APIP_AP_IDP_AUDIENCE` | No | JWT `aud` claim to verify — typically the `client_id`. Leave empty to skip audience check. | -| `callback_url` | `APIP_AP_IDP_CALLBACKURL` | Yes | OAuth2 redirect URI — must be registered in the IDP. Pattern: `https:////callback` | -| `scope` | `APIP_AP_IDP_SCOPE` | No | Space-separated OIDC scopes to request (default: `openid profile email`) | -| `logout_url` | `APIP_AP_IDP_LOGOUTURL` | No | IDP logout endpoint — used for end-session redirect | -| `logout_redirect_uri` | `APIP_AP_IDP_LOGOUTREDIRECTURI` | No | Post-logout redirect back to the portal | -| `jwks_url` | `APIP_AP_IDP_JWKSURL` | No* | JWKS endpoint for token signature verification. Either `jwks_url` or `certificate` is required. | -| `certificate` | `APIP_AP_IDP_CERTIFICATE` | No* | X.509 certificate (PEM) as alternative to JWKS | -| `token_refresh_timeout_ms` | `APIP_AP_IDP_TOKENREFRESHTIMEOUTMS` | No | Token refresh timeout in ms (default: `10000`) | - -### Claim mapping and role fields (`[idp.claims]` / `[idp.roles]` in `config.toml`) - -These tell the portal how to read user identity and roles from the IDP token. - -| Field (TOML) | Env var | Default | Description | -|-------|---------|---------|-------------| -| `idp.claims.org_id` | `APIP_AP_IDP_CLAIMS_ORGID` | `org_name` | JWT claim for the organization UUID. Asgardeo B2B uses `org_name`. Supports dot-notation (e.g. `org.id`). | -| `idp.claims.role` | `APIP_AP_IDP_CLAIMS_ROLE` | `roles` | JWT claim for the user's roles. Supports dot-notation (e.g. `realm_access.roles` for Keycloak). | -| `idp.claims.groups` | `APIP_AP_IDP_CLAIMS_GROUPS` | `groups` | JWT claim for groups | -| `idp.roles.admin` | `APIP_AP_IDP_ROLES_ADMIN` | `admin` | Role value that grants portal admin access | -| `idp.roles.super_admin` | `APIP_AP_IDP_ROLES_SUPERADMIN` | `superAdmin` | Role value that grants portal super-admin access | -| `idp.roles.subscriber` | `APIP_AP_IDP_ROLES_SUBSCRIBER` | `Internal/subscriber` | Role value for standard subscribers | -| `idp.fidp` | — | `{}` | Map of `?fidp=` query param values to IDP identifiers for federated login hints | +Set these in `config.toml` under `[api_portal.auth.idp]`. None of them is settable from +the environment as shipped — `configs/config.toml` references no `APIP_AP_IDP_*` +variable, and there is no automatic override layer, so a variable no key names does +nothing. Add a `{{ env "..." }}` token to the key yourself if you need one (the client +secret is the usual candidate). + +| Field (TOML) | Required | Description | +| ------- | ---------- | ------------- | +| `name` | No | Friendly name used in logs (default: `oauth2`) | +| `issuer` | Yes | IDP token issuer URL — used for issuer claim verification | +| `authorization_url` | Yes | OAuth2 authorization endpoint | +| `token_url` | Yes | OAuth2 token endpoint | +| `user_info_url` | No | OIDC userinfo endpoint | +| `client_id` | Yes | OAuth2 client ID | +| `client_secret` | No* | Client secret for confidential clients (Traditional Web App, Keycloak). Leave empty for PKCE-only public clients. | +| `audience` | No | JWT `aud` claim to verify — typically the `client_id`. Leave empty to skip audience check. | +| `callback_url` | Yes | OAuth2 redirect URI — must be registered in the IDP. Pattern: `https:////callback` | +| `scope` | No | Space-separated OIDC scopes to request (default: `openid profile email`) | +| `logout_url` | No | IDP logout endpoint — used for end-session redirect | +| `logout_redirect_uri` | No | Post-logout redirect back to the portal | +| `jwks_url` | No* | JWKS endpoint for token signature verification. Either `jwks_url` or `certificate` is required. | +| `certificate` | No* | X.509 certificate (PEM) as alternative to JWKS | +| `token_refresh_timeout_ms` | No | Token refresh timeout in ms (default: `10000`) | + +### Claim mapping (`[auth.claim_mappings]` in `config.toml`) + +These tell the portal which token claim carries each field. They sit under `auth`, not +under `auth.idp`, because the same mapping applies in local auth mode. + +| Field (TOML) | Default | Description | +|-------|---------|-------------| +| `auth.claim_mappings.organization` | `org_name` | JWT claim for the organization. Asgardeo B2B uses `org_name`. Supports dot-notation (e.g. `org.id`). | +| `auth.claim_mappings.roles` | `roles` | JWT claim for the user's roles. Supports dot-notation (e.g. `realm_access.roles` for Keycloak). | +| `auth.claim_mappings.groups` | `groups` | JWT claim for groups | +| `auth.idp.fidp` | `{}` | Map of `?fidp=` query param values to IDP identifiers for federated login hints | > Claim names can also be overridden per-organization in the database (via the admin API), allowing different orgs to use different IDPs or claim structures. +Which role name grants each portal access tier is **authorization**, not authentication — +see [Authorization](#authorization) below. It used to live here as `[idp.roles]`. + --- ## Local Auth Mode @@ -72,6 +79,161 @@ This mode is intended for development and local testing only. --- +## Authorization + +Authentication settles *who* a caller is; authorization settles *what they may do*. It is +configured in `[api_portal.auth.authorization]` — a section of its own, outside both +`auth.local` and `auth.idp`, because a token carries the same roles claim whether the +portal verified it against a JWKS endpoint or against the Platform API's public key. + +Two surfaces are governed separately: + +| Surface | Governed by | Decided from | +|---|---|---| +| REST API (`/api/v0.9`) | `enabled` + `mode` | the `dp:*` scopes each operation declares | +| Portal pages (applications, api-keys, subscriptions, settings) | `page_role_validation` + `portal_roles` | the caller's role tier | + +### Settings + +| Field (TOML) | Default | Description | +|---|---|---| +| `auth.authorization.enabled` | `true` | Enforce each REST operation's declared scopes. `false` lets any *authenticated* caller through (authentication still applies) and logs a startup warning — development only. | +| `auth.authorization.mode` | `role` | `scope` \| `role`. Validated at startup even when `enabled = false`, so a typo surfaces when it is written rather than when enforcement is switched back on. | +| `auth.authorization.role_to_scope_mapping` | `./resources/role-to-scope-mapping.yaml` | Path to the YAML grant table. **Required** in `role` mode, which is the default — hence a real default rather than empty. Points at the copy baked into the image; `docker-compose.yaml` overrides it to the mounted, editable copy. Loaded and validated at startup whenever it is set. | +| `auth.authorization.page_role_validation` | `false` | Require the caller's roles claim to name the tier a page demands. | +| `auth.authorization.portal_roles.admin` | `admin` | Role name granting the admin tier (portal settings, plus everything the subscriber tier allows). | +| `auth.authorization.portal_roles.subscriber` | `Internal/subscriber` | Role name granting the subscriber tier (own applications, api-keys, subscriptions). | + +### `role` mode (default) + +The scope claim is ignored entirely and the roles claim (`auth.claim_mappings.roles`) is +expanded through the grant table instead. This is the default because it works for every +issuer: an external IDP emits the roles its estate is organized around and has no reason +to mint `dp:*` scopes, and the Platform API mints its own `ap_*` role names, which the +shipped table aliases so the local-auth quickstart works unchanged. + +### `scope` mode + +Effective scopes are the token's own `scope` claim. Switch to this when the issuer mints +`dp:*` scopes directly: + +- **Asgardeo**, set up via `production/scripts/register_asgardeo_scopes.sh` — it registers every `dp:*` scope as an API resource, and you then attach them to an Asgardeo role (see [asgardeo-setup.md](asgardeo-setup.md)). Asgardeo is the role→scope mapper in that setup, so the portal-side grant table is redundant. +- **The Platform API in local auth mode**, which expands a file user's roles into the `scope` claim of the token it issues. + +Note that in `scope` mode an IDP *browser session* still bypasses the per-operation check +(`preauthorized`), because the OIDC client would otherwise have to request all `dp:*` +scopes; the only authorization left for it is the page role gate. `role` mode enforces +the operation-level check for those sessions too. + +### Multiple roles, and unknown ones + +Multiple roles union their scopes — most-permissive wins, duplicates collapsed — and a +role absent from the table grants nothing, so the failure mode is a denied request rather +than an unintended grant. + +Ignoring rather than merging the scope claim in `role` mode is deliberate: a caller must +not be able to widen a role's grant by asking their IDP for extra scope values. + +```toml +[api_portal.auth] +mode = "idp" + +[api_portal.auth.claim_mappings] +roles = "realm_access.roles" # Keycloak's nested shape; dot-notation is resolved + +[api_portal.auth.authorization] +mode = "role" +role_to_scope_mapping = "/etc/api-portal/role-to-scope-mapping.yaml" +page_role_validation = true + +# Drive page tiers from the same roles as REST authorization. The shipped defaults are +# the legacy IDP values ("admin", "Internal/subscriber"), so set these explicitly if you +# want pages gated by the grant table's roles. +[api_portal.auth.authorization.portal_roles] +admin = "dp_admin" +subscriber = "dp_subscriber" +``` + +### The grant table + +`resources/role-to-scope-mapping.yaml` is the shipped sample. + +```yaml +roles: + - name: dp_subscriber + scopes: + - dp:application:manage + - dp:subscription:manage + - dp:api:read +``` + +It defines **two** roles, matching the two personas this portal recognises: + +| Role | For | +|---|---| +| `dp_admin` | Runs the portal — content and theme, the API/MCP catalogue, views, labels, subscription plans, key managers, webhook subscribers, and every application and subscription in the organization | +| `dp_subscriber` | Consumes APIs — owns their own applications, subscriptions and keys, and browses the catalogue | + +The page gate has exactly these two tiers. An earlier `superAdmin` tier gated the +multi-organization pages of the old devportal (`/portal`, `/devportal`); those routes are +not served here, so it guarded nothing and has been removed. Publisher, operator and +viewer are platform-side personas and live in +`platform-api/resources/role-to-scope-mapping.yaml` instead — API publishing reaches this +portal through the Platform API, the management portal or the `ap` CLI, not through a +human browsing here. + +Add your own roles freely; nothing in the file is special-cased in code, and the +validation below applies to whatever you add. + +The Platform API's grant table names its roles `ap_*`. The two files are read by +different components, so the names need not agree — but to drive both from one set of IDP +groups, either rename these entries to match theirs or add an alias entry carrying the +same scope list: + +```yaml + - name: ap_admin # alias so one IDP group covers platform-api and this portal + scopes: [ ... same list as dp_admin ... ] +``` + +Startup validation is fail-closed: + +- Every `dp:*` scope must be declared in `docs/api-portal-openapi-spec-v0.9.yaml`. An undeclared one aborts startup — otherwise the role would authenticate fine and be denied every request. +- Scopes in another component's namespace (`ap:*`, for a table shared with the Platform API) are checked for well-formedness only. This portal mints no `ap:*` scope and enforces none, so it can neither confirm nor deny their existence. +- A duplicate role name is rejected rather than last-wins, and every problem in the file is reported at once. + +The file is mounted rather than baked into the image, so operators can edit what a role +grants. It requires a restart to take effect. + +### Migrating from the previous shape + +Both retired keys govern authorization, and a retired key is *ignored* — so the effective +setting would silently become the default rather than what the file says. Startup fails +instead, naming the replacement: + +```toml +# before +[api_portal.auth] +role_validation = true + +[api_portal.auth.idp.roles] +admin = "admin" + +# after +[api_portal.auth.authorization] +page_role_validation = true + +[api_portal.auth.authorization.portal_roles] +admin = "admin" +``` + +Note that `role_validation` maps to `page_role_validation`, **not** to `enabled`. The two +are not the same switch: `role_validation` only ever gated portal pages, while REST scope +enforcement was unconditional. Renaming it to `enabled` would mean a config that had +`role_validation = false` silently turned off REST scope enforcement that was previously +active. + +--- + ## Multi-Organization Isolation When multiple devportal organizations share one IDP, the portal enforces per-org isolation using the `ORGANIZATION_IDENTIFIER` field on each organization (stored in the database, set via the admin API). @@ -80,7 +242,7 @@ When multiple devportal organizations share one IDP, the portal enforces per-org 1. Each devportal org has an `ORGANIZATION_IDENTIFIER` — the IDP-side identifier for that org (e.g. an Asgardeo sub-org handle). 2. When a user clicks Login, the portal looks up the org's `ORGANIZATION_IDENTIFIER` and passes it to the IDP in the authorization request, scoping the login session to that org. -3. The IDP issues an org-scoped token. On every authenticated request, the portal checks that the token's org claim (`idp.claims.org_id`) matches the org's `ORGANIZATION_IDENTIFIER`. A mismatch returns a 403. +3. The IDP issues an org-scoped token. On every authenticated request, the portal checks that the token's org claim (`auth.claim_mappings.organization`) matches the org's `ORGANIZATION_IDENTIFIER`. A mismatch returns a 403. **User flow with multiple orgs:** @@ -102,7 +264,9 @@ authorization_url = "https://keycloak.example.com/realms/myrealm/protocol/openid token_url = "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token" user_info_url = "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/userinfo" client_id = "devportal" -client_secret = "" # env: APIP_AP_IDP_CLIENTSECRET +# Write the secret as a token rather than a literal — the variable works because +# this key references it, not automatically. {{ file }} is better still. +client_secret = '{{ env "APIP_AP_AUTH_IDP_CLIENT_SECRET" }}' audience = "devportal" callback_url = "https:///default/callback" logout_url = "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/logout" @@ -110,9 +274,9 @@ logout_redirect_uri = "https:///default" jwks_url = "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs" scope = "openid profile email" -[idp.claims] -org_id = "organization" # custom claim — add via Keycloak protocol mapper -role = "realm_access.roles" # Keycloak nests realm roles here +[api_portal.auth.claim_mappings] +organization = "organization" # custom claim — add via Keycloak protocol mapper +roles = "realm_access.roles" # Keycloak nests realm roles here ``` **Keycloak setup steps:** @@ -120,8 +284,8 @@ role = "realm_access.roles" # Keycloak nests realm roles here 2. Set redirect URI to `https:////callback` 3. Enable PKCE (set `PKCE Code Challenge Method` to `S256`) 4. Copy the client secret -5. Add a custom protocol mapper for your organization UUID claim (`idp.claims.org_id`) -6. Realm roles are exposed at `realm_access.roles` — configure `idp.claims.role` accordingly +5. Add a custom protocol mapper for your organization UUID claim (`auth.claim_mappings.organization`) +6. Realm roles are exposed at `realm_access.roles` — configure `auth.claim_mappings.roles` accordingly --- diff --git a/portals/api-portal/it/configs/roles-platform-api-it.yaml b/portals/api-portal/it/configs/roles-platform-api-it.yaml index f7b7fd3ec..9a46f31fe 100644 --- a/portals/api-portal/it/configs/roles-platform-api-it.yaml +++ b/portals/api-portal/it/configs/roles-platform-api-it.yaml @@ -14,7 +14,7 @@ # user's roles are its entire grant, so the three IT accounts (admin / publisher / # developer) get one role each, defined here rather than as per-user scope lists. # -# These are Developer Portal ("dp:") scopes throughout: the Platform API mints +# These are API Portal & MCP Hub ("dp:") scopes throughout: the Platform API mints # them into the tokens it issues but does not enforce them, so it checks only # their shape. The suite's own authorization assertions are what exercise them. # -------------------------------------------------------------------- @@ -29,16 +29,15 @@ roles: - dp:organization:manage - dp:organization:delete - dp:organization_content:read - - dp:organization_content:write - dp:organization_content:manage - - dp:organization_content:delete - dp:api:read - dp:api:create - dp:api:update - dp:api:manage - dp:api:delete - dp:api_content:read - - dp:api_content:write + - dp:api_content:create + - dp:api_content:update - dp:api_content:manage - dp:api_content:delete - dp:mcp_server:read @@ -75,7 +74,7 @@ roles: - dp:application_key:manage - dp:application_key:revoke - dp:application_key_mapping:read - - dp:application_key_mapping:write + - dp:application_key_mapping:create - dp:application_key_mapping:manage - dp:subscription:create - dp:subscription:read @@ -121,7 +120,8 @@ roles: - dp:api:manage - dp:api:delete - dp:api_content:read - - dp:api_content:write + - dp:api_content:create + - dp:api_content:update - dp:api_content:manage - dp:api_content:delete - dp:mcp_server:read @@ -199,7 +199,7 @@ roles: - dp:application_key:manage - dp:application_key:revoke - dp:application_key_mapping:read - - dp:application_key_mapping:write + - dp:application_key_mapping:create - dp:application_key_mapping:manage - dp:subscription:create - dp:subscription:read diff --git a/portals/api-portal/it/test-config.toml b/portals/api-portal/it/test-config.toml index 633d0df66..73e7d0288 100644 --- a/portals/api-portal/it/test-config.toml +++ b/portals/api-portal/it/test-config.toml @@ -51,6 +51,15 @@ handle = '{{ env "APIP_AP_ORGANIZATION_HANDLE" "default" }}' display_name = '{{ env "APIP_AP_ORGANIZATION_DISPLAY_NAME" "Default" }}' auto_create_subscription_plans = true +# The IT suite authorizes against the dp:* scopes the Platform API sidecar mints into +# each token's scope claim — its own grant table (configs/roles-platform-api-it.yaml) +# is where the three IT accounts' privileges are defined. That is scope mode by +# definition, so it is pinned here rather than inheriting the "role" default: role mode +# would expand the dp_*_it role names against the PORTAL's grant table, which does not +# define them, and every REST assertion would 403. +[api_portal.auth.authorization] +mode = "scope" + [api_portal.auth.local] # File-based (local) auth against the Platform API sidecar. Host is identical in # both DB variants; tls_skip_verify accepts its self-signed cert inside the test network. diff --git a/portals/api-portal/production/scripts/register_asgardeo_scopes.sh b/portals/api-portal/production/scripts/register_asgardeo_scopes.sh index b9cc91777..e449fd2e2 100755 --- a/portals/api-portal/production/scripts/register_asgardeo_scopes.sh +++ b/portals/api-portal/production/scripts/register_asgardeo_scopes.sh @@ -75,6 +75,18 @@ echo "Token obtained." echo "" # ── dp:* scope list ─────────────────────────────────────────────────────────── +# +# Must stay identical to the scopes declared under components.securitySchemes in +# docs/api-portal-openapi-spec-v0.9.yaml — that document is what the portal enforces +# per operation, so a scope missing here cannot be granted in Asgardeo at all (this +# list had drifted: the whole dp:mcp_server* family was absent), and a scope here +# that the spec does not declare is registered for nothing. +# +# To check after changing the spec (run from portals/api-portal; no output = in sync): +# diff <(grep -oE 'dp:[a-z_]+:[a-z_]+' production/scripts/register_asgardeo_scopes.sh \ +# | sort -u) \ +# <(grep -oE '^ +dp:[a-z_]+:[a-z_]+:' docs/api-portal-openapi-spec-v0.9.yaml \ +# | tr -d ' ' | sed 's/:$//' | sort -u) SCOPES=( # organization @@ -85,10 +97,7 @@ SCOPES=( "dp:organization:manage" # organization content - "dp:organization_content:create" "dp:organization_content:read" - "dp:organization_content:update" - "dp:organization_content:delete" "dp:organization_content:manage" # views @@ -105,13 +114,6 @@ SCOPES=( "dp:label:delete" "dp:label:manage" - # providers - "dp:provider:create" - "dp:provider:read" - "dp:provider:update" - "dp:provider:delete" - "dp:provider:manage" - # key managers "dp:key_manager:create" "dp:key_manager:read" @@ -133,12 +135,12 @@ SCOPES=( "dp:api_content:delete" "dp:api_content:manage" - # API flows - "dp:api_flow:create" - "dp:api_flow:read" - "dp:api_flow:update" - "dp:api_flow:delete" - "dp:api_flow:manage" + # API workflows + "dp:api_workflow:create" + "dp:api_workflow:read" + "dp:api_workflow:update" + "dp:api_workflow:delete" + "dp:api_workflow:manage" # API keys "dp:api_key:create" @@ -147,6 +149,27 @@ SCOPES=( "dp:api_key:revoke" "dp:api_key:manage" + # MCP servers + "dp:mcp_server:create" + "dp:mcp_server:read" + "dp:mcp_server:update" + "dp:mcp_server:delete" + "dp:mcp_server:manage" + + # MCP server content + "dp:mcp_server_content:create" + "dp:mcp_server_content:read" + "dp:mcp_server_content:update" + "dp:mcp_server_content:delete" + "dp:mcp_server_content:manage" + + # MCP server keys + "dp:mcp_server_key:create" + "dp:mcp_server_key:read" + "dp:mcp_server_key:update" + "dp:mcp_server_key:revoke" + "dp:mcp_server_key:manage" + # applications "dp:application:create" "dp:application:read" @@ -188,10 +211,6 @@ SCOPES=( # webhook events "dp:event:read" - - # utilities - "dp:utility:create" - "dp:utility:manage" ) # ── Build and POST the resource ─────────────────────────────────────────────── diff --git a/portals/api-portal/resources/role-to-scope-mapping.yaml b/portals/api-portal/resources/role-to-scope-mapping.yaml new file mode 100644 index 000000000..920c62d69 --- /dev/null +++ b/portals/api-portal/resources/role-to-scope-mapping.yaml @@ -0,0 +1,146 @@ +# Role-to-scope mapping used by the API Portal (auth.authorization.role_to_scope_mapping). +# +# Each entry maps a role name to the dp:* scopes that role grants. It is read when +# auth.authorization.mode = "role": the roles claim of an incoming token +# (auth.claim_mappings.roles) is expanded through this file on every request, and the +# resulting scopes are what the /api/v0.9 operations are authorized against. +# +# Why role mode exists: the portal's REST surface is guarded by fine-grained dp:* +# scopes, but an external OIDC IDP has no reason to mint them — it emits the roles or +# groups its own estate is organized around. Without this file the only way an +# IDP-mode session could reach the REST API was to bypass the per-operation scope +# check entirely. Here the IDP keeps emitting roles and the portal decides what each +# role may do. +# +# When a token carries multiple roles the effective scopes are the union of all +# matching entries; most-permissive wins, duplicates collapsed. A role not listed +# here grants nothing — the failure mode is a denied request, never an unintended +# grant. +# +# WHY TWO GRANTS +# +# This portal is the consumer-facing surface, and it recognises exactly two personas: +# whoever administers it, and whoever consumes APIs through it — which is exactly what +# its page-access gate has tiers for. +# +# Publisher/operator/viewer personas live on the platform side (gateways, projects, +# deployments) and are defined in platform-api/resources/role-to-scope-mapping.yaml. +# API publishing reaches this portal through the Platform API, the management portal +# or the `ap` CLI rather than a human browsing here, so those roles deliberately have +# no entry here: a role with dp:* scopes but no portal page tier would authenticate, +# reach the REST API, and still not open a single portal page. +# +# Add a role of your own if you need a narrower grant — nothing here is special-cased +# in code, and the startup validation below applies to whatever you add. +# +# NAMING AND YOUR IDP +# +# These names are prefixed dp_ because they grant dp: scopes and are read only by this +# portal. Map your IDP's groups onto them via auth.claim_mappings.roles; supported +# claim paths: +# Asgardeo — roles: roles +# Keycloak — roles: realm_access.roles (or resource_access..roles) +# Microsoft Entra ID — roles: roles +# +# The Platform API's own grant table names its roles ap_*, and those are the names it +# mints into the tokens it issues. Because role mode is the default, this file aliases +# the two that map cleanly (see the aliases at the bottom) so one set of IDP groups +# drives both components and the shipped local-auth quickstart works unchanged. +# +# PAGE ACCESS IS SEPARATE +# +# This file governs the REST API only. Which role name grants each of the portal's two +# page-access tiers is auth.authorization.portal_roles, whose shipped defaults are the +# legacy IDP values ("admin", "Internal/subscriber") rather than the names below. To +# drive pages from these same roles, set: +# +# [api_portal.auth.authorization.portal_roles] +# admin = "dp_admin" +# subscriber = "dp_subscriber" +# +# Scope convention: +# dp::read — read that resource +# dp::manage — every action on that resource (read operations accept +# :manage as well as :read, so :manage need not be paired +# with :read) +# +# Every dp:* scope below must be declared in docs/api-portal-openapi-spec-v0.9.yaml — +# an unknown one fails startup rather than surfacing later as a role that +# authenticates fine and is then denied every request. Scopes in another component's +# namespace (ap:*, if you merge this with platform-api's table) are checked for +# well-formedness only, since this portal mints no ap:* scope and enforces none. +# +# This file requires a server restart to take effect. + +roles: + # Portal administrator — runs the portal: its content and theme, the API and MCP + # server catalogue, views and labels, subscription plans, key managers, webhook + # subscribers, and every application/subscription in the organization. + - name: dp_admin + scopes: &admin_grant + - dp:organization:manage + - dp:organization_content:manage + - dp:api:manage + - dp:api_content:manage + - dp:mcp_server:manage + - dp:mcp_server_content:manage + - dp:api_workflow:manage + - dp:api_key:manage + - dp:mcp_server_key:manage + - dp:application:manage + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:manage + - dp:subscription:manage + - dp:subscription_plan:manage + - dp:key_manager:manage + - dp:key_manager:read + - dp:view:manage + - dp:label:manage + - dp:webhook_subscriber:manage + - dp:event:read + + # API consumer — owns their own applications, subscriptions and keys, and browses + # the catalogue. This is the persona the portal exists for. + - name: dp_subscriber + scopes: &subscriber_grant + - dp:application:manage + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:manage + - dp:subscription:manage + - dp:api_key:manage + - dp:mcp_server_key:manage + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:api_workflow:read + - dp:subscription_plan:read + - dp:organization:read + - dp:organization_content:read + - dp:view:read + - dp:label:read + + # --- Aliases for the role names other components mint ----------------------- + # + # Role mode is the default, so the portal expands the roles claim of every token + # rather than reading its scope claim. The Platform API names its own roles ap_* + # and mints those names into the tokens it issues (auth.file.users[].roles — the + # shipped quickstart admin is ap_admin), so without these aliases a local-auth + # login would succeed and then be denied every REST request. + # + # They point at the same scope lists above via YAML anchors, so a grant is still + # defined exactly once. + # + # Only the two that map cleanly onto this portal's tiers are aliased. + # ap_operator, ap_publisher and ap_viewer are platform personas whose portal-side + # needs don't line up with either tier — aliasing ap_publisher to dp_admin would + # hand it organization settings, and ap_viewer would gain the ability to create + # applications. Give them an entry of their own if someone holding one needs + # portal access. + - name: ap_admin + scopes: *admin_grant + + - name: ap_subscriber + scopes: *subscriber_grant diff --git a/portals/api-portal/src/config/authorizationConfig.test.js b/portals/api-portal/src/config/authorizationConfig.test.js new file mode 100644 index 000000000..eb9ee0e8d --- /dev/null +++ b/portals/api-portal/src/config/authorizationConfig.test.js @@ -0,0 +1,470 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use strict'; + +/* + * Startup validation for [api_portal.auth.authorization] (configLoader.js). + * + * Driven through a child process rather than by calling the validator directly: these + * checks are fail-closed via process.exit, and configLoader runs them as a side effect + * of module load. Spawning is what lets the test assert the thing that actually + * matters — that the portal REFUSES TO START — rather than that a function returned an + * error object. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const PROJECT_ROOT = path.join(__dirname, '..', '..'); +const SHIPPED_MAPPING_PATH = path.join(PROJECT_ROOT, 'resources', 'role-to-scope-mapping.yaml'); + +// A config carrying only what the unrelated startup checks demand, so anything this +// suite observes comes from the authorization validation and nothing else. +const BASE_CONFIG = ` +[api_portal.security] +encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +session_secret = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + +[api_portal.organization] +handle = "default" +`; + +let tmpDir; +function fixture(name, contents) { + if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-authz-config-')); + const file = path.join(tmpDir, name); + fs.writeFileSync(file, contents); + return file; +} + +function loadConfig(overlayToml) { + const base = fixture('base.toml', BASE_CONFIG); + const args = ['--config', base]; + if (overlayToml !== undefined) { + args.push('--config', fixture(`overlay-${Math.abs(hash(overlayToml))}.toml`, overlayToml)); + } + const runner = fixture('runner.js', ` + const { config } = require(${JSON.stringify(path.join(__dirname, 'configLoader.js'))}); + // Marker-prefixed: dotenv writes a banner to stdout, so the JSON cannot be + // the only thing there. + process.stdout.write('\\nAUTHZ_JSON:' + JSON.stringify(config.auth.authorization) + '\\n'); + `); + const result = spawnSync(process.execPath, [runner, ...args], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + // Keep the parent's environment out of it: config.toml-free fixtures must not + // pick up an APIP_AP_* value that happens to be set in the developer's shell. + env: { PATH: process.env.PATH, HOME: process.env.HOME }, + }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +/** Pulls the marked JSON line out of a child's stdout, ignoring any banners around it. */ +function parseAuthz(stdout) { + const line = String(stdout).split('\n').find(l => l.startsWith('AUTHZ_JSON:')); + assert.ok(line, `no AUTHZ_JSON line in child stdout: ${stdout}`); + return JSON.parse(line.slice('AUTHZ_JSON:'.length)); +} + +/** + * Runs `body` inside a child that has loaded the given config, with `authz` bound to + * middlewares/authorization.js, and returns whatever it hands to `emit`. + * + * That module reads config at call time and configLoader resolves it at module load, so + * a spawned child is the only way to exercise it under a chosen config. It deliberately + * pulls in only authorization.js — not authMiddleware.js, whose DAO chain would drag the + * native database driver into a test that has nothing to do with the database. + */ +function inChild(overlayToml, body) { + const base = fixture('base.toml', BASE_CONFIG); + const overlay = fixture(`ov-${Math.abs(hash(overlayToml + body))}.toml`, overlayToml); + const runner = fixture(`probe-${Math.abs(hash(body))}.js`, ` + const authz = require(${JSON.stringify(path.join(__dirname, '..', 'middlewares', 'authorization.js'))}); + const emit = (v) => process.stdout.write('\\nAUTHZ_JSON:' + JSON.stringify(v) + '\\n'); + ${body} + `); + const result = spawnSync(process.execPath, [runner, '--config', base, '--config', overlay], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: process.env.HOME }, + }); + assert.equal(result.status, 0, result.stderr); + return parseAuthz(result.stdout); +} + +const ROLE_MODE_NESTED_CLAIM = ` +[api_portal.auth.claim_mappings] +roles = "realm_access.roles" + +[api_portal.auth.authorization] +mode = "role" +role_to_scope_mapping = ${JSON.stringify(SHIPPED_MAPPING_PATH)} +`; + +// Stable per-content fixture name, so concurrent tests don't clobber each other's overlay. +function hash(s) { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +} + +test.after(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('defaults resolve to enabled role-mode authorization backed by the shipped table', () => { + const { status, stdout } = loadConfig(); + assert.equal(status, 0); + const authz = parseAuthz(stdout); + assert.equal(authz.enabled, true); + // Role mode is the default, so the default mapping path must point at a file that + // actually exists — the copy baked into the image / present in the project root. + assert.equal(authz.mode, 'role'); + assert.equal(authz.roleToScopeMapping, './resources/role-to-scope-mapping.yaml'); + // Page gating stays off by default — the behaviour auth.role_validation had. + assert.equal(authz.pageRoleValidation, false); + assert.deepEqual(authz.portalRoles, { admin: 'admin', subscriber: 'Internal/subscriber' }); +}); + +test('an unknown mode refuses to start', () => { + const { status, stderr } = loadConfig('[api_portal.auth.authorization]\nmode = "scopes"\n'); + assert.equal(status, 1); + assert.match(stderr, /auth\.authorization\.mode must be one of/); +}); + +test('an unknown mode refuses to start even while authorization is disabled', () => { + // Rejected regardless of `enabled`, so a typo surfaces when it is written rather + // than months later when enforcement is switched back on. + const { status, stderr } = loadConfig( + '[api_portal.auth.authorization]\nenabled = false\nmode = "rolls"\n'); + assert.equal(status, 1); + assert.match(stderr, /auth\.authorization\.mode must be one of/); +}); + +test('role mode without a mapping file refuses to start', () => { + // The default supplies a path, so this is the case where an operator blanks it out. + const { status, stderr } = loadConfig( + '[api_portal.auth.authorization]\nmode = "role"\nrole_to_scope_mapping = ""\n'); + assert.equal(status, 1); + assert.match(stderr, /requires auth\.authorization\.role_to_scope_mapping/); +}); + +test('role mode without a roles claim mapping refuses to start', () => { + const { status, stderr } = loadConfig(` +[api_portal.auth.claim_mappings] +roles = "" + +[api_portal.auth.authorization] +mode = "role" +role_to_scope_mapping = ${JSON.stringify(SHIPPED_MAPPING_PATH)} +`); + assert.equal(status, 1); + assert.match(stderr, /requires auth\.claim_mappings\.roles/); +}); + +test('role mode with the shipped mapping starts and loads its two roles', () => { + const { status, stderr } = loadConfig(` +[api_portal.auth.authorization] +mode = "role" +role_to_scope_mapping = ${JSON.stringify(SHIPPED_MAPPING_PATH)} +`); + assert.equal(status, 0, stderr); + assert.match(stderr, /loaded 4 role\(s\)/); +}); + +test('a mapping file is loaded and validated even in scope mode', () => { + // Loaded whenever a path is set, so an operator finds out at the next restart that + // their grant table is valid — not the first time a request is authorized with it. + const bad = fixture('bad.yaml', 'roles:\n - name: r1\n scopes: [dp:not_a_real_scope:read]\n'); + const { status, stderr } = loadConfig(` +[api_portal.auth.authorization] +mode = "scope" +role_to_scope_mapping = ${JSON.stringify(bad)} +`); + assert.equal(status, 1); + assert.match(stderr, /does not declare/); +}); + +test('disabling authorization starts, but warns', () => { + const { status, stderr } = loadConfig('[api_portal.auth.authorization]\nenabled = false\n'); + assert.equal(status, 0, stderr); + assert.match(stderr, /\[WARN\].*enabled = false/); +}); + +// --------------------------------------------------------------------------- +// Retired keys (hard break) +// --------------------------------------------------------------------------- + +test('the retired auth.role_validation key refuses to start, naming its replacement', () => { + // A retired key is ignored, so the effective setting would be the DEFAULT rather + // than what the file says — "role_validation = true" would read as "page gating on" + // to the operator and mean "off" to the portal. + const { status, stderr } = loadConfig('[api_portal.auth]\nrole_validation = true\n'); + assert.equal(status, 1); + assert.match(stderr, /auth\.role_validation is retired/); + assert.match(stderr, /auth\.authorization\.page_role_validation/); + // The migration note matters: role_validation is NOT auth.authorization.enabled. + assert.match(stderr, /auth\.authorization\.enabled/); +}); + +test('the retired auth.idp.roles key refuses to start, naming its replacement', () => { + const { status, stderr } = loadConfig('[api_portal.auth.idp.roles]\nadmin = "admin"\n'); + assert.equal(status, 1); + assert.match(stderr, /auth\.idp\.roles is retired/); + assert.match(stderr, /auth\.authorization\.portal_roles/); +}); + +test('both retired keys are reported together, not one per run', () => { + const { status, stderr } = loadConfig(` +[api_portal.auth] +role_validation = false + +[api_portal.auth.idp.roles] +admin = "admin" +`); + assert.equal(status, 1); + assert.match(stderr, /auth\.role_validation is retired/); + assert.match(stderr, /auth\.idp\.roles is retired/); +}); + +test('a retired key is rejected even when the new section is also present', () => { + // Otherwise a half-migrated config would start and quietly enforce the new + // section while the operator believes the old key still applies. + const { status, stderr } = loadConfig(` +[api_portal.auth] +role_validation = true + +[api_portal.auth.authorization] +page_role_validation = true +`); + assert.equal(status, 1); + assert.match(stderr, /auth\.role_validation is retired/); +}); + +test('the shipped config.toml resolves the authorization section from plain literals', () => { + // The whole section is written as literals, matching how + // [platform_api.auth.authorization] is written — deployment policy an operator edits + // in place, not a per-environment value or a secret. The env map below is + // deliberately bare: it proves nothing here depends on an APIP_AP_* variable, so a + // reader of config.toml sees the effective settings without tracing indirection. + // + // Secrets in the shipped file come from container-only {{ file }} paths, so the base + // fixture is layered on top to supply them; everything else is exercised as shipped. + const base = fixture('base.toml', BASE_CONFIG); + const runner = fixture('runner-shipped.js', ` + const { config } = require(${JSON.stringify(path.join(__dirname, 'configLoader.js'))}); + process.stdout.write('\\nAUTHZ_JSON:' + JSON.stringify(config.auth.authorization) + '\\n'); + `); + const result = spawnSync(process.execPath, + [runner, '--config', path.join(PROJECT_ROOT, 'configs', 'config.toml'), '--config', base], + { + cwd: PROJECT_ROOT, + encoding: 'utf8', + env: { PATH: process.env.PATH, HOME: process.env.HOME }, + }); + assert.equal(result.status, 0, result.stderr); + const authz = parseAuthz(result.stdout); + assert.equal(authz.enabled, true); + assert.equal(authz.mode, 'role'); + // Relative on purpose: the one literal resolves against WORKDIR /app in the + // container (where compose mounts the host copy over the baked one) and against the + // project root for `npm run start:local`. + assert.equal(authz.roleToScopeMapping, './resources/role-to-scope-mapping.yaml'); + assert.equal(authz.pageRoleValidation, true); + assert.deepEqual(authz.portalRoles, { admin: 'ap_admin', subscriber: 'ap_subscriber' }); +}); + +test('the shipped config.toml adds no APIP_AP_AUTH_AUTHORIZATION_* env indirection', () => { + // Guards the convention itself: platform-api's config.toml reserves {{ env }} for + // secrets and the log level, and this section follows suit. A token creeping back in + // would mean the file no longer states its own effective policy. + const shipped = fs.readFileSync(path.join(PROJECT_ROOT, 'configs', 'config.toml'), 'utf8'); + const section = shipped.slice(shipped.indexOf('[api_portal.auth.authorization]')); + const upToNextTable = section.slice(0, section.indexOf('\n[api_portal.organization]')); + assert.ok(!/\{\{\s*env/.test(upToNextTable), + `the authorization section should use plain literals, found:\n${upToNextTable}`); +}); + +// --------------------------------------------------------------------------- +// effectiveScopes — the decision every credential path shares +// --------------------------------------------------------------------------- + +test('scope mode passes the token scope claim through, as an array or a string', () => { + const out = inChild('[api_portal.auth.authorization]\nmode = "scope"\n', ` + emit({ + fromString: authz.effectiveScopes('dp:api:read dp:view:read', {}), + fromArray: authz.effectiveScopes(['dp:api:read'], {}), + // A roles claim is irrelevant in scope mode — it must not leak in. + rolesIgnored: authz.effectiveScopes('dp:api:read', { roles: ['dp_admin'] }), + }); + `); + assert.deepEqual(out.fromString, ['dp:api:read', 'dp:view:read']); + assert.deepEqual(out.fromArray, ['dp:api:read']); + assert.deepEqual(out.rolesIgnored, ['dp:api:read']); +}); + +test('role mode expands a nested roles claim and ignores the scope claim entirely', () => { + const out = inChild(ROLE_MODE_NESTED_CLAIM, ` + // Keycloak's shape, reached via auth.claim_mappings.roles = "realm_access.roles". + const token = { sub: 'alice', realm_access: { roles: ['dp_subscriber'] }, scope: 'openid profile' }; + emit(authz.effectiveScopes('openid profile', token)); + `); + assert.ok(out.includes('dp:application:manage'), 'expected the role grant to be applied'); + assert.ok(out.includes('dp:api:read')); + // Ignoring rather than merging the scope claim is what stops a caller widening a + // role's grant by asking their IDP for extra scope values. + assert.ok(!out.includes('openid'), 'the raw scope claim must not be merged in'); + assert.ok(!out.includes('dp:organization:manage'), 'dp_subscriber must not reach admin scopes'); +}); + +test('role mode falls back to the flat roles key for a session profile', () => { + // passportConfig stores a session's roles at the flat `roles` key even when the + // configured claim path is nested, so the fallback is what keeps an IDP browser + // session authorized rather than silently granted nothing. + const out = inChild(ROLE_MODE_NESTED_CLAIM, ` + emit(authz.effectiveScopes('openid', { roles: ['dp_subscriber'], grantedScopes: 'openid' })); + `); + assert.ok(out.includes('dp:api:read'), 'the flat roles key was not read'); + assert.ok(out.includes('dp:application:manage')); + assert.ok(!out.includes('dp:organization:manage'), 'dp_subscriber is not an administrator'); +}); + +test('role mode grants nothing for a role absent from the grant table', () => { + const out = inChild(ROLE_MODE_NESTED_CLAIM, ` + emit(authz.effectiveScopes('dp:organization:manage', { realm_access: { roles: ['some_other_idp_group'] } })); + `); + // Not even the scope claim survives — an unmapped role is a denied request. + assert.deepEqual(out, []); +}); + +test('portalRoles and the two switches read from the authorization section', () => { + const out = inChild(` +[api_portal.auth.authorization] +enabled = false +page_role_validation = true + +[api_portal.auth.authorization.portal_roles] +admin = "dp_admin" +`, ` + emit({ + enabled: authz.isAuthorizationEnabled(), + pageRoleValidation: authz.isPageRoleValidationEnabled(), + roles: authz.portalRoles(), + }); + `); + assert.equal(out.enabled, false); + assert.equal(out.pageRoleValidation, true); + assert.equal(out.roles.admin, 'dp_admin'); + // The unset tier keeps its default rather than becoming undefined. + assert.equal(out.roles.subscriber, 'Internal/subscriber'); + // There is no superAdmin tier any more. + assert.equal(out.roles.superAdmin, undefined); +}); + +// --------------------------------------------------------------------------- +// Page tiers (ensurePermission) — two tiers, admin and subscriber +// --------------------------------------------------------------------------- + +/** + * Exercises ensureAuthenticated.js's page-tier decision in a child, the same way + * inChild() does for the scope decision. req.user carries the tier role NAMES (that is + * what ensureAuthenticated assigns onto it before calling ensurePermission), and + * `roles` is the caller's own roles claim. + */ +function checkPage(overlayToml, page, callerRoles) { + return inChild(overlayToml, ` + const { ensurePermission } = require(${JSON.stringify(path.join(__dirname, '..', 'middlewares', 'ensureAuthenticated.js'))}); + const { portalRoles } = authz; + const { admin, subscriber } = portalRoles(); + const req = { user: { admin, subscriber } }; + emit(ensurePermission(${JSON.stringify(page)}, ${JSON.stringify(callerRoles)}, req)); + `); +} + +const DEFAULT_TIERS = '[api_portal.auth.authorization]\npage_role_validation = true\n'; + +test('the settings page requires the admin tier', () => { + assert.equal(checkPage(DEFAULT_TIERS, '/acme/settings', ['admin']), true); + assert.equal(checkPage(DEFAULT_TIERS, '/acme/settings', ['Internal/subscriber']), false); +}); + +test('applications, api-keys and subscriptions accept either tier', () => { + for (const page of ['/acme/applications', '/acme/api-keys', '/acme/subscriptions']) { + assert.equal(checkPage(DEFAULT_TIERS, page, ['Internal/subscriber']), true, `${page} / subscriber`); + assert.equal(checkPage(DEFAULT_TIERS, page, ['admin']), true, `${page} / admin`); + } +}); + +test('a page outside both tier lists is denied, not defaulted open', () => { + assert.equal(checkPage(DEFAULT_TIERS, '/acme/something-else', ['admin']), false); +}); + +test('a caller with no matching role is denied', () => { + assert.equal(checkPage(DEFAULT_TIERS, '/acme/applications', ['some_other_group']), false); + assert.equal(checkPage(DEFAULT_TIERS, '/acme/applications', []), false); +}); + +test('the retired superAdmin tier no longer grants anything on its own', () => { + // Deliberate behaviour change: "superAdmin" used to grant the settings page and the + // /portal pages. Those routes are not served here, and the tier is gone — a + // deployment whose IDP still emits it must now map it via portal_roles.admin. + assert.equal(checkPage(DEFAULT_TIERS, '/acme/settings', ['superAdmin']), false); + assert.equal(checkPage(DEFAULT_TIERS, '/acme/applications', ['superAdmin']), false); + // ...which is exactly how an operator restores it. + const mapped = '[api_portal.auth.authorization.portal_roles]\nadmin = "superAdmin"\n'; + assert.equal(checkPage(mapped, '/acme/settings', ['superAdmin']), true); +}); + +test('the old /portal pages are no longer a tier of their own', () => { + // API_PORTAL_ROOT is gone, so these fall through to the deny at the end rather + // than to a superAdmin-only branch. + for (const page of ['/portal', '/portal/x/edit', '/devportal']) { + assert.equal(checkPage(DEFAULT_TIERS, page, ['admin']), false, page); + } +}); + +test('the shipped config pins page tiers to roles the shipped grant table defines', () => { + // A coherence check on the shipped pack, not a general rule: page tiers and REST + // authorization are separate mechanisms, and portal_roles names roles as they + // appear in the TOKEN. But with role mode the default, a shipped page tier naming + // a role the shipped table doesn't define would mean the quickstart admin can + // reach the REST API and still be refused the settings page. + const roleScopeMap = require('./roleScopeMap'); + const table = roleScopeMap.loadRoleScopeMap(SHIPPED_MAPPING_PATH, + path.join(PROJECT_ROOT, 'docs', 'api-portal-openapi-spec-v0.9.yaml')); + const shipped = fs.readFileSync(path.join(PROJECT_ROOT, 'configs', 'config.toml'), 'utf8'); + // Scope the scan to the portal_roles table so an `admin = ...` key in some other + // table can never be mistaken for a page tier. + const start = shipped.indexOf('[api_portal.auth.authorization.portal_roles]'); + assert.notEqual(start, -1, 'shipped config.toml has no portal_roles table'); + const rest = shipped.slice(start + 1); + const nextTable = rest.indexOf('\n['); + const portalRolesToml = nextTable === -1 ? rest : rest.slice(0, nextTable); + const tiers = [...portalRolesToml.matchAll(/^(admin|subscriber)\s*=\s*"([a-z0-9_]+)"/gm)] + .map((m) => ({ tier: m[1], role: m[2] })); + assert.equal(tiers.length, 2, `expected two portal_roles entries, found ${tiers.length}`); + for (const { tier, role } of tiers) { + assert.ok(table.has(role), + `portal_roles.${tier} defaults to "${role}", which the shipped grant table does not define`); + } +}); diff --git a/portals/api-portal/src/config/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index 290030598..e07c45ffe 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -81,14 +81,13 @@ const DEFAULTS = { value: '', }, }, - // Authentication: a mode gate plus the two backends it selects between — - // local (default) and idp. + // Authentication — HOW a token is verified: a mode gate plus the two backends it + // selects between, local (default) and idp. What a verified token may DO is + // authorization, which lives in its own mode-independent section below. auth: { // "local" — username/password validated against the Platform API control // plane (auth.local below). "idp" — external OIDC IDP (auth.idp below). mode: 'local', // local | idp - // Enforce per-operation role validation. - roleValidation: false, // was: advanced.disabledRoleValidation, inverted // JWT claim name mappings — which token claim carries each field. // Dot-notation supported for nested claims (e.g. "realm_access.roles"). claimMappings: { @@ -96,6 +95,52 @@ const DEFAULTS = { roles: 'roles', // claim carrying the user's roles groups: 'groups', }, + // Authorization — what a VERIFIED token may do. Deliberately outside both + // auth.local and auth.idp: a token carries the same roles claim whether the + // portal verified it against a JWKS endpoint or against the Platform API's + // public key, so these settings apply in every auth mode. (They used to live + // as auth.role_validation and auth.idp.roles, which made role configuration + // reachable only in idp mode even though both branches of + // ensureAuthenticated read it.) + authorization: { + // Master switch for REST-API (/api/v0.9) authorization. When false, an + // authenticated caller satisfies every operation's declared scope list — + // an explicit development opt-out, never the default. + enabled: true, + // How a REST request's effective scopes are derived: + // "role" — (default) by expanding the token's roles claim through + // roleToScopeMapping. Works for every issuer: an external IDP + // emits the roles its estate is organized around and has no + // reason to mint dp:* scopes, and the Platform API mints its + // own ap_* role names, which the shipped table aliases. + // "scope" — from the token's own scope claim. Use this when the issuer + // mints dp:* scopes directly (an Asgardeo tenant set up with + // production/scripts/register_asgardeo_scopes.sh, or the + // Platform API in local auth mode). + mode: 'role', // scope | role + // Path to the role-to-scope grant table (YAML). Required when + // mode = "role", so it has a default rather than being empty: the shipped + // table is baked into the image at ./resources (Dockerfile's COPY . ., + // WORKDIR /app), and the same relative path resolves for `npm start` from + // the project root. docker-compose.yaml points this at the mounted, + // operator-editable copy under /etc/api-portal instead. + roleToScopeMapping: './resources/role-to-scope-mapping.yaml', + // Per-page role-tier gating (ensurePermission in ensureAuthenticated.js): + // requires the caller's roles claim to name the tier a page demands. + // Distinct from `enabled` above, which governs REST scopes — collapsing + // the two would mean an operator turning page gating off also silently + // turned REST scope enforcement off. + pageRoleValidation: false, // was: auth.role_validation + // Which role name, as it appears in the token's roles claim, grants each + // of the portal's two access tiers. Was auth.idp.roles, despite being read in + // local mode too (authController.js's login). A third tier, superAdmin, used + // to gate the earlier devportal's /portal pages; those are not served here, so + // it guarded nothing and was removed. + portalRoles: { + admin: 'admin', + subscriber: 'Internal/subscriber', + }, + }, // Local auth backend (the Platform API control plane) — used when // mode = "local". Validates username/password and verifies its JWTs. local: { @@ -126,11 +171,6 @@ const DEFAULTS = { tokenRefreshTimeoutMs: 10000, silentSso: true, // was: advanced.disableSilentSSO, inverted orgCallback: false, // was: advanced.disableOrgCallback, inverted - roles: { - admin: 'admin', - subscriber: 'Internal/subscriber', - superAdmin: 'superAdmin', - }, // Maps ?fidp= query param to IDP identifier for federated login hints // (authController.js#login -> passportConfig.js's authorizationParams). Only // takes effect in OIDC mode. Kept out of config-template.toml since it's not diff --git a/portals/api-portal/src/config/configLoader.js b/portals/api-portal/src/config/configLoader.js index 97fe2d03b..b30d1bee9 100644 --- a/portals/api-portal/src/config/configLoader.js +++ b/portals/api-portal/src/config/configLoader.js @@ -24,6 +24,9 @@ const toml = require('smol-toml'); const Handlebars = require('handlebars'); const { DEFAULTS } = require('./configDefaults'); const { snakeToCamelDeep, mergeOver, parseConfigPaths } = require('./configMerge'); +// Requires nothing from this module in return, so loading the grant table from the +// startup validation below cannot cycle. +const roleScopeMap = require('./roleScopeMap'); // Load api-platform.env if present (silently ignored if absent) try { @@ -532,4 +535,134 @@ function validateArtifactConfig(artifacts) { validateArtifactConfig(config.artifacts); -module.exports = { config, KNOWN_ARTIFACT_TYPES }; +// --------------------------------------------------------------------------- +// Authorization config (auth.authorization) +// --------------------------------------------------------------------------- + +const AUTHORIZATION_MODES = ['scope', 'role']; + +// The OpenAPI document apiPortalRouter serves /api/v0.9 from — the authority on +// which dp:* scopes exist, so a grant table naming one that isn't declared there +// can be rejected at startup rather than denying requests later. +const PORTAL_SPEC_PATH = path.join( + __dirname, '..', '..', 'docs', 'api-portal-openapi-spec-v0.9.yaml' +); + +/** + * Rejects config keys retired when authentication and authorization were split into + * separate sections. + * + * A retired key no longer maps to anything, so leaving it in place would silently + * apply the DEFAULTS value instead of what the file says — `role_validation = true` + * would read as "page gating on" to the operator and mean "off" to the portal. Both + * of these keys govern authorization, so failing here is what keeps a half-migrated + * config from starting and enforcing something other than what it states. + * + * Checked against the raw config.toml tree, not the merged one: DEFAULTS no longer + * carries either key, so a hit here can only be operator-supplied. + */ +function rejectRetiredAuthKeys(tomlAuth) { + if (!tomlAuth) return; + const retired = []; + if (tomlAuth.roleValidation !== undefined) { + retired.push( + 'auth.role_validation is retired — it is now ' + + 'auth.authorization.page_role_validation (per-page role gating). Note that ' + + 'REST-API scope enforcement is a separate switch, auth.authorization.enabled, ' + + 'which is on by default' + ); + } + if (tomlAuth.idp?.roles !== undefined) { + retired.push( + 'auth.idp.roles is retired — it is now auth.authorization.portal_roles, ' + + 'outside the idp block, because the portal reads these role names in local ' + + 'auth mode as well' + ); + } + if (retired.length) { + process.stderr.write( + `[FATAL] Retired authorization config key(s):\n - ${retired.join('\n - ')}\n` + + 'Refusing to start: a retired key is ignored, so the effective setting would ' + + 'be the default rather than what the config file says.\n' + ); + process.exit(1); + } +} + +/** + * Fail-closed startup check for the authorization section. + * + * Runs in every auth mode, not inside a per-mode branch: a token carries the same + * roles claim whether it was verified against a JWKS endpoint (idp) or the Platform + * API's public key (local), so role authorization is configurable — and must be + * validated — in both. + * + * `mode` is checked even when `enabled = false`, so a typo surfaces when it is + * written rather than months later when enforcement is switched back on. + */ +function validateAuthorizationConfig(cfg) { + const authz = cfg.auth?.authorization; + if (!authz) { + process.stderr.write('[FATAL] auth.authorization is missing from the resolved config.\n'); + process.exit(1); + } + + if (!AUTHORIZATION_MODES.includes(authz.mode)) { + process.stderr.write( + `[FATAL] auth.authorization.mode must be one of ${AUTHORIZATION_MODES.map(m => `"${m}"`).join(' | ')}, ` + + `got ${JSON.stringify(authz.mode)}.\n` + ); + process.exit(1); + } + + if (authz.mode === 'role') { + // Without a roles claim mapping there is nothing to expand; without the grant + // table the role names would be used verbatim as scope values, which matches no + // operation and denies every request. + if (!cfg.auth?.claimMappings?.roles) { + process.stderr.write( + '[FATAL] auth.authorization.mode = "role" requires auth.claim_mappings.roles — ' + + 'the token claim carrying the roles to expand (e.g. "roles", or ' + + '"realm_access.roles" for Keycloak).\n' + ); + process.exit(1); + } + if (!authz.roleToScopeMapping) { + process.stderr.write( + '[FATAL] auth.authorization.mode = "role" requires ' + + 'auth.authorization.role_to_scope_mapping — the path to the YAML grant table ' + + 'defining what each role may do. Without it, role names would be used as ' + + 'scope values and every request would be denied.\n' + ); + process.exit(1); + } + } + + // Loaded whenever a path is configured, regardless of mode: an operator switching + // to role mode should find out at the next restart that their grant table is + // valid, not the first time a request is authorized against it. + if (authz.roleToScopeMapping) { + try { + const map = roleScopeMap.init(authz.roleToScopeMapping, PORTAL_SPEC_PATH); + process.stderr.write( + `[INFO] Authorization: loaded ${map.size} role(s) from ` + + `"${authz.roleToScopeMapping}" (mode = "${authz.mode}").\n` + ); + } catch (err) { + process.stderr.write(`[FATAL] ${err.message}\n`); + process.exit(1); + } + } + + if (!authz.enabled) { + process.stderr.write( + '[WARN] auth.authorization.enabled = false — REST API operations accept any ' + + 'authenticated caller regardless of the scopes they declare. Development only.\n' + ); + } +} + +rejectRetiredAuthKeys(interpolatedTomlConfig.auth); +validateAuthorizationConfig(config); + +module.exports = { config, KNOWN_ARTIFACT_TYPES, AUTHORIZATION_MODES }; diff --git a/portals/api-portal/src/config/roleScopeMap.js b/portals/api-portal/src/config/roleScopeMap.js new file mode 100644 index 000000000..49170785f --- /dev/null +++ b/portals/api-portal/src/config/roleScopeMap.js @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use strict'; + +/* + * Role-to-scope grant table (auth.authorization.role_to_scope_mapping). + * + * The JS counterpart of platform-api's internal/middleware/role_scope_map.go. + * One file maps a role name to the scopes that role grants; the roles claim of an + * incoming token is expanded through it on every request when + * auth.authorization.mode = "role". + * + * Why this exists: the portal's REST surface (/api/v0.9) is guarded by fine-grained + * dp:* scopes, but an external OIDC IDP has no reason to mint them — it emits the + * roles or groups its own estate is organized around. Without a grant table, the only + * way an IDP-mode caller could reach the REST API was to bypass the per-operation + * scope check entirely (authMiddleware.js's `preauthorized` session fast-path). Role + * mode closes that gap: the IDP keeps emitting roles, and the portal decides what each + * role may do. + * + * Kept free of any dependency on configLoader so configLoader can require it during + * its own startup validation without a cycle. State lives here (the loaded map) but + * the trigger is configLoader's fail-closed startup check — nothing reads an + * unvalidated map, and expandRoles() before init() returns nothing rather than + * silently granting. + */ + +const fs = require('fs'); +const path = require('path'); +const yaml = require('../utils/yaml'); + +// This portal's own scope namespace — the scopes it mints AND enforces, so an +// unknown one is a configuration error rather than something to pass through. +const OWN_SCOPE_PREFIX = 'dp:'; + +// A grant table is a few hundred lines of YAML; the cap guards against pointing +// the setting at something enormous by mistake. Mirrors configLoader's MAX_FILE_BYTES. +const MAX_MAPPING_BYTES = 1 << 20; // 1 MiB + +// Well-formedness for a scope in ANOTHER component's namespace (ap:*, or a future +// one). Segments may contain hyphens — a foreign namespace picks its own convention — +// and `*` is accepted only as a whole trailing segment, never as a free-floating +// character inside one. Matches platform-api's isWellFormedScope. +const SCOPE_SEGMENT_RE = /^[a-z0-9][a-z0-9_-]*$/; + +/** + * Reads the mapping file with the same file-access discipline platformJwt.js applies + * to auth.local.public_key_path: null-byte and traversal rejection before the read, + * and a size ceiling. The path is operator-supplied config, not request input, so it + * is not confined to the {{ file }} allowlist — an operator may keep the grant table + * wherever they mount it. + */ +function readMappingFile(filePath) { + if (typeof filePath !== 'string' || !filePath || filePath.includes('\0')) { + throw new Error('role_to_scope_mapping is not a usable file path'); + } + // Checked on the RAW input, before normalization: path.normalize collapses + // "/etc/api-portal/../../etc/passwd" to "/etc/passwd", which contains no ".." and + // would pass a post-normalization check. There is no allowlist root to contain the + // result against here — the grant table may legitimately live wherever an operator + // mounts it — so rejecting the traversal itself is the control. + const rawSegments = filePath.split(/[/\\]/); + if (rawSegments.includes('..')) { + throw new Error(`role_to_scope_mapping "${filePath}" must not contain traversal sequences`); + } + const cleaned = path.normalize(filePath); + let stat; + try { + stat = fs.statSync(cleaned); + } catch (_) { + throw new Error(`role_to_scope_mapping file "${filePath}" could not be read`); + } + if (!stat.isFile()) { + throw new Error(`role_to_scope_mapping "${filePath}" is not a file`); + } + if (stat.size > MAX_MAPPING_BYTES) { + throw new Error(`role_to_scope_mapping file "${filePath}" exceeds the maximum allowed size`); + } + try { + return fs.readFileSync(cleaned, 'utf8'); + } catch (_) { + throw new Error(`role_to_scope_mapping file "${filePath}" could not be read`); + } +} + +/** + * Parses the grant table into a Map of role name -> deduplicated scope list. + * + * Shape (identical to platform-api's role-to-scope-mapping.yaml, so one file can + * serve both components): + * + * roles: + * - name: dp_admin + * scopes: + * - dp:api:manage + * + * A duplicate role name is rejected rather than last-wins: two entries for one role + * means one of them is silently inert, and which one depends on file order. + */ +function parseRoleScopeMap(contents, filePath) { + let doc; + try { + doc = yaml.load(contents); + } catch (err) { + throw new Error(`role_to_scope_mapping file "${filePath}" is not valid YAML: ${err.message}`); + } + if (!doc || typeof doc !== 'object' || !Array.isArray(doc.roles)) { + throw new Error( + `role_to_scope_mapping file "${filePath}" must contain a top-level "roles" list` + ); + } + + const map = new Map(); + doc.roles.forEach((entry, index) => { + if (!entry || typeof entry !== 'object') { + throw new Error(`role_to_scope_mapping "${filePath}": entry ${index} is not a mapping`); + } + const name = typeof entry.name === 'string' ? entry.name.trim() : ''; + if (!name) { + throw new Error(`role_to_scope_mapping "${filePath}": entry ${index} has no "name"`); + } + if (map.has(name)) { + throw new Error( + `role_to_scope_mapping "${filePath}": role "${name}" is declared more than once` + ); + } + if (!Array.isArray(entry.scopes)) { + throw new Error( + `role_to_scope_mapping "${filePath}": role "${name}" has no "scopes" list` + ); + } + const scopes = []; + for (const scope of entry.scopes) { + if (typeof scope !== 'string' || !scope.trim()) { + throw new Error( + `role_to_scope_mapping "${filePath}": role "${name}" has a non-string scope` + ); + } + const trimmed = scope.trim(); + if (!scopes.includes(trimmed)) scopes.push(trimmed); + } + map.set(name, scopes); + }); + return map; +} + +/** + * Shape check for a scope this portal does not enforce. It cannot confirm or deny + * such a scope's existence — it only mints it into nothing and passes it along — so + * the check is deliberately weaker than the spec lookup applied to dp:* below. + */ +function isWellFormedScope(scope) { + const segments = scope.split(':'); + if (segments.length < 2) return false; + return segments.every((segment, i) => { + if (segment === '*') return i === segments.length - 1; // trailing wildcard only + return SCOPE_SEGMENT_RE.test(segment); + }); +} + +/** + * Extracts every dp:* scope the portal's OpenAPI spec declares, from each security + * scheme's OAuth2 flows. This is the authority on what dp:* scopes exist: the same + * document express-openapi-validator enforces per operation, so a scope absent here + * can never satisfy any operation. + */ +function readDeclaredPortalScopes(specPath) { + let doc; + try { + doc = yaml.load(fs.readFileSync(specPath, 'utf8')); + } catch (err) { + throw new Error(`API Portal OpenAPI spec "${specPath}" could not be read: ${err.message}`); + } + const declared = new Set(); + const schemes = doc?.components?.securitySchemes || {}; + for (const scheme of Object.values(schemes)) { + for (const flow of Object.values(scheme?.flows || {})) { + for (const scope of Object.keys(flow?.scopes || {})) { + declared.add(scope); + } + } + } + if (declared.size === 0) { + throw new Error(`API Portal OpenAPI spec "${specPath}" declares no OAuth2 scopes`); + } + return declared; +} + +/** + * Namespace-scoped validation, mirroring platform-api's ValidateRoleScopeMap: + * + * dp:* this portal's own scopes — must be declared in its OpenAPI spec. An + * unknown one fails startup rather than surfacing later as a role that + * authenticates fine and is then denied every request. + * other a namespace this portal neither declares nor enforces (ap:* from the + * Platform API) — checked for well-formedness only, so one shared grant + * table can carry another component's scopes without this one rejecting it. + * + * Collects every problem before throwing so an operator fixing a hand-written file + * sees the whole list, not the first line that happens to be wrong. + */ +function validateRoleScopeMap(map, declaredScopes, filePath) { + const problems = []; + for (const [role, scopes] of map) { + if (scopes.length === 0) { + problems.push(`role "${role}" grants no scopes`); + continue; + } + for (const scope of scopes) { + if (scope.startsWith(OWN_SCOPE_PREFIX)) { + if (!declaredScopes.has(scope)) { + problems.push( + `role "${role}" grants "${scope}", which the API Portal OpenAPI spec does not declare` + ); + } + continue; + } + if (!isWellFormedScope(scope)) { + problems.push(`role "${role}" grants malformed scope "${scope}"`); + } + } + } + if (problems.length > 0) { + throw new Error( + `role_to_scope_mapping file "${filePath}" is invalid:\n - ${problems.join('\n - ')}` + ); + } +} + +// The validated map, installed by init(). Null until then — expandRoles() treats +// that as "no grants", never as "grant everything". +let roleScopeMap = null; + +/** + * Loads, parses and validates the grant table. Throws on any problem; the caller + * (configLoader) turns that into a fail-closed startup abort. + */ +function loadRoleScopeMap(filePath, specPath) { + const map = parseRoleScopeMap(readMappingFile(filePath), filePath); + validateRoleScopeMap(map, readDeclaredPortalScopes(specPath), filePath); + return map; +} + +function init(filePath, specPath) { + roleScopeMap = loadRoleScopeMap(filePath, specPath); + return roleScopeMap; +} + +function isLoaded() { + return roleScopeMap !== null; +} + +function roleNames() { + return roleScopeMap ? [...roleScopeMap.keys()] : []; +} + +/** + * Expands a token's roles claim into the union of the scopes those roles grant, + * duplicates collapsed — most-permissive wins across multiple roles, matching + * platform-api's effectiveScopes. + * + * Accepts the claim in any shape an IDP might emit it: an array, or a + * space/comma-separated string. An unknown role contributes nothing rather than + * being treated as a scope value in its own right — the failure mode is a denied + * request, never an unintended grant. + */ +function expandRoles(rolesClaim) { + if (!roleScopeMap || !rolesClaim) return []; + const roles = Array.isArray(rolesClaim) + ? rolesClaim + : String(rolesClaim).split(/[\s,]+/); + const scopes = []; + for (const role of roles) { + const name = typeof role === 'string' ? role.trim() : ''; + if (!name) continue; + for (const scope of roleScopeMap.get(name) || []) { + if (!scopes.includes(scope)) scopes.push(scope); + } + } + return scopes; +} + +module.exports = { + init, + isLoaded, + roleNames, + expandRoles, + // Exported for tests and for configLoader's startup validation. + loadRoleScopeMap, + parseRoleScopeMap, + validateRoleScopeMap, + readDeclaredPortalScopes, + isWellFormedScope, +}; diff --git a/portals/api-portal/src/config/roleScopeMap.test.js b/portals/api-portal/src/config/roleScopeMap.test.js new file mode 100644 index 000000000..2cf4eec62 --- /dev/null +++ b/portals/api-portal/src/config/roleScopeMap.test.js @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const roleScopeMap = require('./roleScopeMap'); + +const SPEC_PATH = path.join(__dirname, '..', '..', 'docs', 'api-portal-openapi-spec-v0.9.yaml'); +const SHIPPED_MAPPING_PATH = path.join(__dirname, '..', '..', 'resources', 'role-to-scope-mapping.yaml'); + +let tmpDir; +function writeFixture(name, contents) { + if (!tmpDir) tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-role-scope-')); + const file = path.join(tmpDir, name); + fs.writeFileSync(file, contents); + return file; +} + +test.after(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Scope well-formedness (foreign namespaces) +// --------------------------------------------------------------------------- + +test('isWellFormedScope accepts a trailing wildcard but not a non-trailing one', () => { + assert.equal(roleScopeMap.isWellFormedScope('ap:gateway:*'), true); + assert.equal(roleScopeMap.isWellFormedScope('ap:*'), true); + // A wildcard that isn't the last segment would imply prefix/transitive matching, + // which no component implements. + assert.equal(roleScopeMap.isWellFormedScope('ap:*:read'), false); + // ...and a wildcard glued into a segment is not a wildcard at all. + assert.equal(roleScopeMap.isWellFormedScope('ap:gate*way:read'), false); +}); + +test('isWellFormedScope allows hyphens, since a foreign namespace picks its own convention', () => { + assert.equal(roleScopeMap.isWellFormedScope('dp:api-key_read'), true); + assert.equal(roleScopeMap.isWellFormedScope('ap:rest_api:deployment:manage'), true); +}); + +test('isWellFormedScope rejects a value that is not namespaced at all', () => { + assert.equal(roleScopeMap.isWellFormedScope('notascope'), false); + assert.equal(roleScopeMap.isWellFormedScope(''), false); + assert.equal(roleScopeMap.isWellFormedScope('dp:'), false); + assert.equal(roleScopeMap.isWellFormedScope(':read'), false); +}); + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +test('parseRoleScopeMap reads roles into a map and collapses duplicate scopes within a role', () => { + const map = roleScopeMap.parseRoleScopeMap(` +roles: + - name: r1 + scopes: + - dp:api:read + - dp:api:read + - dp:view:read +`, 'fixture'); + assert.deepEqual([...map.keys()], ['r1']); + assert.deepEqual(map.get('r1'), ['dp:api:read', 'dp:view:read']); +}); + +test('parseRoleScopeMap rejects a duplicate role name rather than silently last-wins', () => { + assert.throws(() => roleScopeMap.parseRoleScopeMap(` +roles: + - name: dup + scopes: [dp:api:read] + - name: dup + scopes: [dp:view:read] +`, 'fixture'), /declared more than once/); +}); + +test('parseRoleScopeMap rejects a file with no top-level roles list', () => { + assert.throws(() => roleScopeMap.parseRoleScopeMap('something: else', 'fixture'), + /must contain a top-level "roles" list/); + assert.throws(() => roleScopeMap.parseRoleScopeMap('', 'fixture'), + /must contain a top-level "roles" list/); +}); + +test('parseRoleScopeMap rejects an entry with no name or no scopes list', () => { + assert.throws(() => roleScopeMap.parseRoleScopeMap('roles:\n - scopes: [dp:api:read]\n', 'fixture'), + /has no "name"/); + assert.throws(() => roleScopeMap.parseRoleScopeMap('roles:\n - name: r1\n', 'fixture'), + /has no "scopes" list/); +}); + +// --------------------------------------------------------------------------- +// Namespace-scoped validation +// --------------------------------------------------------------------------- + +test('validateRoleScopeMap rejects a dp: scope the OpenAPI spec does not declare', () => { + const declared = new Set(['dp:api:read']); + const map = new Map([['r1', ['dp:no_such_resource:manage']]]); + assert.throws(() => roleScopeMap.validateRoleScopeMap(map, declared, 'fixture'), + /does not declare/); +}); + +test('validateRoleScopeMap accepts a well-formed scope in another component namespace', () => { + // This portal mints ap:* into nothing and enforces none, so it can neither confirm + // nor deny their existence — shape is all it may check. + const declared = new Set(['dp:api:read']); + const map = new Map([['r1', ['dp:api:read', 'ap:rest_api:manage', 'ap:gateway:*']]]); + assert.doesNotThrow(() => roleScopeMap.validateRoleScopeMap(map, declared, 'fixture')); +}); + +test('validateRoleScopeMap reports every problem at once', () => { + const declared = new Set(['dp:api:read']); + const map = new Map([['r1', ['dp:nope:read', 'ap:*:read', 'notascope']]]); + assert.throws(() => roleScopeMap.validateRoleScopeMap(map, declared, 'fixture'), (err) => { + assert.match(err.message, /dp:nope:read/); + assert.match(err.message, /ap:\*:read/); + assert.match(err.message, /notascope/); + return true; + }); +}); + +test('validateRoleScopeMap rejects a role that grants nothing', () => { + assert.throws(() => roleScopeMap.validateRoleScopeMap(new Map([['r1', []]]), new Set(['dp:api:read']), 'fixture'), + /grants no scopes/); +}); + +// --------------------------------------------------------------------------- +// File access +// --------------------------------------------------------------------------- + +test('loadRoleScopeMap refuses a path containing a traversal sequence or a null byte', () => { + assert.throws(() => roleScopeMap.loadRoleScopeMap('/etc/api-portal/../../etc/passwd', SPEC_PATH), + /traversal/); + assert.throws(() => roleScopeMap.loadRoleScopeMap('/etc/api-portal/x\0.yaml', SPEC_PATH), + /not a usable file path/); +}); + +test('loadRoleScopeMap reports an unreadable path without leaking why', () => { + const missing = path.join(os.tmpdir(), 'ap-role-scope-does-not-exist.yaml'); + assert.throws(() => roleScopeMap.loadRoleScopeMap(missing, SPEC_PATH), /could not be read/); +}); + +// --------------------------------------------------------------------------- +// Declared-scope extraction +// --------------------------------------------------------------------------- + +test('readDeclaredPortalScopes pulls dp:* scopes out of the shipped spec', () => { + const declared = roleScopeMap.readDeclaredPortalScopes(SPEC_PATH); + assert.ok(declared.size > 50, `expected the spec to declare many scopes, got ${declared.size}`); + assert.ok(declared.has('dp:api:read')); + assert.ok(declared.has('dp:application:manage')); + assert.ok(!declared.has('dp:not_a_real_scope:read')); +}); + +// --------------------------------------------------------------------------- +// The shipped sample — the counterpart of platform-api's +// TestShippedSampleRolesValidateAgainstShippedSpec, so a pack cannot ship a +// grant table that fails startup. +// --------------------------------------------------------------------------- + +test('the shipped role-to-scope-mapping.yaml validates against the shipped OpenAPI spec', () => { + const map = roleScopeMap.loadRoleScopeMap(SHIPPED_MAPPING_PATH, SPEC_PATH); + // Two grants by design — the portal recognises an administrator and a consumer, + // which is exactly what its page gate has tiers for — plus aliases for the role + // names other components mint. The publisher/operator/viewer personas belong to + // platform-api's own grant table. + assert.deepEqual([...map.keys()], ['dp_admin', 'dp_subscriber', 'ap_admin', 'ap_subscriber']); +}); + +test('the shipped admin role covers every resource the shipped subscriber role touches', () => { + // A narrower admin than consumer would be a packaging mistake, not a policy: an + // administrator who cannot see what a subscriber can manage is never intended. + const map = roleScopeMap.loadRoleScopeMap(SHIPPED_MAPPING_PATH, SPEC_PATH); + const resourcesOf = (role) => new Set(map.get(role).map((s) => s.split(':')[1])); + const adminResources = resourcesOf('dp_admin'); + for (const resource of resourcesOf('dp_subscriber')) { + assert.ok(adminResources.has(resource), `dp_admin has no scope for dp:${resource}:*`); + } +}); + +// --------------------------------------------------------------------------- +// Expansion +// --------------------------------------------------------------------------- + +test('expandRoles grants nothing before a grant table is loaded', () => { + // Fresh module instance: an unloaded map must never read as "grant everything". + const isolated = require.cache[require.resolve('./roleScopeMap')]; + delete require.cache[require.resolve('./roleScopeMap')]; + const fresh = require('./roleScopeMap'); + assert.equal(fresh.isLoaded(), false); + assert.deepEqual(fresh.expandRoles(['dp_admin']), []); + require.cache[require.resolve('./roleScopeMap')] = isolated; +}); + +test('expandRoles expands a single role, and unions across several without duplicates', () => { + const file = writeFixture('union.yaml', ` +roles: + - name: reader + scopes: + - dp:api:read + - dp:view:read + - name: writer + scopes: + - dp:api:manage + - dp:view:read +`); + roleScopeMap.init(file, SPEC_PATH); + + assert.deepEqual(roleScopeMap.expandRoles(['reader']), ['dp:api:read', 'dp:view:read']); + + const both = roleScopeMap.expandRoles(['reader', 'writer']); + // Union, most-permissive wins; dp:view:read is granted by both and appears once. + assert.deepEqual(both.slice().sort(), ['dp:api:manage', 'dp:api:read', 'dp:view:read']); + assert.equal(both.length, new Set(both).size); +}); + +test('expandRoles accepts a string claim as well as an array', () => { + const file = writeFixture('string-claim.yaml', ` +roles: + - name: reader + scopes: [dp:api:read] + - name: writer + scopes: [dp:api:manage] +`); + roleScopeMap.init(file, SPEC_PATH); + + assert.deepEqual(roleScopeMap.expandRoles('reader writer').sort(), ['dp:api:manage', 'dp:api:read']); + assert.deepEqual(roleScopeMap.expandRoles('reader,writer').sort(), ['dp:api:manage', 'dp:api:read']); +}); + +test('expandRoles grants nothing for an unknown role, an empty claim, or a blank entry', () => { + const file = writeFixture('nothing.yaml', 'roles:\n - name: reader\n scopes: [dp:api:read]\n'); + roleScopeMap.init(file, SPEC_PATH); + + // An unknown role must contribute nothing rather than being treated as a scope + // value of its own — the failure mode is a denied request, never a grant. + assert.deepEqual(roleScopeMap.expandRoles(['no_such_role']), []); + assert.deepEqual(roleScopeMap.expandRoles([]), []); + assert.deepEqual(roleScopeMap.expandRoles(undefined), []); + assert.deepEqual(roleScopeMap.expandRoles(''), []); + assert.deepEqual(roleScopeMap.expandRoles(['', ' ', null]), []); + // A known role alongside an unknown one still grants its own scopes. + assert.deepEqual(roleScopeMap.expandRoles(['no_such_role', 'reader']), ['dp:api:read']); +}); + +test('the shipped aliases resolve to the same grant as the role they mirror', () => { + // They are YAML anchors, so a grant is defined once; this pins that they stay + // wired up rather than drifting into two hand-maintained lists. + const map = roleScopeMap.loadRoleScopeMap(SHIPPED_MAPPING_PATH, SPEC_PATH); + assert.deepEqual(map.get('ap_admin'), map.get('dp_admin')); + assert.deepEqual(map.get('ap_subscriber'), map.get('dp_subscriber')); +}); + +test('role mode is usable by the shipped local-auth quickstart out of the box', () => { + // platform-api's shipped config grants its admin roles = ["ap_admin"] and mints + // that name into the roles claim. With role mode now the default, a missing alias + // would mean login succeeds and every REST request is denied. + roleScopeMap.init(SHIPPED_MAPPING_PATH, SPEC_PATH); + const scopes = roleScopeMap.expandRoles(['ap_admin']); + assert.ok(scopes.includes('dp:organization:manage'), 'ap_admin must reach admin scopes'); + assert.ok(scopes.includes('dp:api:manage')); +}); diff --git a/portals/api-portal/src/controllers/authController.js b/portals/api-portal/src/controllers/authController.js index e600a6c48..ba7bd25da 100644 --- a/portals/api-portal/src/controllers/authController.js +++ b/portals/api-portal/src/controllers/authController.js @@ -28,6 +28,7 @@ const orgDao = require('../dao/organizationDao'); const orgContext = require('../utils/orgContext'); const { validationResult } = require('express-validator'); const { verifyPlatformJwtClaims } = require('../utils/platformJwt'); +const { portalRoles, rolesFromClaims } = require('../middlewares/authorization'); @@ -303,11 +304,36 @@ const handleLocalLogin = async (req, res) => { return res.redirect(`${baseUrl}/login?error=Invalid+username+or+password`); } - const adminRole = config.auth.idp?.roles?.admin || 'admin'; - const subscriberRole = config.auth.idp?.roles?.subscriber || 'Internal/subscriber'; - // Users with any dp: manage scope are treated as admins in the API Portal. - const isAdmin = claims.scopes.some(s => s.startsWith('dp:') && s.endsWith(':manage')); - const roles = isAdmin ? [adminRole] : [subscriberRole]; + // Role tiers come from the mode-independent authorization section — this is local + // auth mode, where the old auth.idp.roles placement meant configuring the portal's + // role names inside a block the docs describe as idp-only. + const { admin: adminRole, subscriber: subscriberRole } = portalRoles(); + // Prefer the token's own roles claim: since authentication and authorization were + // split, the Platform API expands a file-mode user's roles into both the scope claim + // and a roles claim, so the portal can read the roles it was actually granted + // instead of inferring a tier from the scopes they happened to expand into. The + // scope heuristic below remains as a fallback for a Platform API predating that. + const claimedRoles = rolesFromClaims(claims); + const normalizedRoles = Array.isArray(claimedRoles) + ? claimedRoles.filter(r => typeof r === 'string' && r.trim()) + : String(claimedRoles || '').split(/[\s,]+/).filter(Boolean); + // Only honour the claim when it names a tier this portal was configured to + // recognise. The Platform API's shipped roles are named for the platform + // ("ap_admin"), not for the portal's tiers, so a claim carrying them would + // otherwise resolve to no tier at all and silently demote an admin — the operator + // opts in by pointing portal_roles at their role names. + const configuredTiers = [adminRole, subscriberRole].filter(Boolean); + const recognisedRoles = normalizedRoles.filter(r => configuredTiers.includes(r)); + let roles; + let isAdmin; + if (recognisedRoles.length) { + roles = recognisedRoles; + isAdmin = roles.includes(adminRole); + } else { + // Users with any dp: manage scope are treated as admins in the API Portal. + isAdmin = claims.scopes.some(s => s.startsWith('dp:') && s.endsWith(':manage')); + roles = isAdmin ? [adminRole] : [subscriberRole]; + } const returnTo = req.session.returnTo; let view = viewName; diff --git a/portals/api-portal/src/middlewares/authMiddleware.js b/portals/api-portal/src/middlewares/authMiddleware.js index a8885d324..8a85d6285 100644 --- a/portals/api-portal/src/middlewares/authMiddleware.js +++ b/portals/api-portal/src/middlewares/authMiddleware.js @@ -33,7 +33,7 @@ * */ -const { safeDecodeJwt } = require('../utils/jwtDecode'); +const { safeDecodeJwt, getNestedClaim } = require('../utils/jwtDecode'); const { jwtVerify, createRemoteJWKSet } = require('jose'); const { config } = require('../config/configLoader'); @@ -44,7 +44,7 @@ const { accessTokenPresent, refreshAccessToken, verifyWithCertificate, resolveOr const orgDao = require('../dao/organizationDao'); const orgContext = require('../utils/orgContext'); const userIdpReferenceDao = require('../dao/userIdpReferenceDao'); -const { getNestedClaim } = require('./passportConfig'); +const { effectiveScopes, isAuthorizationEnabled, isRoleMode } = require('./authorization'); const { NotFoundError } = require('../utils/errors/customErrors'); const userOrganizationMappingDao = require('../dao/userOrganizationMappingDao'); @@ -302,17 +302,27 @@ async function authResolver(req, res, next) { req.auth = { mode: 'platform-jwt', preauthorized: false, - scopes: claims?.scopes ?? [], + // Platform API tokens carry both a scope claim and (since the + // authentication/authorization split) a roles claim, so either + // authorization mode can be applied to the same token. + scopes: effectiveScopes(claims?.scopes ?? [], claims), userId: userUuid, rawSub: req.user[constants.USER_ID], }; return next(); } - // 2. Session fast-path: browser login via IDP — role check is done by ensureAuthenticated - // on page routes, so scope enforcement here is redundant and would require listing all - // dp:* scopes in the OIDC scope config. Set preauthorized to bypass the per-operation - // scope check for session users (same as API key and mTLS paths). + // 2. Session fast-path: browser login via IDP. + // + // In "scope" mode the per-operation check is bypassed (preauthorized, same as the + // API key and mTLS paths): the IDP mints whatever scopes its client is registered + // for, which would mean listing all dp:* scopes in the OIDC scope config, so the + // authorization that actually applies to these sessions is ensureAuthenticated's + // page role check. + // + // In "role" mode the grant table makes the session's own roles claim sufficient to + // derive dp:* scopes, so the operation-level check is enforced here instead of + // bypassed — that is the gap role mode exists to close. if (req.isAuthenticated && req.isAuthenticated() && req.user?.grantedScopes !== undefined && config.auth.mode === 'idp') { // The session's org claim is populated at login from // config.auth.claimMappings.organization (see passportConfig) and stored @@ -334,8 +344,8 @@ async function authResolver(req, res, next) { req[constants.USER_ID] = userUuid; req.auth = { mode: 'oauth2', - preauthorized: true, - scopes: String(req.user.grantedScopes || '').split(' ').filter(Boolean), + preauthorized: !isRoleMode(), + scopes: effectiveScopes(req.user.grantedScopes, req.user), userId: userUuid, rawSub, }; @@ -377,7 +387,10 @@ async function authResolver(req, res, next) { req[constants.USER_ID] = userUuid; req.auth = { mode: 'oauth2', - scopes: String(scopes || '').split(' ').filter(Boolean), + // `decoded` is the same payload verifyBearerToken just verified, so the + // roles claim role mode expands is a verified one — never a claim read + // out of an unverified token. + scopes: effectiveScopes(scopes, decoded), userId: userUuid, rawSub, }; @@ -447,6 +460,10 @@ async function OAuth2Security(req /* , requiredScopes, schema */) { err.status = 401; throw err; } + // Authentication is still required above — only the per-operation scope check is + // waived, and only by an explicit opt-out (auth.authorization.enabled = false, + // which logs a warning at startup). + if (!isAuthorizationEnabled()) return true; if (!requiredScopes || requiredScopes.length === 0) return true; const tokenScopes = req.auth.scopes || []; const ok = requiredScopes.some(s => tokenScopes.includes(s)); diff --git a/portals/api-portal/src/middlewares/authorization.js b/portals/api-portal/src/middlewares/authorization.js new file mode 100644 index 000000000..bccc658c9 --- /dev/null +++ b/portals/api-portal/src/middlewares/authorization.js @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com) All Rights Reserved. + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use strict'; + +/* + * Authorization decisions shared by every credential path (auth.authorization). + * + * Deliberately one module rather than a rule spelled out per call site: the effective + * scopes of a request are computed identically whether it arrived as a local-auth + * session, an IDP session, or a bearer token, and whether it is heading for the + * spec-driven /api/v0.9 router (authMiddleware.js) or an enforceSecurity-gated route + * (ensureAuthenticated.js). A second implementation is how one of those paths ends up + * still reading the raw scope claim after the portal has been switched to role mode. + */ + +const { config } = require('../config/configLoader'); +const constants = require('../utils/constants'); +const roleScopeMap = require('../config/roleScopeMap'); +const { getNestedClaim } = require('../utils/jwtDecode'); + +function authorizationConfig() { + return config.auth?.authorization || {}; +} + +/** True when per-operation scope enforcement applies to the REST surface. */ +function isAuthorizationEnabled() { + return authorizationConfig().enabled !== false; +} + +/** True when effective scopes come from expanding the roles claim, not the scope claim. */ +function isRoleMode() { + return authorizationConfig().mode === 'role'; +} + +/** + * Reads the roles claim out of a decoded token, honouring the configured claim path + * (dot-notation supported, e.g. Keycloak's "realm_access.roles"). + */ +function rolesFromClaims(claims) { + if (!claims) return undefined; + const claimPath = config.auth?.claimMappings?.roles; + return getNestedClaim(claims, claimPath) ?? claims[constants.ROLES.ROLE_CLAIM]; +} + +/** + * The scopes a request is authorized against. + * + * In "scope" mode this is the token's own scope claim, unchanged. In "role" mode the + * scope claim is ignored entirely and the roles claim is expanded through the grant + * table instead — an external IDP emits the roles its estate is organized around and + * has no reason to mint dp:* scopes, so the portal decides what each role may do. + * Ignoring rather than merging the scope claim is deliberate: a caller must not be + * able to widen a role's grant by asking their IDP for extra scope values. + * + * @param {string[]|string} tokenScopes scope claim, as an array or space-separated string + * @param {object} claims decoded token payload (or session profile) carrying the roles claim + * @returns {string[]} + */ +function effectiveScopes(tokenScopes, claims) { + if (isRoleMode()) { + return roleScopeMap.expandRoles(rolesFromClaims(claims)); + } + if (Array.isArray(tokenScopes)) return tokenScopes.filter(Boolean); + return String(tokenScopes || '').split(' ').filter(Boolean); +} + +/** + * Which role name grants each of the portal's three access tiers. Read from the + * mode-independent authorization section — the local-auth login path and the IDP + * login path both need it, which is why it no longer lives under auth.idp. + */ +function portalRoles() { + return authorizationConfig().portalRoles || {}; +} + +/** True when a page's required role tier must be present in the caller's roles claim. */ +function isPageRoleValidationEnabled() { + return authorizationConfig().pageRoleValidation === true; +} + +module.exports = { + isAuthorizationEnabled, + isRoleMode, + effectiveScopes, + rolesFromClaims, + portalRoles, + isPageRoleValidationEnabled, +}; diff --git a/portals/api-portal/src/middlewares/ensureAuthenticated.js b/portals/api-portal/src/middlewares/ensureAuthenticated.js index e150ca559..50f745183 100644 --- a/portals/api-portal/src/middlewares/ensureAuthenticated.js +++ b/portals/api-portal/src/middlewares/ensureAuthenticated.js @@ -27,6 +27,12 @@ const logger = require('../config/logger'); const { decodePlatformJwtClaims } = require('../utils/platformJwt'); const { accessTokenPresent } = require('../utils/tokenUtil'); const { resolveUserUuid, verifyBearerToken } = require('./authMiddleware'); +const { + effectiveScopes, + isAuthorizationEnabled, + portalRoles, + isPageRoleValidationEnabled, +} = require('./authorization'); // System page-access gates (constants.js) merged with any deployer-supplied additions // (config.pageAccessRules, via config.toml) — the config side only ever adds patterns, @@ -48,6 +54,11 @@ const AUTHORIZED_PAGES = [ // CRUD operation on the same resource would. function matchesAnyScope(tokenScopes, requiredScope) { if (!requiredScope) return true; + // Authentication has already been established by the caller; this only waives the + // scope comparison, via the same explicit opt-out OAuth2Security honours, so an + // enforceSecurity-gated route and a /api/v0.9 operation agree on whether + // authorization applies at all. + if (!isAuthorizationEnabled()) return true; const required = Array.isArray(requiredScope) ? requiredScope : [requiredScope]; return required.some((s) => tokenScopes.includes(s)); } @@ -67,7 +78,8 @@ function enforceSecurity(scope) { if (req.isAuthenticated() && req.user && req.user.isLocalAuth && config.auth.mode !== 'idp') { const platformToken = req.user[constants.ACCESS_TOKEN]; if (!platformToken) return util.handleError(res, new CustomError(401, constants.ERROR_CODE[401], constants.ERROR_MESSAGE.UNAUTHENTICATED)); - const tokenScopes = decodePlatformJwtClaims(platformToken)?.scopes ?? []; + const platformClaims = decodePlatformJwtClaims(platformToken); + const tokenScopes = effectiveScopes(platformClaims?.scopes ?? [], platformClaims); req.tokenScopes = tokenScopes; if (matchesAnyScope(tokenScopes, scope)) return next(); return util.handleError(res, new CustomError(403, constants.ERROR_CODE[403], constants.ERROR_MESSAGE.FORBIDDEN)); @@ -129,19 +141,20 @@ function belongsToTargetOrg(req, orgDetails) { ); } +// Two tiers, matching the two personas this portal serves: an administrator, and a +// consumer of its APIs. There was a third, superAdmin, gating the multi-organization +// pages of the earlier devportal (/portal, /devportal) — those routes are not served +// here (customPageRoute.js 404s /portal outright), so the tier guarded nothing and is +// gone along with the route list it keyed on. const ensurePermission = (currentPage, role, req) => { - let adminRole, superAdminRole, subscriberRole; - if (req.user) { - adminRole = req.user[constants.ROLES.ADMIN]; - superAdminRole = req.user[constants.ROLES.SUPER_ADMIN]; - subscriberRole = req.user[constants.ROLES.SUBSCRIBER]; - if (constants.ROUTE.API_PORTAL_CONFIGURE.some(pattern => minimatch.minimatch(currentPage, pattern))) { - return hasRole(role, superAdminRole) || hasRole(role, adminRole); - } else if (constants.ROUTE.API_PORTAL_ROOT.some(pattern => minimatch.minimatch(currentPage, pattern))) { - return hasRole(role, superAdminRole); - } else if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(currentPage, pattern))) { - return hasRole(role, subscriberRole) || hasRole(role, adminRole) || hasRole(role, superAdminRole); - } + if (!req.user) return false; + const adminRole = req.user[constants.ROLES.ADMIN]; + const subscriberRole = req.user[constants.ROLES.SUBSCRIBER]; + if (constants.ROUTE.API_PORTAL_CONFIGURE.some(pattern => minimatch.minimatch(currentPage, pattern))) { + return hasRole(role, adminRole); + } + if (AUTHORIZED_PAGES.some(pattern => minimatch.minimatch(currentPage, pattern))) { + return hasRole(role, subscriberRole) || hasRole(role, adminRole); } return false; } @@ -183,9 +196,10 @@ const ensureAuthenticated = async (req, res, next) => { logger.warn('Rejected request with path-traversal sequence', { operation: 'ensureAuthenticated' }); return res.status(400).json({ error: 'bad_request', message: 'Invalid request path.' }); } - let adminRole = config.auth.idp?.roles?.admin; - let superAdminRole = config.auth.idp?.roles?.superAdmin; - let subscriberRole = config.auth.idp?.roles?.subscriber; + // Read from the mode-independent authorization section: this function's local-auth + // branch and its token/OAuth2 branch both need these names, so they cannot live + // under auth.idp. + const { admin: adminRole, subscriber: subscriberRole } = portalRoles(); const rules = util.validateRequestParameters(); for (let validation of rules) { await validation.run(req); @@ -214,7 +228,7 @@ const ensureAuthenticated = async (req, res, next) => { } } } - // Glob patterns below (AUTHENTICATED_PAGES/AUTHORIZED_PAGES/API_PORTAL_ROOT) match the + // Glob patterns below (AUTHENTICATED_PAGES/AUTHORIZED_PAGES) match the // full string with no implicit query-string handling, so req.originalUrl (which retains // "?...") would silently fail to match any pattern lacking an explicit "?**" suffix — // e.g. "/*/settings" never matches "/org/settings?view=x", which would skip this entire @@ -238,22 +252,25 @@ const ensureAuthenticated = async (req, res, next) => { // Reject cross-org access: the URL's :orgName must resolve (via orgDetails.idp_ref_id) // to the org the authenticated (local-auth) user's token claims it belongs to — the // same comparison the token/OAuth2 branch below uses (belongsToTargetOrg). - const isApiPortalRoot = constants.ROUTE.API_PORTAL_ROOT.some(pattern => minimatch.minimatch(pathname, pattern)); - if (!isApiPortalRoot && !belongsToTargetOrg(req, orgDetails)) { + // + // This check used to be skipped for API_PORTAL_ROOT paths, because the + // superAdmin tier administered several organizations from /portal. Those + // pages are not served here and that tier no longer exists, so the + // exemption is gone too — every authorized page is now org-checked. + if (!belongsToTargetOrg(req, orgDetails)) { const err = new Error('Forbidden'); err.status = 403; return next(err); } if (req.user) { req.user[constants.ROLES.ADMIN] = adminRole; - req.user[constants.ROLES.SUPER_ADMIN] = superAdminRole; req.user[constants.ROLES.SUBSCRIBER] = subscriberRole; if (orgDetails) { req.user[constants.ORG_UUID] = orgDetails.uuid; req.user[constants.ORG_IDENTIFIER] = orgDetails.idp_ref_id; } } - if (config.auth.roleValidation) { + if (isPageRoleValidationEnabled()) { role = req.user[constants.ROLES.ROLE_CLAIM]; if (ensurePermission(pathname, role, req)) { return next(); @@ -276,20 +293,19 @@ const ensureAuthenticated = async (req, res, next) => { role = req.user[constants.ROLES.ROLE_CLAIM]; if (req.user) { req.user[constants.ROLES.ADMIN] = adminRole; - req.user[constants.ROLES.SUPER_ADMIN] = superAdminRole; req.user[constants.ROLES.SUBSCRIBER] = subscriberRole; if (orgDetails) { req.user[constants.ORG_UUID] = orgDetails.uuid; req.user[constants.ORG_IDENTIFIER] = orgDetails.idp_ref_id; } } - const isMatch = constants.ROUTE.API_PORTAL_ROOT.some(pattern => minimatch.minimatch(pathname, pattern)); - if (!isMatch && !belongsToTargetOrg(req, orgDetails)) { + // No API_PORTAL_ROOT exemption here either — see the local-auth branch above. + if (!belongsToTargetOrg(req, orgDetails)) { const err = new Error('Forbidden'); err.status = 403; return next(err); } - if (config.auth.roleValidation) { + if (isPageRoleValidationEnabled()) { if (ensurePermission(pathname, role, req)) { return next(); } else { @@ -342,7 +358,11 @@ function validateAuthentication(scope) { const { valid, scopes } = await verifyBearerToken(accessToken, req); if (valid) { - const tokenScopes = String(scopes || '').split(' '); + // Decoded only after verification succeeded, and from the token + // verifyBearerToken just accepted (which may be a refreshed one it wrote + // back onto the session) — so role mode expands a verified roles claim. + const verifiedClaims = safeDecodeJwt(req.user?.[constants.ACCESS_TOKEN] || accessToken); + const tokenScopes = effectiveScopes(scopes, verifiedClaims); req.tokenScopes = tokenScopes; if (matchesAnyScope(tokenScopes, scope)) { return next(); @@ -402,4 +422,7 @@ module.exports = { validateAuthentication, enforceSecurity, matchesAnyScope, + // Exported for tests: the page-tier decision is security-relevant enough to pin + // directly rather than only through the integration suite. + ensurePermission, } diff --git a/portals/api-portal/src/middlewares/passportConfig.js b/portals/api-portal/src/middlewares/passportConfig.js index 9e7893444..8ffdfa507 100644 --- a/portals/api-portal/src/middlewares/passportConfig.js +++ b/portals/api-portal/src/middlewares/passportConfig.js @@ -18,8 +18,9 @@ const passport = require('passport'); const OAuth2Strategy = require('passport-oauth2'); -const { safeDecodeJwt } = require('../utils/jwtDecode'); +const { safeDecodeJwt, getNestedClaim } = require('../utils/jwtDecode'); const { config } = require('../config/configLoader'); +const { portalRoles } = require('./authorization'); const constants = require('../utils/constants'); const logger = require('../config/logger'); const orgContext = require('../utils/orgContext'); @@ -70,19 +71,6 @@ async function assertLoginOrgAllowed(organizationId) { } } -// Resolves a dot-notation path (e.g. "realm_access.roles") from a decoded JWT. -// Falls back gracefully so plain claim names (e.g. "roles") still work. -function getNestedClaim(obj, path) { - if (!path || typeof obj !== 'object' || obj === null) return undefined; - const parts = String(path).split('.'); - let cur = obj; - for (const part of parts) { - if (typeof cur !== 'object' || cur === null) return undefined; - cur = cur[part]; - } - return cur; -} - function configurePassport(SERVER_ID) { if (config.auth.mode === 'idp') { const idpScope = config.auth.idp.scope; @@ -123,7 +111,8 @@ function configurePassport(SERVER_ID) { const groups = Array.isArray(rawGroups) ? rawGroups : String(rawGroups).split(/[\s,]+/).filter(Boolean); - if (roles.includes(config.auth.idp.roles.superAdmin) || roles.includes(config.auth.idp.roles.admin)) { + const { admin: adminRole } = portalRoles(); + if (roles.includes(adminRole)) { isAdmin = true; } // The IDP is trusted to say who the user is, not which organization this @@ -235,5 +224,5 @@ function configurePassport(SERVER_ID) { }); } -module.exports = { configurePassport, getNestedClaim }; +module.exports = { configurePassport }; diff --git a/portals/api-portal/src/utils/constants.js b/portals/api-portal/src/utils/constants.js index cad63be2c..f6ab10f7a 100644 --- a/portals/api-portal/src/utils/constants.js +++ b/portals/api-portal/src/utils/constants.js @@ -177,7 +177,6 @@ module.exports = { API_LANDING_PAGE_PATH: '/api/', API_DOCS_PATH: '/docs/', API_PORTAL_CONFIGURE: ['/*/settings'], - API_PORTAL_ROOT: ['/portal', '/portal/*/edit', '/devportal'], API_PORTAL_API_LISTING: '/*/apis', API_PORTAL_TECHNICAL_PAGES: ['*/application'], VIEWS_PATH: "/views/", @@ -204,7 +203,6 @@ module.exports = { ROLES: { ADMIN: 'admin', SUBSCRIBER: 'subscriber', - SUPER_ADMIN: 'superAdmin', ROLE_CLAIM: 'roles', GROUP_CLAIM: 'groups', ORGANIZATION_CLAIM: 'orgClaimName' diff --git a/portals/api-portal/src/utils/jwtDecode.js b/portals/api-portal/src/utils/jwtDecode.js index bc40189e7..0e7465696 100644 --- a/portals/api-portal/src/utils/jwtDecode.js +++ b/portals/api-portal/src/utils/jwtDecode.js @@ -38,4 +38,24 @@ function safeDecodeJwt(token) { } } -module.exports = { safeDecodeJwt }; +/** + * Resolves a dot-notation claim path (e.g. Keycloak's "realm_access.roles") from a + * decoded JWT, falling back gracefully so a plain claim name ("roles") still works. + * + * Lives here rather than in passportConfig.js — where it started, next to its first + * caller — because the authorization layer needs it to read the configured roles + * claim, and requiring passportConfig from there would make the middleware graph + * circular (passportConfig -> authorization -> passportConfig). + */ +function getNestedClaim(obj, path) { + if (!path || typeof obj !== 'object' || obj === null) return undefined; + const parts = String(path).split('.'); + let cur = obj; + for (const part of parts) { + if (typeof cur !== 'object' || cur === null) return undefined; + cur = cur[part]; + } + return cur; +} + +module.exports = { safeDecodeJwt, getNestedClaim };