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
9 changes: 7 additions & 2 deletions portals/api-portal/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,13 @@ docker-compose*.yaml
# ── Database init / seed scripts (volume-mounted into postgres) ───
artifacts/

# ── Sample APIs/MCPs (deployed via ./scripts/seed-samples.sh against the
# REST API, not read from disk by the running app) ─────────────────
# ── Sample APIs/MCPs ──────────────────────────────────────────────
# Not baked into the image. Both consumers reach them from OUTSIDE the image:
# - ./scripts/seed-samples.sh deploys them via the REST API (host-side), and
# - design mode reads them from disk via a bind-mount — docker-compose.yaml
# mounts ./samples at /app/samples (the standalone dist rewrites this to
# ./resources/samples; see the Makefile), so operators can edit the sample
# set and restart to preview it without rebuilding the image.
samples/

# ── Host-side operational scripts (setup.sh, seed-samples.sh) — run against
Expand Down
5 changes: 5 additions & 0 deletions portals/api-portal/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ dist: clean-dist ## Build standalone API Portal distribution zip
@mkdir -p $(DIST_DIR)/resources/samples
@cp -R samples/apis $(DIST_DIR)/resources/samples/
@cp -R samples/mcps $(DIST_DIR)/resources/samples/
# subscription-plans.yaml and applications.yaml complete the design-mode sample
# set (seed-samples.sh only needs apis/ + mcps/, but design mode reads these too).
@cp samples/subscription-plans.yaml $(DIST_DIR)/resources/samples/
@cp samples/applications.yaml $(DIST_DIR)/resources/samples/
# Stage platform-api configs + db-scripts (from tag or working tree) for the merge below.
ifeq ($(PLATFORM_API_FROM_TAG),true)
@echo "Fetching platform-api configs and db-scripts from tag $(PLATFORM_API_TAG)..."
Expand Down Expand Up @@ -317,6 +321,7 @@ endif
@sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \
-e 's#\.\./\.\./platform-api/resources/role-to-scope-mapping\.yaml:#./resources/platform-api/role-to-scope-mapping.yaml:#' \
-e 's#\./resources/role-to-scope-mapping\.yaml:#./resources/api-portal/role-to-scope-mapping.yaml:#' \
-e 's#\./samples:/app/samples:#./resources/samples:/app/samples:#' \
docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml
@cp distribution/README.md $(DIST_DIR)/README.md
@mkdir -p $(DIST_DIR)/scripts
Expand Down
6 changes: 3 additions & 3 deletions portals/api-portal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ Prompts for the admin username/password (or set `ADMIN_USERNAME`/`ADMIN_PASSWORD

## Configuration Reference

All settings live in `configs/config.toml`. Every setting can also be overridden with an `APIP_AP_*` environment variable.
All settings live in `configs/config.toml`. There is no automatic `APIP_AP_*` override layer: an environment variable takes effect only for a setting whose `config.toml` entry explicitly references it with a `{{ env "NAME" "fallback" }}` token (see [Environment variable overrides](#environment-variable-overrides)).

The full annotated list of settings is in [`configs/config-template.toml`](configs/config-template.toml).

Expand All @@ -265,9 +265,9 @@ Note there are two files with this name, read by different components in differe
| File | Read by | When |
|---|---|---|
| [`platform-api/resources/role-to-scope-mapping.yaml`](../../platform-api/resources/role-to-scope-mapping.yaml) | Platform API | Local auth — expands a file user's `roles` into the `scope` claim of the token it issues (roles named `ap_*`) |
| [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml) | This portal | `auth.authorization.mode = "role"` — expands an incoming token's roles claim on every request (roles named `dp_admin`, `dp_subscriber`) |
| [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml) | This portal | `auth.authorization.mode = "role"` (the default) — expands an incoming token's roles claim on every request to authorize the portal's REST surface. Recognises `dp_admin`/`dp_subscriber`, and **aliases** `ap_admin`/`ap_subscriber` so local-auth tokens (which carry `ap_*` roles) are authorized unchanged |

Local auth uses the first; an external IDP in role mode uses the second. See [Authorization](docs/administer/authentication.md#authorization).
Because role mode is the default, the local-auth quickstart uses **both** files, each governing a different surface: the Platform API expands the file user's `roles` into the token it issues (and authorizes its own surface with that mapping), and the portal then expands that same token's roles claim on every request to authorize its REST surface — which is why the portal file aliases `ap_admin`/`ap_subscriber`. An external IDP in role mode drives the portal file directly with its own groups. So to change what a role may do, edit the file for the surface you mean — the portal file for the portal's REST permissions, the Platform API file for the Platform API's — and both if the change must hold across both components. See [Authorization](docs/administer/authentication.md#authorization).

The portal config (or `APIP_AP_AUTH_LOCAL_*` env vars) must point to the Platform API. `config.toml`'s own defaults assume Docker Compose, where `platform-api` is a resolvable hostname on the compose network — `npm run start:local` already overrides `platform_api_url` to `https://localhost:9243` (the sidecar's port published to the host) and `tls_skip_verify = true` (self-signed cert), so no manual edit is needed for that flow:

Expand Down
18 changes: 14 additions & 4 deletions portals/api-portal/configs/config-template.toml
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,22 @@ enabled_types = ["apis", "mcp-servers", "api-workflows"]
# =============================================================================
# DESIGN MODE CONFIGURATION
# =============================================================================
# Local, file-based preview mode — APIs/layouts load from disk instead of the
# database. For content authoring and theming workflows only; do not enable
# in a production environment. Disabled (commented) by default.
# Local, file-based preview mode — APIs, MCP servers, layouts, and applications
# render straight from the bundled sample files on disk (samples/,
# src/defaultContent/) instead of the database. The portal opens no database
# connection and never calls the Platform API in this mode, so it also waives the
# encryption_key/session_secret requirement (sessions are in-memory and an
# ephemeral secret is minted at startup). For content-authoring and theming
# previews only; never enable it in a production environment. OFF by default.
#
# To turn it on, copy the whole [api_portal.design_mode] block below into your
# active configs/config.toml (the same copy-it-from-the-template opt-in used for
# AI Workspace) and restart. The sample paths are relative to the app's working
# directory, where the bundled samples/ already live — leave them as-is unless
# you're pointing at your own sample set.

# [api_portal.design_mode]
# enabled = false
# enabled = true
# path_to_layout = "./src/defaultContent/"
# api_samples_path = "./samples/apis/"
# mcp_samples_path = "./samples/mcps/"
Expand Down
19 changes: 16 additions & 3 deletions portals/api-portal/distribution/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ docker compose --profile ai-workspace up -d

AI Workspace comes up at `https://localhost:9643`, backed by the same Platform API. Omitting `--profile ai-workspace` on a later `docker compose` command neither starts nor stops it — an already-running instance keeps running. To stop it explicitly, run `docker compose stop ai-workspace`, or `docker compose --profile ai-workspace down` to remove it.

## Design Mode (optional)

**Design mode** turns the API Portal into a file-based preview: it renders APIs, MCP servers, layouts, and applications straight from the bundled sample files instead of the database. With it on, the portal **opens no database connection and never calls the Platform API** — it's purely for previewing content and theming. Do **not** enable it in production.

Like AI Workspace, it's an opt-in you turn on by editing `configs/config.toml` — there's no separate config file or Compose profile:

1. **Copy the `[api_portal.design_mode]` block** from the "DESIGN MODE CONFIGURATION" section of the shipped `configs/config-template.toml` into `configs/config.toml` (keep `enabled = true`). The sample paths are already correct for the bundled samples — leave them as-is.
2. **Restart the API Portal:** `docker compose up -d` (or `docker compose restart api-portal`).
Comment thread
Piumal1999 marked this conversation as resolved.

The portal then serves from disk at `/views/default` (e.g. `http://localhost:9543/views/default`). Because design mode never touches the database, the accompanying Platform API and its database go unused while it's on — set `enabled` back to `false` and restart to return to the normal, database-backed portal.
Comment thread
Piumal1999 marked this conversation as resolved.

The sample content lives in **`resources/samples/`** (`apis/`, `mcps/`, `applications.yaml`, `subscription-plans.yaml`), which the API Portal container mounts at `/app/samples`. To preview **your own** APIs and MCP servers, add or edit files there and restart — no image rebuild needed.

## Configuration

All settings live in the single `configs/config.toml`. It carries two sections — `[api_portal.*]` and `[platform_api.*]` — and the **same file is mounted into both containers**; each service reads only its own section and ignores the other's. Edit it in place — no rebuild required, just restart the affected service.
Expand All @@ -126,10 +139,10 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co
| `[api_portal.auth.local].platform_api_url` | Address of the Platform API local-auth sidecar | `https://platform-api:9243` |
| `[api_portal.auth.local].public_key_path` | Path to the Platform API RS256 public key PEM used to verify login tokens | `/etc/api-portal/keys/jwt_public.pem` |
| `[api_portal.auth.authorization].enabled` | Enforce each REST operation's declared `dp:*` scopes. `false` lets any authenticated caller through — development only | `true` |
| `[api_portal.auth.authorization].mode` | `scope` reads the token's own scope claim; `role` expands its roles claim through the grant table instead (for an IDP that emits roles, not `dp:*` scopes) | `scope` |
| `[api_portal.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/api-portal/role-to-scope-mapping.yaml` — required in `role` mode; edit that file to change what a role grants | _(empty)_ |
| `[api_portal.auth.authorization].mode` | `role` (the default) expands the token's roles claim through the grant table; `scope` reads the token's own scope claim instead | `role` |
| `[api_portal.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/api-portal/role-to-scope-mapping.yaml` — used in `role` mode; edit that file to change what a role grants | `./resources/role-to-scope-mapping.yaml` |
| `[api_portal.auth.authorization].page_role_validation` | Gate portal pages on the caller's role tier (`portal_roles` below). Separate from `enabled`, which governs REST scopes | `false` |
| `[api_portal.auth.authorization.portal_roles]` | Which role name in the token's roles claim grants each page tier (`admin`, `super_admin`, `subscriber`) | `admin`, `superAdmin`, `Internal/subscriber` |
| `[api_portal.auth.authorization.portal_roles]` | Which role name in the token's roles claim grants each page tier (`admin`, `subscriber`) | `admin`, `Internal/subscriber` |
| `[api_portal.organization].handle` | The single organization this instance serves, bootstrapped on first start. Required — the portal refuses to start without it | `default` |
| `[api_portal.organization].display_name` | Display name applied when the organization is first seeded | `Default` |

Expand Down
8 changes: 7 additions & 1 deletion portals/api-portal/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ services:
networks:
- api-portal-network

devportal:
api-portal:
image: ghcr.io/wso2/api-platform/api-portal:1.0.0-SNAPSHOT
container_name: api-portal
restart: unless-stopped
Expand All @@ -62,6 +62,12 @@ services:
# editing this file on the host changes what a role grants after a restart, with
# no rebuild and no env-var indirection.
- ./resources/role-to-scope-mapping.yaml:/app/resources/role-to-scope-mapping.yaml:ro
# Design-mode sample content (APIs, MCP servers, applications, subscription
# plans), read from disk when [api_portal.design_mode] is enabled. Edit these
# files on the host and restart to preview your own content — no image rebuild
# (design mode isn't baked into the image). Unused when design mode is off. The
# standalone dist rewrites the host path to ./resources/samples (see Makefile).
- ./samples:/app/samples:ro
- ./resources/certificates:/etc/api-portal/tls:ro
- ./resources/keys/jwt_public.pem:/etc/api-portal/keys/jwt_public.pem:ro
- ./resources/keys/api-portal-encryption.key:/etc/api-portal/keys/encryption.key:ro
Expand Down
2 changes: 1 addition & 1 deletion portals/api-portal/docs/administer/asgardeo-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ For local development, the default `ASGARDEO_RESOURCE_IDENTIFIER=https://localho
Update `configs/config.toml`:

```toml
[idp]
[api_portal.auth.idp]
name = "Asgardeo"
issuer = "https://api.asgardeo.io/t/<your-tenant>/oauth2/token"
authorization_url = "https://api.asgardeo.io/t/<your-tenant>/oauth2/authorize"
Expand Down
2 changes: 1 addition & 1 deletion portals/api-portal/docs/administer/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ When multiple devportal organizations share one IDP, the portal enforces per-org
## Keycloak Example

```toml
[idp]
[api_portal.auth.idp]
name = "Keycloak"
issuer = "https://keycloak.example.com/realms/myrealm"
authorization_url = "https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth"
Expand Down
25 changes: 23 additions & 2 deletions portals/api-portal/src/config/configLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const toml = require('smol-toml');
const Handlebars = require('handlebars');
const { DEFAULTS } = require('./configDefaults');
Expand Down Expand Up @@ -365,8 +366,28 @@ function requireHexSecret(value, fieldName) {
}
}

requireHexSecret(config.security.encryptionKey, 'encryptionKey');
requireHexSecret(config.security.sessionSecret, 'sessionSecret');
// Design mode renders entirely from disk: no database, so there are no stored
// secrets to encrypt (encryptionKey stays unused — createCryptoUtil is lazy and
// only throws if an encrypt/decrypt actually runs, which the disabled
// webhook/subscription paths never do here), and sessions use an in-memory store
// (see sessionStoreConfig.js). The session cookie is still signed, so mint an
// ephemeral secret when none was supplied rather than forcing an operator to
// configure secret files a local preview never persists anything with. This is
// the one place the fail-closed secret requirement is waived, gated on the
// explicit, off-by-default design_mode.enabled flag.
if (config.designMode?.enabled) {
if (!config.security.sessionSecret) {
config.security.sessionSecret = crypto.randomBytes(32).toString('hex');
}
// Validate whatever we ended up with — the freshly minted value passes, and an
// operator-supplied one is held to the same format check as every other mode
// rather than silently bypassing it. encryptionKey stays unchecked: it is
// genuinely unused in design mode (no database, createCryptoUtil is lazy).
requireHexSecret(config.security.sessionSecret, 'sessionSecret');
} else {
requireHexSecret(config.security.encryptionKey, 'encryptionKey');
requireHexSecret(config.security.sessionSecret, 'sessionSecret');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Fail-closed startup check: database connection-pool settings must resolve to
Expand Down
23 changes: 23 additions & 0 deletions portals/api-portal/src/db/driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,30 @@ const dialect = config.database.driver;
*/
const txStorage = new AsyncLocalStorage();

// Design mode renders every page from disk (see app.js's designMode branch) and
// never issues a query, so it needs no database connection at all — not even the
// empty sqlite file the sqlite adapter would create on open, and not the native
// better-sqlite3 binding it would load. Return a stub whose query methods fail
// loudly: reaching one means a code path that should have been gated on
// design_mode.enabled slipped through, which we want surfaced, not silently run
// against a phantom connection.
function createDesignModeStub() {
const refuse = () => {
throw new Error('Database access is not available in design mode (design_mode.enabled = true).');
};
return {
query: refuse,
queryOne: refuse,
execute: refuse,
withTransaction: refuse,
close: () => {},
};
}

function loadAdapter() {
if (config.designMode?.enabled) {
return createDesignModeStub();
}
switch (dialect) {
case rebindHelpers.DIALECTS.SQLITE:
return require('./adapters/sqliteAdapter').createSqliteAdapter(config);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@
><span class="aov-plan-spinner"></span>Subscribe</button>
{{/if}}
{{else}}
<a class="dp-btn dp-btn--outline dp-btn--block" href="{{@root.baseUrl}}/login">Subscribe</a>
{{!-- Unauthenticated Subscribe links to login, which 404s in design mode — disable it there. --}}
<a class="dp-btn dp-btn--outline dp-btn--block{{#if (designMode)}} design-disabled{{/if}}"
{{#unless (designMode)}}href="{{@root.baseUrl}}/login"{{else}}aria-disabled="true" tabindex="-1" title="Not available in design mode"{{/unless}}>Subscribe</a>
{{/if}}
</div>
<div class="aov-plan-error">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@
><span class="aov-plan-spinner"></span>Subscribe</button>
{{/if}}
{{else}}
<a class="dp-btn dp-btn--outline dp-btn--block" href="{{@root.baseUrl}}/login">Subscribe</a>
{{!-- Unauthenticated Subscribe links to login, which 404s in design mode — disable it there. --}}
<a class="dp-btn dp-btn--outline dp-btn--block{{#if (designMode)}} design-disabled{{/if}}"
{{#unless (designMode)}}href="{{@root.baseUrl}}/login"{{else}}aria-disabled="true" tabindex="-1" title="Not available in design mode"{{/unless}}>Subscribe</a>
{{/if}}
</div>
<div class="aov-plan-error">
Expand Down
4 changes: 3 additions & 1 deletion portals/api-portal/src/defaultContent/partials/header.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
</ul>
{{else}}
<div id="auth-section">
<a class="login-btn" href="{{baseUrl}}/login">Log In</a>
{{!-- Login isn't served in design mode, so render it disabled (no href) rather than linking to a 404 --}}
<a class="login-btn{{#if (designMode)}} design-disabled{{/if}}"
{{#unless (designMode)}}href="{{baseUrl}}/login"{{else}}aria-disabled="true" tabindex="-1" title="Not available in design mode"{{/unless}}>Log In</a>
</div>
{{/if}}
</div>
Expand Down
Loading
Loading