diff --git a/portals/api-portal/configs/config-template.toml b/portals/api-portal/configs/config-template.toml index 82dcf86fc..a6dbdb3d9 100644 --- a/portals/api-portal/configs/config-template.toml +++ b/portals/api-portal/configs/config-template.toml @@ -148,6 +148,21 @@ session_secret = "" # 64-char hex — express-session signing secret # ([api_portal.auth.local] below). "idp" — external OIDC IDP (auth.idp below). mode = "local" # local | idp +# `idp_org_id` is your IdP's identifier for this organization — the VALUE expected in +# the claim named by claim_mappings.organization below, which is what incoming tokens +# are matched against. It defaults to organization.handle (leave it unset for the +# common case, including local/platform-api-managed mode where the org_handle claim IS +# the handle). Set it only when your IdP's org claim differs from the URL handle. +# +# This setting owns the value — stored as the organization's idp_ref_id, the name the +# REST API uses. It is seeded on first boot and re-applied on every restart, so +# correcting it here and restarting is how you change it. The admin API refuses to +# change it, since a write there would be reverted at the next restart. Leaving it +# unset does NOT reset a previously configured value back to the handle — remove it +# only along with the IdP claim it mirrored. Anyone already logged in with the previous +# claim value must log in again after a change. +# idp_org_id = "default" + # JWT claim name mappings — which token claim carries each field. # Dot-notation supported for nested claims (e.g. "realm_access.roles"). [api_portal.auth.claim_mappings] @@ -284,6 +299,10 @@ subscriber = "ap_subscriber" # [platform_api.auth.file.organization] id — that is what the Platform API puts in # the org_handle claim of the tokens this portal verifies. A mismatch means every # login is rejected with 403. +# +# The org identifier your IdP asserts for this organization is configured separately, +# as `idp_org_id` in [api_portal.auth] — it belongs with the claim mapping that names +# the claim it arrives in. [api_portal.organization] handle = "default" # URL slug: /{handle}/views/{viewName} diff --git a/portals/api-portal/docs/administer/manage-organizations.md b/portals/api-portal/docs/administer/manage-organizations.md index f8c7ddd83..f3ad5f6e7 100644 --- a/portals/api-portal/docs/administer/manage-organizations.md +++ b/portals/api-portal/docs/administer/manage-organizations.md @@ -84,7 +84,7 @@ curl -k -X PUT https://localhost:9543/api/v0.9/organizations/acme \ |---|---|---| | `metadata.name` | Yes | The org handle. **Immutable** — must equal `organization.handle`; any other value returns `400` | | `spec.displayName` | Yes | Human-friendly organization name shown in the portal UI | -| `spec.idpRefId` | No | The org claim value asserted by your Identity Provider at SSO login. **Immutable** — changing it returns `400` | +| `spec.idpRefId` | No | The org identifier asserted by your Identity Provider at SSO login. **Configuration-owned** — changing it here returns `400`; change `auth.idp_org_id` and restart instead | | `spec.cpRefId` | No | Control Plane reference ID, included in outbound webhook event payloads. Not used for authentication | | `spec.businessOwner` | No | Contact name for the organization owner | | `spec.businessOwnerContact` | No | Business owner's phone or contact string | @@ -92,7 +92,27 @@ curl -k -X PUT https://localhost:9543/api/v0.9/organizations/acme \ | `spec.labels` | No | Labels to upsert (array of `{name, displayName}`) | | `spec.views` | No | Views to upsert (array of `{handle, name, labels}`) | -The handle and `idpRefId` are immutable because they are what page URLs and incoming token organization claims are matched against. Renaming either would leave the running instance unable to find its own organization — every page returning `404` and every login `403` — until an operator edited the configuration to match. +The handle and `idpRefId` cannot be changed through this API because they are what page URLs and incoming token organization claims are matched against. Renaming either here would leave the running instance unable to find its own organization — every page returning `404` and every login `403` — until an operator edited the configuration to match. + +### Changing `idpRefId` + +`idpRefId` is owned by the `auth.idp_org_id` configuration setting — it sits in `[api_portal.auth]` alongside `claim_mappings.organization`, which names the claim this value is expected to arrive in. Edit it and restart the portal: + +```toml +[api_portal.auth] +mode = "idp" +idp_org_id = "ACME-PROD" + +[api_portal.auth.claim_mappings] +organization = "org_name" # the claim; idp_org_id above is the value expected in it +``` + +The startup seeder re-applies the configured value to the organization row on every boot, so this is also how a value that was wrong on first boot gets corrected. Two things to know: + +- Anyone already signed in with the previous claim value is rejected (`403`) until they log in again — their session carries the old org claim. +- Leaving the setting unset does not reset a previously configured value back to the handle. Remove it only when the IdP claim it mirrored is gone too. + +In a shared multi-organization database, a configured value that another organization already answers to (as its handle, display name, or `idpRefId`) is refused: the seeder logs an error and keeps the stored value, rather than shadowing that organization's identifier resolution. --- 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 a50a8ae2f..6db4c0c36 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 @@ -107,6 +107,8 @@ paths: this instance's own organization; any other returns 403. The `id` (handle) and `idpRefId` fields cannot be changed — they are what page URLs and incoming token organization claims are matched against, so a rename would leave the running instance unable to find its own organization. Sending a different value returns 400. + `idpRefId` is owned by the portal's `auth.idp_org_id` configuration, which is re-applied on every + restart; change it there rather than here. operationId: updateOrganization requestBody: $ref: "#/components/requestBodies/OrganizationUpdateBody" @@ -1822,9 +1824,11 @@ paths: operationId: updateView summary: Update a view description: >- - Updates the view display name and/or label associations. When `labels` is supplied, it fully replaces the - view's label set — labels present in the list are attached and any others are detached. The service returns - the accepted request payload. + Updates the view handle, display name and/or label associations. When `labels` is supplied, it fully + replaces the view's label set — labels present in the list are attached and any others are detached. + Supplying `id` renames the view's handle, which keeps the view's identity (labels, assets and API workflows + follow it) but invalidates every existing URL built from the old handle. The service returns the accepted + request payload. requestBody: $ref: "#/components/requestBodies/ViewUpdateBody" responses: @@ -1866,7 +1870,11 @@ paths: - Views operationId: deleteView summary: Delete a view - description: Deletes a view by its `viewId` handle. A missing view is returned as a not-found error. + description: >- + Deletes a view by its `viewId` handle. A missing view is returned as a not-found error. Any view may be + deleted, including the one seeded as `default` — the portal resolves whichever view remains as its landing + view. The organization's LAST view cannot be deleted (`400`), since an organization with no views has no + page to serve. A view that still has API workflows is rejected with `409`; delete those first. responses: "204": description: View deleted successfully. @@ -1874,6 +1882,8 @@ paths: $ref: "#/components/responses/BadRequest" "404": $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" "500": $ref: "#/components/responses/InternalServerError" security: @@ -2697,7 +2707,9 @@ components: schema: type: string example: default - description: API Portal view name used to filter visible APIs. + description: >- + The view's handle (unique per org), used to filter visible APIs. Not the view's + display name, and not the internal database uuid. artifactIdQueryOptional: name: artifactId in: query @@ -5382,6 +5394,13 @@ components: ViewUpdateRequest: type: object properties: + id: + type: string + description: >- + New handle for the view (unique per org). Omit to leave the handle unchanged. The view keeps its + identity, so its labels, assets and API workflows follow the rename — but every portal URL embeds + the handle, so links to the old one stop resolving. Returns 409 if another view already uses it. + example: partner-apis displayName: type: string example: Partner and Public APIs diff --git a/portals/api-portal/it/rest-api/organizations/organizations.spec.js b/portals/api-portal/it/rest-api/organizations/organizations.spec.js index 04e789df9..3c219a703 100644 --- a/portals/api-portal/it/rest-api/organizations/organizations.spec.js +++ b/portals/api-portal/it/rest-api/organizations/organizations.spec.js @@ -98,11 +98,14 @@ describe('organizations', () => { }); }); - describe('identity fields are immutable', () => { + describe('identity fields are not writable through this API', () => { // The handle and idp_ref_id are what page URLs and incoming token // organization claims are matched against. Renaming either would leave the // running instance unable to find its own organization — every page 404ing - // and every login 403ing until config was edited to match. + // and every login 403ing until config was edited to match. idp_ref_id is + // additionally re-applied from auth.idp_org_id by the startup seeder + // (seederService.reconcileIdpOrgId), so a write accepted here would be + // reverted on the next restart; config stays its single writer. it('rejects changing the organization handle', async () => { const res = await client.as('admin').put(`/organizations/${OWN_ORG}`, { id: 'renamed-org', diff --git a/portals/api-portal/it/rest-api/views-and-labels/view-fallback-and-delete.spec.js b/portals/api-portal/it/rest-api/views-and-labels/view-fallback-and-delete.spec.js new file mode 100644 index 000000000..01aacabfe --- /dev/null +++ b/portals/api-portal/it/rest-api/views-and-labels/view-fallback-and-delete.spec.js @@ -0,0 +1,86 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// The bare org root (/{orgName}) and the portal root (/) redirect to a RESOLVED view, +// not to a hardcoded 'default'. +// +// That hardcoding is what made the 'default' view undeletable and unrenameable: a +// redirect into a view that no longer existed would 404 the portal's own front door. +// orgContentRoute now asks viewDao.getFallbackHandle, which prefers a view whose handle +// is 'default' and otherwise takes the earliest-created one. +// +// The last-view guard (deleting the only remaining view returns 400) is deliberately +// NOT exercised here: this suite shares one seeded organization with every other spec, +// so driving it down to a single view would break whatever runs next. It is covered by +// hand against a scratch database instead. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +describe('view fallback resolution and deletion rules', () => { + beforeAll(async () => { + await client.login('admin'); + }); + + it('redirects the bare org root to the resolved fallback view', async () => { + const res = await client.raw().get(`/${client.ORG_HANDLE}`).redirects(0); + expect(res.status).toBe(302); + // The fixture org still has its seeded 'default' view, so that is what the + // resolver prefers — the assertion that matters is that the target is a view + // that exists, reached through the resolver rather than a literal. + expect(res.headers.location).toMatch(new RegExp(`^/${client.ORG_HANDLE}/views/[^/]+$`)); + const target = res.headers.location.split('/views/')[1].split(/[?#]/)[0]; + const view = await client.as('admin').get(`/views/${target}`); + expect(view.status).toBe(200); + }); + + it('redirects the bare org root WITH a trailing slash to the same absolute target', async () => { + // Express strict routing is off, so /{org}/ matches this route too. The redirect + // must be absolute: a relative Location resolves against the current directory, + // which for a trailing-slash URL is /{org}/ — producing /{org}/{org}/views/x. + const res = await client.raw().get(`/${client.ORG_HANDLE}/`).redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).toMatch(new RegExp(`^/${client.ORG_HANDLE}/views/[^/]+$`)); + }); + + it('redirects the portal root into this org and a view that exists', async () => { + const res = await client.raw().get('/').redirects(0); + expect(res.status).toBe(302); + expect(res.headers.location).toContain(`/${client.ORG_HANDLE}/views/`); + const target = res.headers.location.split('/views/')[1].split(/[?#]/)[0]; + expect((await client.as('admin').get(`/views/${target}`)).status).toBe(200); + }); + + it('deletes a view that is not the last one', async () => { + // No longer special-cased by handle — what governs the delete is how many views + // remain, and the fixture org has several. + const id = uniqueHandle('view'); + expect((await client.as('admin').post('/views', { id, displayName: 'Deletable View' })).status).toBe(201); + + const del = await client.as('admin').del(`/views/${id}`); + expect(del.status).toBe(204); + expect((await client.as('admin').get(`/views/${id}`)).status).toBe(404); + }); + + it('returns 404, not 400, for deleting a view that does not exist', async () => { + // The old guard rejected the handle 'default' up front with a 400. With that + // gone, an unknown handle is a plain not-found. + const res = await client.as('admin').del(`/views/${uniqueHandle('view-absent')}`); + expect(res.status).toBe(404); + }); +}); diff --git a/portals/api-portal/it/rest-api/views-and-labels/view-handle-resolution.spec.js b/portals/api-portal/it/rest-api/views-and-labels/view-handle-resolution.spec.js new file mode 100644 index 000000000..fe8fb6838 --- /dev/null +++ b/portals/api-portal/it/rest-api/views-and-labels/view-handle-resolution.spec.js @@ -0,0 +1,95 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// A view is addressed by its HANDLE, never by its display name. +// +// viewDao.getId used to fall back to a display_name lookup when the handle missed. +// Only (handle, org_uuid) is unique — display_name has no constraint — so two views in +// one organization can share a display name and the fallback resolved to whichever row +// the database ordered first. It also disagreed with viewDao.get/update/deleteView, +// which are all handle-exact: a display name could clear DELETE's "view has workflows" +// gate and then 404 on a delete that matched nothing. Display names are renameable too, +// so any URL built from one breaks at the next rename. +// +// Every assertion below uses a display name that is deliberately NOT any view's handle. + +const client = require('../support/client'); +const { uniqueHandle } = require('../support/fixtures'); + +const DISPLAY_NAME = 'View Handle Resolution Display Name'; + +describe('view resolution is by handle, not display name', () => { + let handle; + + beforeAll(async () => { + await client.login('admin'); + handle = uniqueHandle('view'); + const res = await client.as('admin').post('/views', { id: handle, displayName: DISPLAY_NAME }); + expect(res.status).toBe(201); + }); + + afterAll(async () => { + await client.as('admin').del(`/views/${handle}`); + }); + + it('resolves the handle', async () => { + const res = await client.as('admin').get(`/views/${handle}`); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe(DISPLAY_NAME); + }); + + it('does not resolve the display name on GET /views/{viewId}', async () => { + const res = await client.as('admin').get(`/views/${encodeURIComponent(DISPLAY_NAME)}`); + expect(res.status).toBe(404); + }); + + it('does not resolve the display name in the ?view= filter on /apis', async () => { + // The apiDao.list path — this is the one the fallback made non-deterministic, + // since it decides which APIs a portal view shows. + const byHandle = await client.as('admin').get(`/apis?view=${handle}`); + expect(byHandle.status).toBe(200); + + const byDisplayName = await client.as('admin').get(`/apis?view=${encodeURIComponent(DISPLAY_NAME)}`); + expect(byDisplayName.status).toBe(404); + }); + + it('does not resolve the display name in the ?view= filter on /mcp-servers', async () => { + const byHandle = await client.as('admin').get(`/mcp-servers?view=${handle}`); + expect(byHandle.status).toBe(200); + + const byDisplayName = await client.as('admin').get(`/mcp-servers?view=${encodeURIComponent(DISPLAY_NAME)}`); + expect(byDisplayName.status).toBe(404); + }); + + it('does not delete a view addressed by its display name', async () => { + const del = await client.as('admin').del(`/views/${encodeURIComponent(DISPLAY_NAME)}`); + expect(del.status).toBe(404); + + // The view is still there — the display name must not have reached the delete + // through getId while the delete itself matched on handle. + const stillThere = await client.as('admin').get(`/views/${handle}`); + expect(stillThere.status).toBe(200); + }); + + it('keeps an omitted ?view= filter working (no view scoping)', async () => { + // getId short-circuits on a falsy view name and returns undefined rather than + // 404ing — an absent filter is not a missing view. + const res = await client.as('admin').get('/apis'); + expect(res.status).toBe(200); + }); +}); diff --git a/portals/api-portal/it/rest-api/views-and-labels/view-scoped-detail-pages.spec.js b/portals/api-portal/it/rest-api/views-and-labels/view-scoped-detail-pages.spec.js new file mode 100644 index 000000000..fc1bc2266 --- /dev/null +++ b/portals/api-portal/it/rest-api/views-and-labels/view-scoped-detail-pages.spec.js @@ -0,0 +1,165 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// A view-scoped PAGE URL must serve only artifacts that view includes. +// +// /{org}/views/{view}/api/{handle} used to resolve the artifact on handle + org_uuid +// alone (apiDao.getId), so the view segment was decoration: any published API or MCP +// server in the organization rendered under any view's URL, and unpublished ones too. +// Membership for APIs/MCP servers is a labels join (api_label_mappings → +// view_label_mappings) rather than a column, so it has to be asked for explicitly — +// apiDao.getIdInView now applies the same predicates GET /apis?view= applies. +// +// view-label-visibility.spec.js covers the LIST side of the same mapping. This is the +// single-artifact side, which had no coverage at all. + +const client = require('../support/client'); +const { createApi, createView, uniqueHandle } = require('../support/fixtures'); + +async function createLabel() { + const id = uniqueHandle('label'); + const res = await client.as('admin').post('/labels', { id, displayName: id }); + if (res.status !== 201) { + throw new Error(`Failed to seed label: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +// The portal pages are HTML, not the REST API, so they're fetched with the raw agent +// against the page URL rather than through client.as(...). +function pageUrl(viewHandle, artifactPath) { + return `/${client.ORG_HANDLE}/views/${viewHandle}${artifactPath}`; +} + +// MCP servers have their own resource family and require a tools schema as the +// definition — mirrors createMcpServer in mcp-servers/mcp-servers.spec.js, with labels +// added so the artifact can be placed in a view. +const MCP_TOOLS_SCHEMA = [ + '- type: TOOL', + ' name: ping', + ' description: Health check tool.', + ' inputSchema:', + ' type: object', + ' properties: {}', +].join('\n'); + +async function createMcpServer(labels) { + const id = uniqueHandle('mcp-server'); + const metadata = { + id, + name: `Test MCP Server ${id}`, + version: 'v1.0', + type: 'MCP', + status: 'PUBLISHED', + labels, + endPoints: { + productionURL: `https://backend.example.invalid/${id}`, + sandboxURL: `https://sandbox.example.invalid/${id}`, + }, + }; + const res = await client + .as('publisher') + .postMultipart('/mcp-servers') + .field('metadata', JSON.stringify(metadata)) + .attach('definition', Buffer.from(MCP_TOOLS_SCHEMA), 'definition.yaml'); + if (res.status !== 201) { + throw new Error(`Failed to seed MCP server: ${res.status} ${JSON.stringify(res.body)}`); + } + return res.body; +} + +describe('view-scoped detail pages', () => { + let labelIn; + let viewIn; + let viewOut; + let restApi; + let mcpServer; + + beforeAll(async () => { + await client.login('admin'); + await client.login('publisher'); + + labelIn = await createLabel(); + const labelOut = await createLabel(); + // viewIn carries the artifacts' label; viewOut deliberately carries a different + // one, so both views exist and only one includes the artifacts. + viewIn = await createView({ labels: [labelIn.id] }); + viewOut = await createView({ labels: [labelOut.id] }); + + restApi = await createApi({ labels: [labelIn.id] }); + mcpServer = await createMcpServer([labelIn.id]); + }); + + it('serves an API detail page in a view that includes it', async () => { + const res = await client.raw().get(pageUrl(viewIn.id, `/api/${restApi.id}`)); + expect(res.status).toBe(200); + }); + + it('404s the API detail page in a view that excludes it', async () => { + const res = await client.raw().get(pageUrl(viewOut.id, `/api/${restApi.id}`)); + expect(res.status).toBe(404); + }); + + it('serves an MCP server detail page in a view that includes it', async () => { + const res = await client.raw().get(pageUrl(viewIn.id, `/mcp/${mcpServer.id}`)); + expect(res.status).toBe(200); + }); + + it('404s the MCP server detail page in a view that excludes it', async () => { + const res = await client.raw().get(pageUrl(viewOut.id, `/mcp/${mcpServer.id}`)); + expect(res.status).toBe(404); + }); + + it('404s the agent-facing markdown for an API outside the view', async () => { + const included = await client.raw().get(pageUrl(viewIn.id, `/api/${restApi.id}.md`)); + expect(included.status).toBe(200); + + const excluded = await client.raw().get(pageUrl(viewOut.id, `/api/${restApi.id}.md`)); + expect(excluded.status).toBe(404); + }); + + it('404s the raw specification for an API outside the view', async () => { + // The spec download hangs off the same view-scoped URL, so it must not be a way + // around the page check. + const excluded = await client.raw() + .get(pageUrl(viewOut.id, `/api/${restApi.id}/docs/specification.json`)); + expect(excluded.status).toBe(404); + }); + + it('404s an unknown handle in a valid view', async () => { + // Same answer as an artifact that exists but is out of view — the two cases must + // not be distinguishable, or the response becomes a way to enumerate other views' + // artifacts. + const res = await client.raw().get(pageUrl(viewIn.id, `/api/${uniqueHandle('api-absent')}`)); + expect(res.status).toBe(404); + }); + + it('keeps hiding an artifact after its label is removed from the view', async () => { + const label = await createLabel(); + const view = await createView({ labels: [label.id] }); + const api = await createApi({ labels: [label.id] }); + + expect((await client.raw().get(pageUrl(view.id, `/api/${api.id}`))).status).toBe(200); + + // Detach every label from the view — the same operation + // view-label-visibility.spec.js checks on the list side. + expect((await client.as('admin').put(`/views/${view.id}`, { labels: [] })).status).toBe(200); + + expect((await client.raw().get(pageUrl(view.id, `/api/${api.id}`))).status).toBe(404); + }); +}); 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 215889aa7..cb81279ec 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 @@ -106,6 +106,57 @@ describe('views', () => { expect(get.status).toBe(404); }); + it('renames a view handle, keeping its labels', async () => { + const id = uniqueHandle('view'); + const renamed = uniqueHandle('view-renamed'); + await client.as('admin').post('/views', { id, displayName: 'Renameable View', labels: [label.id] }); + + const res = await client.as('admin').put(`/views/${id}`, { id: renamed, displayName: 'Renameable View' }); + expect(res.status).toBe(200); + + // The view answers to its new handle and not the old one — same view, so the + // labels attached before the rename came with it (everything is keyed on the + // view's uuid, which the rename preserves). + const byNew = await client.as('admin').get(`/views/${renamed}`); + expect(byNew.status).toBe(200); + expect(byNew.body.labels).toContain(label.id); + const byOld = await client.as('admin').get(`/views/${id}`); + expect(byOld.status).toBe(404); + + await client.as('admin').del(`/views/${renamed}`); + }); + + it('rejects a rename onto another view\'s handle with 409', async () => { + const first = uniqueHandle('view'); + const second = uniqueHandle('view'); + await client.as('admin').post('/views', { id: first, displayName: 'First View' }); + await client.as('admin').post('/views', { id: second, displayName: 'Second View' }); + + const res = await client.as('admin').put(`/views/${second}`, { id: first, displayName: 'Second View' }); + expect(res.status).toBe(409); + + // Neither view moved. + expect((await client.as('admin').get(`/views/${first}`)).body.displayName).toBe('First View'); + expect((await client.as('admin').get(`/views/${second}`)).body.displayName).toBe('Second View'); + + await client.as('admin').del(`/views/${first}`); + await client.as('admin').del(`/views/${second}`); + }); + + it('leaves the handle alone when the update omits id', async () => { + const id = uniqueHandle('view'); + await client.as('admin').post('/views', { id, displayName: 'Keeps Its Handle' }); + + const res = await client.as('admin').put(`/views/${id}`, { displayName: 'Renamed Display Only' }); + expect(res.status).toBe(200); + + const get = await client.as('admin').get(`/views/${id}`); + expect(get.status).toBe(200); + expect(get.body.displayName).toBe('Renamed Display Only'); + + await client.as('admin').del(`/views/${id}`); + }); + it('lists views for an org', async () => { const id = uniqueHandle('view'); await client.as('admin').post('/views', { id, displayName: 'Listed View', labels: [label.id] }); 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 b0add2118..85a857b17 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 @@ -148,13 +148,15 @@ describe('Applications', () => { // 2. Generate a token with the consumer secret (portal → mock key manager). cy.get(`#tab-btn-token-${KM_ID}-PRODUCTION`).click(); - cy.get('#tokenKeyBtn-PRODUCTION').click(); + // Every id on the Manage Keys page is scoped by key manager as well as key + // type — see key-managers-multiple.cy.js for why. + cy.get(`#tokenKeyBtn-${KM_ID}-PRODUCTION`).click(); cy.get('#generateTokenPromptModal').should('be.visible'); cy.get('#generateTokenPromptSecretInput').type(mockToken.secret); cy.get('#generateTokenPromptConfirmBtn').click(); cy.get(`#token_${KM_ID}_PRODUCTION`, { timeout: 15000 }) .should('contain', mockToken.accessToken); - cy.get('[data-cyid="keysTokenModal-PRODUCTION-close"]').click(); + cy.get(`[data-cyid="keysTokenModal-${KM_ID}-PRODUCTION-close"]`).click(); // 3. Revoke the keys — confirm in the shared delete-confirmation modal. // Scope the revoke button to the Production pane — the Sandbox card diff --git a/portals/api-portal/it/ui/cypress/e2e/applications/key-managers-multiple.cy.js b/portals/api-portal/it/ui/cypress/e2e/applications/key-managers-multiple.cy.js new file mode 100644 index 000000000..2824028eb --- /dev/null +++ b/portals/api-portal/it/ui/cypress/e2e/applications/key-managers-multiple.cy.js @@ -0,0 +1,203 @@ +// -------------------------------------------------------------------- +// 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. +// -------------------------------------------------------------------- + +// Manage Keys with MORE THAN ONE key manager in the organization. +// +// manage-keys.hbs renders one KM card per enabled key manager per key type, and +// keys-token.hbs one result modal per the same. Ids used to be keyed on the key type +// alone, so a second key manager produced duplicate ids and every +// document.getElementById in oauth2-key-generation.js resolved to the FIRST-rendered +// key manager. Generating a token from the second card then wrote the token into that +// card's modal but opened the first card's — which reads as "generate access token +// fails" — and spinners, errors and scope chips all landed on the wrong card. With +// only one key manager configured nothing collides, which is why the single-KM spec +// (application-flows.cy.js) never caught it. +// +// Everything here therefore asserts the KM the interaction *came from*, never just +// the key type. + +describe('Applications — multiple key managers', () => { + const APP = 'IT Multi KM App'; + // Alphabetical handles, because the cards render in whatever order the DAO + // returns: 'alpha' being first makes "the second card" unambiguous below. + const KM_A = { id: 'it-km-alpha', displayName: 'IT KM Alpha' }; + const KM_B = { id: 'it-km-beta', displayName: 'IT KM Beta' }; + const CLIENT_ID_A = 'it-client-id-alpha'; + const CLIENT_ID_B = 'it-client-id-beta'; + + let appHandle; + let mockToken; + + before(() => { + cy.login(); + cy.createApplication(APP, 'Used by the multiple-key-manager tests') + .then((handle) => { appHandle = handle; }); + // One mock token endpoint serves both key managers — the portal reaches it + // server-side, and which KM a token came from is established by the card the + // click started in, not by the endpoint. + cy.task('startMockTokenServer').then((mock) => { + mockToken = mock; + cy.seedKeyManager({ ...KM_A, tokenEndpoint: mock.endpoint }); + cy.seedKeyManager({ ...KM_B, tokenEndpoint: mock.endpoint }); + }); + }); + + after(() => { + cy.login(); + cy.request({ + method: 'DELETE', + url: `/api/v0.9/applications/${appHandle}`, + failOnStatusCode: false, + }).its('status').should('be.oneOf', [200, 404]); + cy.deleteKeyManager(KM_A.id); + cy.deleteKeyManager(KM_B.id); + cy.task('stopMockTokenServer'); + }); + + it('renders one card per key manager, with no duplicate element ids', () => { + cy.login(); + cy.visitPortal(`/applications/${appHandle}`); + + cy.get('#production').find('.mk-km-card').should('have.length', 2); + cy.get('.mk-km-name').should('contain', KM_A.displayName); + cy.get('.mk-km-name').should('contain', KM_B.displayName); + + // Each card owns its own generate button and result modal. + cy.get(`#tokenKeyBtn-${KM_A.id}-PRODUCTION`).should('exist'); + cy.get(`#tokenKeyBtn-${KM_B.id}-PRODUCTION`).should('exist'); + cy.get(`#keysTokenModal-${KM_A.id}-PRODUCTION`).should('exist'); + cy.get(`#keysTokenModal-${KM_B.id}-PRODUCTION`).should('exist'); + + // The regression guard proper: any id repeated on this page is a getElementById + // that silently resolves to whichever key manager happens to render first. + cy.document().then((doc) => { + const ids = Array.from(doc.querySelectorAll('[id]')).map((el) => el.id); + const duplicates = [...new Set(ids.filter((id, i) => ids.indexOf(id) !== i))]; + expect(duplicates, 'duplicate element ids on the application page').to.deep.equal([]); + }); + }); + + it('links a separate client ID to each key manager', () => { + cy.login(); + cy.visitPortal(`/applications/${appHandle}`); + + cy.get(`#addClientIdInput-${KM_A.id}-PRODUCTION`).type(CLIENT_ID_A); + cy.get(`#addClientIdBtn-${KM_A.id}-PRODUCTION`).click(); + cy.get(`#consumer-key-${KM_A.id}-PRODUCTION-view`, { timeout: 15000 }) + .should('have.value', CLIENT_ID_A); + + cy.get(`#addClientIdInput-${KM_B.id}-PRODUCTION`).type(CLIENT_ID_B); + cy.get(`#addClientIdBtn-${KM_B.id}-PRODUCTION`).click(); + cy.get(`#consumer-key-${KM_B.id}-PRODUCTION-view`, { timeout: 15000 }) + .should('have.value', CLIENT_ID_B); + + // Each card keeps its own credentials — the first card's client id must not + // have been overwritten by, or copied onto, the second. + cy.get(`#consumer-key-${KM_A.id}-PRODUCTION-view`).should('have.value', CLIENT_ID_A); + // appRefId used to be appKeyMappings[0].asClientId for every card. + cy.get(`#app-ref-${KM_A.id}-PRODUCTION`).should('have.value', CLIENT_ID_A); + cy.get(`#app-ref-${KM_B.id}-PRODUCTION`).should('have.value', CLIENT_ID_B); + // Distinct mappings, so distinct mapping ids. + cy.get(`#key-map-${KM_A.id}-PRODUCTION`).invoke('val').then((mappingA) => { + cy.get(`#key-map-${KM_B.id}-PRODUCTION`).invoke('val').should('not.eq', mappingA); + }); + }); + + it('generates a token from the second key manager into that key manager\'s own modal', () => { + cy.login(); + cy.visitPortal(`/applications/${appHandle}`); + + cy.get(`#tab-btn-token-${KM_B.id}-PRODUCTION`).click(); + cy.get(`#tokenKeyBtn-${KM_B.id}-PRODUCTION`).click(); + cy.get('#generateTokenPromptModal').should('be.visible'); + cy.get('#generateTokenPromptSecretInput').type(mockToken.secret); + cy.get('#generateTokenPromptConfirmBtn').click(); + + // The token lands in the second key manager's modal, and that modal is the one + // shown. Before the fix the token was written here but the FIRST key manager's + // (empty) modal was opened instead. + cy.get(`#keysTokenModal-${KM_B.id}-PRODUCTION`, { timeout: 15000 }) + .should('be.visible'); + cy.get(`#token_${KM_B.id}_PRODUCTION`, { timeout: 15000 }) + .should('contain', mockToken.accessToken); + + // The other key manager's modal stays closed and empty. + cy.get(`#keysTokenModal-${KM_A.id}-PRODUCTION`).should('not.be.visible'); + cy.get(`#token_${KM_A.id}_PRODUCTION`).should('not.contain', mockToken.accessToken); + + // The clicked button returns to its normal state (the spinner used to be + // applied to, and left on, the first card's button). + cy.get(`[data-cyid="keysTokenModal-${KM_B.id}-PRODUCTION-close"]`).click(); + cy.get(`#tokenKeyBtn-${KM_B.id}-PRODUCTION`) + .should('not.be.disabled') + .find('.button-normal-state').should('be.visible'); + }); + + it('generates a token from the first key manager as well', () => { + cy.login(); + cy.visitPortal(`/applications/${appHandle}`); + + cy.get(`#tab-btn-token-${KM_A.id}-PRODUCTION`).click(); + cy.get(`#tokenKeyBtn-${KM_A.id}-PRODUCTION`).click(); + cy.get('#generateTokenPromptModal').should('be.visible'); + cy.get('#generateTokenPromptSecretInput').type(mockToken.secret); + cy.get('#generateTokenPromptConfirmBtn').click(); + + cy.get(`#keysTokenModal-${KM_A.id}-PRODUCTION`, { timeout: 15000 }) + .should('be.visible'); + cy.get(`#token_${KM_A.id}_PRODUCTION`, { timeout: 15000 }) + .should('contain', mockToken.accessToken); + cy.get(`#keysTokenModal-${KM_B.id}-PRODUCTION`).should('not.be.visible'); + cy.get(`[data-cyid="keysTokenModal-${KM_A.id}-PRODUCTION-close"]`).click(); + }); + + it('shows a token failure in the card it came from, not the first one', () => { + cy.login(); + cy.visitPortal(`/applications/${appHandle}`); + + // The mock rejects any secret but its own, so this is a genuine upstream 401 + // surfaced through the portal. + cy.get(`#tab-btn-token-${KM_B.id}-PRODUCTION`).click(); + cy.get(`#tokenKeyBtn-${KM_B.id}-PRODUCTION`).click(); + cy.get('#generateTokenPromptSecretInput').type('it-wrong-secret'); + cy.get('#generateTokenPromptConfirmBtn').click(); + + cy.get(`#keyGenerationErrorContainer-${KM_B.id}-PRODUCTION`, { timeout: 15000 }) + .should('be.visible') + .and('contain', 'Failed to generate access token'); + cy.get(`#keyGenerationErrorContainer-${KM_A.id}-PRODUCTION`).should('not.be.visible'); + cy.get(`#keysTokenModal-${KM_B.id}-PRODUCTION`).should('not.be.visible'); + }); + + it('revokes one key manager\'s keys without touching the other\'s', () => { + cy.login(); + cy.visitPortal(`/applications/${appHandle}`); + + cy.get(`#tab-btn-creds-${KM_B.id}-PRODUCTION`).click(); + // Scope the revoke click to the second card — every card renders its own. + cy.get(`#keyActionsContainer-${KM_B.id}-PRODUCTION`).find('.mk-btn-danger').click(); + cy.get('#deleteConfirmation').should('be.visible'); + cy.get('#deleteConfirmationBtn').click(); + + // The second key manager is back to the empty state; the first still holds its + // own client id (the unscoped fallback used to delete the first card's mapping). + cy.get(`#addClientIdBtn-${KM_B.id}-PRODUCTION`, { timeout: 15000 }).should('exist'); + cy.get(`#consumer-key-${KM_B.id}-PRODUCTION-view`).should('have.value', ''); + cy.get(`#consumer-key-${KM_A.id}-PRODUCTION-view`).should('have.value', CLIENT_ID_A); + }); +}); 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 409c2fdb0..bb41f377f 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 @@ -32,6 +32,10 @@ describe('Settings — Views & Labels', () => { // 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}`; + // The rename test's own view, seeded through the API so it doesn't depend on the + // create test having run. + const RENAME_FROM = `it-rename-from-${uid}`; + const RENAME_TO = `it-rename-to-${uid}`; const settingsUrl = () => `/${Cypress.env('ORG_HANDLE')}/settings`; @@ -44,6 +48,9 @@ describe('Settings — Views & Labels', () => { // Robust API cleanup, idempotent (404 if the create step never persisted). cy.login(); cy.apiRequest('DELETE', `/api/v0.9/views/${VIEW_HANDLE}`, { failOnStatusCode: false }); + // Whichever handle the rename test left behind. + cy.apiRequest('DELETE', `/api/v0.9/views/${RENAME_TO}`, { failOnStatusCode: false }); + cy.apiRequest('DELETE', `/api/v0.9/views/${RENAME_FROM}`, { failOnStatusCode: false }); cy.apiRequest('DELETE', `/api/v0.9/labels/${LABEL_HANDLE}`, { failOnStatusCode: false }); cy.apiRequest('DELETE', `/api/v0.9/labels/${VIEW_LABEL}`, { failOnStatusCode: false }); }); @@ -72,6 +79,64 @@ describe('Settings — Views & Labels', () => { .and('contain', VIEW_NAME); }); + it('renames a view handle from the edit modal', () => { + // A handle used to be read-only after creation. It is editable now: a rename + // keeps the view's identity (labels, assets and workflows are keyed on its uuid) + // and only changes its URL, which the modal warns about. + cy.login(); + cy.apiRequest('POST', '/api/v0.9/views', { + body: { id: RENAME_FROM, displayName: 'IT Rename View', labels: [VIEW_LABEL] }, + }); + cy.visit(settingsUrl()); + + cy.get('.cfg-nav-item[data-panel="cfg-views"]').click(); + cy.get(`#cfg-view-row-${RENAME_FROM} .cfg-view-edit-btn`).first().click(); + cy.get('#cfg-view-modal').should('be.visible'); + + // Editable, pre-filled with the current handle, and the rename warning names the + // URL that is about to stop working. + // Two statements, not a chain: `should('not.have.attr', …)` yields the attribute + // value (undefined), so a chained `.and('have.value', …)` would assert against + // that instead of the element. + cy.get('#view-handle').should('not.have.attr', 'readonly'); + cy.get('#view-handle').should('have.value', RENAME_FROM); + cy.get('#view-handle-rename-warning').should('be.visible'); + cy.get('#view-handle-rename-old').should('contain', `/views/${RENAME_FROM}`); + + cy.get('#view-handle').clear().type(RENAME_TO); + cy.get('#cfg-view-modal-save').click(); + + // The row is keyed on the handle, so the new one appearing (and the old one + // gone) is the rename landing. + cy.get(`#cfg-view-row-${RENAME_TO}`, { timeout: 15000 }).should('exist').and('contain', RENAME_TO); + cy.get(`#cfg-view-row-${RENAME_FROM}`).should('not.exist'); + + // Same view, so the label attached before the rename is still attached. The row + // renders label DISPLAY names (viewConfigureController maps handle → name), not + // the handles. + cy.get(`#cfg-view-row-${RENAME_TO}`).should('contain', 'IT View Label'); + + // And the view is served at its new URL (visitPortal is pinned to the fixture's + // own view, so this navigates explicitly). + cy.visit(`/${Cypress.env('ORG_HANDLE')}/views/${RENAME_TO}`); + cy.url().should('include', `/views/${RENAME_TO}`); + }); + + it('shows a delete control for the seeded default view', () => { + // The default view used to render no delete button at all. Any view is deletable + // now — only the last one is held back, enforced server-side with a 400 — so the + // control must be present for 'default' too. + cy.login(); + cy.visit(settingsUrl()); + cy.get('.cfg-nav-item[data-panel="cfg-views"]').click(); + + // More than one view exists at this point (the seeded 'default' plus this + // spec's), so the control is present and enabled. + cy.get('#cfg-view-row-default .cfg-view-delete-btn') + .should('exist') + .and('not.be.disabled'); + }); + it('creates a label from a display name', () => { cy.login(); cy.visit(settingsUrl()); diff --git a/portals/api-portal/src/app.js b/portals/api-portal/src/app.js index bb85e07b2..733fc0386 100644 --- a/portals/api-portal/src/app.js +++ b/portals/api-portal/src/app.js @@ -219,7 +219,7 @@ app.use((req, res, next) => { }); // Central error handler -app.use((err, req, res, next) => { +app.use(async (err, req, res, next) => { if (res.headersSent) return; const status = err.status || 500; @@ -249,7 +249,10 @@ app.use((err, req, res, next) => { // failed request. A 404 from orgGuard means that segment named some *other* // organization, and echoing it back would point the error page's "home" link // outside this portal. - const baseUrl = '/' + orgContext.getHandle() + constants.ROUTE.VIEWS_PATH + 'default'; + // Resolved rather than hardcoded to 'default', so the "home" link still points at a + // view that exists after that one has been renamed or deleted. Never throws — see + // orgContext.getFallbackViewHandle — which matters on this path above all others. + const baseUrl = '/' + orgContext.getHandle() + constants.ROUTE.VIEWS_PATH + await orgContext.getFallbackViewHandle(); const templateContent = { baseUrl, errorType, diff --git a/portals/api-portal/src/config/configDefaults.js b/portals/api-portal/src/config/configDefaults.js index b7c0d6b60..71bb72960 100644 --- a/portals/api-portal/src/config/configDefaults.js +++ b/portals/api-portal/src/config/configDefaults.js @@ -83,6 +83,21 @@ const DEFAULTS = { // "local" — username/password validated against the Platform API control // plane (auth.local below). "idp" — external OIDC IDP (auth.idp below). mode: 'local', // local | idp + // The org identifier the IdP asserts at SSO login — the expected VALUE of the + // claim named by claimMappings.organization below, which is what incoming + // tokens are matched against (ensureAuthenticated.belongsToTargetOrg). Stored + // on the organization row as idp_ref_id, where token claims are compared + // against it. + // + // Applied at startup only: seeded with the organization and re-applied on + // every later boot (seederService.reconcileIdpOrgId), which makes this setting + // the single writer of that field — the admin API refuses to change it. Empty + // means "use organization.handle" for a fresh organization (the common case, + // incl. the platform-api-managed mode where the org_handle claim IS the + // handle) and "leave the stored value alone" for an existing one, so dropping + // the setting never silently resets it. Matched verbatim against the claim, so + // it is NOT lowercased, unlike the handle. + idpOrgId: '', // JWT claim name mappings — which token claim carries each field. // Dot-notation supported for nested claims (e.g. "realm_access.roles"). claimMappings: { diff --git a/portals/api-portal/src/controllers/apiContentController.js b/portals/api-portal/src/controllers/apiContentController.js index bd1bc82c3..a1630ffff 100644 --- a/portals/api-portal/src/controllers/apiContentController.js +++ b/portals/api-portal/src/controllers/apiContentController.js @@ -220,7 +220,9 @@ const loadAPIContent = async (req, res, next) => { try { const orgDetails = await orgDao.get(orgName); const orgId = orgDetails.uuid; - const apiId = await apiDao.getId(orgId, apiHandle); + // View-scoped: an artifact the URL's view doesn't include is a 404 here, not + // a rendered page (util.resolveApiIdInView). + const apiId = await util.resolveApiIdInView(orgId, apiHandle, viewName); const metaData = await loadAPIMetaData(req, orgId, apiId); // Log API access for audit trail @@ -462,8 +464,10 @@ const getAPIDefinition = async (orgName, viewName, apiHandle) => { } } else { const orgId = await orgDao.getId(orgName); - const apiId = await apiDao.getId(orgId, apiHandle); - metaData = await apiMetadataService.getMetadataFromDB(orgId, apiId, viewName); + // Docs and raw-specification routes are view-scoped page URLs too: a spec must + // not be downloadable through a view that doesn't include its API. + const apiId = await util.resolveApiIdInView(orgId, apiHandle, viewName); + metaData = await apiMetadataService.getMetadataFromDB(orgId, apiId); const data = metaData ? JSON.stringify(metaData) : {}; metaData = JSON.parse(data); const apiType = metaData.type; @@ -809,6 +813,7 @@ const loadDocument = async (req, res, next) => { firstName: req.user.firstName, lastName: req.user.lastName, email: req.user.email, + isAdmin: req.user.isAdmin, } } templateContent.profile = req.isAuthenticated() ? profile : null; @@ -1176,7 +1181,12 @@ const loadAPIContentMd = async (req, res) => { return res.status(404).send('# Not Found\n\nThis resource is not available for agents.'); } - const apiId = await apiDao.getId(orgId, apiHandle); + // View-scoped like the HTML page — the agent-facing markdown must not expose an + // artifact the view excludes either. + const apiId = await apiDao.getIdInView(orgId, apiHandle, viewName); + if (!apiId) { + return res.status(404).send('# Not Found\n\nThis API is not available in this view.'); + } const metaData = await loadAPIMetaData(req, orgId, apiId); if (metaData?.agentVisibility === 'HIDDEN') { @@ -1557,7 +1567,8 @@ const loadAPIDefinitionRaw = async (req, res) => { error: error.message, stack: error.stack }); - util.sendError(res, 500, 'Failed to load specification.'); + const status = Number.isInteger(error.status) ? error.status : 500; + util.sendError(res, status, status === 500 ? 'Failed to load specification.' : 'API specification not found'); } }; diff --git a/portals/api-portal/src/controllers/apiKeysPageController.js b/portals/api-portal/src/controllers/apiKeysPageController.js index 4a3671285..d2887dd09 100644 --- a/portals/api-portal/src/controllers/apiKeysPageController.js +++ b/portals/api-portal/src/controllers/apiKeysPageController.js @@ -39,13 +39,15 @@ const loadAPIApiKeys = async (req, res, next) => { if (!req.user) { return res.redirect(`/${orgName}${constants.ROUTE.VIEWS_PATH}${viewName}/login`); } - const apiId = await apiDao.getId(orgId, apiHandle); + // View-scoped: the per-API keys page hangs off a view URL, so it answers 404 for + // an artifact that view doesn't include (apiDao.getIdInView). + const apiId = await apiDao.getIdInView(orgId, apiHandle, viewName); if (!apiId) { const err = new Error('API not found'); err.status = 404; return next(err); } - let metaData = await apiMetadataService.getMetadataFromDB(orgId, apiId, viewName); + let metaData = await apiMetadataService.getMetadataFromDB(orgId, apiId); if (metaData && typeof metaData === 'object') { metaData = JSON.parse(JSON.stringify(metaData)); const images = metaData.apiImageMetadata; diff --git a/portals/api-portal/src/controllers/applicationsContentController.js b/portals/api-portal/src/controllers/applicationsContentController.js index 98c87dc07..6aaf08bdd 100644 --- a/portals/api-portal/src/controllers/applicationsContentController.js +++ b/portals/api-portal/src/controllers/applicationsContentController.js @@ -69,10 +69,8 @@ const loadApplicationData = async (req, orgName, applicationHandle, viewName) => const applicationId = appRecord.uuid; const applicationList = await adminService.getApplicationKeyMap(orgId, applicationId, userId); - let applicationReference = ""; let applicationKeyList; if (Array.isArray(applicationList.appKeyMappings) && applicationList.appKeyMappings.length > 0) { - applicationReference = applicationList.appKeyMappings[0].asClientId; try { const localMappings = await appDao.getKeyMappings(orgId, applicationId); const keyList = []; @@ -127,7 +125,10 @@ const loadApplicationData = async (req, orgName, applicationHandle, viewName) => consumerKey: key.consumerKey, keyMappingId: key.keyMappingId, keyType: key.keyType, - appRefId: applicationReference + // This key set's own client id. Previously appKeyMappings[0].asClientId, + // which stamped the first key manager's client id onto every key manager's + // card once an organization had more than one. + appRefId: key.consumerKey }; if (key.keyType === constants.KEY_TYPE.PRODUCTION) { productionKeys.push(keyData); diff --git a/portals/api-portal/src/controllers/viewConfigureController.js b/portals/api-portal/src/controllers/viewConfigureController.js index 593f70fc9..96332df56 100644 --- a/portals/api-portal/src/controllers/viewConfigureController.js +++ b/portals/api-portal/src/controllers/viewConfigureController.js @@ -22,6 +22,7 @@ const orgDao = require('../dao/organizationDao'); const apiDao = require('../dao/apiDao'); const apiFileDao = require('../dao/apiFileDao'); const viewDao = require('../dao/viewDao'); +const orgContext = require('../utils/orgContext'); const labelDao = require('../dao/labelDao'); const subscriptionPlanDao = require('../dao/subscriptionPlanDao'); const whDao = require('../dao/webhookSubscriberDao'); @@ -80,17 +81,20 @@ const loadSettingsPage = async (req, res) => { }; // Views for the selector and the merged Views management tab. The in-page // view selector picks which view the LLM + API Workflow panels edit via the - // ?view= query param (the path stays org-scoped). Default to 'default', then - // fall back to the first view; ignore an unknown ?view value. + // ?view= query param (the path stays org-scoped); an unknown ?view value is + // ignored. With none given, the view comes from the single portal-wide resolver + // (orgContext.getFallbackViewHandle → viewDao.getFallbackHandle): prefer the view + // whose handle is 'default', else the earliest-created one. This page used to + // hardcode 'default' with its own views[0] fallback, so it could land on a + // different view than the bare-org redirect, the error page's home link and the + // chrome partials — all of which resolve through that resolver. const views = await apiMetadataService.getViewsFromDB(orgId); templateContent.views = views; const requestedView = typeof req.query.view === 'string' ? req.query.view : ''; const viewExists = (name) => views.some(v => v.id === name); - let viewName = 'default'; + let viewName = await orgContext.getFallbackViewHandle(); if (requestedView && viewExists(requestedView)) { viewName = requestedView; - } else if (!viewExists('default') && views.length > 0) { - viewName = views[0].id; } templateContent.viewName = viewName; templateContent.selectedView = viewName; diff --git a/portals/api-portal/src/dao/apiDao.js b/portals/api-portal/src/dao/apiDao.js index a1779ff37..bd127062a 100644 --- a/portals/api-portal/src/dao/apiDao.js +++ b/portals/api-portal/src/dao/apiDao.js @@ -415,6 +415,44 @@ const getId = async (orgId, apiHandle) => { return api?.uuid; }; +/** + * Resolves an API/MCP handle to its uuid ONLY IF that artifact is visible in `viewName`, + * applying exactly the predicates list() applies: the view's label set must intersect the + * artifact's labels, and the status must be published/deprecated. Returns undefined + * otherwise, which callers turn into a 404. + * + * This is what the detail pages must use instead of getId. `api_metadata` has no + * view_uuid column (unlike api_workflows, which is why the workflow pages were already + * correct) — membership is the two-hop join below, so resolving on + * `handle + org_uuid` alone rendered any published API under any view's URL, and any + * non-published one too. + * + * `type` narrows the same lookup to one artifact kind, so /views/{v}/api/{handle} cannot + * resolve an MCP server's handle, nor /views/{v}/mcp/{handle} a REST API's. + */ +const getIdInView = async (orgId, apiHandle, viewName, { type, excludeType } = {}, t) => { + const exec = t || db; + const viewDao = require('./viewDao'); + const viewId = await viewDao.getId(orgId, viewName, t); + + const conditions = ['handle = ?', 'org_uuid = ?', `status IN (${STATUS_PLACEHOLDERS})`]; + const params = [apiHandle, orgId, ...PUBLISHED_STATUSES]; + if (type) { conditions.push('type = ?'); params.push(type); } + if (excludeType) { conditions.push('type != ?'); params.push(excludeType); } + conditions.push( + `EXISTS (SELECT 1 FROM ${API_LABEL_MAPPINGS_TABLE} alm + WHERE alm.api_uuid = ${API_METADATA_TABLE}.uuid + AND alm.label_uuid IN (SELECT label_uuid FROM ${VIEW_LABEL_MAPPINGS_TABLE} WHERE view_uuid = ?))` + ); + params.push(viewId); + + const api = await exec.queryOne( + `SELECT uuid FROM ${API_METADATA_TABLE} WHERE ${conditions.join(' AND ')}`, + params + ); + return api?.uuid; +}; + // Same as getId, but also constrains the match to a specific `type` (e.g. 'MCP') in a // single query — used by resource families that only manage one API type. const getIdByType = async (orgId, apiHandle, type) => { @@ -497,6 +535,7 @@ module.exports = { search, searchFallback, getId, + getIdInView, getIdByType, getIdExcludingType, getHandle, diff --git a/portals/api-portal/src/dao/organizationDao.js b/portals/api-portal/src/dao/organizationDao.js index b3692830c..db80898f4 100644 --- a/portals/api-portal/src/dao/organizationDao.js +++ b/portals/api-portal/src/dao/organizationDao.js @@ -170,6 +170,41 @@ const update = async (orgData, t) => { return [rowCount, [updatedOrg]]; }; +/** + * Narrow, targeted write of idp_ref_id alone — used by the startup seeder to + * reconcile the stored value with auth.idp_org_id in config. update() above writes + * display_name, the business_owner fields, and cp_ref_id unconditionally, so + * reusing it here would clear whatever an operator set through the settings UI. + */ +const updateIdpRefId = async (orgUuid, idpRefId, actor, t) => { + const exec = t || db; + const { rowCount } = await exec.execute( + `UPDATE ${ORG_TABLE} SET idp_ref_id = ?, updated_by = ?, updated_at = ? WHERE uuid = ?`, + [idpRefId, actor, new Date(), orgUuid] + ); + if (rowCount < 1) { + throw new NotFoundError('Organization not found'); + } +}; + +/** + * Returns another organization that findOrgByIdentifier would resolve `value` to — + * i.e. one whose handle, display_name, or idp_ref_id already equals it — or null. + * + * A shared multi-organization database is the case this guards: pointing this + * instance's idp_ref_id at a value another organization already answers to would + * shadow that organization's own identifier resolution, so the seeder refuses the + * change rather than breaking a neighbouring tenant. + */ +const findOtherOrgClaimingIdentifier = async (value, excludeUuid, t) => { + const exec = t || db; + const rows = await exec.query( + `SELECT * FROM ${ORG_TABLE} WHERE (handle = ? OR display_name = ? OR idp_ref_id = ?) AND uuid <> ?`, + [String(value).toLowerCase(), value, value, excludeUuid] + ); + return rows.length ? normalizeOrgRow(rows[0]) : null; +}; + // Tables whose org_uuid FK is ON DELETE NO ACTION (database/schema.*.sql) block // deleting the organization row unless their rows are removed first. Tables with // ON DELETE CASCADE/SET NULL (api_metadata, subscription_plans, audit, @@ -350,6 +385,8 @@ module.exports = { getId, list, update, + updateIdpRefId, + findOtherOrgClaimingIdentifier, delete: deleteOrg, createContent, updateContent, diff --git a/portals/api-portal/src/dao/viewDao.js b/portals/api-portal/src/dao/viewDao.js index 71a981b6c..afba98687 100644 --- a/portals/api-portal/src/dao/viewDao.js +++ b/portals/api-portal/src/dao/viewDao.js @@ -24,6 +24,9 @@ const constants = require('../utils/constants'); const { CustomError } = require('../utils/errors/customErrors'); const VIEWS_TABLE = 'views'; +// The conventional handle of the view every org is seeded with, and the first choice of +// getFallbackHandle. Not special-cased anywhere else — it can be renamed or deleted. +const DEFAULT_VIEW_HANDLE = 'default'; const VIEW_LABELS_TABLE = 'view_label_mappings'; const LABELS_TABLE = 'labels'; const ORG_ASSETS_TABLE = 'organization_assets'; @@ -140,29 +143,100 @@ const get = async (orgId, handle) => { }; const getId = async (orgId, viewName, t) => { - // `view` is an optional query param on /apis and /mcp-servers (apiViewQuery in the - // OpenAPI spec) — a bare handle/display_name lookup with `undefined` throws at the - // Sequelize layer ("WHERE parameter has invalid undefined value") rather than the - // 404 below, so short-circuit before ever building that query. + // `view` is an optional query param on /apis and /mcp-servers (viewQuery in the + // OpenAPI spec) — a bare handle lookup with `undefined` throws at the driver layer + // ("WHERE parameter has invalid undefined value") rather than the 404 below, so + // short-circuit before ever building that query. if (!viewName) return undefined; const exec = t || db; - let view = await exec.queryOne( + const view = await exec.queryOne( `SELECT uuid FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, [viewName, orgId] ); - if (!view) { - view = await exec.queryOne( - `SELECT uuid FROM ${VIEWS_TABLE} WHERE display_name = ? AND org_uuid = ?`, - [viewName, orgId] - ); - } if (!view) { throw new CustomError(404, constants.ERROR_CODE[404], "View not found"); } return view.uuid; }; +// The handle of the view the portal falls back to when a URL names no view — the bare +// org root (/{orgName}), the error page's home link, and the org-scoped settings page's +// chrome. 'default' used to be hardcoded at each of those sites, which is why the +// 'default' view could not be deleted or renamed; this resolves it instead: +// +// 1. the view whose handle is 'default', when it still exists (unchanged behaviour +// for every existing deployment, since the seeder creates it), else +// 2. the org's earliest-created view, handle breaking a same-timestamp tie so the +// answer is stable across requests and dialects. +// +// Falls back to the literal 'default' only for an org with no views at all, which the +// last-view delete guard (apiMetadataService.deleteView) prevents reaching through the +// API — a fresh org is seeded with one. +const getFallbackHandle = async (orgId, t) => { + const exec = t || db; + const preferred = await exec.queryOne( + `SELECT handle FROM ${VIEWS_TABLE} WHERE org_uuid = ? AND handle = ?`, + [orgId, DEFAULT_VIEW_HANDLE] + ); + if (preferred) { + return preferred.handle; + } + const earliest = await exec.queryOne( + `SELECT handle FROM ${VIEWS_TABLE} WHERE org_uuid = ? ORDER BY created_at ASC, handle ASC`, + [orgId] + ); + return earliest ? earliest.handle : DEFAULT_VIEW_HANDLE; +}; + +// Number of views in the org — the last-view delete guard's input. +const count = async (orgId, t) => { + const exec = t || db; + const row = await exec.queryOne(`SELECT COUNT(*) AS total FROM ${VIEWS_TABLE} WHERE org_uuid = ?`, [orgId]); + return Number(row?.total ?? 0); +}; + +/** + * Renames a view's handle in place, keeping its uuid — so every reference survives: + * organization_assets, view_label_mappings and api_workflows all key on view_uuid and + * no table stores the handle, so this is a single-row update with nothing to migrate. + * + * URLs are the thing that does NOT survive: every portal page embeds the handle, so + * links to the old one 404 afterwards. That is the caller's (and the operator's) + * decision to make, which is why the settings UI warns before saving a rename. + * + * Returns null when no view carries `oldHandle`; throws CustomError(409) when + * `newHandle` is already taken in this organization. + */ +const rename = async (orgId, oldHandle, newHandle, updatedBy, t) => { + const exec = t || db; + const existing = await exec.queryOne( + `SELECT * FROM ${VIEWS_TABLE} WHERE handle = ? AND org_uuid = ?`, + [oldHandle, orgId] + ); + if (!existing) { + return null; + } + if (newHandle === oldHandle) { + return existing; + } + const updatedAt = new Date(); + try { + await db.withSavepoint(exec, () => exec.execute( + `UPDATE ${VIEWS_TABLE} SET handle = ?, updated_by = ?, updated_at = ? WHERE uuid = ? AND org_uuid = ?`, + [newHandle, updatedBy, updatedAt, existing.uuid, orgId] + )); + } catch (error) { + // uq_view_handle_org_uuid — another view already answers to this handle. Report + // it as a conflict rather than letting a raw driver error surface. + if (db.isDuplicateKeyError(error)) { + throw new CustomError(409, constants.ERROR_CODE[409], `A view with the handle '${newHandle}' already exists`); + } + throw error; + } + return { ...existing, handle: newHandle, updated_by: updatedBy, updated_at: updatedAt }; +}; + const list = async (orgId) => { const views = await db.query(`SELECT * FROM ${VIEWS_TABLE} WHERE org_uuid = ?`, [orgId]); if (views.length === 0) return views; @@ -216,9 +290,12 @@ async function getLabelId(orgId, labels, t) { module.exports = { create, update, + rename, delete: deleteView, get, getId, + getFallbackHandle, + count, list, addLabels, replaceLabels, diff --git a/portals/api-portal/src/defaultContent/layout/main.hbs b/portals/api-portal/src/defaultContent/layout/main.hbs index b48a64ae4..8114ef95b 100644 --- a/portals/api-portal/src/defaultContent/layout/main.hbs +++ b/portals/api-portal/src/defaultContent/layout/main.hbs @@ -16,6 +16,13 @@ // Browser scripts build invocation URLs via window.apiPortalApi (common.js). window.__API_PORTAL_API__ = { base: "{{apiPortalApiConfig.base}}", version: "{{apiPortalApiConfig.version}}" }; + {{{slots.head}}} diff --git a/portals/api-portal/src/defaultContent/pages/mcp/partials/mcp-listing.hbs b/portals/api-portal/src/defaultContent/pages/mcp/partials/mcp-listing.hbs index 1e5580c04..fc1eb738e 100644 --- a/portals/api-portal/src/defaultContent/pages/mcp/partials/mcp-listing.hbs +++ b/portals/api-portal/src/defaultContent/pages/mcp/partials/mcp-listing.hbs @@ -22,7 +22,7 @@
Explore our extensive MCP catalog and discover how to integrate them seamlessly into your application.