diff --git a/.github/workflows/devportal-integration-test.yml b/.github/workflows/devportal-integration-test.yml index 83167bbae1..d9692ef3d8 100644 --- a/.github/workflows/devportal-integration-test.yml +++ b/.github/workflows/devportal-integration-test.yml @@ -18,12 +18,18 @@ permissions: jobs: rest-api-test: - name: REST API tests (${{ matrix.db }}) + name: REST API tests (${{ matrix.db }}, ${{ matrix.mode }} mode) runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: db: [sqlite, postgres] + # auth.authorization.mode — the whole suite runs in each. "role" is the + # shipped default; "scope" is what an issuer minting dp:* scopes directly + # uses. A matrix dimension rather than the both-modes `make test-rest-api` + # target so the four combinations run in parallel and CI wall-clock stays + # where it was. See portals/api-portal/it/README.md "Authorization modes". + mode: [scope, role] steps: - name: Checkout code uses: actions/checkout@v4 @@ -39,21 +45,21 @@ jobs: - name: Build API Portal image run: make -C portals/api-portal build - - name: Run REST API integration tests (${{ matrix.db }}) + - name: Run REST API integration tests (${{ matrix.db }}, ${{ matrix.mode }} mode) env: PLATFORM_API_IMAGE: platform-api:it-api-portal run: | if [ "${{ matrix.db }}" = "postgres" ]; then - make -C portals/api-portal/it test-rest-api-postgres + make -C portals/api-portal/it test-rest-api-postgres-${{ matrix.mode }} else - make -C portals/api-portal/it test-rest-api + make -C portals/api-portal/it test-rest-api-${{ matrix.mode }} fi - name: Upload test reports uses: actions/upload-artifact@v4 if: always() with: - name: rest-api-test-reports-${{ matrix.db }} + name: rest-api-test-reports-${{ matrix.db }}-${{ matrix.mode }} path: portals/api-portal/it/reports/ retention-days: 7 diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index c74ed22287..64465cedd7 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -92,12 +92,6 @@ pool_request_timeout_ms = 30000 # MSSQL only - per-query execution timeo encryption_key = "" # 64-char hex — AES-256-GCM key for encrypting secrets at rest session_secret = "" # 64-char hex — express-session signing secret -# Static shared-secret header for calling the API Portal's own REST API. -[api_portal.security.service_api_key] -enabled = true -header_name = "x-wso2-api-key" -value = "" - # ============================================================================= # AUTHENTICATION # ============================================================================= diff --git a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml index 577b30a763..a50a8ae2f8 100644 --- a/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml +++ b/portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml @@ -5359,7 +5359,6 @@ components: type: object required: - id - - labels properties: id: type: string @@ -5371,8 +5370,9 @@ components: example: Partner APIs labels: type: array - minItems: 1 - description: Label names to attach to the view. + description: >- + Label names to attach to the view. Optional — omit or pass an empty array to create a view with no + labels, which surfaces no APIs until labels are attached later via the update endpoint. items: type: string example: diff --git a/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md b/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md index 55f7433e1e..13fc6f42e5 100644 --- a/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md +++ b/portals/api-portal/docs/consume-an-api/consume-with-oauth2.md @@ -76,7 +76,7 @@ curl -X GET "https://api.example.com/orders/v1/orders" \ ## Revoke a Client ID -To remove a linked client ID, go to **Manage Keys** and click **Revoke keys** for that key manager. This only removes the local reference in the portal — it does not deregister or delete the OAuth application in the key manager, and any tokens already issued remain valid until they expire. To invalidate the OAuth application itself or revoke a specific token, use the key manager's own console or revoke endpoint. +To remove a linked client ID, go to **Manage Keys** and click **Remove keys** for that key manager. This only removes the local reference in the portal — it does not deregister or delete the OAuth application in the key manager, and any tokens already issued remain valid until they expire. To invalidate the OAuth application itself or revoke a specific token, use the key manager's own console or revoke endpoint. --- diff --git a/portals/api-portal/it/Makefile b/portals/api-portal/it/Makefile index 1254870c82..a7d7ee7afb 100644 --- a/portals/api-portal/it/Makefile +++ b/portals/api-portal/it/Makefile @@ -16,7 +16,9 @@ # under the License. # -------------------------------------------------------------------- -.PHONY: all test test-postgres test-rest-api test-rest-api-postgres open clean deps ensure-test-tag ensure-certs +.PHONY: all test test-postgres test-rest-api test-rest-api-scope test-rest-api-role \ + test-rest-api-postgres test-rest-api-postgres-scope test-rest-api-postgres-role \ + open clean deps ensure-test-tag ensure-certs VERSION ?= $(shell cat ../VERSION 2>/dev/null | tr -d '[:space:]' || echo "0.0.1-SNAPSHOT") DOCKER_REGISTRY ?= ghcr.io/wso2/api-platform @@ -107,19 +109,48 @@ test-postgres: ensure-test-tag ensure-certs docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ exit $$EXIT -# Run the REST API integration test suite (Jest + Supertest) against SQLite. +# --- REST API suite (Jest + Supertest) ------------------------------------- +# +# The whole suite runs twice, once per authorization mode, because +# auth.authorization.mode changes where a request's effective scopes come from: +# +# scope — the portal reads the token's own scope claim (what platform-api mints). +# role — the portal IGNORES that claim and expands the token's roles claim +# through its own grant table (configs/portal-roles-it.yaml). This is the +# SHIPPED DEFAULT in configs/config.toml. +# +# Both are real deployment configurations, so both must pass the same specs. The +# portal-side grant table mirrors platform-api's, which is what lets one set of +# expectations hold in either mode; auth/grant-table-parity.spec.js guards the mirror +# and auth/authorization-mode.spec.js covers the deliberate difference between them. +# +# `test-rest-api` runs both, sequentially. Use the -scope / -role targets to run one. # Requires the API Portal image to be built first: make build (from portals/api-portal/). -test-rest-api: ensure-test-tag ensure-certs - @DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml up api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ +test-rest-api: + @$(MAKE) test-rest-api-scope + @$(MAKE) test-rest-api-role + +test-rest-api-scope: AUTH_MODE = scope +test-rest-api-role: AUTH_MODE = role +test-rest-api-scope test-rest-api-role: ensure-test-tag ensure-certs + @echo "==> REST API suite (SQLite, authorization mode = $(AUTH_MODE))" + @AUTH_MODE=$(AUTH_MODE) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml up api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ EXIT=$$?; \ - docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml down -v --remove-orphans; \ + AUTH_MODE=$(AUTH_MODE) docker compose -p $(IT_PROJECT) -f docker-compose.test.yaml down -v --remove-orphans; \ exit $$EXIT -# Run the REST API integration test suite (Jest + Supertest) against PostgreSQL. -test-rest-api-postgres: ensure-test-tag ensure-certs - @DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml up postgres api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ +# Same, against PostgreSQL. +test-rest-api-postgres: + @$(MAKE) test-rest-api-postgres-scope + @$(MAKE) test-rest-api-postgres-role + +test-rest-api-postgres-scope: AUTH_MODE = scope +test-rest-api-postgres-role: AUTH_MODE = role +test-rest-api-postgres-scope test-rest-api-postgres-role: ensure-test-tag ensure-certs + @echo "==> REST API suite (PostgreSQL, authorization mode = $(AUTH_MODE))" + @AUTH_MODE=$(AUTH_MODE) DOCKER_REGISTRY=$(DOCKER_REGISTRY) docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml up postgres api-portal rest-api-tests --abort-on-container-exit --exit-code-from rest-api-tests; \ EXIT=$$?; \ - docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ + AUTH_MODE=$(AUTH_MODE) docker compose -p $(IT_PROJECT_POSTGRES) -f docker-compose.test.postgres.yaml down -v --remove-orphans; \ exit $$EXIT # Open Cypress interactive UI — runs against a LOCALLY running portal (not in Docker). diff --git a/portals/api-portal/it/README.md b/portals/api-portal/it/README.md index 912c570301..b7b69dd4b0 100644 --- a/portals/api-portal/it/README.md +++ b/portals/api-portal/it/README.md @@ -45,6 +45,35 @@ Each suite can run against either **SQLite** (default, no external DB) or **Post - **Cypress** — UI E2E test framework (headless Electron). - **SQLite / PostgreSQL** — SQLite by default; the `-postgres` targets swap in a Postgres service. +## Authorization modes + +`auth.authorization.mode` decides where a request's effective scopes come from, and the +REST suite runs **in full, once per mode**: + +| Mode | Effective scopes come from | Grant table | +|---|---|---| +| `scope` | the token's own `scope` claim, as minted by platform-api | `configs/roles-platform-api-it.yaml` | +| `role` (shipped default) | expanding the token's `roles` claim — the scope claim is **ignored** | `configs/portal-roles-it.yaml` | + +Both are real deployment configurations, so both must pass the same specs. That works +because the portal-side table mirrors platform-api's exactly, giving each IT account the +same grant either way. Two things keep that honest: + +- **`rest-api/auth/grant-table-parity.spec.js`** fails if the two tables drift apart, + naming the role and the missing scopes — instead of surfacing as a puzzling 403 in + some unrelated spec. Regenerate the portal table after editing platform-api's. +- **`rest-api/auth/authorization-mode.spec.js`** covers the one deliberate divergence. + The `narrow` account's roles claim (`dp_narrow_it`) is granted the full developer scope + set by platform-api but read-only by the portal, so the *same token* creating an + application succeeds in scope mode and is refused in role mode. Each assertion runs in + exactly one mode; together they prove the scope claim really is ignored under role mode + rather than merged — i.e. a caller cannot widen a role's grant by getting extra scopes + from their issuer. No other spec uses that account. + +Mode is selected by `AUTH_MODE`, which the compose fixture feeds to both the portal +(`APIP_AP_AUTH_AUTHORIZATION_MODE`) and the test process (`API_PORTAL_AUTH_MODE`) so they +cannot disagree. Cypress always runs in the default `scope` mode. + ## Prerequisites - Docker and Docker Compose @@ -96,8 +125,12 @@ portals/api-portal/it/ |---------|-------------| | `make test` | Run the Cypress UI suite headlessly (SQLite, CI-friendly) | | `make test-postgres` | Run the Cypress UI suite headlessly (PostgreSQL) | -| `make test-rest-api` | Run the Jest REST API suite (SQLite) | -| `make test-rest-api-postgres` | Run the Jest REST API suite (PostgreSQL) | +| `make test-rest-api` | Run the Jest REST API suite (SQLite) — **both** authorization modes, sequentially | +| `make test-rest-api-scope` | Same, scope mode only | +| `make test-rest-api-role` | Same, role mode only (the shipped default) | +| `make test-rest-api-postgres` | Run the Jest REST API suite (PostgreSQL) — both modes | +| `make test-rest-api-postgres-scope` | Same, scope mode only | +| `make test-rest-api-postgres-role` | Same, role mode only | | `make open` | Open the Cypress interactive UI against a locally running portal | | `make deps` | Install Node dependencies (only needed for `make open`) | | `make clean` | Remove test containers, volumes, and report artifacts | @@ -115,8 +148,9 @@ You can also run both UI suites from the portal root: `make -C portals/api-porta Both suites run automatically on pull requests that touch `portals/api-portal/**`, via [`.github/workflows/devportal-integration-test.yml`](../../../.github/workflows/devportal-integration-test.yml): -- **`rest-api-test`** — builds the image and runs `make test-rest-api` / - `make test-rest-api-postgres` in an `sqlite` × `postgres` matrix. +- **`rest-api-test`** — builds the image and runs the suite in an + `sqlite` × `postgres` × `scope` × `role` matrix (four parallel jobs, via the + per-mode targets, so covering both authorization modes doesn't double wall-clock). - **`ui-test`** — builds the image and runs `make test` (Cypress, SQLite). Test reports (`it/reports/`) are uploaded as workflow artifacts on every run. The workflow @@ -134,7 +168,7 @@ Defined in `ui/cypress/support/`: |---------|-------------| | `cy.visitPortal(path)` | Navigate to a path inside the default portal view | | `cy.portalUrl(path)` | Build a URL under the default view without visiting it | -| `cy.apiRequest(method, path, options)` | `cy.request` wrapper that injects the IT API key header for admin-protected endpoints | +| `cy.apiRequest(method, path, options)` | `cy.request` wrapper that authenticates with the current session cookie plus the `X-CSRF-Token` header (call `cy.login()` first) | | `cy.login(username, password)` | Perform a real login flow (see `support/commands/auth.js`) | | `cy.logout()` | Log the current user out | | `cy.createApplication(name)` / `cy.deleteApplication(name)` | Create/delete an application (see `support/commands/applications.js`) | @@ -195,8 +229,10 @@ After a run, artifacts are available under `reports/`: - **REST API suite** performs **real session logins** against `platform-api` using the file-based users defined in `configs/config-platform-api-it.toml` (`admin`/`admin`, `publisher`/`publisher`, `developer`/`developer`). -- **UI suite** uses both real login flows (`auth/` specs) and, for admin-protected REST - calls, an IT API key injected via the `x-wso2-api-key` header. +- **UI suite** uses real login flows throughout. Seeding and cleanup hooks call + `cy.login()` and then `cy.apiRequest`, which authenticates with the resulting session + cookie plus the `X-CSRF-Token` double-submit header. `testIsolation` clears cookies + between tests, so each `before`/`after` hook needs its own `cy.login()`. ## Adding New Tests diff --git a/portals/api-portal/it/configs/config-platform-api-it.toml b/portals/api-portal/it/configs/config-platform-api-it.toml index 7864292ee1..6081c92009 100644 --- a/portals/api-portal/it/configs/config-platform-api-it.toml +++ b/portals/api-portal/it/configs/config-platform-api-it.toml @@ -77,3 +77,17 @@ roles = ["dp_publisher_it"] username = "developer" password_hash = "$2y$10$jX3o2E5jF4i3EOgoyJ0k.uegbDYmsmFNDfIxnvcZgTNJifAPjgKKK" roles = ["dp_developer_it"] + +# Exists only to make the difference between authorization modes observable. +# +# Its roles claim is dp_narrow_it, which platform-api's grant table +# (roles-platform-api-it.yaml) gives the FULL developer scope set — so the token it +# receives carries dp:application:create in its scope claim. The portal's own table +# (portal-roles-it.yaml) grants dp_narrow_it read-only. +# +# So this one account can create an application in scope mode and is refused in role +# mode, which is what auth/authorization-mode.spec.js asserts. No other spec uses it. +[[platform_api.auth.file.users]] +username = "narrow" +password_hash = "$2b$10$87Aj.eQ6JU4WZ2SohdyZEOKRXfVVt7QPGwE9bwaYxk1WqYbF8uTR6" +roles = ["dp_narrow_it"] diff --git a/portals/api-portal/it/configs/portal-roles-it.yaml b/portals/api-portal/it/configs/portal-roles-it.yaml new file mode 100644 index 0000000000..dfefcd24bd --- /dev/null +++ b/portals/api-portal/it/configs/portal-roles-it.yaml @@ -0,0 +1,248 @@ +# -------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# 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 +# -------------------------------------------------------------------- +# +# The PORTAL-side role-to-scope grant table for the IT fixture +# (auth.authorization.role_to_scope_mapping). Read ONLY when the suite runs with +# AUTH_MODE=role; in scope mode the portal reads the token's scope claim instead +# and never consults this file. +# +# Two tables exist because the two components answer different questions: +# +# roles-platform-api-it.yaml — platform-api's. Expands each IT account's roles +# into the SCOPE CLAIM of the token it mints. +# this file — the portal's. In role mode the portal ignores +# that scope claim and expands the token's ROLES +# CLAIM through this file instead. +# +# The three real accounts are mirrored EXACTLY, so every spec in the suite holds +# in both modes — that is what lets the whole suite run twice rather than needing +# a mode-specific set of expectations. auth/grant-table-parity.spec.js fails if +# the two files ever drift apart, so this is checked rather than hoped for. +# Regenerate after changing platform-api's table; do not hand-edit these three. +# +# dp_narrow_it is the deliberate exception — see its comment below. +# +# Every dp:* scope must be declared in docs/api-portal-openapi-spec-v0.9.yaml; the +# portal validates this file against the spec at startup and refuses to boot on an +# unknown one, so a typo fails the fixture loudly. +# -------------------------------------------------------------------- + +roles: + # Mirror of dp_admin_it in roles-platform-api-it.yaml (84 scopes). + - name: dp_admin_it + scopes: + - dp:organization:read + - dp:organization:create + - dp:organization:update + - dp:organization:manage + - dp:organization:delete + - dp:organization_content:read + - dp:organization_content:manage + - dp:api:read + - dp:api:create + - dp:api:update + - dp:api:manage + - dp:api:delete + - dp:api_content:read + - dp:api_content:create + - dp:api_content:update + - dp:api_content:manage + - dp:api_content:delete + - dp:mcp_server:read + - dp:mcp_server:create + - dp:mcp_server:update + - dp:mcp_server:manage + - dp:mcp_server:delete + - dp:mcp_server_content:read + - dp:mcp_server_content:create + - dp:mcp_server_content:update + - dp:mcp_server_content:manage + - dp:mcp_server_content:delete + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:mcp_server_key:create + - dp:mcp_server_key:read + - dp:mcp_server_key:update + - dp:mcp_server_key:manage + - dp:mcp_server_key:revoke + - dp:api_workflow:create + - dp:api_workflow:read + - dp:api_workflow:update + - dp:api_workflow:delete + - dp:api_workflow:manage + - dp:application:create + - dp:application:read + - dp:application:update + - dp:application:manage + - dp:application:delete + - dp:application_key:create + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:read + - dp:application_key_mapping:create + - dp:application_key_mapping:manage + - dp:subscription:create + - dp:subscription:read + - dp:subscription:update + - dp:subscription:manage + - dp:subscription:delete + - dp:subscription_plan:create + - dp:subscription_plan:read + - dp:subscription_plan:update + - dp:subscription_plan:manage + - dp:subscription_plan:delete + - dp:key_manager:create + - dp:key_manager:read + - dp:key_manager:update + - dp:key_manager:manage + - dp:key_manager:delete + - dp:view:create + - dp:view:read + - dp:view:update + - dp:view:manage + - dp:view:delete + - dp:label:create + - dp:label:read + - dp:label:update + - dp:label:manage + - dp:label:delete + - dp:webhook_subscriber:create + - dp:webhook_subscriber:read + - dp:webhook_subscriber:update + - dp:webhook_subscriber:delete + - dp:webhook_subscriber:manage + - dp:event:read + + # Mirror of dp_publisher_it in roles-platform-api-it.yaml (62 scopes). + - name: dp_publisher_it + scopes: + - dp:organization:read + - dp:api:read + - dp:api:create + - dp:api:update + - dp:api:manage + - dp:api:delete + - dp:api_content:read + - dp:api_content:create + - dp:api_content:update + - dp:api_content:manage + - dp:api_content:delete + - dp:mcp_server:read + - dp:mcp_server:create + - dp:mcp_server:update + - dp:mcp_server:manage + - dp:mcp_server:delete + - dp:mcp_server_content:read + - dp:mcp_server_content:create + - dp:mcp_server_content:update + - dp:mcp_server_content:manage + - dp:mcp_server_content:delete + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:mcp_server_key:create + - dp:mcp_server_key:read + - dp:mcp_server_key:update + - dp:mcp_server_key:manage + - dp:mcp_server_key:revoke + - dp:api_workflow:create + - dp:api_workflow:read + - dp:api_workflow:update + - dp:api_workflow:delete + - dp:api_workflow:manage + - dp:subscription_plan:create + - dp:subscription_plan:read + - dp:subscription_plan:update + - dp:subscription_plan:manage + - dp:subscription_plan:delete + - dp:key_manager:create + - dp:key_manager:read + - dp:key_manager:update + - dp:key_manager:manage + - dp:key_manager:delete + - dp:view:create + - dp:view:read + - dp:view:update + - dp:view:manage + - dp:view:delete + - dp:label:create + - dp:label:read + - dp:label:update + - dp:label:manage + - dp:label:delete + - dp:webhook_subscriber:create + - dp:webhook_subscriber:read + - dp:webhook_subscriber:update + - dp:webhook_subscriber:delete + - dp:webhook_subscriber:manage + - dp:event:read + + # Mirror of dp_developer_it in roles-platform-api-it.yaml (29 scopes). + - name: dp_developer_it + scopes: + - dp:organization:read + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:application:create + - dp:application:read + - dp:application:update + - dp:application:manage + - dp:application:delete + - dp:application_key:create + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:read + - dp:application_key_mapping:create + - dp:application_key_mapping:manage + - dp:subscription:create + - dp:subscription:read + - dp:subscription:update + - dp:subscription:manage + - dp:subscription:delete + - dp:subscription_plan:read + - dp:view:read + - dp:label:read + + # The one role deliberately NOT mirrored, and the only reason this file can + # prove anything scope mode cannot. + # + # platform-api grants dp_narrow_it the full developer scope set, including + # dp:application:create — so its token's scope claim permits creating an + # application. Here it gets read-only. The same account therefore succeeds in + # scope mode and is refused in role mode, which is exactly the assertion pair in + # auth/authorization-mode.spec.js: proof that role mode IGNORES the scope claim + # rather than merging it, and that a caller cannot widen a role's grant by + # obtaining extra scopes from their issuer. + # + # No other spec uses this account, so the divergence costs the suite nothing. + - name: dp_narrow_it + scopes: + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:organization:read + - dp:organization_content:read + - dp:subscription_plan:read + - dp:view:read + - dp:label:read 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 9a46f31fe5..4f191df0a7 100644 --- a/portals/api-portal/it/configs/roles-platform-api-it.yaml +++ b/portals/api-portal/it/configs/roles-platform-api-it.yaml @@ -209,3 +209,40 @@ roles: - dp:subscription_plan:read - dp:view:read - dp:label:read + + # Same grant as dp_developer_it above — deliberately broad HERE so that the + # portal-side table (portal-roles-it.yaml), which grants it read-only, visibly + # diverges. The gap between the two is what auth/authorization-mode.spec.js reads + # to prove role mode ignores the scope claim. Keep these in step with + # dp_developer_it; narrowing this one would make the proof vacuous. + - name: dp_narrow_it + scopes: + - dp:organization:read + - dp:api:read + - dp:api_content:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:api_key:create + - dp:api_key:read + - dp:api_key:update + - dp:api_key:manage + - dp:api_key:revoke + - dp:application:create + - dp:application:read + - dp:application:update + - dp:application:manage + - dp:application:delete + - dp:application_key:create + - dp:application_key:manage + - dp:application_key:revoke + - dp:application_key_mapping:read + - dp:application_key_mapping:create + - dp:application_key_mapping:manage + - dp:subscription:create + - dp:subscription:read + - dp:subscription:update + - dp:subscription:manage + - dp:subscription:delete + - dp:subscription_plan:read + - dp:view:read + - dp:label:read diff --git a/portals/api-portal/it/docker-compose.test.postgres.yaml b/portals/api-portal/it/docker-compose.test.postgres.yaml index 3a86742f98..5f93ac1a9e 100644 --- a/portals/api-portal/it/docker-compose.test.postgres.yaml +++ b/portals/api-portal/it/docker-compose.test.postgres.yaml @@ -51,7 +51,7 @@ services: # build) instead of the last release — this fixture's config-platform-api-it.toml # tracks platform-api's current config schema, which a pinned old release may not # understand. - image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.13.0-SNAPSHOT} + image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.14.0-SNAPSHOT} command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -91,6 +91,7 @@ services: # Mount only jwt_public.pem, not the whole .certs dir, so the private key # (also in .certs, for platform-api) is never exposed to the portal. - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro + - ./configs/portal-roles-it.yaml:/etc/api-portal/portal-roles-it.yaml:ro environment: # DB-agnostic settings (tls/logging/api-key/org/platform-api) live in ./test-config.toml. APIP_AP_DATABASE_HOST: postgres @@ -99,6 +100,10 @@ services: APIP_AP_DATABASE_USER: api_portal APIP_AP_DATABASE_PASSWORD: api_portal APIP_AP_DATABASE_NAME: api_portal + # Which authorization mode the ENTIRE suite runs against this time — see the + # matching note in docker-compose.test.yaml and it/README.md. + APIP_AP_AUTH_AUTHORIZATION_MODE: ${AUTH_MODE:-scope} + APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-it.yaml # portal fails closed at startup without these — fixed test-only values, # not meant to be reused outside this CI fixture. APIP_AP_SECURITY_ENCRYPTION_KEY: "7f40672c96fd437dc33550755e218d586014232ceb5b01c9405aab575bcb1f4a" @@ -169,7 +174,6 @@ services: shm_size: '2gb' environment: CYPRESS_BASE_URL: "http://api-portal:9543" - CYPRESS_API_KEY: "api-portal-it-test-key" volumes: - ./ui:/e2e - ./reports:/e2e/reports @@ -195,6 +199,9 @@ services: # tokens for. auth/foreign-org-login.spec.js skips itself when this is unset. API_PORTAL_OTHER_ORG_BASE_URL: "http://api-portal-other-org:9543" API_PORTAL_OTHER_ORG_HANDLE: "other-org" + # Must track the portal service's APIP_AP_AUTH_AUTHORIZATION_MODE above; + # authorization-mode.spec.js asserts they agree. + API_PORTAL_AUTH_MODE: ${AUTH_MODE:-scope} API_PORTAL_ORG_HANDLE: "default" API_PORTAL_ADMIN_USERNAME: "admin" API_PORTAL_ADMIN_PASSWORD: "admin" @@ -214,6 +221,9 @@ services: volumes: - ./rest-api:/rest-api - ./reports:/rest-api/reports + # The two grant tables, for auth/grant-table-parity.spec.js — see the note on + # the same mount in docker-compose.test.yaml. + - ./configs:/it-configs:ro networks: - it-api-portal-network # better-sqlite3 (an optionalDependency used only by the SQLite variant) is the diff --git a/portals/api-portal/it/docker-compose.test.yaml b/portals/api-portal/it/docker-compose.test.yaml index cb50fa7d2b..c5bf27195a 100644 --- a/portals/api-portal/it/docker-compose.test.yaml +++ b/portals/api-portal/it/docker-compose.test.yaml @@ -30,7 +30,7 @@ services: # build) instead of the last release — this fixture's config-platform-api-it.toml # tracks platform-api's current config schema, which a pinned old release may not # understand. - image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.13.0-SNAPSHOT} + image: ${PLATFORM_API_IMAGE:-ghcr.io/wso2/api-platform/platform-api:0.14.0-SNAPSHOT} command: ["-config", "/etc/platform-api/config-platform-api.toml"] environment: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -65,6 +65,16 @@ services: # (tls/logging/api-key/org/platform-api) live in ./test-config.toml. APIP_AP_DATABASE_DRIVER: sqlite APIP_AP_DATABASE_PATH: /tmp/api-portal-it.db + # Which authorization mode the ENTIRE suite runs against this time — + # "scope" (default) or "role", selected by the Makefile's + # test-rest-api-scope / test-rest-api-role targets. Both modes must pass + # the same specs; see it/README.md "Authorization modes". + APIP_AP_AUTH_AUTHORIZATION_MODE: ${AUTH_MODE:-scope} + # Read only in role mode. Mirrors the scopes platform-api puts in the token's + # scope claim, so the two modes grant each IT account the same thing and the + # suite's expectations hold either way — grant-table-parity.spec.js fails if + # the two tables ever drift apart. + APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING: /etc/api-portal/portal-roles-it.yaml # portal fails closed at startup without these — fixed test-only values, # not meant to be reused outside this CI fixture. APIP_AP_SECURITY_ENCRYPTION_KEY: "7f40672c96fd437dc33550755e218d586014232ceb5b01c9405aab575bcb1f4a" @@ -83,6 +93,7 @@ services: # Mount only jwt_public.pem, not the whole .certs dir, so the private key # (also in .certs, for platform-api) is never exposed to the portal. - ./.certs/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro + - ./configs/portal-roles-it.yaml:/etc/api-portal/portal-roles-it.yaml:ro # Named volume at /tmp (not a new mount point) so Docker copies the image's # existing /tmp ownership/permissions into it on first use — a fresh mount # at a brand-new path would be root-owned and unwritable by the app user. @@ -143,7 +154,6 @@ services: shm_size: '2gb' environment: CYPRESS_BASE_URL: "http://api-portal:9543" - CYPRESS_API_KEY: "api-portal-it-test-key" volumes: - ./ui:/e2e - ./reports:/e2e/reports @@ -169,6 +179,11 @@ services: # tokens for. auth/foreign-org-login.spec.js skips itself when this is unset. API_PORTAL_OTHER_ORG_BASE_URL: "http://api-portal-other-org:9543" API_PORTAL_OTHER_ORG_HANDLE: "other-org" + # Which authorization mode the portal under test is running. Must track the + # portal service's APIP_AP_AUTH_AUTHORIZATION_MODE above — authorization-mode.spec.js + # asserts they agree, so a mismatch fails loudly instead of silently testing + # the wrong mode. + API_PORTAL_AUTH_MODE: ${AUTH_MODE:-scope} # Real session login (config-platform-api-it.toml) — password == username # for all three, matching the admin/admin convention documented in # configs/config-platform-api-template.toml. @@ -187,6 +202,9 @@ services: volumes: - ./rest-api:/rest-api - ./reports:/rest-api/reports + # The two grant tables, so auth/grant-table-parity.spec.js can compare them + # directly. Read-only — the suite reads these, the portal and platform-api own them. + - ./configs:/it-configs:ro # Mounted at a path distinct from this container's own /tmp (needed for # npm install), read-only since only the portal process should write it. - sqlite-data:/shared-db:ro diff --git a/portals/api-portal/it/rest-api/ai-discovery/apis-md.spec.js b/portals/api-portal/it/rest-api/ai-discovery/apis-md.spec.js new file mode 100644 index 0000000000..4816aeae2d --- /dev/null +++ b/portals/api-portal/it/rest-api/ai-discovery/apis-md.spec.js @@ -0,0 +1,67 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// 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. +// -------------------------------------------------------------------- + +// GET /:orgName/views/:viewName/apis.md — the agent-facing API catalog +// (src/controllers/apiContentController.js loadAPIsMd). Public agent-discovery +// route, so client.raw() rather than client.as(role). +// +// loadAPIsMd buckets by api.type, which holds the *stored* constant +// (constants.API_TYPE: REST -> "RestApi", WEBSUB -> "WebSubApi"), not the enum +// key the buckets are named after. Bucketing on the raw value dropped every +// REST and WebSub API from this catalog while GraphQL and WebSocket — whose +// stored value happens to equal the enum key — still showed up, so the +// regression is invisible unless a REST API is asserted specifically. + +const client = require('../support/client'); +const { createApi } = require('../support/fixtures'); + +describe('AI/LLM discovery (apis.md)', () => { + beforeAll(async () => { + await client.login('publisher'); + }); + + it('lists a REST API in the catalog', async () => { + const api = await createApi({ name: 'Catalogued REST API', type: 'REST', labels: ['default'] }); + + const res = await client.raw().get(`/${client.ORG_HANDLE}/views/default/apis.md`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/markdown/); + expect(res.text).toContain(api.name); + }); + + it('lists a WebSub API in the catalog', async () => { + const api = await createApi({ name: 'Catalogued WebSub API', type: 'WEBSUB', labels: ['default'] }); + + const res = await client.raw().get(`/${client.ORG_HANDLE}/views/default/apis.md`); + expect(res.status).toBe(200); + expect(res.text).toContain(api.name); + }); + + it('excludes APIs with agentVisibility HIDDEN', async () => { + const hidden = await createApi({ + name: 'Hidden REST API', + type: 'REST', + labels: ['default'], + agentVisibility: 'HIDDEN', + }); + + const res = await client.raw().get(`/${client.ORG_HANDLE}/views/default/apis.md`); + expect(res.status).toBe(200); + expect(res.text).not.toContain(hidden.name); + }); +}); diff --git a/portals/api-portal/it/rest-api/auth/authorization-mode.spec.js b/portals/api-portal/it/rest-api/auth/authorization-mode.spec.js new file mode 100644 index 0000000000..fe5b2dbc38 --- /dev/null +++ b/portals/api-portal/it/rest-api/auth/authorization-mode.spec.js @@ -0,0 +1,130 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// 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. +// -------------------------------------------------------------------- + +// Where a request's effective scopes come from — auth.authorization.mode. +// +// scope — the portal authorizes against the token's own scope claim. +// role — the portal IGNORES that claim and expands the token's roles claim +// through its own grant table instead. This is the shipped default +// (configs/config.toml). +// +// The whole suite runs once per mode (`make test-rest-api-scope` / `-role`), so every +// OTHER spec is already a per-mode assertion: they pass unchanged in both because the +// portal's IT grant table mirrors platform-api's. What this file adds is the part a +// mirrored table cannot show — that the two modes are genuinely different mechanisms +// rather than the same one under two names. +// +// That is what the `narrow` account is for. platform-api grants dp_narrow_it the full +// developer scope set, so its token's scope claim permits creating an application; the +// portal's table grants it read-only. One account, one token, opposite outcomes: +// +// scope mode — create SUCCEEDS (the scope claim is honoured) +// role mode — create is REFUSED (the scope claim is ignored; the role decides) +// +// Neither assertion alone proves much. Together they prove the mode switch works, and +// that a caller cannot widen a role's grant by obtaining extra scopes from their +// issuer — the security property role mode exists for. Both run in exactly one mode, +// so each is meaningful where it runs rather than skipped as "not applicable". + +const client = require('../support/client'); + +const MODE = client.AUTH_MODE; +const ORG = client.ORG_HANDLE; +const describeScopeMode = MODE === 'scope' ? describe : describe.skip; +const describeRoleMode = MODE === 'role' ? describe : describe.skip; + +const uniq = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + +describe(`authorization mode = ${MODE}`, () => { + beforeAll(async () => { + await client.login('narrow'); + await client.login('admin'); + }); + + describe('common to both modes', () => { + it('runs against a portal in the mode this run was configured for', async () => { + // Guards the whole file. API_PORTAL_AUTH_MODE (this process) and + // APIP_AP_AUTH_AUTHORIZATION_MODE (the portal) are set from the same + // AUTH_MODE by the compose fixture — if they ever drift, the mode-gated + // describes below would silently skip or assert against the wrong mode. + expect(['scope', 'role']).toContain(MODE); + const res = await client.raw().get(`/${ORG}/views/default`); + expect(res.status).toBe(200); + }); + + it('authorizes an admin operation for the admin account', async () => { + // The same grant, reached by two different mechanisms depending on the + // mode — mirrored tables are what make this hold either way. + const id = uniq('authzmode-label'); + const res = await client.as('admin').post('/labels', { id, displayName: 'Mode label' }); + expect(res.status).toBe(201); + }); + + it('refuses an unauthenticated caller regardless of mode', async () => { + const res = await client.raw().get(`${client.API_PREFIX}/organizations/${ORG}`); + expect([401, 403]).toContain(res.status); + }); + + it('lets the narrow account read, in either mode', async () => { + // Read is the one thing both its scope claim and its portal-side role + // grant permit. Without this, the create assertions below could both be + // explained by a broken session rather than by an authorization decision. + const res = await client.as('narrow').get('/apis'); + expect(res.status).toBe(200); + }); + }); + + describeScopeMode('scope mode honours the token scope claim', () => { + it("lets `narrow` create an application, because its scope claim allows it", async () => { + // dp_narrow_it's scope claim carries dp:application:create. The portal's + // own grant table says read-only, and in this mode that table is not + // consulted at all — so the create succeeds. + const res = await client.as('narrow').post('/applications', { + displayName: uniq('scopemode-app'), + description: 'Permitted by the scope claim', + }); + expect(res.status).toBe(201); + }); + }); + + describeRoleMode('role mode ignores the token scope claim', () => { + it("refuses `narrow` the same create, because its ROLE grants no application scope", async () => { + // Same account, same token, same scope claim as the scope-mode case above. + // Only the portal's interpretation differs. A 403 here is only meaningful + // because the scope-mode run asserts 201 for the identical call. + const res = await client.as('narrow').post('/applications', { + displayName: uniq('rolemode-app'), + description: 'Must be refused — the role grants no application scope', + }); + expect(res.status).toBe(403); + }); + + it('refuses a key-manager read to the narrow role', async () => { + const res = await client.as('narrow').get('/key-managers'); + expect(res.status).toBe(403); + }); + + it('expands a role into scopes rather than denying everything', async () => { + // The counterweight to the two denials: role expansion is granting real + // access, not failing closed across the board. Without it, both denials + // above would also pass if role mode were simply broken. + const res = await client.as('admin').get('/key-managers'); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js b/portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js new file mode 100644 index 0000000000..f2a8b1b59f --- /dev/null +++ b/portals/api-portal/it/rest-api/auth/grant-table-parity.spec.js @@ -0,0 +1,83 @@ +// -------------------------------------------------------------------- +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// 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. +// -------------------------------------------------------------------- + +// Keeps the two IT grant tables in step. +// +// The whole suite runs in both authorization modes against ONE set of expectations, +// which only works because each IT account is granted the same thing either way: +// +// scope mode — platform-api expands the account's roles into the token's scope claim +// (configs/roles-platform-api-it.yaml) +// role mode — the portal expands the same roles through its own table +// (configs/portal-roles-it.yaml) +// +// Two files, so they can drift. The failure that drift causes is nasty: add a scope to +// platform-api's table for a new endpoint's tests, and the scope-mode run goes green +// while the role-mode run fails somewhere unrelated-looking, with a 403 that points at +// the endpoint rather than at the table. This spec turns that into one obvious failure +// naming the role and the missing scopes. +// +// No HTTP — it reads the two fixture files directly, so it costs nothing and reports +// the same way in both modes. + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +// Mounted at /it-configs in the test container (the repo's it/configs is outside +// the /rest-api mount); the relative path is the fallback for a host-side run. +const CONFIGS = process.env.IT_CONFIGS_DIR + || (fs.existsSync('/it-configs') ? '/it-configs' : path.join(__dirname, '..', '..', 'configs')); + +// Deliberately divergent — see the comments in both files. This is the one role whose +// portal-side grant is narrower than its scope claim, which is what makes the +// mode difference observable in authorization-mode.spec.js. +const INTENTIONALLY_DIVERGENT = new Set(['dp_narrow_it']); + +function loadRoles(file) { + const doc = yaml.load(fs.readFileSync(path.join(CONFIGS, file), 'utf8')); + return new Map((doc.roles || []).map((r) => [r.name, [...r.scopes].sort()])); +} + +describe('IT grant tables', () => { + const platformApi = loadRoles('roles-platform-api-it.yaml'); + const portal = loadRoles('portal-roles-it.yaml'); + const shared = [...platformApi.keys()].filter((r) => !INTENTIONALLY_DIVERGENT.has(r)); + + it('define the same roles on both sides', () => { + expect(shared.length).toBeGreaterThan(0); // guards against an empty-file false pass + expect([...portal.keys()].sort()).toEqual([...platformApi.keys()].sort()); + }); + + it.each(shared)('grant %s identical scopes on both sides', (role) => { + // Fails as a readable scope diff naming the role, rather than as a 403 in + // whichever unrelated spec happened to need the missing scope first. + expect(portal.get(role)).toEqual(platformApi.get(role)); + }); + + it('keeps dp_narrow_it deliberately narrower on the portal side', () => { + // The divergence is load-bearing, so assert it rather than merely excluding + // it: if someone "fixes" the mirror by syncing this role too, the scope-claim- + // is-ignored proof in authorization-mode.spec.js silently stops proving it. + const paScopes = platformApi.get('dp_narrow_it'); + const portalScopes = portal.get('dp_narrow_it'); + expect(paScopes).toContain('dp:application:create'); + expect(portalScopes).not.toContain('dp:application:create'); + expect(portalScopes.every((s) => s.endsWith(':read'))).toBe(true); + }); +}); diff --git a/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js b/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js index 960813fb44..6c73c9d3b7 100644 --- a/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js +++ b/portals/api-portal/it/rest-api/organizations/single-org-isolation.spec.js @@ -21,18 +21,22 @@ // accept one must reject anything but this instance's own organization: // // page URLs /{orgHandle}/... -> 404 (src/middlewares/orgGuard.js) -// `organization` header on an API-key request -> 403 (src/middlewares/authMiddleware.js) // -// These are all unauthenticated or API-key surfaces — the point is that no -// credential is needed to attempt them, so the rejection cannot depend on one. -// Token/session organization claims are covered by auth/file-based-login.spec.js. +// The page surfaces are unauthenticated — the point is that no credential is +// needed to attempt them, so the rejection cannot depend on one. +// +// authMiddleware's `organization` header check (resolvePortalOrg -> 403) now only +// applies to mTLS, the sole remaining credential that carries no organization of +// its own; the static service API key that used to reach it was removed, and this +// fixture provisions no client certificates, so that path is not exercised here. +// For session and bearer credentials the organization comes from the credential's +// own claim (resolveScopedOrg) and the header is never a selector — asserted +// below, and covered further by auth/file-based-login.spec.js. const client = require('../support/client'); const OWN_ORG = client.ORG_HANDLE; const FOREIGN_ORG = 'some-other-org'; -const API_KEY_HEADER = 'x-wso2-api-key'; -const API_KEY = process.env.API_PORTAL_API_KEY || 'api-portal-it-test-key'; describe('single-organization isolation', () => { describe('page routes', () => { @@ -71,31 +75,31 @@ describe('single-organization isolation', () => { }); }); - describe('organization request header', () => { - it('scopes an API-key request to this organization when no header is sent', async () => { - const res = await client.raw() - .get(`${client.API_PREFIX}/apis`) - .set(API_KEY_HEADER, API_KEY); + describe('organization request header on a session credential', () => { + beforeAll(async () => { + await client.login('admin'); + }); + + it('serves the caller organization when no header is sent', async () => { + const res = await client.as('admin').get('/apis'); expect(res.status).toBe(200); }); - it('accepts a header naming this organization', async () => { - const res = await client.raw() - .get(`${client.API_PREFIX}/apis`) - .set(API_KEY_HEADER, API_KEY) - .set('organization', OWN_ORG); + it('serves the caller organization when the header names it', async () => { + const res = await client.as('admin').get('/apis').set('organization', OWN_ORG); expect(res.status).toBe(200); }); - it('rejects a header naming another organization with 403', async () => { - // Honouring this header would make one API key able to address every - // tenant in the shared database. Rejecting rather than ignoring it also - // keeps a caller from believing it wrote to the organization it named. - const res = await client.raw() - .get(`${client.API_PREFIX}/apis`) - .set(API_KEY_HEADER, API_KEY) - .set('organization', FOREIGN_ORG); - expect(res.status).toBe(403); + it('does not let the header redirect the request to another organization', async () => { + // The organization comes from the session's own claim, so this header is + // not a selector. Honouring it would let one credential address every + // tenant in the shared database; the request must stay scoped to the + // caller's organization rather than reaching the one it named. + const res = await client.as('admin').get('/apis').set('organization', FOREIGN_ORG); + expect(res.status).toBe(200); + + const own = await client.as('admin').get('/apis'); + expect(res.body).toEqual(own.body); }); }); diff --git a/portals/api-portal/it/rest-api/package-lock.json b/portals/api-portal/it/rest-api/package-lock.json index 27ac2b0a80..e7c4a93330 100644 --- a/portals/api-portal/it/rest-api/package-lock.json +++ b/portals/api-portal/it/rest-api/package-lock.json @@ -11,6 +11,7 @@ "cookiejar": "2.1.4", "jest": "29.7.0", "jest-junit": "17.0.0", + "js-yaml": "5.2.3", "nock": "13.5.6", "pg": "8.22.0", "supertest": "7.2.2" @@ -532,6 +533,30 @@ "node": ">=8" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -1107,14 +1132,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } + "license": "Python-2.0" }, "node_modules/asap": { "version": "2.0.6", @@ -3208,17 +3230,26 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/jsesc": { diff --git a/portals/api-portal/it/rest-api/package.json b/portals/api-portal/it/rest-api/package.json index 03d4811a46..41ac97b3e9 100644 --- a/portals/api-portal/it/rest-api/package.json +++ b/portals/api-portal/it/rest-api/package.json @@ -11,6 +11,7 @@ "cookiejar": "2.1.4", "jest": "29.7.0", "jest-junit": "17.0.0", + "js-yaml": "5.2.3", "nock": "13.5.6", "pg": "8.22.0", "supertest": "7.2.2" diff --git a/portals/api-portal/it/rest-api/support/client.js b/portals/api-portal/it/rest-api/support/client.js index e06093e352..c38b386931 100644 --- a/portals/api-portal/it/rest-api/support/client.js +++ b/portals/api-portal/it/rest-api/support/client.js @@ -42,8 +42,18 @@ const CREDENTIALS = { admin: { username: process.env.API_PORTAL_ADMIN_USERNAME || 'admin', password: process.env.API_PORTAL_ADMIN_PASSWORD || 'admin' }, publisher: { username: process.env.API_PORTAL_PUBLISHER_USERNAME || 'publisher', password: process.env.API_PORTAL_PUBLISHER_PASSWORD || 'publisher' }, developer: { username: process.env.API_PORTAL_DEVELOPER_USERNAME || 'developer', password: process.env.API_PORTAL_DEVELOPER_PASSWORD || 'developer' }, + // Used by auth/authorization-mode.spec.js only. Its portal-side grant is + // deliberately narrower than its token's scope claim, which is what makes the + // scope-mode/role-mode difference observable. Don't reach for it in other specs — + // what it can do depends on which mode the run is in. + narrow: { username: process.env.API_PORTAL_NARROW_USERNAME || 'narrow', password: process.env.API_PORTAL_NARROW_PASSWORD || 'narrow' }, }; +// Which authorization mode the portal under test is running ("scope" | "role"), +// set by the compose fixture from the Makefile's AUTH_MODE. Specs whose expectations +// differ per mode branch on this; everything else must pass in both. +const AUTH_MODE = process.env.API_PORTAL_AUTH_MODE || 'scope'; + // One supertest agent per role, logged in once and reused — the agent's cookie // jar carries the session across every request made through it. const agents = {}; @@ -144,6 +154,7 @@ module.exports = { BASE_URL, API_PREFIX, ORG_HANDLE, + AUTH_MODE, login, as, page, diff --git a/portals/api-portal/it/rest-api/support/fixtures.js b/portals/api-portal/it/rest-api/support/fixtures.js index 956f8adedb..ced8bedb32 100644 --- a/portals/api-portal/it/rest-api/support/fixtures.js +++ b/portals/api-portal/it/rest-api/support/fixtures.js @@ -98,8 +98,9 @@ async function createApi(overrides = {}) { } // `admin` manages org-level integration config; pass `role` to override. -// displayName is required by WebhookSubscriberRequest (the settings UI collects a -// name, not a handle, and the handle is generated from it when `id` is omitted). +// displayName is required by WebhookSubscriberRequest; `id` (the handle) is +// optional here — the server generates a UUID when it is omitted, which is what +// these fixtures rely on. The settings UI collects both explicitly. async function createWebhookSubscriber(overrides = {}) { const { role = 'admin', ...bodyOverrides } = overrides; const res = await client.as(role).post('/webhook-subscribers', { diff --git a/portals/api-portal/it/rest-api/views-and-labels/views.spec.js b/portals/api-portal/it/rest-api/views-and-labels/views.spec.js index be1092a987..215889aa72 100644 --- a/portals/api-portal/it/rest-api/views-and-labels/views.spec.js +++ b/portals/api-portal/it/rest-api/views-and-labels/views.spec.js @@ -17,7 +17,8 @@ // -------------------------------------------------------------------- // POST/GET/PUT/DELETE /views. A view groups a set of labels to filter which -// APIs are visible in that portal view. ViewCreateRequest requires { id, labels }. +// APIs are visible in that portal view. ViewCreateRequest requires only { id } — +// labels are optional, so a view can be created first and labelled later. // `admin` manages org-level config. const client = require('../support/client'); @@ -49,6 +50,28 @@ describe('views', () => { expect(res.status).toBe(201); }); + it('creates a view with no labels', async () => { + const id = uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id, displayName: 'Unlabelled View' }); + expect(res.status).toBe(201); + + const fetched = await client.as('admin').get(`/views/${id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.labels).toEqual([]); + }); + + it('creates a view with an empty label array', async () => { + const id = uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id, displayName: 'Empty Labels View', labels: [] }); + expect(res.status).toBe(201); + + // Read back, as the omitted-labels case above does: an explicit [] has to + // persist as no associations, not merely be accepted by the create. + const fetched = await client.as('admin').get(`/views/${id}`); + expect(fetched.status).toBe(200); + expect(fetched.body.labels).toEqual([]); + }); + it('retrieves a view', async () => { const id = uniqueHandle('view'); await client.as('admin').post('/views', { id, displayName: 'Retrievable View', labels: [label.id] }); diff --git a/portals/api-portal/it/test-config.toml b/portals/api-portal/it/test-config.toml index 73e7d02888..a290f4abf8 100644 --- a/portals/api-portal/it/test-config.toml +++ b/portals/api-portal/it/test-config.toml @@ -38,10 +38,6 @@ name = '{{ env "APIP_AP_DATABASE_NAME" "api_portal" }}' encryption_key = '{{ env "APIP_AP_SECURITY_ENCRYPTION_KEY" }}' session_secret = '{{ env "APIP_AP_SECURITY_SESSION_SECRET" }}' -[api_portal.security.service_api_key] -enabled = true -value = "api-portal-it-test-key" - [api_portal.organization] # The single organization each portal instance serves. Tokenized because the # fixture runs a SECOND instance (api-portal-other-org) off this same file with a @@ -51,14 +47,25 @@ 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) +# Most of the 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. +# definition, so it is the default here rather than the shipped "role": role mode would +# expand the dp_*_it role names against the PORTAL's grant table, and the table baked +# into the image does not define them, so every REST assertion would 403. +# +# Tokenized, not hardcoded, because the fixture also runs a THIRD portal instance +# (api-portal-role-mode) off this same file with mode = "role" and a grant table that +# does define those roles (configs/portal-roles-role-mode-it.yaml) — role mode is the +# shipped default in configs/config.toml, so leaving it unexercised meant the suite +# never covered the configuration most deployments actually run. Driven by +# rest-api/auth/role-mode-authorization.spec.js. [api_portal.auth.authorization] -mode = "scope" +mode = '{{ env "APIP_AP_AUTH_AUTHORIZATION_MODE" "scope" }}' +# Only read in role mode. The default points at the copy baked into the image so the +# scope-mode instances resolve a real path; the role-mode instance overrides it with +# its mounted IT table. +role_to_scope_mapping = '{{ env "APIP_AP_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING" "./resources/role-to-scope-mapping.yaml" }}' [api_portal.auth.local] # File-based (local) auth against the Platform API sidecar. Host is identical in diff --git a/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js b/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js index 79249ccc98..424136de44 100644 --- a/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/001-basic/001-portal-access.cy.js @@ -27,11 +27,15 @@ describe('API Portal — Portal Access', () => { let mcpHandle; before(() => { + // Seeding goes through the REST API as a logged-in admin — the portal's + // static service API key is gone. + cy.login(); cy.seedApi().then((handle) => { apiHandle = handle; }); cy.seedMcp().then((handle) => { mcpHandle = handle; }); }); after(() => { + cy.login(); cy.deleteApi(apiHandle); cy.deleteMcp(mcpHandle); }); diff --git a/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js b/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js index 14ce730bf9..1e6e81d46f 100644 --- a/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/002-apis/001-api-listing.cy.js @@ -29,6 +29,7 @@ describe('API listing', () => { let mcpHandle; before(() => { + cy.login(); // Seed two APIs of different types so the listing shows distinct badges, // plus an MCP server — which must NOT appear on the /apis listing (it // belongs on /mcps). loadAPIs filters type !== MCP for the APIs page. @@ -44,6 +45,7 @@ describe('API listing', () => { }); after(() => { + cy.login(); cy.deleteApi(restHandle); cy.deleteApi(gqlHandle); cy.deleteMcp(mcpHandle); diff --git a/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js b/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js index f8461e462d..0d91d464bd 100644 --- a/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/002-apis/002-rest-api-details.cy.js @@ -26,6 +26,7 @@ describe('REST API — overview, documentation & try-out', () => { let apiHandle; before(() => { + cy.login(); cy.seedApi({ name: API_NAME, version: 'v1.0', @@ -67,6 +68,7 @@ describe('REST API — overview, documentation & try-out', () => { }); after(() => { + cy.login(); cy.deleteApi(apiHandle); }); diff --git a/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js b/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js index 24b956747b..535c6ba634 100644 --- a/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/003-mcp-servers/001-mcp-listing.cy.js @@ -30,6 +30,7 @@ describe('MCP server listing', () => { let restHandle; before(() => { + cy.login(); cy.seedMcp({ name: MCP_ONE }).then((h) => { mcpOneHandle = h; }); cy.seedMcp({ name: MCP_TWO }).then((h) => { mcpTwoHandle = h; }); // A REST API — must NOT appear on the /mcps listing (it belongs on /apis). @@ -37,6 +38,7 @@ describe('MCP server listing', () => { }); after(() => { + cy.login(); cy.deleteMcp(mcpOneHandle); cy.deleteMcp(mcpTwoHandle); cy.deleteApi(restHandle); diff --git a/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js b/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js index 9c98bb5095..0adef3f2b0 100644 --- a/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/applications/application-flows.cy.js @@ -100,6 +100,9 @@ describe('Applications', () => { let mockToken; before(() => { + // Key managers are admin-only over the REST API, and seeding now + // authenticates with a session rather than the removed service API key. + cy.login(); // Start the mock OAuth2 token endpoint only for this context, and point // the key manager at it so the token round-trip can actually resolve. cy.task('startMockTokenServer').then((mock) => { @@ -113,6 +116,7 @@ describe('Applications', () => { }); after(() => { + cy.login(); cy.deleteKeyManager(KM_ID); cy.task('stopMockTokenServer'); }); diff --git a/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js b/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js index 000bc01703..409c2fdb02 100644 --- a/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/settings/001-views-labels.cy.js @@ -28,9 +28,9 @@ describe('Settings — Views & Labels', () => { const VIEW_HANDLE = `it-view-${uid}`; // slugify(VIEW_NAME) const LABEL_DISPLAY = `IT Label ${uid}`; const LABEL_HANDLE = `it-label-${uid}`; // slugify(LABEL_DISPLAY) - // A view requires at least one label (ViewCreateRequest.labels has minItems: 1), so - // the view test needs an existing label to pick. Seed one up front, distinct from the - // label the label test creates. + // Labels are optional on a view, but this test exercises attaching one from the + // picker, so it needs an existing label to click. Seed one up front, distinct from + // the label the label test creates. const VIEW_LABEL = `it-vlabel-${uid}`; const settingsUrl = () => `/${Cypress.env('ORG_HANDLE')}/settings`; @@ -42,6 +42,7 @@ describe('Settings — Views & Labels', () => { after(() => { // Robust API cleanup, idempotent (404 if the create step never persisted). + cy.login(); cy.apiRequest('DELETE', `/api/v0.9/views/${VIEW_HANDLE}`, { failOnStatusCode: false }); cy.apiRequest('DELETE', `/api/v0.9/labels/${LABEL_HANDLE}`, { failOnStatusCode: false }); cy.apiRequest('DELETE', `/api/v0.9/labels/${VIEW_LABEL}`, { failOnStatusCode: false }); diff --git a/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js b/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js index 29c3ad191a..d48ce233f3 100644 --- a/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js +++ b/portals/api-portal/it/ui/cypress/e2e/settings/002-key-managers.cy.js @@ -37,11 +37,12 @@ describe('Settings — Key Managers', () => { cy.clearCookies(); cy.login('developer', 'developer'); cy.deleteApplication(APP_NAME); - // Then the key manager. Clear the developer session so this authorizes via the - // admin API key (x-wso2-api-key) — with the developer session still set, the - // server would authorize as `developer`, who lacks dp:key_manager:read, and return 403. + // Then the key manager. Swap the developer session for an admin one — the + // REST call authorizes as whoever is logged in, and `developer` lacks + // dp:key_manager:read, so it would 403. // The handle is a server-generated UUID, so discover it by display name. cy.clearCookies(); + cy.login(); cy.apiRequest('GET', '/api/v0.9/key-managers').then((res) => { (res.body.list || []) .filter((km) => km.displayName === KM_NAME) diff --git a/portals/api-portal/it/ui/cypress/support/commands/portal.js b/portals/api-portal/it/ui/cypress/support/commands/portal.js index a98b18ef15..df24bd2d88 100644 --- a/portals/api-portal/it/ui/cypress/support/commands/portal.js +++ b/portals/api-portal/it/ui/cypress/support/commands/portal.js @@ -30,21 +30,28 @@ Cypress.Commands.add('portalUrl', (path = '') => { // --------------------------------------------------------------------------- // cy.apiRequest(method, path, options) -// Thin wrapper around cy.request that includes the API key header for -// accessing admin-protected portal endpoints in the IT environment. +// Call a portal REST endpoint as whichever user is currently logged in. +// +// The portal's static service API key (x-wso2-api-key) was removed, so these +// calls authenticate with the ordinary session cookie — cy.request shares the +// browser's cookie jar — plus the X-CSRF-Token header that csrfProtection +// requires on mutating cookie-authenticated requests (double-submit of the +// XSRF-TOKEN cookie the server sets on every response). +// +// Callers must have established a session first: testIsolation clears cookies +// between tests, so before()/after() hooks need their own cy.login(). // --------------------------------------------------------------------------- Cypress.Commands.add('apiRequest', (method, path, options = {}) => { - const apiKey = Cypress.env('API_KEY'); - const headers = apiKey - ? { 'x-wso2-api-key': apiKey, ...(options.headers || {}) } - : (options.headers || {}); - return cy.request({ + return cy.getCookie('XSRF-TOKEN').then((csrf) => cy.request({ method, url: path, failOnStatusCode: options.failOnStatusCode !== false, ...options, - headers, - }); + headers: { + ...(csrf && csrf.value ? { 'X-CSRF-Token': decodeURIComponent(csrf.value) } : {}), + ...(options.headers || {}), + }, + })); }); // --------------------------------------------------------------------------- diff --git a/portals/api-portal/it/ui/cypress/support/commands/seed.js b/portals/api-portal/it/ui/cypress/support/commands/seed.js index 1b767a6693..451d2e0677 100644 --- a/portals/api-portal/it/ui/cypress/support/commands/seed.js +++ b/portals/api-portal/it/ui/cypress/support/commands/seed.js @@ -19,11 +19,12 @@ // --------------------------------------------------------------------------- // Seed helpers — create demo REST APIs / MCP servers through the real portal // management API (POST /api/v0.9/apis, /mcp-servers) so UI browse tests have -// something to render. They go through cy.apiRequest, which injects the -// service API-key header; the `organization` header selects the target org -// (authMiddleware.resolveOrgFromHeader), and `labels: ['default']` maps the -// resource into the default view so it appears on the /apis and /mcps listings -// (apiDao.list requires a label mapped to the view). +// something to render. They go through cy.apiRequest, which authenticates with +// the caller's session cookie — so the calling hook must cy.login() first. The +// `organization` header names the target org (authMiddleware.resolvePortalOrg +// rejects any other), and `labels: ['default']` maps the resource into the +// default view so it appears on the /apis and /mcps listings (apiDao.list +// requires a label mapped to the view). // // These endpoints take multipart/form-data. cy.request runs in Node, not the // browser, so a browser FormData won't serialize — instead we build the @@ -50,7 +51,7 @@ function buildMultipart(parts) { function seedHeaders(contentType) { return { - // authMiddleware resolves the target org from this header for API-key requests. + // Checked against the organization this instance serves; a mismatch is a 403. organization: Cypress.env('ORG_HANDLE'), 'content-type': contentType, }; diff --git a/portals/api-portal/src/config/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index e07c45ffe8..05602b315e 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -75,11 +75,6 @@ const DEFAULTS = { security: { encryptionKey: '', sessionSecret: '', - serviceApiKey: { - enabled: true, - headerName: 'x-wso2-api-key', - value: '', - }, }, // 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 diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index 43ab8048f8..1e9eb08c38 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -145,7 +145,7 @@ const loadAPIs = async (req, res, next) => { const err = Object.assign(new Error(constants.ERROR_MESSAGE.COMMON_AUTH_ERROR_MESSAGE), { status: 401 }); return next(err); } else { - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -436,7 +436,7 @@ const loadAPIContent = async (req, res, next) => { const err = Object.assign(new Error(constants.ERROR_MESSAGE.COMMON_AUTH_ERROR_MESSAGE), { status: 401 }); return next(err); } else { - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -565,7 +565,7 @@ const loadDocsPage = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -613,7 +613,7 @@ const loadDocument = async (req, res, next) => { const schemaAsIntrospectionJSON = await convertSDLToIntrospection(definitionResponse.swagger); templateContent.graphqlSchemaAsIntrospectionJSON = schemaAsIntrospectionJSON ? JSON.stringify(schemaAsIntrospectionJSON) : null; templateContent.graphqlSecurityScheme = '[]'; - templateContent.graphqlApiKeyHeader = config.security?.serviceApiKey?.headerName || 'apikey'; + templateContent.graphqlApiKeyHeader = 'apikey'; templateContent.apiMetadata = metaData; } else { templateContent.graphql = JSON.stringify(definitionResponse.swagger); @@ -665,19 +665,23 @@ const loadDocument = async (req, res, next) => { templateContent.isGraphQLTryout = tryoutEnabled; } let apiMetadata = definitionResponse.metaData; - - const isMCPFromRegistry = apiMetadata?.type === constants.API_TYPE.MCP && !apiMetadata?.refId; //load API definition if (req.originalUrl.includes(constants.FILE_NAME.API_SPECIFICATION_PATH)) { - if (isMCPFromRegistry) { - const remotes = apiMetadata?.remotes || []; - const serverUrl = remotes.length > 0 ? remotes[0].url : ''; - templateContent.swagger = JSON.stringify({ servers: [{ url: serverUrl }] }); - } else if (definitionResponse.apiType === constants.API_TYPE.MCP) { - // CP-registered MCP: use server URL from endPoints - templateContent.swagger = definitionResponse.swagger; + if (definitionResponse.apiType === constants.API_TYPE.MCP) { + // The playground reads its server URL from servers[0].url. A + // registry-sourced MCP carries that endpoint in remotes[]; one + // registered through the control plane carries it in endPoints + // (getAPIDefinition already wraps it as {servers:[...]}). + // Keying purely on refId sent every MCP created directly in the + // portal — which has no refId *and* no remotes[] — down the + // remotes path, leaving the playground with a blank URL. Prefer + // remotes when present, otherwise fall back to endPoints. + const remoteUrl = (apiMetadata?.remotes || [])[0]?.url; + templateContent.swagger = remoteUrl + ? JSON.stringify({ servers: [{ url: remoteUrl }] }) + : definitionResponse.swagger; } else if (definitionResponse.apiType !== constants.API_TYPE.WS && definitionResponse.apiType !== constants.API_TYPE.GRAPHQL && definitionResponse.apiType !== constants.API_TYPE.WEBSUB) { let modifiedSwagger; try { @@ -734,7 +738,7 @@ const loadDocument = async (req, res, next) => { const schemaAsIntrospectionJSON = await convertSDLToIntrospection(definitionResponse.graphql); templateContent.graphqlSchemaAsIntrospectionJSON = schemaAsIntrospectionJSON ? JSON.stringify(schemaAsIntrospectionJSON) : null; templateContent.graphqlSecurityScheme = '[]'; - templateContent.graphqlApiKeyHeader = config.security?.serviceApiKey?.headerName || 'apikey'; + templateContent.graphqlApiKeyHeader = 'apikey'; } else { templateContent.graphql = definitionResponse.graphql ? JSON.stringify(definitionResponse.graphql) : '""'; templateContent.apiMetadataJSON = JSON.stringify(apiMetadata || {}); @@ -804,7 +808,7 @@ const loadDocument = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } res.send(html); @@ -813,7 +817,7 @@ const loadDocument = async (req, res, next) => { const err = Object.assign(new Error(constants.ERROR_MESSAGE.COMMON_AUTH_ERROR_MESSAGE), { status: 401 }); return next(err); } else { - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } } @@ -1124,6 +1128,26 @@ async function convertSDLToIntrospection(sdl) { } +/** + * Markdown/agent-facing endpoints answer in text rather than the HTML error + * page, so they can't hand the error to the central handler. A URL naming a + * view or API that doesn't exist is still the caller's mistake, not a server + * fault — the DAOs raise those as CustomError(404), whose status sits on + * `statusCode`, so treating every failure as 500 told an agent to retry + * something that will never succeed. + * + * `mediaType` must match what the handler's success path sets. The failure can + * happen before that header is applied, and `res.send(string)` then defaults to + * text/html — so an agent asking for markdown got a markdown body labelled HTML. + */ +function sendMarkdownError(res, error, failureMessage, mediaType = 'text/markdown; charset=utf-8') { + res.setHeader('Content-Type', mediaType); + if (util.pageErrorStatus(error) === 404) { + return res.status(404).send('# Not Found\n\nThe requested resource does not exist.'); + } + return res.status(500).send(`# Error\n\n${failureMessage}`); +} + const loadAPIContentMd = async (req, res) => { const { orgName, apiHandle, viewName } = req.params; @@ -1247,7 +1271,7 @@ const loadAPIContentMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load API details.'); + sendMarkdownError(res, error, 'Failed to load API details.'); } }; @@ -1324,7 +1348,7 @@ const loadLlmsTxt = async (req, res) => { res.send(md); } catch (error) { logger.error('Error generating llms.txt', { orgName, error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to generate portal index.'); + sendMarkdownError(res, error, 'Failed to generate portal index.', 'text/plain; charset=utf-8'); } }; @@ -1349,7 +1373,7 @@ const previewLlmsTxt = async (req, res) => { res.send(md); } catch (error) { logger.error('Error previewing llms.txt', { orgName, error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to generate preview.'); + sendMarkdownError(res, error, 'Failed to generate preview.', 'text/plain; charset=utf-8'); } }; @@ -1369,9 +1393,15 @@ const loadAPIsMd = async (req, res) => { const hiddenAPICount = metaDataList.length - agentVisibleAPIs.length; const nonMcpAPIs = agentVisibleAPIs.filter(api => api.type !== constants.API_TYPE.MCP); + // api.type holds the stored constant (e.g. "RestApi", "WebSubApi" — see + // constants.API_TYPE), not the enum key used below — map it back or every + // REST/WebSub API silently drops out of this catalog. + const typeConstantToEnum = Object.fromEntries( + Object.entries(constants.API_TYPE).map(([enumKey, storedValue]) => [storedValue, enumKey]) + ); const byType = { REST: [], GRAPHQL: [], WS: [], WEBSUB: [] }; for (const api of nonMcpAPIs) { - const type = api.type; + const type = typeConstantToEnum[api.type]; if (byType[type]) byType[type].push(api); } const baseUrl = '/' + orgName + constants.ROUTE.VIEWS_PATH + viewName; @@ -1398,7 +1428,7 @@ const loadAPIsMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load API list.'); + sendMarkdownError(res, error, 'Failed to load API list.'); } }; @@ -1439,7 +1469,7 @@ const loadMCPsMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load MCP list.'); + sendMarkdownError(res, error, 'Failed to load MCP list.'); } }; @@ -1550,7 +1580,7 @@ const loadDocumentMd = async (req, res) => { error: error.message, stack: error.stack }); - res.status(500).send('# Error\n\nFailed to load document.'); + sendMarkdownError(res, error, 'Failed to load document.'); } }; diff --git a/portals/api-portal/src/controllers/apiKeysOverviewController.js b/portals/api-portal/src/controllers/apiKeysOverviewController.js index 41da4397a5..ebea5f14d9 100644 --- a/portals/api-portal/src/controllers/apiKeysOverviewController.js +++ b/portals/api-portal/src/controllers/apiKeysOverviewController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplateWithView, resolveActor } = require('../utils/util'); +const { renderTemplateWithView, resolveActor, pageErrorStatus } = require('../utils/util'); const logger = require('../config/logger'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); @@ -88,7 +88,7 @@ const loadApiKeysOverview = async (req, res, next) => { stack: error.stack, orgName, }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } }; diff --git a/portals/api-portal/src/controllers/apiWorkflowsController.js b/portals/api-portal/src/controllers/apiWorkflowsController.js index 45e75ee3e9..6c2d4eb8c0 100644 --- a/portals/api-portal/src/controllers/apiWorkflowsController.js +++ b/portals/api-portal/src/controllers/apiWorkflowsController.js @@ -137,7 +137,7 @@ const loadAPIWorkflows = async (req, res, next) => { orgName, viewName }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } }; @@ -218,7 +218,7 @@ const loadAPIWorkflowDetail = async (req, res, next) => { viewName, handle }); - error.status = 500; + error.status = util.pageErrorStatus(error); return next(error); } }; diff --git a/portals/api-portal/src/controllers/applicationsContentController.js b/portals/api-portal/src/controllers/applicationsContentController.js index 4e35dc7fd3..c3d5e7d981 100644 --- a/portals/api-portal/src/controllers/applicationsContentController.js +++ b/portals/api-portal/src/controllers/applicationsContentController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplate, renderGivenTemplate, loadLayoutFromAPI, resolveActor } = require('../utils/util'); +const { renderTemplate, renderGivenTemplate, loadLayoutFromAPI, resolveActor, pageErrorStatus } = require('../utils/util'); const { config } = require('../config/configLoader'); const logger = require('../config/logger'); const constants = require('../utils/constants'); @@ -230,7 +230,7 @@ const loadApplications = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } res.send(html); diff --git a/portals/api-portal/src/controllers/customContentController.js b/portals/api-portal/src/controllers/customContentController.js index faae42d2f1..1f69f15cde 100644 --- a/portals/api-portal/src/controllers/customContentController.js +++ b/portals/api-portal/src/controllers/customContentController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplate, renderTemplateFromAPI, loadMarkdown, filePrefix } = require('../utils/util'); +const { renderTemplate, renderTemplateFromAPI, loadMarkdown, filePrefix, pageErrorStatus } = require('../utils/util'); const { config } = require('../config/configLoader'); const markdown = require('marked'); const fs = require('fs'); @@ -74,11 +74,16 @@ const loadCustomContent = async (req, res, next) => { // Check if the file exists before attempting to render const resolvedPagePath = path.join(process.cwd(), filePrefix + filePath + '/page.hbs'); if (!fs.existsSync(resolvedPagePath)) { - // If it's a manage-keys route that doesn't exist, return 404 or redirect - if (filePath.includes('manage-keys')) { - throw new Error(`Manage keys page not found. This route should be handled by the application controller.`); - } - throw new Error(`Content page not found at ${resolvedPagePath}`); + // Both branches mean "this page does not exist", so they carry a 404. + // A bare Error has no status, and pageErrorStatus() below falls back to + // 500 — which rendered a server-error page for what is simply a bad URL. + const notFound = filePath.includes('manage-keys') + // A manage-keys route that got here was not claimed by the + // application controller, so there is nothing to render. + ? new Error('Manage keys page not found. This route should be handled by the application controller.') + : new Error(`Content page not found at ${resolvedPagePath}`); + notFound.status = 404; + throw notFound; } const orgDetails = await orgDao.get(orgName); const orgId = orgDetails.uuid; @@ -102,7 +107,7 @@ const loadCustomContent = async (req, res, next) => { stack: error.stack, filePath: req.params.filePath, }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } } diff --git a/portals/api-portal/src/controllers/orgContentController.js b/portals/api-portal/src/controllers/orgContentController.js index 5187c77160..a37283e9c0 100644 --- a/portals/api-portal/src/controllers/orgContentController.js +++ b/portals/api-portal/src/controllers/orgContentController.js @@ -17,7 +17,7 @@ */ /* eslint-disable no-undef */ const logger = require('../config/logger'); -const { renderTemplate, renderTemplateFromAPI } = require('../utils/util'); +const { renderTemplate, renderTemplateFromAPI, pageErrorStatus } = require('../utils/util'); const { config } = require('../config/configLoader'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); @@ -31,6 +31,10 @@ const loadOrganizationContent = async (req, res, next) => { } else { html = await loadOrgContentFromAPI(req, res, next); } + // loadOrgContentFromAPI returns undefined once it has handed the error to + // next() — the error handler has already written the response by then, so + // sending again would throw ERR_HTTP_HEADERS_SENT over the real error. + if (html === undefined || res.headersSent) return; res.send(html); } const loadOrgContentFromFile = async (req, res) => { @@ -72,7 +76,7 @@ const loadOrgContentFromAPI = async (req, res, next) => { error: error.message, stack: error.stack }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } return html; diff --git a/portals/api-portal/src/controllers/subscriptionsContentController.js b/portals/api-portal/src/controllers/subscriptionsContentController.js index 0426f8e885..5cd3e56ed7 100644 --- a/portals/api-portal/src/controllers/subscriptionsContentController.js +++ b/portals/api-portal/src/controllers/subscriptionsContentController.js @@ -16,7 +16,7 @@ * under the License. */ -const { renderTemplateWithView, resolveActor } = require('../utils/util'); +const { renderTemplateWithView, resolveActor, pageErrorStatus } = require('../utils/util'); const logger = require('../config/logger'); const constants = require('../utils/constants'); const orgDao = require('../dao/organizationDao'); @@ -79,7 +79,7 @@ const loadSubscriptions = async (req, res, next) => { stack: error.stack, orgName }); - error.status = 500; + error.status = pageErrorStatus(error); return next(error); } }; diff --git a/portals/api-portal/src/dao/organizationDao.js b/portals/api-portal/src/dao/organizationDao.js index 3bdce9a63f..b3692830ce 100644 --- a/portals/api-portal/src/dao/organizationDao.js +++ b/portals/api-portal/src/dao/organizationDao.js @@ -138,18 +138,19 @@ const update = async (orgData, t) => { const orgHandle = orgData.handle ? orgData.handle.toLowerCase() : existing.handle; const updatedAt = new Date(); + // cp_ref_id is written unconditionally, exactly like idp_ref_id: both are + // plain optional reference fields with no derived default, so a blank one + // clears the stored value rather than silently keeping the old one. const setClauses = [ 'display_name = ?', 'business_owner = ?', 'business_owner_contact = ?', - 'business_owner_email = ?', 'handle = ?', 'idp_ref_id = ?', 'updated_by = ?', 'updated_at = ?', + 'business_owner_email = ?', 'handle = ?', 'idp_ref_id = ?', 'cp_ref_id = ?', + 'updated_by = ?', 'updated_at = ?', ]; const params = [ orgData.displayName, orgData.businessOwner, orgData.businessOwnerContact, - orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.updatedBy, updatedAt, + orgData.businessOwnerEmail, orgHandle, orgData.idpRefId, orgData.cpRefId, + orgData.updatedBy, updatedAt, ]; - if (orgData.cpRefId !== undefined) { - setClauses.push('cp_ref_id = ?'); - params.push(orgData.cpRefId); - } if (orgData.configuration !== undefined) { setClauses.push('configuration = ?'); params.push(orgData.configuration); diff --git a/portals/api-portal/src/defaultContent/images/api-portal-logo-white.png b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.png new file mode 100644 index 0000000000..9a99bf6184 Binary files /dev/null and b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.png differ diff --git a/portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg new file mode 100644 index 0000000000..4ba5bec9a4 --- /dev/null +++ b/portals/api-portal/src/defaultContent/images/api-portal-logo-white.svg @@ -0,0 +1,5 @@ + diff --git a/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs b/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs index 019426ead1..ead1ef2850 100644 --- a/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs +++ b/portals/api-portal/src/defaultContent/pages/apis/partials/apis-md.hbs @@ -6,7 +6,7 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # APIs {{#restAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/restAPIs}} {{/if}} {{#if graphqlAPIs}} @@ -14,7 +14,7 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # GraphQL APIs {{#graphqlAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/graphqlAPIs}} {{/if}} {{#if wsAPIs}} @@ -22,7 +22,7 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # Async / WebSocket APIs {{#wsAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/wsAPIs}} {{/if}} {{#if websubAPIs}} @@ -30,6 +30,6 @@ Explore our extensive API catalog and discover how to integrate them seamlessly # WebSub APIs {{#websubAPIs}} -- [{{name}} v{{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} +- [{{name}} {{version}}]({{../baseUrl}}/api/{{id}}.md){{#if description}} - {{description}}{{/if}} {{/websubAPIs}} {{/if}} diff --git a/portals/api-portal/src/defaultContent/pages/docs/page.hbs b/portals/api-portal/src/defaultContent/pages/docs/page.hbs index 00475b3b49..2a31cb9654 100644 --- a/portals/api-portal/src/defaultContent/pages/docs/page.hbs +++ b/portals/api-portal/src/defaultContent/pages/docs/page.hbs @@ -19,7 +19,7 @@
Auto-generated from name & version. Edit to override.
+Auto-generated from name & version. Edit to override — cannot be changed once created.
| Display name | Name | +Handle | |||||
|---|---|---|---|---|---|---|---|
| Display name | +Name | +Handle | Target URL | Events | Secret | @@ -33,6 +34,7 @@ +{{id}} | @@ -60,7 +62,7 @@ |
|---|---|---|---|---|---|---|---|
| No webhooks yet. Add one to get started. | |||||||
| No webhooks yet. Add one to get started. | |||||||