Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions portals/api-portal/configs/config-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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}
Expand Down
24 changes: 22 additions & 2 deletions portals/api-portal/docs/administer/manage-organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,35 @@ 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 |
| `spec.businessOwnerEmail` | No | Business owner's email address |
| `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.

---

Expand Down
29 changes: 24 additions & 5 deletions portals/api-portal/docs/api-portal-openapi-spec-v0.9.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1866,14 +1870,20 @@ 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.
"400":
$ref: "#/components/responses/BadRequest"
"404":
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
"500":
$ref: "#/components/responses/InternalServerError"
security:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading