From eee2a1ff8367c73a51657780e2b0b42ce03ecd5d Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 14:02:53 +0530 Subject: [PATCH 1/7] Enhance authentication and authorization configuration --- distribution/all-in-one/docker-compose.yaml | 1 + .../templates/configmap.yaml | 11 +- .../helm/platform-api-helm-chart/values.yaml | 30 +- platform-api/README.md | 104 +++++-- platform-api/config/config-template.toml | 65 +++-- platform-api/config/config.go | 168 ++++++++--- platform-api/config/config.toml | 19 +- platform-api/config/config_multifile_test.go | 4 +- platform-api/config/config_test.go | 155 +++++++++- platform-api/config/default_config.go | 19 +- platform-api/internal/handler/auth_login.go | 53 +++- .../internal/handler/auth_login_test.go | 78 ++++++ platform-api/internal/middleware/auth.go | 2 +- .../middleware/auth_role_extraction_test.go | 8 +- .../internal/middleware/role_scope_map.go | 34 ++- .../middleware/role_scope_map_test.go | 87 ++++++ .../internal/server/role_scope_map_test.go | 115 +++++++- .../server/scope_route_coverage_test.go | 14 +- platform-api/internal/server/server.go | 75 +++-- platform-api/plugins/eventgateway/plugin.go | 4 +- platform-api/resources/roles.yaml | 264 ++++++++++++++---- portals/ai-workspace/Makefile | 3 +- portals/ai-workspace/README.md | 2 +- portals/ai-workspace/distribution/README.md | 4 +- portals/ai-workspace/docker-compose.yaml | 3 + portals/ai-workspace/production/README.md | 5 +- portals/developer-portal/Makefile | 6 +- .../developer-portal/distribution/README.md | 1 + .../docker-compose.platform-api.yaml | 3 + portals/developer-portal/docker-compose.yaml | 1 + 30 files changed, 1110 insertions(+), 228 deletions(-) create mode 100644 platform-api/internal/handler/auth_login_test.go create mode 100644 platform-api/internal/middleware/role_scope_map_test.go diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index 30f2d52678..a29d4eb070 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -137,6 +137,7 @@ services: - "9243:9243" volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-data:/api-platform/data - platform-api-certs:/app/data/certs # RS256 JWT signing/verification keys — on the Platform API's {{ file }} diff --git a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml index d11f076511..af8ad08f47 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml @@ -65,7 +65,11 @@ data: [platform_api.auth] mode = {{ $auth.mode | quote }} - scope_validation = {{ $auth.scopeValidation }} + + [platform_api.auth.authorization] + enabled = {{ $auth.authorization.enabled }} + mode = {{ $auth.authorization.mode | quote }} + role_mappings = {{ $auth.authorization.roleMappings | quote }} [platform_api.auth.claim_mappings] organization = {{ $auth.claimMappings.organization | quote }} @@ -90,8 +94,6 @@ data: jwks_url = {{ required "config.auth.idp.jwksUrl is required when auth.mode is \"idp\"" $auth.idp.jwksUrl | quote }} issuer = {{ toJson $auth.idp.issuer }} audience = {{ toJson $auth.idp.audience }} - validation_mode = {{ $auth.idp.validationMode | quote }} - role_mappings = {{ $auth.idp.roleMappings | quote }} {{- end }} {{- if eq $auth.mode "file" }} @@ -109,6 +111,9 @@ data: # provisions a generated username and a bcrypt password hash. username = {{ `'{{ env "APIP_CP_ADMIN_USERNAME" }}'` }} password_hash = {{ `'{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}'` }} + {{- with $auth.file.admin.role }} + role = {{ . | quote }} + {{- end }} scopes = {{ $auth.file.admin.scopes | quote }} {{- end }} diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index f2baa66411..6303f95c11 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -106,13 +106,24 @@ config: # --- Authentication --- auth: # Exactly one mode: - # external_token — verify externally-minted RS256 JWTs with the public key. - # file — external_token + local username/password login (issues + # internal_token — verify RS256 JWTs minted by another trusted platform + # component, using the public key. + # file — internal_token + local username/password login (issues # RS256 tokens signed with the private key). # idp — validate tokens against an external IDP's JWKS. mode: file - # Enforce per-endpoint OAuth2 scopes on validated tokens. - scopeValidation: true + # --- Authorization --- applies in every auth mode above: an enterprise-IDP + # token carries the same roles claim whether it is verified against a JWKS + # endpoint or with a local public key. + authorization: + # Enforce per-endpoint OAuth2 scopes on validated tokens. + enabled: true + mode: scope # scope | role + # Path to a role→scope mapping YAML. Required when mode=role, and also when + # auth.file.admin.role below is set. The chart mounts no such file by default — + # supply one (ConfigMap volume via extraVolumes/extraVolumeMounts) and point + # this at it; platform-api/resources/roles.yaml is the shipped sample. + roleMappings: "" # Claim-name mappings shared by all modes. claimMappings: organization: organization @@ -122,7 +133,9 @@ config: username: username email: email scope: scope - roles: "" # e.g. "realm_access.roles" (Keycloak) + # Claim carrying the user's roles — read in role authorization mode, and the + # claim the file-mode login endpoint signs auth.file.admin.role into. + roles: roles # Keycloak nests it: "realm_access.roles" # Local RS256 JWT keys. public_key_file verifies tokens (every mode); # private_key_file signs login tokens (file mode only). Both are mounted as # PEM files from the Secret (secrets.keys.jwtPublicKey / jwtPrivateKey). @@ -147,6 +160,11 @@ config: # hash. There is no admin/admin default: startup fails closed if unset. Only # the granted scopes are configured here (add more users via configToml). admin: + # Optional: a role from the roleMappings file, expanded into the token's + # scopes at login (e.g. "ap_admin"). Requires that file to be mounted and + # roleMappings to point at it; leave empty to grant scopes only. A role + # named here but absent from the file fails startup. + role: "" scopes: "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:api_key:all:manage ap:secret:manage" # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl is # required in that mode. @@ -155,8 +173,6 @@ config: jwksUrl: "" issuer: [] # accepted token issuers audience: [] # accepted audiences; empty = don't check - validationMode: scope # scope | role - roleMappings: "" # path to a role→scope mapping YAML # --- Server listeners --- server: diff --git a/platform-api/README.md b/platform-api/README.md index b9ebf446ca..563cd3af0c 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -6,7 +6,7 @@ Backend service that powers the API Platform portals, gateways, and automation f ### Prerequisites -Before using the Platform API, obtain a bearer token for authentication. In `file` or `external_token` auth mode you can generate a token using the HMAC key configured at `platform_api.auth.jwt.secret_key`. In `idp` mode, obtain a token from your identity provider. See [Configuration](#configuration) below. +Before using the Platform API, obtain a bearer token for authentication. In `file` auth mode you can obtain a token from the login endpoint. In `internal_token` mode the token is minted by another trusted platform component, signed with the RSA private key matching `platform_api.auth.jwt.public_key_file`. In `idp` mode, obtain a token from your identity provider. See [Configuration](#configuration) below. ### Build and Run @@ -23,8 +23,11 @@ go run ./cmd/main.go `config/config.toml` is the local-development config, used with `platform_api.auth.mode = "file"` (username/password login backed by the organization/user block in that file) — the same mode the AI Workspace and Developer Portal quickstarts use. It's the one Platform API config shared by every -quickstart (both docker-compose setups mount it directly), so its admin user's scopes cover both the -`ap:*` (AI Workspace / platform-admin) and `dp:*` (Developer Portal) namespaces. +quickstart (both docker-compose setups mount it directly), so its admin user is granted the +`ap_admin` role from the mounted [`resources/roles.yaml`](resources/roles.yaml), which covers both +the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces. Override the role with +`APIP_CP_ADMIN_ROLE`; the small `scopes` list alongside it carries the few scopes `roles.yaml` +cannot name (see the comment there). There is no default admin credential: `APIP_CP_ADMIN_USERNAME` and `APIP_CP_ADMIN_PASSWORD_HASH` are **required** in this mode, and startup fails closed if either is unset or empty. `portals/scripts/setup.sh` @@ -32,7 +35,7 @@ provisions both for the quickstarts, printing the generated password once. To se generate a hash with `htpasswd -nBC 12 "" | tr -d ':\n'`, which prompts for the password instead of taking it as an argument — a password on the command line lands in shell history, `ps` output, and CI logs. Alternatively set -`platform_api.auth.mode = "external_token"` +`platform_api.auth.mode = "internal_token"` for locally-signed HMAC tokens with no local users — see [`config/config-template.toml`](config/config-template.toml) for the full reference. @@ -279,9 +282,10 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | `[platform_api.security]` | `encryption_key` (**required** — at-rest AES-256 key, 32 bytes as hex or base64, never auto-generated) | | `[platform_api.security.api_key]` | `hashing_algorithms` accepted for API key verification | | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | -| `[platform_api.auth]` | `mode` — one of `external_token`, `file`, or `idp`; `scope_validation` | +| `[platform_api.auth]` | `mode` — one of `internal_token`, `file`, or `idp` | +| `[platform_api.auth.authorization]` | `enabled`, `mode` (`scope` / `role`), `role_mappings` — applies in every auth mode | | `[platform_api.auth.jwt]` | Asymmetric (RS256) token settings: `issuer`, `public_key` (**required** — PEM RSA public key, verifies tokens), `private_key` (**required in `file` mode** — PEM RSA private key, signs login tokens), `token_ttl` | -| `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint, issuer/audience, validation mode, and JWT claim-name mappings for `idp` mode | +| `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint and issuer/audience for `idp` mode; JWT claim-name mappings (all modes) | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | | `[platform_api.server.http]` / `[platform_api.server.https]` | Listener enablement, ports, and (HTTPS) `cert_file` / `key_file` paths (certificates are always required for HTTPS — no self-signed fallback) | | `[platform_api.server.timeouts]` | Read/write/idle timeouts | @@ -296,42 +300,102 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections `platform_api.auth.mode` selects exactly one mode; only that mode's section is read: -- **`external_token`** — verify locally-issued, asymmetrically-signed (RS256) JWTs (`[platform_api.auth.jwt]`); tokens are minted externally (e.g. by the Developer Portal) and signed with the matching RSA private key, verified here against `public_key`. Symmetric (HMAC) and unsigned (`none`) tokens are rejected. -- **`file`** — `external_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues RS256 JWTs signed with `[platform_api.auth.jwt].private_key`, verified with the matching `public_key`. Used by the AI Workspace and Developer Portal quickstarts. +- **`internal_token`** — verify asymmetrically-signed (RS256) JWTs (`[platform_api.auth.jwt]`); tokens are minted by another trusted platform component and signed with the matching RSA private key, verified here against `public_key`. Symmetric (HMAC) and unsigned (`none`) tokens are rejected. +- **`file`** — `internal_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues RS256 JWTs signed with `[platform_api.auth.jwt].private_key`, verified with the matching `public_key`. Used by the AI Workspace and Developer Portal quickstarts. - **`idp`** — validate tokens against an external IDP's JWKS endpoint (Thunder, Asgardeo, Keycloak, Azure AD, Okta, etc.) via `[platform_api.auth.idp]`; `jwks_url` and `issuer` are required. The paths that bypass authentication and scope enforcement — health/metrics probes, the login endpoint, and the internal routes authenticated by a gateway token instead of a user JWT — are not configurable: the list is a property of the product's own routing, and a wrong entry in it is an auth bypass. Plugins declare their own public prefixes through `AuthSkipPaths()`, which are -validated at startup. Use `scope_validation` to control authorization enforcement. +validated at startup. Use `auth.authorization.enabled` to control authorization enforcement. A config file still carrying `platform_api.auth.skip_paths` fails startup rather than having the key silently ignored. #### Role-Based Access Control (RBAC) -Per-route scope checks are enforced when `platform_api.auth.scope_validation = true`. Five built-in platform roles exist: +Per-route scope checks are enforced when `platform_api.auth.authorization.enabled = true`. The +shipped [`resources/roles.yaml`](resources/roles.yaml) defines five roles, each granting scopes in +both the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces — one role covers a persona +across both components: | Role | Persona | Access level | |---|---|---| -| `admin` | Platform administrator | Full access to all resources and operations | -| `developer` | API designer | Full API lifecycle; cannot manage gateways or subscription plans | -| `publisher` | DevPortal manager | Read APIs and publish/unpublish to DevPortals; cannot create or deploy | -| `operator` | CI/CD service account | Deploy and undeploy operations only; cannot create resources or manage credentials | -| `viewer` | Auditor | Read-only access to all resources | +| `ap_admin` | Platform administrator | Every resource and operation, both components | +| `ap_operator` | Platform operator / CI-CD service account | Gateways, deployments, subscription plans, key managers, webhooks; reads everything else | +| `ap_publisher` | API publisher | Full API/MCP/LLM lifecycle and its Developer Portal content; reads applications, subscriptions, plans | +| `ap_subscriber` | API consumer | Own applications, subscriptions and keys; reads the API/MCP catalog and plans | +| `ap_viewer` | Auditor | Read-only across both components | + +The roles are named after the platform rather than after any one IDP's convention, since the same +file serves every auth mode — map your IDP's groups onto these names via `claim_mappings.roles`. +Edit the file to change what a role grants; it needs a server restart to take effect. All three modes read identity fields — including scope — through the same `[platform_api.auth.claim_mappings]` table (`scope` defaults to the `scope` claim); `file` mode's login endpoint also signs the tokens it issues using these same claim names, so issuance and -validation never drift apart. In **`idp` mode**, `validation_mode` additionally controls whether -authorization uses the scope claim directly or expands IDP roles from `claim_mappings.roles` via -`role_mappings`. +validation never drift apart. + +Authorization is configured separately from authentication, in +`[platform_api.auth.authorization]`, and applies in **every** auth mode — an enterprise IDP's token +carries the same roles claim whether the platform verifies it against a JWKS endpoint or with a +local public key: + +```toml +[platform_api.auth.authorization] +enabled = true +mode = "role" # "scope" (default) or "role" +role_mappings = "/etc/platform-api/roles.yaml" # required when mode = "role" +``` + +`mode = "scope"` authorizes from the scope claim directly. `mode = "role"` expands the roles claim +named by `claim_mappings.roles` into platform scopes via the `role_mappings` YAML file; both that +claim mapping and the file path are required in role mode, so startup fails rather than falling +back to using role names verbatim as scopes. + +The mapping file is operator-owned config, not part of the image: the packs mount their editable +sample (`resources/roles.yaml`) at `/etc/platform-api/roles.yaml`. + +Validation of that file is namespace-scoped. An `ap:` scope must be declared in this server's OpenAPI +spec (plus any its compiled-in plugins declare) — an unknown one fails startup rather than silently +denying requests later. A scope in another component's namespace (`dp:*`) is checked only for shape: +this server mints it into the token but never enforces it, so it can neither confirm nor deny that it +exists. That is what lets one role describe a persona across the whole platform. + +##### Granting a file-mode user a role + +A `file`-mode user is granted a role rather than a hand-maintained scope list. The login endpoint +expands that role through the same `role_mappings` file when it mints the token: + +```toml +[[platform_api.auth.file.users]] +username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' +password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' +role = "ap_admin" # expanded via auth.authorization.role_mappings +# scopes = "" # optional: unioned with the role's scopes +``` + +The issued token carries **both**: the expanded scopes as the `scope` claim, and the role name as the +`roles` claim. So the same login works under either authorization mode — `scope` (the default) checks +the expanded claim, and flipping `auth.authorization.mode = "role"` re-expands the role from the same +`roles.yaml` on every request instead. `claim_mappings.roles` defaults to the flat `roles` claim the +login endpoint signs, so that switch needs no extra claim wiring. + +A role is normally the whole grant — the mapping file may name scopes in any namespace, so there is +usually nothing left to add. `scopes` remains available for anything the file cannot name (an `ap:` +scope this server's spec doesn't declare, which `roles.yaml` would reject) or on its own instead of a +role; the two are unioned, and one of them is required — a user granted neither would authenticate +and then be denied every route. A role absent from the mapping file fails startup. + +This is how the shipped `config/config.toml` grants its admin user: `role = "ap_admin"` and nothing +else. Changing what that user can do means editing the mounted `roles.yaml`, which makes that file the +security-relevant one to review in a pack. ### Providing secrets via the config file Never write raw secret values into the config file, and never hardcode them as literals in a -compose file. Reference each secret (`security.encryption_key`, `auth.jwt.secret_key`, -`database.password`, `webhook.secret`, …) with an interpolation token, preferring a mounted file +compose file. Reference each secret (`security.encryption_key`, `database.password`, +`webhook.secret`, …) with an interpolation token, preferring a mounted file over an env var: ```toml diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 78848adef1..6ada12f344 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -130,11 +130,11 @@ conn_max_lifetime = 300 # seconds before a connection is recycled # Authentication # # auth.mode selects exactly one mode: -# "external_token" — verify locally-issued, asymmetrically-signed (RS256) JWTs, -# minted externally (e.g. by the Developer Portal) and -# signed with the matching RSA private key; verified here -# against auth.jwt.public_key_file. -# "file" — external_token + local username/password login: the +# "internal_token" — verify asymmetrically-signed (RS256) JWTs minted by +# another trusted platform component, signed with the +# matching RSA private key; verified here against +# auth.jwt.public_key_file. +# "file" — internal_token + local username/password login: the # login endpoint authenticates from # [platform_api.auth.file] and issues RS256 JWTs signed # with auth.jwt.private_key_file. @@ -145,13 +145,28 @@ conn_max_lifetime = 300 # seconds before a connection is recycled [platform_api.auth] mode = "file" +# Authorization — how a verified token's privileges are checked. Independent of +# the authentication mode above: these settings apply whether the token was +# verified against an IDP's JWKS or with a local public key, because an +# enterprise-IDP-minted token carries the same roles claim either way. +[platform_api.auth.authorization] # Enforce per-endpoint OAuth2 scopes on validated tokens. Set false only to # temporarily bypass authorization during development. -scope_validation = true +enabled = true + +# "scope" (default) checks the scope claim; "role" checks the roles claim +# configured below at claim_mappings.roles, expanding each role via role_mappings. +mode = "scope" + +# Path to a YAML file mapping role names to platform scopes. Required when +# mode = "role" (startup fails if unset), and also when any file-mode user below +# names a role — the login endpoint expands that role through this same file. +# The packs mount their editable sample at /etc/platform-api/roles.yaml. +role_mappings = "" # JWT claim name mappings — shared by all three auth modes ("idp" reads # incoming claims by these names; "file" mode's login endpoint signs tokens -# using these names; "external_token" mode reads externally-minted tokens by +# using these names; "internal_token" mode reads tokens minted elsewhere by # these names too), so issuance and validation never drift apart. Each value # is either a flat top-level claim name ("org_id") or a dot-separated path # into a nested claim ("realm_access.org_id") — useful for IDPs like @@ -164,7 +179,10 @@ user_id = "sub" # claim used as the user's unique ID username = "username" email = "email" scope = "scope" # space-separated scope string -roles = "" # e.g. "realm_access.roles" (Keycloak) or "roles" (Asgardeo) +# Claim carrying the user's roles. Read in role authorization mode, and it is the +# claim the file-mode login endpoint signs the user's role into. Default suits +# Asgardeo/Entra ID; Keycloak nests it: "realm_access.roles". +roles = "roles" # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). # jwks_url and issuer are required in that mode. @@ -174,11 +192,6 @@ jwks_url = "https://accounts.example.com/oauth2/jwks" issuer = ["https://accounts.example.com"] # list of accepted issuers audience = [] # accepted "aud" values; empty = skip audience check -# Authorization mode: "scope" (default) checks the scope claim; -# "role" checks the roles claim configured below at claim_mappings.roles. -validation_mode = "scope" -role_mappings = "" # path to a YAML file mapping IDP roles to platform scopes - # File auth — local username/password login, used when mode = "file". Ideal # for initial / air-gapped setup; not recommended for production — prefer # an IDP. @@ -197,8 +210,24 @@ uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # starting with a blank or guessable credential. username = "" password_hash = "" -# Full scope set — trim to restrict permissions for this user. -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:api_key:all:manage ap:secret:manage" +# One of role / scopes is REQUIRED — a user with neither authenticates and is then +# authorized for nothing. +# +# A role from auth.authorization.role_mappings is the normal way to grant a +# file-mode user. The login endpoint expands it into the token's scope claim and +# also emits the role itself as the roles claim, so the same token works whether +# auth.authorization.mode is "scope" (default) or "role". It is commented out here +# because role_mappings above is empty in this template: naming a role without a +# mapping file fails startup. Set role_mappings first, then uncomment. +# role = "ap_admin" # see resources/roles.yaml for the shipped roles +# +# Space-separated scope list, unioned with whatever the role grants. Use it +# alongside a role to grant something the mapping file cannot name — an ap: scope +# this server's OpenAPI spec doesn't declare is rejected there, which is why the +# shipped config.toml lists the AI Workspace (ap:devportal:*, ap:git:read) and +# event-gateway plugin scopes here rather than in roles.yaml — or on its own +# instead of a role, as below. +scopes = "ap:organization:manage ap:project:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:llm_template:manage ap:mcp_proxy:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:secret:manage ap:api_key:read ap:api_key:all:manage ap:devportal:manage ap:git:read" # Additional users — uncomment the WHOLE block (including the [[...]] header) # and replace the placeholder hash with a real bcrypt hash before use. @@ -207,8 +236,8 @@ scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:mana # password_hash = "$2a$12$" # scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" -# JWT (local RS256) — used by "external_token" and "file" modes. Tokens are -# signed asymmetrically: "external_token" only verifies externally-minted tokens +# JWT (local RS256) — used by "internal_token" and "file" modes. Tokens are +# signed asymmetrically: "internal_token" only verifies tokens minted elsewhere # with the public key; "file" also signs the tokens its login endpoint issues # with the private key. Symmetric (HMAC) and unsigned ("none") tokens are # rejected. Keys are mounted PEM files, referenced here by path only — the @@ -225,7 +254,7 @@ issuer = "platform-api" public_key_file = "/etc/platform-api/keys/jwt_public.pem" private_key_file = "/etc/platform-api/keys/jwt_private.pem" # Lifetime of tokens issued by the file-mode login endpoint (Go duration -# syntax). Not used for "external_token" tokens — their expiry is whatever +# syntax). Not used for "internal_token" tokens — their expiry is whatever # "exp" claim the issuer set. token_ttl = "1h" diff --git a/platform-api/config/config.go b/platform-api/config/config.go index c07f1762a6..2590317b0e 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -45,7 +45,17 @@ import ( type FileBasedUser struct { Username string `json:"username" koanf:"username"` PasswordHash string `json:"password_hash" koanf:"password_hash"` - Scopes string `json:"scopes" koanf:"scopes"` + // Role names one of the roles in the auth.authorization.role_mappings file. + // The login endpoint expands it into that role's scopes, so a user's grants + // can be expressed the same way an IDP expresses them — as a role — instead + // of a hand-maintained scope string. + Role string `json:"role" koanf:"role"` + // Scopes is a space-separated scope list granted directly, unioned with + // whatever Role expands to. It is still needed for scopes this server does + // not itself declare (Developer Portal "dp:*" scopes, for example): the role + // mapping is validated against this server's OpenAPI spec, so it can only + // name scopes this server knows. + Scopes string `json:"scopes" koanf:"scopes"` } // FileBasedUsers is a slice of FileBasedUser that can be decoded from a JSON string (env var) @@ -105,12 +115,12 @@ type Server struct { // modeling the choice as a single discriminator (rather than per-mode enabled // flags) makes conflicting configurations inexpressible. const ( - // AuthModeExternalToken verifies locally-issued, asymmetrically-signed JWTs - // (RS256) minted externally, e.g. by the Developer Portal. Verification uses - // the RSA public key in auth.jwt.public_key_file; symmetric (HMAC) and unsigned - // ("none") tokens are rejected. - AuthModeExternalToken = "external_token" - // AuthModeFile is AuthModeExternalToken plus local username/password login: the + // AuthModeInternalToken verifies asymmetrically-signed JWTs (RS256) minted by + // another trusted platform component holding the matching RSA private key. + // Verification uses the RSA public key in auth.jwt.public_key_file; symmetric + // (HMAC) and unsigned ("none") tokens are rejected. + AuthModeInternalToken = "internal_token" + // AuthModeFile is AuthModeInternalToken plus local username/password login: the // login endpoint authenticates users from auth.file and issues RS256 JWTs signed // with the RSA private key in auth.jwt.private_key_file, verified with the matching // auth.jwt.public_key_file. @@ -121,29 +131,33 @@ const ( // Auth groups all authentication-related configuration. type Auth struct { - // Mode selects the active authentication mode: "external_token", "file", or "idp". + // Mode selects the active authentication mode: "internal_token", "file", or "idp". Mode string `koanf:"mode"` - // ScopeValidation enforces per-endpoint OAuth2 scopes on validated tokens. - // Disable only to temporarily bypass authorization during development. - ScopeValidation bool `koanf:"scope_validation"` + // Authorization holds every authorization setting — whether it is enforced, + // how (scope or role), and the role-to-scope mapping file. It is deliberately + // its own section rather than living under [auth.idp]: authorization applies + // in every auth mode, because a token minted by an enterprise IDP carries the + // same roles claim whether the platform verifies it via JWKS or with a local + // public key. + Authorization Authorization `koanf:"authorization"` // SkipPaths are the path prefixes that bypass authentication and scope // enforcement — health/metrics probes, the login endpoint, and the internal // routes authenticated by a gateway token instead of a user JWT. It is not // operator-configurable (koanf:"-"): the list is a property of the product's // own routing, and a wrong entry here is an auth bypass, so it comes from // DefaultConfig plus the prefixes plugins declare. Operators turn - // authorization on and off with scope_validation instead. + // authorization on and off with auth.authorization.enabled instead. SkipPaths []string `koanf:"-"` IDP IDP `koanf:"idp"` - // JWT is shared by two modes — "external_token" mode only verifies - // externally-minted tokens with the public key, "file" mode both signs (with + // JWT is shared by two modes — "internal_token" mode only verifies + // tokens minted elsewhere with the public key, "file" mode both signs (with // the private key) and verifies (with the public key) using the RSA key pair. JWT JWT `koanf:"jwt"` File FileBased `koanf:"file"` // ClaimMappings names the JWT claims that carry each identity field. It is // shared by all three auth modes: "idp" reads incoming claims by these // names, "file" mode's login endpoint signs tokens using these names, and - // "external_token" mode reads externally-minted tokens by these names too + // "internal_token" mode reads tokens minted elsewhere by these names too // — one mapping, so issuance and validation can never drift apart. Every // field accepts either a flat top-level claim name ("org_id") or a // dot-separated path into a nested claim ("realm_access.org_id") — see @@ -151,6 +165,32 @@ type Auth struct { ClaimMappings ClaimMappings `koanf:"claim_mappings"` } +// Authorization modes selectable via auth.authorization.mode. +const ( + // AuthzModeScope authorizes using the JWT scope claim directly. + AuthzModeScope = "scope" + // AuthzModeRole authorizes by expanding the token's roles claim into + // platform scopes via the auth.authorization.role_mappings file. + AuthzModeRole = "role" +) + +// Authorization groups all authorization configuration. It applies in every +// auth mode — authentication (how a token is verified) and authorization (what +// a verified token may do) are configured independently, mirroring the +// separation Kubernetes draws between its authentication and authorization +// configs and Envoy draws between JWT providers and rules. +type Authorization struct { + // Enabled enforces per-endpoint OAuth2 scopes on validated tokens. + // Disable only to temporarily bypass authorization during development. + Enabled bool `koanf:"enabled"` + // Mode selects how authorization is enforced: "scope" (default) or "role". + Mode string `koanf:"mode"` + // RoleMappings is the path to a YAML file mapping IDP roles to platform + // scopes. Required in "role" mode (validateAuthorizationConfig rejects an + // empty path there); unused in "scope" mode. + RoleMappings string `koanf:"role_mappings"` +} + // ClaimMappings holds JWT claim name mappings, shared across all auth modes. type ClaimMappings struct { Organization string `koanf:"organization"` @@ -166,12 +206,10 @@ type ClaimMappings struct { // IDP holds configuration for JWKS-based identity providers. Active when // Auth.Mode is AuthModeIDP. type IDP struct { - Name string `koanf:"name"` - JWKSUrl string `koanf:"jwks_url"` - Issuer []string `koanf:"issuer"` - Audience []string `koanf:"audience"` - ValidationMode string `koanf:"validation_mode"` - RoleMappings string `koanf:"role_mappings"` + Name string `koanf:"name"` + JWKSUrl string `koanf:"jwks_url"` + Issuer []string `koanf:"issuer"` + Audience []string `koanf:"audience"` } // EventHub holds EventHub-specific configuration for multi-replica HA event delivery. @@ -269,8 +307,9 @@ type CORS struct { } // JWT holds configuration for local asymmetric (RS256) JWT authentication. -// Active when Auth.Mode is AuthModeExternalToken (verify-only, externally-minted -// tokens) or AuthModeFile (file mode also issues these tokens). Signature +// Active when Auth.Mode is AuthModeInternalToken (verify-only; tokens minted by +// another platform component) or AuthModeFile (file mode also issues these +// tokens). Signature // validation is always on and strictly asymmetric — symmetric (HMAC) and // unsigned ("none") algorithms are rejected. // @@ -278,14 +317,14 @@ type CORS struct { // signature once a Go JWT library exposes it. See post-quantum-cryptography.md. type JWT struct { // PublicKeyFile is the path to a mounted PEM-encoded RSA public key file, - // used to verify token signatures. Required in both "external_token" and + // used to verify token signatures. Required in both "internal_token" and // "file" modes. The key is read from disk at the point of use rather than // being interpolated into config at load time, so the PEM content is never // held in the config struct. PublicKeyFile string `koanf:"public_key_file"` // PrivateKeyFile is the path to a mounted PEM-encoded RSA private key file, // used to sign tokens. Required only in "file" mode, whose login endpoint - // mints tokens; unused (and not required) in verify-only "external_token" + // mints tokens; unused (and not required) in verify-only "internal_token" // mode. Read from disk at the point of use, never cached as content. PrivateKeyFile string `koanf:"private_key_file"` Issuer string `koanf:"issuer"` @@ -496,7 +535,7 @@ func LoadConfig(configPaths ...string) (*Server, error) { if k.Exists(removedAuthSkipPathsKey) { return nil, fmt.Errorf("config key %q.%s is no longer supported: the auth skip-path list is "+ "built in and, for plugins, declared by the plugin — remove the key "+ - "(use auth.scope_validation to control authorization enforcement)", + "(use auth.authorization.enabled to control authorization enforcement)", platformAPIConfigKey, removedAuthSkipPathsKey) } @@ -653,9 +692,10 @@ func validateTimeoutsConfig(cfg *Timeouts) error { } // validateAuthConfig validates the selected auth mode and the section that mode -// activates. Modes are mutually exclusive by construction: auth.mode is a single -// discriminator, so conflicting-mode configurations are inexpressible and only -// the active mode's section is validated. +// activates, plus the authorization section that applies in every mode. Modes +// are mutually exclusive by construction: auth.mode is a single discriminator, +// so conflicting-mode configurations are inexpressible and only the active +// mode's section is validated. func validateAuthConfig(auth *Auth) error { for _, p := range auth.SkipPaths { if err := ValidateAuthSkipPath(p); err != nil { @@ -663,8 +703,18 @@ func validateAuthConfig(auth *Auth) error { } } + if err := validateAuthModeConfig(auth); err != nil { + return err + } + + // Authorization is validated outside the mode switch, not inside any one + // mode's branch: it applies in every authentication mode. + return validateAuthorizationConfig(&auth.Authorization, &auth.ClaimMappings) +} + +func validateAuthModeConfig(auth *Auth) error { switch auth.Mode { - case AuthModeExternalToken: + case AuthModeInternalToken: // Verify-only: a public key is sufficient (tokens are minted elsewhere). return validateJWTConfig(&auth.JWT, false) case AuthModeFile: @@ -673,17 +723,17 @@ func validateAuthConfig(auth *Auth) error { return err } // TokenTTL only matters in file mode: the login endpoint mints tokens - // itself here, whereas in plain "external_token" mode tokens are minted - // externally and their expiry is whatever "exp" claim the issuer set. + // itself here, whereas in plain "internal_token" mode tokens are minted + // elsewhere and their expiry is whatever "exp" claim the issuer set. if auth.JWT.TokenTTL <= 0 { return fmt.Errorf("Auth.JWT.TokenTTL must be a positive duration when auth.mode is %q "+ "(set auth.jwt.token_ttl, e.g. \"8h\")", AuthModeFile) } - return validateFileBasedConfig(&auth.File) + return validateFileBasedConfig(&auth.File, &auth.Authorization) case AuthModeIDP: - return validateIDPConfig(&auth.IDP, &auth.ClaimMappings) + return validateIDPConfig(&auth.IDP) default: - return fmt.Errorf("auth.mode must be %q, %q, or %q (got %q)", AuthModeExternalToken, AuthModeFile, AuthModeIDP, auth.Mode) + return fmt.Errorf("auth.mode must be %q, %q, or %q (got %q)", AuthModeInternalToken, AuthModeFile, AuthModeIDP, auth.Mode) } } @@ -710,7 +760,7 @@ func ValidateAuthSkipPath(path string) error { // validateJWTConfig verifies the local asymmetric JWT key material is present // and readable. The RSA public key verifies token signatures and is required -// in both the "external_token" and "file" auth modes. When requireSigningKey +// in both the "internal_token" and "file" auth modes. When requireSigningKey // is true (file mode, which mints tokens at its login endpoint) the RSA // private key is also required and must form a matching pair with the public // key. Keys are mounted files, read fresh here rather than cached: a missing @@ -721,7 +771,7 @@ func validateJWTConfig(jwtCfg *JWT, requireSigningKey bool) error { if jwtCfg.PublicKeyFile == "" { return fmt.Errorf("Auth.JWT.PublicKeyFile is required when auth.mode is %q or %q "+ "(set auth.jwt.public_key_file to the path of a mounted PEM-encoded RSA public key)", - AuthModeExternalToken, AuthModeFile) + AuthModeInternalToken, AuthModeFile) } pub, err := jwtCfg.LoadPublicKey() if err != nil { @@ -847,25 +897,43 @@ func validateCORSConfig(c *CORS) error { return nil } -func validateIDPConfig(idp *IDP, claimMappings *ClaimMappings) error { +func validateIDPConfig(idp *IDP) error { if idp.JWKSUrl == "" { return fmt.Errorf("auth.mode=%q requires auth.idp.jwks_url to be configured", AuthModeIDP) } if len(idp.Issuer) == 0 { return fmt.Errorf("auth.mode=%q requires auth.idp.issuer to be configured", AuthModeIDP) } - switch idp.ValidationMode { - case "scope", "role": + return nil +} + +// validateAuthorizationConfig validates the [auth.authorization] section. It is +// checked in every auth mode: role-based authorization is equally meaningful +// against a locally-verified token as against a JWKS-verified one, so its +// validity must not depend on which authentication mode is active. +func validateAuthorizationConfig(authz *Authorization, claimMappings *ClaimMappings) error { + switch authz.Mode { + case AuthzModeScope, AuthzModeRole: default: - return fmt.Errorf("auth.idp.validation_mode must be \"scope\" or \"role\" (got %q)", idp.ValidationMode) + return fmt.Errorf("auth.authorization.mode must be %q or %q (got %q)", AuthzModeScope, AuthzModeRole, authz.Mode) } - if idp.ValidationMode == "role" && claimMappings.Roles == "" { - return fmt.Errorf("auth.idp.validation_mode=role requires auth.claim_mappings.roles to be configured") + if authz.Mode == AuthzModeRole { + if claimMappings.Roles == "" { + return fmt.Errorf("auth.authorization.mode=%s requires auth.claim_mappings.roles to be configured", AuthzModeRole) + } + // Without a mapping file, role names would be used verbatim as scope + // values — an operator's IDP role would have to happen to be spelled + // exactly like a platform scope, so silently accepting an empty path + // means authorization that denies everything (or, for a role named after + // a scope, grants unintentionally). Require the mapping explicitly. + if authz.RoleMappings == "" { + return fmt.Errorf("auth.authorization.mode=%s requires auth.authorization.role_mappings to be configured", AuthzModeRole) + } } return nil } -func validateFileBasedConfig(cfg *FileBased) error { +func validateFileBasedConfig(cfg *FileBased, authz *Authorization) error { if cfg.Organization.ID == "" { return fmt.Errorf("auth.mode=%q requires auth.file.organization.id to be configured", AuthModeFile) } @@ -882,6 +950,18 @@ func validateFileBasedConfig(cfg *FileBased) error { if u.PasswordHash == "" { return fmt.Errorf("auth.file.users[%d] (%s): password_hash is required (set it in config via {{ env }}/{{ file }})", i, u.Username) } + // A user with neither is authenticated but authorized for nothing — a + // login that succeeds and then fails every request. Reject it at startup + // instead of shipping a token with an empty scope claim. + if u.Role == "" && u.Scopes == "" { + return fmt.Errorf("auth.file.users[%d] (%s): one of role or scopes is required", i, u.Username) + } + // The role is expanded from the mapping file at login, so without the + // file the role silently grants nothing. + if u.Role != "" && authz.RoleMappings == "" { + return fmt.Errorf("auth.file.users[%d] (%s): role %q requires auth.authorization.role_mappings to be configured", + i, u.Username, u.Role) + } } return nil } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 6660f393cb..3f75c8f143 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -20,8 +20,15 @@ driver = "sqlite3" path = "./data/api_platform.db" [platform_api.auth] -mode = "file" -scope_validation = "true" +mode = "file" + +[platform_api.auth.authorization] +enabled = true +mode = "scope" +# Mounted role-to-scope mapping (resources/roles.yaml in this pack). Edit it to +# change what each role grants — it is config, not part of the image. The admin +# user below names a role from this file instead of listing platform scopes. +role_mappings = "/etc/platform-api/roles.yaml" [platform_api.auth.jwt] issuer = "platform-api" @@ -36,4 +43,10 @@ region = "us" [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:git:read ap:api_key:read ap:api_key:all:manage ap:secret:manage dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" +role = '{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}' +# Unioned with whatever the role grants. These four live here rather than in +# roles.yaml because that file's ap: scopes are validated against this server's +# OpenAPI spec: ap:devportal:manage / ap:git:read are AI Workspace BFF scopes this +# server only mints, and the event-gateway scopes are declared only on a build +# that compiles in that plugin — naming any of them in roles.yaml fails startup. +scopes = "ap:devportal:manage ap:git:read ap:websub_api:manage ap:webbroker_api:manage" diff --git a/platform-api/config/config_multifile_test.go b/platform-api/config/config_multifile_test.go index 52e18e81f4..a3128d7f82 100644 --- a/platform-api/config/config_multifile_test.go +++ b/platform-api/config/config_multifile_test.go @@ -81,8 +81,8 @@ func TestLoadConfig_MultiFile_ArrayReplaceNotAppend(t *testing.T) { // weakly-typed unmarshal after the merge rather than at merge time. func TestLoadConfig_MultiFile_TypeMismatchFails(t *testing.T) { dir := t.TempDir() - base := writeMultiTOML(t, dir, "base.toml", "[platform_api.auth]\nscope_validation = true\n") - over := writeMultiTOML(t, dir, "overlay.toml", "[platform_api.auth]\nscope_validation = \"maybe\"\n") + base := writeMultiTOML(t, dir, "base.toml", "[platform_api.auth.authorization]\nenabled = true\n") + over := writeMultiTOML(t, dir, "overlay.toml", "[platform_api.auth.authorization]\nenabled = \"maybe\"\n") _, err := LoadConfig(base, over) require.Error(t, err, "a non-coercible cross-file override must still fail (at unmarshal)") diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 892117f5ea..918a363fa6 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -83,7 +83,7 @@ func genRSAKeyPEMs() (pubPEM, privPEM string) { // APIP_CP_ENCRYPTION_KEY / APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE env vars via {{ env }} // interpolation. Environment variables reach config ONLY through these tokens now // (there is no direct env-key override), so tests must go through a config file. -// The default auth mode is "external_token", which needs only the verification +// The default auth mode is "internal_token", which needs only the verification // public key. const validKeysBase = ` [platform_api.security] @@ -216,7 +216,7 @@ public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" }}' assert.Contains(t, err.Error(), "invalid EncryptionKey") } -// The JWT public key is required (default auth mode is "external_token") and never generated. +// The JWT public key is required (default auth mode is "internal_token") and never generated. func TestLoadConfig_MissingJWTPublicKey_Errors(t *testing.T) { t.Setenv("APIP_CP_ENCRYPTION_KEY", validInlineKey) @@ -290,13 +290,13 @@ func TestValidateAuthConfig(t *testing.T) { wantErr string }{ { - name: "external_token mode with valid public key", - auth: Auth{Mode: AuthModeExternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}}, + name: "internal_token mode with valid public key", + auth: Auth{Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}}, }, { name: "skip path exempting every route is rejected", auth: Auth{ - Mode: AuthModeExternalToken, + Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, SkipPaths: []string{"/health", "/"}, }, @@ -305,7 +305,7 @@ func TestValidateAuthConfig(t *testing.T) { { name: "empty skip path is rejected", auth: Auth{ - Mode: AuthModeExternalToken, + Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, SkipPaths: []string{""}, }, @@ -314,7 +314,7 @@ func TestValidateAuthConfig(t *testing.T) { { name: "relative skip path is rejected", auth: Auth{ - Mode: AuthModeExternalToken, + Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, SkipPaths: []string{"health"}, }, @@ -323,15 +323,15 @@ func TestValidateAuthConfig(t *testing.T) { { name: "traversal in skip path is rejected", auth: Auth{ - Mode: AuthModeExternalToken, + Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, SkipPaths: []string{"/health/../api"}, }, wantErr: "invalid entry in auth.skip_paths", }, { - name: "external_token mode without public key", - auth: Auth{Mode: AuthModeExternalToken}, + name: "internal_token mode without public key", + auth: Auth{Mode: AuthModeInternalToken}, wantErr: "Auth.JWT.PublicKeyFile is required", }, { @@ -362,6 +362,19 @@ func TestValidateAuthConfig(t *testing.T) { }, { name: "file mode fully configured", + auth: Auth{ + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + File: FileBased{ + Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Scopes: "ap:organization:manage"}}, + }, + }, + }, + { + // A user granted nothing authenticates successfully and is then + // denied every route — reject the config instead. + name: "file mode user with neither role nor scopes", auth: Auth{ Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, @@ -370,18 +383,51 @@ func TestValidateAuthConfig(t *testing.T) { Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, }, }, + wantErr: "one of role or scopes is required", + }, + { + // The role is expanded from the mapping file at login, so without the + // file it would silently grant nothing. + name: "file mode user with a role but no mapping file", + auth: Auth{ + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope}, + File: FileBased{ + Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, + }, + }, + wantErr: "auth.authorization.role_mappings", + }, + { + // A file-mode user may name a role while authorization itself runs in + // scope mode: the login endpoint expands the role into the scope claim. + name: "file mode user with a role in scope authorization mode", + auth: Auth{ + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Authorization: Authorization{ + Enabled: true, + Mode: AuthzModeScope, + RoleMappings: "/etc/platform-api/roles.yaml", + }, + File: FileBased{ + Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, + }, + }, }, { name: "idp mode requires jwks_url", - auth: Auth{Mode: AuthModeIDP, IDP: IDP{ValidationMode: "scope"}}, + auth: Auth{Mode: AuthModeIDP}, wantErr: "auth.idp.jwks_url", }, { name: "idp mode fully configured", auth: Auth{Mode: AuthModeIDP, IDP: IDP{ - JWKSUrl: "https://idp.example.com/jwks", - Issuer: []string{"https://idp.example.com"}, - ValidationMode: "scope", + JWKSUrl: "https://idp.example.com/jwks", + Issuer: []string{"https://idp.example.com"}, }}, }, { @@ -397,6 +443,13 @@ func TestValidateAuthConfig(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Every case exercises an authentication concern, so fill in the + // authorization mode DefaultConfig would supply unless the case + // sets one itself — TestValidateAuthorizationConfig covers that + // section on its own. + if tt.auth.Authorization.Mode == "" { + tt.auth.Authorization.Mode = AuthzModeScope + } err := validateAuthConfig(&tt.auth) if tt.wantErr != "" { require.Error(t, err) @@ -408,6 +461,80 @@ func TestValidateAuthConfig(t *testing.T) { } } +// validateAuthorizationConfig: [auth.authorization] is validated in every auth +// mode, so role-based authorization is reachable whether tokens are verified +// against an IDP's JWKS or with a local public key. +func TestValidateAuthorizationConfig(t *testing.T) { + tests := []struct { + name string + authz Authorization + claims ClaimMappings + wantErr string + }{ + { + name: "scope mode needs nothing else", + authz: Authorization{Enabled: true, Mode: AuthzModeScope}, + }, + { + name: "role mode fully configured", + authz: Authorization{Enabled: true, Mode: AuthzModeRole, RoleMappings: "/etc/platform-api/roles.yaml"}, + claims: ClaimMappings{Roles: "realm_access.roles"}, + }, + { + name: "role mode without roles claim mapping", + authz: Authorization{Enabled: true, Mode: AuthzModeRole, RoleMappings: "/etc/platform-api/roles.yaml"}, + wantErr: "auth.claim_mappings.roles", + }, + { + name: "role mode without role_mappings file", + authz: Authorization{Enabled: true, Mode: AuthzModeRole}, + claims: ClaimMappings{Roles: "roles"}, + wantErr: "auth.authorization.role_mappings", + }, + { + name: "unknown mode rejected", + authz: Authorization{Enabled: true, Mode: "rbac"}, + wantErr: "auth.authorization.mode must be", + }, + { + name: "empty mode rejected", + authz: Authorization{Enabled: true}, + wantErr: "auth.authorization.mode must be", + }, + { + // Disabling enforcement doesn't excuse an invalid mode: flipping + // enabled back on must not be what surfaces the misconfiguration. + name: "invalid mode rejected even when disabled", + authz: Authorization{Mode: "rbac"}, + wantErr: "auth.authorization.mode must be", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAuthorizationConfig(&tt.authz, &tt.claims) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +// Role-based authorization is configured independently of the authentication +// mode, so it must validate in internal_token mode too — where it previously +// lived under [auth.idp] and was unreachable. +func TestValidateAuthConfig_RoleAuthorizationInInternalTokenMode(t *testing.T) { + auth := Auth{ + Mode: AuthModeInternalToken, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeRole, RoleMappings: "/etc/platform-api/roles.yaml"}, + ClaimMappings: ClaimMappings{Roles: "roles"}, + } + assert.NoError(t, validateAuthConfig(&auth)) +} + // The HTTPS listener is on (and the plain-HTTP listener off) unless an operator // explicitly opts otherwise, so a deployment that forgets the knob never // silently downgrades to plain HTTP. diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index af03df0f99..854f45334f 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -43,8 +43,15 @@ func defaultConfig() *Server { Auth: Auth{ // Default mode verifies locally-issued, asymmetrically-signed (RS256) JWTs; // the quickstart config selects "file" to add username/password login on top. - Mode: AuthModeExternalToken, - ScopeValidation: true, + Mode: AuthModeInternalToken, + Authorization: Authorization{ + Enabled: true, + Mode: AuthzModeScope, + // RoleMappings is left empty on purpose: the mapping file is + // operator-owned and mounted (the packs ship a sample), so a + // built-in path would make startup depend on a file the image + // does not carry. The shipped config.toml points at the mount. + }, // SkipPaths bypasses JWT/IDP auth middleware. Paths below the health/metrics // probes are internal gateway routes authenticated via gateway token instead. SkipPaths: []string{ @@ -69,9 +76,6 @@ func defaultConfig() *Server { Issuer: "platform-api", TokenTTL: time.Hour, }, - IDP: IDP{ - ValidationMode: "scope", - }, ClaimMappings: ClaimMappings{ Organization: "organization", OrgName: "org_name", @@ -80,6 +84,11 @@ func defaultConfig() *Server { Username: "username", Email: "email", Scope: "scope", + // Default to the flat "roles" claim — what Asgardeo and Entra ID + // emit, and what the file-mode login endpoint signs — so switching + // auth.authorization.mode to "role" needs no extra claim wiring. + // Keycloak overrides it with "realm_access.roles". + Roles: "roles", }, File: FileBased{ Organization: FileBasedOrg{ diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 970d07e0e3..5d754d597d 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -20,6 +20,7 @@ package handler import ( "log/slog" "net/http" + "strings" "time" "github.com/wso2/api-platform/platform-api/config" @@ -43,12 +44,17 @@ type loginResponse struct { // AuthLoginHandler issues JWT tokens for locally-configured users (file-based auth mode). type AuthLoginHandler struct { - cfg *config.Server - slogger *slog.Logger + cfg *config.Server + // roleScopeMap is the role-to-scope mapping from auth.authorization.role_mappings, + // used to expand a user's configured role into the scopes its token carries. + // Nil when no mapping file is configured, in which case no user may name a + // role (config validation enforces that pairing). + roleScopeMap map[string][]string + slogger *slog.Logger } -func NewAuthLoginHandler(cfg *config.Server) *AuthLoginHandler { - return &AuthLoginHandler{cfg: cfg, slogger: slog.Default()} +func NewAuthLoginHandler(cfg *config.Server, roleScopeMap map[string][]string) *AuthLoginHandler { + return &AuthLoginHandler{cfg: cfg, roleScopeMap: roleScopeMap, slogger: slog.Default()} } func (h *AuthLoginHandler) RegisterPublicRoutes(mux *http.ServeMux) { @@ -98,7 +104,7 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { claims := jwt.MapClaims{ "sub": matched.Username, claimKey(cm.Username, "username"): matched.Username, - claimKey(cm.Scope, "scope"): matched.Scopes, + claimKey(cm.Scope, "scope"): h.effectiveScopes(matched), claimKey(cm.Organization, "organization"): fileBasedAuth.Organization.UUID, claimKey(cm.OrgName, "org_name"): fileBasedAuth.Organization.DisplayName, claimKey(cm.OrgHandle, "org_handle"): fileBasedAuth.Organization.ID, @@ -106,6 +112,13 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { "exp": expiry.Unix(), "iat": time.Now().Unix(), } + // The role travels in the token as well as the scopes it expanded to, so a + // consumer configured for role-based authorization reads the same identity + // this endpoint authorized — the claim is a list, matching the shape IDPs + // emit and the shape the roles claim is read back in. + if matched.Role != "" { + claims[claimKey(cm.Roles, "roles")] = []string{matched.Role} + } // Sign asymmetrically with RS256 using the configured RSA private key, // read fresh from its mounted file. Config validation (validateJWTConfig) @@ -128,6 +141,36 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { return nil } +// effectiveScopes returns the space-separated scope claim for a user: the scopes +// its configured role grants, unioned with the scopes granted directly. A role is +// normally the whole grant — the mapping file may name scopes in any component's +// namespace — and the direct list is for anything beyond it, such as a scope only +// a plugin build declares. +// +// Authorization is still enforced against this scope claim — expanding the role +// at issue time is what lets a role-shaped configuration be checked by the +// scope-mode enforcer, rather than requiring authorization to run in role mode. +func (h *AuthLoginHandler) effectiveScopes(user *config.FileBasedUser) string { + direct := strings.Fields(user.Scopes) + if user.Role == "" { + return strings.Join(direct, " ") + } + + // Role scopes come first so the claim reads role-then-extras; seen dedupes + // an extra that the role already grants. + fromRole := h.roleScopeMap[user.Role] + scopes := make([]string, 0, len(fromRole)+len(direct)) + seen := make(map[string]struct{}, len(fromRole)+len(direct)) + for _, s := range append(append([]string{}, fromRole...), direct...) { + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + scopes = append(scopes, s) + } + return strings.Join(scopes, " ") +} + // claimKey returns name, falling back to def when the operator has left the // corresponding auth.claim_mappings field unset. func claimKey(name, def string) string { diff --git a/platform-api/internal/handler/auth_login_test.go b/platform-api/internal/handler/auth_login_test.go new file mode 100644 index 0000000000..ca2f27f549 --- /dev/null +++ b/platform-api/internal/handler/auth_login_test.go @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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. + * + */ + +package handler + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/wso2/api-platform/platform-api/config" +) + +// A file-mode user's scope claim is the union of the scopes its role grants and +// the scopes granted directly. The role covers the platform scopes (validated +// against the OpenAPI spec at startup); the direct list carries scopes this +// server does not declare, such as the Developer Portal's "dp:*" scopes. +func TestEffectiveScopes(t *testing.T) { + h := NewAuthLoginHandler(&config.Server{}, map[string][]string{ + "ap_admin": {"ap:organization:manage", "ap:rest_api:manage"}, + "ap_viewer": {"ap:organization:read"}, + }) + + tests := []struct { + name string + user config.FileBasedUser + want string + }{ + { + name: "role only", + user: config.FileBasedUser{Role: "ap_viewer"}, + want: "ap:organization:read", + }, + { + name: "scopes only", + user: config.FileBasedUser{Scopes: "dp:org_manage ap:devportal:manage"}, + want: "dp:org_manage ap:devportal:manage", + }, + { + // Role scopes first, then the extras the mapping cannot name. + name: "role unioned with direct scopes", + user: config.FileBasedUser{Role: "ap_admin", Scopes: "dp:org_manage"}, + want: "ap:organization:manage ap:rest_api:manage dp:org_manage", + }, + { + // A direct scope the role already grants must not appear twice. + name: "overlapping scope is deduped", + user: config.FileBasedUser{Role: "ap_admin", Scopes: "ap:rest_api:manage dp:org_manage"}, + want: "ap:organization:manage ap:rest_api:manage dp:org_manage", + }, + { + // validateFileUserRoles rejects this at startup; if it ever reaches + // here the direct scopes must still be honored rather than dropped. + name: "unknown role falls back to direct scopes", + user: config.FileBasedUser{Role: "no-such-role", Scopes: "dp:org_manage"}, + want: "dp:org_manage", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, h.effectiveScopes(&tt.user)) + }) + } +} diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index 0a39bad93a..fa244f90c2 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -79,7 +79,7 @@ type AuthConfig struct { } // ClaimMappings holds the JWT claim names used to extract identity values, -// shared by the local-JWT (external_token/file) and IDP auth paths. +// shared by the local-JWT (internal_token/file) and IDP auth paths. type ClaimMappings struct { OrganizationClaim string OrgNameClaim string diff --git a/platform-api/internal/middleware/auth_role_extraction_test.go b/platform-api/internal/middleware/auth_role_extraction_test.go index 065371857d..9c8cef6db1 100644 --- a/platform-api/internal/middleware/auth_role_extraction_test.go +++ b/platform-api/internal/middleware/auth_role_extraction_test.go @@ -53,24 +53,24 @@ func TestExtractClaimByPath(t *testing.T) { claims: jwt.MapClaims{ "resource_access": map[string]interface{}{ "my-client": map[string]interface{}{ - "roles": []interface{}{"platform-admin", "platform-developer"}, + "roles": []interface{}{"ap_admin", "ap_publisher"}, }, }, }, path: "resource_access.my-client.roles", - want: []string{"platform-admin", "platform-developer"}, + want: []string{"ap_admin", "ap_publisher"}, }, { name: "nested three levels - single role in array", claims: jwt.MapClaims{ "resource_access": map[string]interface{}{ "my-client": map[string]interface{}{ - "roles": []interface{}{"platform-admin"}, + "roles": []interface{}{"ap_admin"}, }, }, }, path: "resource_access.my-client.roles", - want: []string{"platform-admin"}, + want: []string{"ap_admin"}, }, { name: "missing claim returns nil", diff --git a/platform-api/internal/middleware/role_scope_map.go b/platform-api/internal/middleware/role_scope_map.go index bd716116e7..ec83fb5ff2 100644 --- a/platform-api/internal/middleware/role_scope_map.go +++ b/platform-api/internal/middleware/role_scope_map.go @@ -20,6 +20,8 @@ package middleware import ( "fmt" "os" + "regexp" + "strings" "gopkg.in/yaml.v3" ) @@ -58,13 +60,39 @@ func LoadRoleScopeMap(path string) (map[string][]string, error) { return m, nil } -// ValidateRoleScopeMap checks that every scope referenced in the map is declared -// in the OpenAPI spec. An unrecognized scope name is almost certainly a typo that -// would silently deny access, so we fail fast at startup rather than at request time. +// PlatformScopePrefix is the namespace of the scopes this server declares and +// enforces. Scopes in any other namespace belong to a sibling component that +// trusts the same token (the Developer Portal's "dp:" scopes, for example): this +// server only mints them, so it can neither confirm nor deny that they exist. +const PlatformScopePrefix = "ap:" + +// wellFormedScope matches ":" with an optional ":*" wildcard +// tail — enough to catch a missing or malformed namespace on a foreign scope, +// which is the only error class detectable without that component's spec. +var wellFormedScope = regexp.MustCompile(`^[a-z0-9_]+:[a-z0-9_:*]+$`) + +// ValidateRoleScopeMap checks the scopes referenced in the map, failing fast at +// startup rather than at request time — an unrecognized scope name is almost +// certainly a typo that would otherwise surface as a silent 403. +// +// Validation is namespace-scoped. A scope in this server's own namespace +// (PlatformScopePrefix) must be declared in the OpenAPI spec — that includes +// scopes contributed by compiled-in plugins, so this must run after the plugin +// specs are merged. A scope in another component's namespace is checked only for +// well-formedness: the roles file is the natural place to describe what a role +// grants across the platform, and refusing scopes this server doesn't declare +// would push those grants back into per-user scope lists. func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error { known := registry.AllScopes() for role, scopes := range m { for _, s := range scopes { + if !wellFormedScope.MatchString(s) { + return fmt.Errorf("roles.yaml: role %q references malformed scope %q — expected \":\", e.g. %sorganization:manage", + role, s, PlatformScopePrefix) + } + if !strings.HasPrefix(s, PlatformScopePrefix) { + continue // another component's namespace — not ours to validate + } if _, ok := known[s]; !ok { return fmt.Errorf("roles.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) } diff --git a/platform-api/internal/middleware/role_scope_map_test.go b/platform-api/internal/middleware/role_scope_map_test.go new file mode 100644 index 0000000000..a5fca2183b --- /dev/null +++ b/platform-api/internal/middleware/role_scope_map_test.go @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed 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. + * + */ + +package middleware + +import ( + "strings" + "testing" +) + +// A role may grant scopes belonging to a sibling component (the Developer +// Portal's "dp:*"), which this server mints but never enforces and therefore +// cannot check for existence. Its own "ap:" namespace is still validated against +// the spec, and a scope with no namespace at all is rejected in either case. +func TestValidateRoleScopeMap_NamespaceScoping(t *testing.T) { + registry, err := LoadScopeRegistryFromBytes([]byte(` +openapi: 3.0.0 +paths: + /apis: + get: + security: + - oauth2: + - ap:rest_api:read +`)) + if err != nil { + t.Fatalf("building the scope registry: %v", err) + } + + tests := []struct { + name string + scopes []string + wantErr string + }{ + { + name: "declared platform scope", + scopes: []string{"ap:rest_api:read"}, + }, + { + name: "foreign-namespace scope passes without being declared here", + scopes: []string{"dp:org_manage", "dp:api_key_revoke"}, + }, + { + name: "undeclared platform scope is rejected", + scopes: []string{"ap:rest_api:reed"}, + wantErr: "unknown scope", + }, + { + // The one error still detectable in a namespace this server can't + // validate: a scope that isn't namespaced at all. + name: "scope with no namespace is rejected", + scopes: []string{"dporg_manage"}, + wantErr: "malformed scope", + }, + { + name: "uppercase scope is rejected as malformed", + scopes: []string{"DP:org_manage"}, + wantErr: "malformed scope", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRoleScopeMap(map[string][]string{"ap_admin": tt.scopes}, registry) + switch { + case tt.wantErr == "" && err != nil: + t.Fatalf("unexpected error: %v", err) + case tt.wantErr != "" && err == nil: + t.Fatalf("expected an error containing %q, got nil", tt.wantErr) + case tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr): + t.Fatalf("error %q does not contain %q", err, tt.wantErr) + } + }) + } +} diff --git a/platform-api/internal/server/role_scope_map_test.go b/platform-api/internal/server/role_scope_map_test.go index 9decb7fead..4eb6c8053a 100644 --- a/platform-api/internal/server/role_scope_map_test.go +++ b/platform-api/internal/server/role_scope_map_test.go @@ -25,6 +25,7 @@ import ( "testing" "github.com/wso2/api-platform/platform-api/config" + "github.com/wso2/api-platform/platform-api/internal/middleware" ) // writeRolesFile writes a roles.yaml mapping one role to the given scopes and @@ -43,12 +44,13 @@ func writeRolesFile(t *testing.T, role string, scopes ...string) string { return path } -// roleModeConfig returns a config in IDP role-validation mode pointing at path. +// roleModeConfig returns a config in role-authorization mode pointing at path. +// The authentication mode is deliberately left at its zero value: role-based +// authorization is independent of how tokens are authenticated. func roleModeConfig(path string) *config.Server { cfg := &config.Server{} - cfg.Auth.Mode = config.AuthModeIDP - cfg.Auth.IDP.ValidationMode = "role" - cfg.Auth.IDP.RoleMappings = path + cfg.Auth.Authorization.Mode = config.AuthzModeRole + cfg.Auth.Authorization.RoleMappings = path return cfg } @@ -90,19 +92,108 @@ func TestLoadRoleScopeMap_RejectsPluginScopeBeforeMerge(t *testing.T) { } } -// Outside IDP role mode the mapping is not loaded at all, so a roles.yaml -// referencing an unknown scope must not fail startup. -func TestLoadRoleScopeMap_SkippedOutsideRoleMode(t *testing.T) { - path := writeRolesFile(t, "widget-admin", "ap:not_a_real_scope") +// The mapping is loaded whenever it is configured, including in scope +// authorization mode — file-mode users name a role from this same file to +// inherit its scopes, so the login endpoint needs it there too. A bad roles.yaml +// therefore still fails startup in scope mode. +func TestLoadRoleScopeMap_LoadedInScopeMode(t *testing.T) { + reg := emptyRegistry(t) + if _, err := run(t, reg, &fakePlugin{name: "widgets", spec: specWithScopes}); err != nil { + t.Fatalf("initPlugins: unexpected error: %v", err) + } + + cfg := roleModeConfig(writeRolesFile(t, "widget-admin", "ap:widget_read")) + cfg.Auth.Authorization.Mode = config.AuthzModeScope - cfg := roleModeConfig(path) - cfg.Auth.IDP.ValidationMode = "scope" + m, err := loadRoleScopeMap(cfg, reg, testLogger()) + if err != nil { + t.Fatalf("loadRoleScopeMap: unexpected error: %v", err) + } + if got := m["widget-admin"]; len(got) != 1 || got[0] != "ap:widget_read" { + t.Fatalf("expected the mapping to load in scope mode, got %v", m) + } +} - m, err := loadRoleScopeMap(cfg, emptyRegistry(t), testLogger()) +// With no mapping file configured nothing consumes the mapping, so none is +// loaded — config validation is what guarantees the path is set wherever a role +// is actually named. +func TestLoadRoleScopeMap_SkippedWhenUnconfigured(t *testing.T) { + m, err := loadRoleScopeMap(&config.Server{}, emptyRegistry(t), testLogger()) if err != nil { t.Fatalf("loadRoleScopeMap: unexpected error: %v", err) } if m != nil { - t.Fatalf("expected no mapping outside role mode, got %v", m) + t.Fatalf("expected no mapping when role_mappings is unset, got %v", m) + } +} + +// A file-mode user naming a role absent from the mapping would get a token whose +// scope claim silently lacks everything the role was meant to grant — a login +// that succeeds and then 403s on every request. Catch the typo at startup. +func TestValidateFileUserRoles(t *testing.T) { + roleScopeMap := map[string][]string{"ap_admin": {"ap:organization:manage"}} + + cfg := &config.Server{} + cfg.Auth.Mode = config.AuthModeFile + cfg.Auth.Authorization.RoleMappings = "/etc/platform-api/roles.yaml" + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Role: "ap_admin"}} + if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { + t.Fatalf("unexpected error for a defined role: %v", err) + } + + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Role: "ap_admn"}} + err := validateFileUserRoles(cfg, roleScopeMap) + if err == nil { + t.Fatal("expected an error for a role missing from the mapping, got nil") + } + if !strings.Contains(err.Error(), "ap_admn") { + t.Fatalf("unexpected error: %v", err) + } + + // A user granted scopes directly names no role, so there is nothing to check. + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Scopes: "ap:organization:manage"}} + if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { + t.Fatalf("unexpected error for a user with no role: %v", err) + } +} + +// The shipped sample mapping must load and validate against the shipped OpenAPI +// spec in a default build. It is mounted (not baked into the image) and named by +// the shipped config.toml, so a scope that this spec doesn't declare — or that +// only exists on a plugin build — would fail startup for every pack user. +func TestShippedSampleRolesValidateAgainstShippedSpec(t *testing.T) { + reg, err := middleware.LoadScopeRegistry("../../resources/openapi.yaml") + if err != nil { + t.Fatalf("loading the shipped OpenAPI spec: %v", err) + } + + m, err := middleware.LoadRoleScopeMap("../../resources/roles.yaml") + if err != nil { + t.Fatalf("loading the shipped roles.yaml: %v", err) + } + if err := middleware.ValidateRoleScopeMap(m, reg); err != nil { + t.Fatalf("shipped roles.yaml is not valid against the shipped spec: %v", err) + } + + // The documented role set. ap_admin in particular is what the shipped + // config.toml grants its admin user, so a rename here breaks every pack. + for _, role := range []string{"ap_admin", "ap_operator", "ap_publisher", "ap_subscriber", "ap_viewer"} { + if _, ok := m[role]; !ok { + t.Fatalf("shipped roles.yaml does not declare %q", role) + } + } + + // Roles span the platform: a role names Developer Portal scopes too, which + // this server mints but does not enforce. If the namespace-scoped validation + // above ever regresses to registry-only, this is what would start failing. + found := false + for _, s := range m["ap_admin"] { + if strings.HasPrefix(s, "dp:") { + found = true + break + } + } + if !found { + t.Fatalf("expected ap_admin to grant Developer Portal scopes: %v", m["ap_admin"]) } } diff --git a/platform-api/internal/server/scope_route_coverage_test.go b/platform-api/internal/server/scope_route_coverage_test.go index 4168cb1b0a..0056cdbec7 100644 --- a/platform-api/internal/server/scope_route_coverage_test.go +++ b/platform-api/internal/server/scope_route_coverage_test.go @@ -43,19 +43,19 @@ func registerAllRoutes(mux *http.ServeMux) { handler.NewOrganizationHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewProjectHandler(nil, nil, logger).RegisterRoutes(mux) - handler.NewApplicationHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewApplicationHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewAPIHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewGatewayHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewSubscriptionHandler(nil, nil, nil, logger).RegisterRoutes(mux) handler.NewSubscriptionPlanHandler(nil, nil, logger).RegisterRoutes(mux) - handler.NewAPIKeyHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewAPIKeyHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewLLMHandler(nil, nil, nil, nil, logger).RegisterRoutes(mux) handler.NewLLMProviderDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewLLMProxyDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) - handler.NewLLMProviderAPIKeyHandler(nil, nil, logger).RegisterRoutes(mux) - handler.NewLLMProxyAPIKeyHandler(nil, nil, logger).RegisterRoutes(mux) - handler.NewAPIKeyUserHandler(nil, nil, logger).RegisterRoutes(mux) + handler.NewLLMProviderAPIKeyHandler(nil, nil, "scope", logger).RegisterRoutes(mux) + handler.NewLLMProxyAPIKeyHandler(nil, nil, "scope", logger).RegisterRoutes(mux) + handler.NewAPIKeyUserHandler(nil, nil, "scope", logger).RegisterRoutes(mux) handler.NewMCPProxyHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewMCPProxyDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) handler.NewSecretHandler(nil, nil, logger).RegisterRoutes(mux) @@ -65,10 +65,10 @@ func registerAllRoutes(mux *http.ServeMux) { eghandler.NewWebSubAPIHandler(nil, nil, logger).RegisterRoutes(mux) eghandler.NewWebSubAPIDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) eghandler.NewWebSubAPIHmacSecretHandler(nil, nil, logger).RegisterRoutes(mux) - eghandler.NewWebSubAPIKeyHandler(nil, nil, nil, logger).RegisterRoutes(mux) + eghandler.NewWebSubAPIKeyHandler(nil, nil, nil, "scope", logger).RegisterRoutes(mux) eghandler.NewWebBrokerAPIHandler(nil, nil, logger).RegisterRoutes(mux) eghandler.NewWebBrokerAPIDeploymentHandler(nil, nil, logger).RegisterRoutes(mux) - eghandler.NewWebBrokerAPIKeyHandler(nil, nil, nil, logger).RegisterRoutes(mux) + eghandler.NewWebBrokerAPIKeyHandler(nil, nil, nil, "scope", logger).RegisterRoutes(mux) } // loadMergedRegistry loads the shipped spec plus the event-gateway plugin's diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index fc0fb15064..8cf76f6407 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -325,22 +325,22 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, secretService := service.NewSecretService(secretRepo, secretVault, identityService) // Initialize handlers - orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.IDP.ValidationMode, slogger) + orgHandler := handler.NewOrganizationHandler(orgService, identityService, cfg.Auth.Authorization.Mode, slogger) projectHandler := handler.NewProjectHandler(projectService, identityService, slogger) apiHandler := handler.NewAPIHandler(apiService, identityService, slogger) gatewayHandler := handler.NewGatewayHandler(gatewayService, identityService, slogger) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService, subscriptionPlanService, identityService, slogger) subscriptionPlanHandler := handler.NewSubscriptionPlanHandler(subscriptionPlanService, identityService, slogger) - appHandler := handler.NewApplicationHandler(appService, identityService, cfg.Auth.IDP.ValidationMode, slogger) + appHandler := handler.NewApplicationHandler(appService, identityService, cfg.Auth.Authorization.Mode, slogger) wsHandler := handler.NewWebSocketHandler(wsManager, gatewayService, deploymentService, cfg.Listeners.WebSocket.RateLimitPerMin, slogger) internalGatewayHandler := handler.NewGatewayInternalAPIHandler(gatewayService, internalGatewayService, artifactImportService, secretService, slogger) - apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService, identityService, cfg.Auth.IDP.ValidationMode, slogger) + apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService, identityService, cfg.Auth.Authorization.Mode, slogger) deploymentHandler := handler.NewDeploymentHandler(deploymentService, identityService, slogger) llmHandler := handler.NewLLMHandler(llmTemplateService, llmProviderService, llmProxyService, identityService, slogger) llmDeploymentHandler := handler.NewLLMProviderDeploymentHandler(llmProviderDeploymentService, identityService, slogger) - llmProviderAPIKeyHandler := handler.NewLLMProviderAPIKeyHandler(llmProviderAPIKeyService, identityService, cfg.Auth.IDP.ValidationMode, slogger) - llmProxyAPIKeyHandler := handler.NewLLMProxyAPIKeyHandler(llmProxyAPIKeyService, identityService, cfg.Auth.IDP.ValidationMode, slogger) - apiKeyUserHandler := handler.NewAPIKeyUserHandler(apiKeyUserService, identityService, cfg.Auth.IDP.ValidationMode, slogger) + llmProviderAPIKeyHandler := handler.NewLLMProviderAPIKeyHandler(llmProviderAPIKeyService, identityService, cfg.Auth.Authorization.Mode, slogger) + llmProxyAPIKeyHandler := handler.NewLLMProxyAPIKeyHandler(llmProxyAPIKeyService, identityService, cfg.Auth.Authorization.Mode, slogger) + apiKeyUserHandler := handler.NewAPIKeyUserHandler(apiKeyUserService, identityService, cfg.Auth.Authorization.Mode, slogger) llmProxyDeploymentHandler := handler.NewLLMProxyDeploymentHandler(llmProxyDeploymentService, identityService, slogger) mcpProxyHandler := handler.NewMCPProxyHandler(mcpProxyService, identityService, slogger) mcpProxyDeploymentHandler := handler.NewMCPProxyDeploymentHandler(mcpDeploymentService, identityService, slogger) @@ -371,13 +371,12 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, } slogger.Info("Loaded OpenAPI scope registry", "path", cfg.OpenAPISpecPath) - if !cfg.Auth.ScopeValidation { + if !cfg.Auth.Authorization.Enabled { slogger.Warn("scope validation is disabled — all authenticated requests will be allowed regardless of scope") } - // Register all routes on the mux. Public routes (login) are accessible - // because the auth middleware uses cfg.Auth.SkipPaths to bypass them. - handler.NewAuthLoginHandler(cfg).RegisterPublicRoutes(mux) + // Register all routes on the mux. The public login route is registered later, + // once the role-to-scope mapping its tokens are signed against is loaded. orgHandler.RegisterRoutes(mux) projectHandler.RegisterRoutes(mux) appHandler.RegisterRoutes(mux) @@ -456,6 +455,15 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, if err != nil { return nil, err } + if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { + return nil, err + } + + // The login endpoint signs each user's role into the tokens it issues and + // expands that role through the mapping loaded above, so it is registered + // here rather than with the other routes. It stays public because the auth + // middleware bypasses it via cfg.Auth.SkipPaths. + handler.NewAuthLoginHandler(cfg, roleScopeMap).RegisterPublicRoutes(mux) // Declared public paths are appended before the auth middleware is built // below, so the skip-path list is complete when the chain is assembled. @@ -571,15 +579,15 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, // Every route the spec declares a scope for must actually be registered under // that same pattern — otherwise the enforcer's deny-by-default would reject a // live endpoint at runtime. Catch the drift at startup instead. - if cfg.Auth.ScopeValidation { + if cfg.Auth.Authorization.Enabled { if err := middleware.ValidateScopeRegistryRoutes(mux, scopeRegistry); err != nil { return nil, err } } scopeEnforcer, err := middleware.ScopeEnforcer(scopeRegistry, middleware.ScopeEnforcerConfig{ - ValidationMode: cfg.Auth.IDP.ValidationMode, - Enabled: cfg.Auth.ScopeValidation, + ValidationMode: cfg.Auth.Authorization.Mode, + Enabled: cfg.Auth.Authorization.Enabled, Routes: mux, SkipPaths: cfg.Auth.SkipPaths, }) @@ -636,7 +644,7 @@ func buildClaimMappings(cm config.ClaimMappings, roleScopeMap map[string][]strin } // buildAuthenticator constructs an Authenticator from the server configuration. -// Only called when the auth mode is "external_token" or "idp" (file mode wires +// Only called when the auth mode is "internal_token" or "idp" (file mode wires // its own local-JWT middleware). func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap map[string][]string) (middleware.Authenticator, error) { if cfg.Auth.Mode != config.AuthModeIDP { @@ -696,26 +704,53 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m ), nil } -// loadRoleScopeMap loads the role-to-scope mapping for IDP role mode. -// Returns nil when role mode is not active or no mapping file is configured, -// which causes IDP role names to be used as-is as scope values (passthrough). +// loadRoleScopeMap loads the role-to-scope mapping whenever one is configured. +// It is deliberately gated on neither the authentication mode nor the +// authorization mode: a token minted by an enterprise IDP carries the same roles +// claim whether the platform verifies it against a JWKS endpoint or with a local +// public key, and file-mode users name a role from this same file to inherit its +// scopes even while authorization itself runs in "scope" mode. Config validation +// requires the path wherever it is needed, so an empty path here means nothing +// consumes the mapping. func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, slogger *slog.Logger) (map[string][]string, error) { - if cfg.Auth.Mode != config.AuthModeIDP || cfg.Auth.IDP.ValidationMode != "role" || cfg.Auth.IDP.RoleMappings == "" { + if cfg.Auth.Authorization.RoleMappings == "" { return nil, nil } - m, err := middleware.LoadRoleScopeMap(cfg.Auth.IDP.RoleMappings) + m, err := middleware.LoadRoleScopeMap(cfg.Auth.Authorization.RoleMappings) if err != nil { return nil, fmt.Errorf("failed to load role mappings file: %w", err) } if err := middleware.ValidateRoleScopeMap(m, registry); err != nil { return nil, fmt.Errorf("invalid roles.yaml: %w", err) } - slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.IDP.RoleMappings, "roles", len(m)) + slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.Authorization.RoleMappings, "roles", len(m)) return m, nil } +// validateFileUserRoles checks that every role a file-mode user names exists in +// the loaded mapping. A typo would otherwise surface as a token whose scope +// claim silently lacks everything the role was meant to grant — a login that +// succeeds and then 403s on every request. Config validation already guarantees +// a mapping file is configured whenever a user names a role, so an empty map +// here means the file itself declares no roles. +func validateFileUserRoles(cfg *config.Server, roleScopeMap map[string][]string) error { + if cfg.Auth.Mode != config.AuthModeFile { + return nil + } + for i, u := range cfg.Auth.File.Users { + if u.Role == "" { + continue + } + if _, ok := roleScopeMap[u.Role]; !ok { + return fmt.Errorf("auth.file.users[%d]: role %q is not defined in %s", + i, u.Role, cfg.Auth.Authorization.RoleMappings) + } + } + return nil +} + // buildTLSConfig resolves the TLS listener configuration. The caller invokes it // only when the HTTPS listener is enabled. Certificates are always required — // there is no self-signed fallback; use the quickstart setup script (or your diff --git a/platform-api/plugins/eventgateway/plugin.go b/platform-api/plugins/eventgateway/plugin.go index 51e7237e68..d511410f64 100644 --- a/platform-api/plugins/eventgateway/plugin.go +++ b/platform-api/plugins/eventgateway/plugin.go @@ -204,10 +204,10 @@ func (p *EventGatewayPlugin) Init(deps *plugin.Deps) error { p.websubAPIHandler = eghandler.NewWebSubAPIHandler(websubAPISvc, deps.IdentityService, logger) p.websubAPIDeploymentHandler = eghandler.NewWebSubAPIDeploymentHandler(websubDeploymentSvc, deps.IdentityService, logger) p.websubAPIHmacSecretHandler = eghandler.NewWebSubAPIHmacSecretHandler(hmacSecretSvc, deps.IdentityService, logger) - p.websubAPIKeyHandler = eghandler.NewWebSubAPIKeyHandler(websubAPISvc, deps.APIKeyService, deps.IdentityService, cfg.Auth.IDP.ValidationMode, logger) + p.websubAPIKeyHandler = eghandler.NewWebSubAPIKeyHandler(websubAPISvc, deps.APIKeyService, deps.IdentityService, cfg.Auth.Authorization.Mode, logger) p.webbrokerAPIHandler = eghandler.NewWebBrokerAPIHandler(webbrokerAPISvc, deps.IdentityService, logger) p.webbrokerDeploymentHandler = eghandler.NewWebBrokerAPIDeploymentHandler(webbrokerDeploymentSvc, deps.IdentityService, logger) - p.webbrokerAPIKeyHandler = eghandler.NewWebBrokerAPIKeyHandler(webbrokerAPISvc, deps.APIKeyService, deps.IdentityService, cfg.Auth.IDP.ValidationMode, logger) + p.webbrokerAPIKeyHandler = eghandler.NewWebBrokerAPIKeyHandler(webbrokerAPISvc, deps.APIKeyService, deps.IdentityService, cfg.Auth.Authorization.Mode, logger) return nil } diff --git a/platform-api/resources/roles.yaml b/platform-api/resources/roles.yaml index 5a01dfee9b..88d838152a 100644 --- a/platform-api/resources/roles.yaml +++ b/platform-api/resources/roles.yaml @@ -1,101 +1,244 @@ -# Role-to-scope mapping for IDP role mode (auth.idp.validation_mode: role). +# Role-to-scope mapping used by the Platform API (auth.authorization.role_mappings). # -# Each entry maps an IDP role name (as it appears in the token claim configured -# by auth.idp.roles) to the list of platform scopes that role grants. -# When a token carries multiple roles, the effective scopes are the union of all -# role entries — most-permissive wins. +# Each entry maps a role name to the scopes that role grants. Two consumers read +# this file: # -# Supported IDPs: -# Asgardeo — set roles: roles -# Keycloak — set roles: realm_access.roles (or resource_access..roles) -# Microsoft Entra ID — set roles: roles +# * role authorization (auth.authorization.mode = "role") — the roles claim of +# an incoming token is expanded through this file on every request. +# * file-mode users (auth.file.users[].role) — the login endpoint expands the +# role once, into the scope claim of the token it issues. +# +# When a token carries multiple roles the effective scopes are the union of all +# matching entries — most-permissive wins. +# +# Naming: roles are named after the platform ("ap_") rather than after any one +# IDP's convention, because the same file serves every auth mode. Map your IDP's +# groups onto these names via auth.claim_mappings.roles; supported claim paths: +# Asgardeo — roles: roles +# Keycloak — roles: realm_access.roles (or resource_access..roles) +# Microsoft Entra ID — roles: roles +# +# Scopes span the whole platform, not just this server: +# ap:* Platform API scopes. Every one must be declared in this server's OpenAPI +# spec (plus any its compiled-in plugins declare) — an unknown ap: scope +# fails startup rather than silently denying requests later. The +# event-gateway scopes below are commented out for that reason: uncomment +# them on a build that includes that plugin. +# dp:* Developer Portal scopes. This server mints them into the token but never +# enforces them, so it validates only their shape, not their existence. # # Scope convention: -# ap::manage — full access to all actions on that resource (covers read/create/update/delete) -# ap:::manage — full access to the subresource only, not the parent resource +# ap::manage — every action on that resource +# ap:::manage — every action on the subresource only +# dp:_manage — every action on that Developer Portal resource +# (Developer Portal read operations accept the +# _manage scope as well as _read) # # This file requires a server restart to take effect. roles: - - name: platform-admin + # Platform administrator — full access to every resource and operation, + # across both the Platform API and the Developer Portal. + - name: ap_admin scopes: - - ap:api_key:read - - ap:api_key:all:manage + # Platform API - ap:organization:manage - ap:project:manage - ap:gateway:manage + - ap:gateway:token:manage + - ap:gateway:manifest:read - ap:gateway_custom_policy:manage - ap:rest_api:manage + - ap:rest_api:deployment:manage + - ap:rest_api:gateway:manage + - ap:rest_api:api_key:manage - ap:application:manage + - ap:application:api_key:manage + - ap:application:association:manage - ap:subscription:manage - ap:subscription_plan:manage - ap:llm_template:manage - ap:llm_provider:manage + - ap:llm_provider:deployment:manage + - ap:llm_provider:api_key:manage - ap:llm_proxy:manage + - ap:llm_proxy:deployment:manage + - ap:llm_proxy:api_key:manage - ap:mcp_proxy:manage - - ap:websub_api:manage - - ap:webbroker_api:manage + - ap:mcp_proxy:deployment:manage + - ap:secret:manage + - ap:api_key:read + # Administrative access to every user's API keys, not just the caller's. + - ap:api_key:all:manage + # - ap:websub_api:manage # event-gateway build only + # - ap:webbroker_api:manage # event-gateway build only + # Developer Portal + - dp:org_manage + - dp:org_content_manage + - dp:api_manage + - dp:api_content_manage + - dp:mcp_manage + - dp:mcp_content_manage + - dp:api_workflow_manage + - dp:api_key_manage + - dp:mcp_key_manage + - dp:app_manage + - dp:app_key_manage + - dp:app_key_revoke + - dp:app_key_mapping_manage + - dp:subscription_manage + - dp:sub_plan_manage + - dp:km_manage + - dp:km_read + - dp:view_manage + - dp:label_manage + - dp:webhook_subscriber_manage + - dp:event_read + - dp:delivery_manage - - name: platform-operator + # Platform operator / CI-CD service account — runs gateways, deployments, + # subscription plans, key managers and webhooks; reads everything else. + - name: ap_operator scopes: - - ap:api_key:read + # Platform API - ap:organization:read - ap:project:read - ap:gateway:manage + - ap:gateway:token:manage + - ap:gateway:manifest:read - ap:gateway_custom_policy:manage - - ap:subscription_plan:manage - - ap:llm_template:manage - ap:rest_api:read - - ap:rest_api:deployment:read + - ap:rest_api:deployment:manage + - ap:rest_api:gateway:read + - ap:llm_template:manage + - ap:llm_provider:read + - ap:llm_provider:deployment:manage + - ap:llm_proxy:read + - ap:llm_proxy:deployment:manage + - ap:mcp_proxy:read + - ap:mcp_proxy:deployment:manage + - ap:subscription_plan:manage - ap:application:read - ap:application:api_key:read - ap:application:association:read - - ap:application:association:api_key:read - ap:subscription:read - - ap:llm_provider:read - - ap:llm_provider:deployment:read - - ap:llm_provider:api_key:read - - ap:llm_proxy:read - - ap:llm_proxy:deployment:read - - ap:llm_proxy:api_key:read - - ap:mcp_proxy:read - - ap:mcp_proxy:deployment:read - - ap:websub_api:read - - ap:websub_api:deployment:read - - ap:webbroker_api:read - - ap:webbroker_api:deployment:read + - ap:secret:read + - ap:api_key:read + # - ap:websub_api:read # event-gateway build only + # - ap:websub_api:deployment:read # event-gateway build only + # - ap:webbroker_api:read # event-gateway build only + # - ap:webbroker_api:deployment:read # event-gateway build only + # Developer Portal + - dp:km_manage + - dp:km_read + - dp:webhook_subscriber_manage + - dp:sub_plan_manage + - dp:org_read + - dp:org_content_read + - dp:api_read + - dp:mcp_read + - dp:mcp_content_read + - dp:app_read + - dp:subscription_read + - dp:view_read + - dp:label_read + - dp:api_key_read + - dp:mcp_key_read + - dp:api_workflow_read + - dp:event_read - - name: platform-developer + # API publisher — owns the full API/MCP/LLM lifecycle and the Developer + # Portal content for it; reads applications, subscriptions and plans. + - name: ap_publisher scopes: - - ap:api_key:read + # Platform API - ap:organization:read - ap:project:manage + - ap:rest_api:manage + - ap:rest_api:deployment:manage + - ap:rest_api:gateway:read + - ap:rest_api:api_key:manage + - ap:mcp_proxy:manage + - ap:mcp_proxy:deployment:manage + - ap:llm_provider:manage + - ap:llm_provider:deployment:manage + - ap:llm_proxy:manage + - ap:llm_proxy:deployment:manage + - ap:llm_template:read - ap:gateway:read - - ap:gateway:artifact:read - ap:gateway:manifest:read - ap:gateway_custom_policy:read - - ap:rest_api:manage + - ap:subscription_plan:read + - ap:application:read + - ap:subscription:read + - ap:secret:read + - ap:api_key:read + # - ap:websub_api:manage # event-gateway build only + # - ap:webbroker_api:manage # event-gateway build only + # Developer Portal + - dp:api_manage + - dp:api_content_manage + - dp:mcp_manage + - dp:mcp_content_manage + - dp:api_workflow_manage + - dp:label_manage + - dp:view_manage + - dp:org_read + - dp:org_content_read + - dp:app_read + - dp:subscription_read + - dp:sub_plan_read + - dp:api_key_read + - dp:mcp_key_read + - dp:event_read + + # API consumer — manages its own applications, subscriptions and keys; + # reads the API/MCP catalog and the plans it can subscribe to. + - name: ap_subscriber + scopes: + # Platform API + - ap:organization:read + - ap:project:read - ap:application:manage + - ap:application:api_key:manage + - ap:application:association:manage - ap:subscription:manage - ap:subscription_plan:read - - ap:llm_template:read - - ap:llm_provider:manage - - ap:llm_proxy:manage - - ap:mcp_proxy:manage - - ap:websub_api:manage - - ap:webbroker_api:manage + - ap:rest_api:read + - ap:mcp_proxy:read + - ap:llm_proxy:read + - ap:llm_provider:read + - ap:api_key:read + # Developer Portal + - dp:app_manage + - dp:app_key_manage + - dp:app_key_revoke + - dp:subscription_manage + - dp:api_key_manage + - dp:mcp_key_manage + - dp:api_read + - dp:mcp_read + - dp:sub_plan_read + - dp:org_read + - dp:org_content_read + - dp:view_read + - dp:label_read - - name: platform-viewer + # Auditor — read-only across both components. Grants no mutating scope + # (the one POST it can reach, mcp-proxies/fetch-server-info, is a read + # the spec models as a POST). + - name: ap_viewer scopes: - - ap:api_key:read + # Platform API - ap:organization:read - ap:project:read - ap:gateway:read - - ap:gateway:artifact:read - ap:gateway:manifest:read + - ap:gateway:token:read - ap:gateway_custom_policy:read - ap:rest_api:read - ap:rest_api:deployment:read + - ap:rest_api:gateway:read - ap:application:read - ap:application:api_key:read - ap:application:association:read @@ -111,7 +254,26 @@ roles: - ap:llm_proxy:api_key:read - ap:mcp_proxy:read - ap:mcp_proxy:deployment:read - - ap:websub_api:read - - ap:websub_api:deployment:read - - ap:webbroker_api:read - - ap:webbroker_api:deployment:read + - ap:secret:read + - ap:api_key:read + # - ap:websub_api:read # event-gateway build only + # - ap:websub_api:deployment:read # event-gateway build only + # - ap:webbroker_api:read # event-gateway build only + # - ap:webbroker_api:deployment:read # event-gateway build only + # Developer Portal + - dp:org_read + - dp:org_content_read + - dp:api_read + - dp:mcp_read + - dp:mcp_content_read + - dp:app_read + - dp:subscription_read + - dp:sub_plan_read + - dp:view_read + - dp:label_read + - dp:km_read + - dp:api_key_read + - dp:mcp_key_read + - dp:api_workflow_read + - dp:webhook_subscriber_read + - dp:event_read diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 58fa3d2c25..052cc2ce85 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -392,7 +392,8 @@ endif $(DIST_DIR)/scripts/setup.ps1 @rm -f $(DIST_DIR)/scripts/setup.ps1.bak # Point the platform-api mount at the merged config so both containers share one file. - @sed 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ + @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ + -e 's#\.\./\.\./platform-api/resources/roles\.yaml:#./resources/roles.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @sed -i.bak -E \ diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 79fec2da4d..510e172478 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -374,7 +374,7 @@ failures, by symptom: | Symptom | Cause | Fix | |---|---|---| | `unauthorized_client` / *"not authorized to use the requested grant type"* | App registered as SPA, or Code/Refresh grant not enabled | Recreate as Standard-Based OIDC app; enable **Code** + **Refresh Token** (step 1) | -| Platform API exits at startup with *`auth.mode must be "external_token", "file", or "idp"`* | `auth.mode` is unset or misspelled | Compose: set `APIP_CP_AUTH_MODE=idp` in `api-platform.env` (step 3, Option 1). Local: set `[auth] mode = "idp"` in `config.toml` (step 3, Option 2) | +| Platform API exits at startup with *`auth.mode must be "internal_token", "file", or "idp"`* | `auth.mode` is unset or misspelled | Compose: set `APIP_CP_AUTH_MODE=idp` in `api-platform.env` (step 3, Option 1). Local: set `[auth] mode = "idp"` in `config.toml` (step 3, Option 2) | | `502` + `dial tcp: lookup platform-api: no such host` | BFF run locally but `[ai_workspace.control_plane] url` points at the compose hostname | Set `APIP_AIW_CONTROL_PLANE_URL=https://localhost:9243` (step 3, Option 2) | | Proxied calls return `authentication_failed` | Platform API still on local JWT/file-based, validating the IDP token with the wrong validator | Switch it to the IDP — compose: set the `APIP_CP_AUTH_IDP_*` keys in `api-platform.env` (step 3, Option 1); local: enable `[auth.idp]` (step 3, Option 2) | | Proxied calls return `authentication_failed`, Platform API logs `token contains an invalid number of segments` | IDP is issuing **opaque** access tokens — the BFF forwards the access token and the Platform API can only validate a **JWT** via JWKS | Set **Access Token Type = JWT** on the app's Protocol tab (step 1) and re-login | diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 5e2c1718e6..ccfdcd102e 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -18,7 +18,7 @@ wso2apip-ai-workspace-/ │ └── config-template.toml # Full configuration reference for both, │ # plus optional [developer_portal] at the bottom └── resources/ - ├── roles.yaml # Platform API role definitions + ├── roles.yaml # Platform API role-to-scope mapping (edit to change what a role grants) └── platform-api/ └── db-scripts/ # Platform API schema scripts (schema.*.sql) ``` @@ -124,7 +124,7 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.logging].level` | Log level (`debug`, `info`, `warn`, `error`; matched case-insensitively) | | `[platform_api.security].encryption_key` | Single 32-byte key (64 hex chars or base64) used for all at-rest encryption (secrets, subscription tokens, WebSub HMAC secrets). Generate with `openssl rand -hex 32` | | `[platform_api.database].driver` | `sqlite3` or `postgres` | -| `[platform_api.auth].mode` | `file` (quickstart default), `external_token`, or `idp` — selects exactly one auth mode | +| `[platform_api.auth].mode` | `file` (quickstart default), `internal_token`, or `idp` — selects exactly one auth mode | | `[platform_api.auth.jwt].public_key_file` / `private_key_file` | RS256 (asymmetric) PEM keys; `public_key_file` verifies every token, `private_key_file` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | | `[platform_api.auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | | `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index f7901dcc68..9054be8645 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -26,6 +26,9 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro + # Role-to-scope mapping named by auth.authorization.role_mappings. Mounted + # rather than baked into the image so it can be edited in place. + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-data:/app/data - ./resources/certificates:/app/data/certs:ro - ./resources/keys:/etc/platform-api/keys:ro diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 60104c286b..79d850dc4f 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -113,8 +113,9 @@ org_handle = "org_handle" Optional overrides (defaults shown): ```toml -[auth.idp] -validation_mode = "scope" # or "role" for role-based auth +[auth.authorization] +enabled = true +mode = "scope" # or "role" for role-based auth (then set role_mappings) [auth.claim_mappings] user_id = "sub" diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index f39c464cf9..c3eb499f76 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -284,10 +284,13 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) > $(DIST_DIR)/configs/.pa-config.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config-template.toml" \ > $(DIST_DIR)/configs/.pa-config-template.toml + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles.yaml" \ + > $(DIST_DIR)/resources/roles.yaml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-config.toml @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml + @cp ../../platform-api/resources/roles.yaml $(DIST_DIR)/resources/roles.yaml endif # Require a [platform_api] root table — pre-unified configs would merge into a broken file. @if ! grep -q '^\[platform_api' $(DIST_DIR)/configs/.pa-config.toml; then \ @@ -307,7 +310,8 @@ endif $(call append_section,AI WORKSPACE (optional),$(DIST_DIR)/configs/.aiw-config-template.toml,$(DIST_DIR)/configs/config-template.toml) @rm -f $(DIST_DIR)/configs/.pa-config.toml $(DIST_DIR)/configs/.pa-config-template.toml $(DIST_DIR)/configs/.aiw-config-template.toml # Point the platform-api mount at the merged config so both containers share one file. - @sed 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ + @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ + -e 's#\.\./\.\./platform-api/resources/roles\.yaml:#./resources/roles.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md index 9aad2fd8e0..6032826574 100644 --- a/portals/developer-portal/distribution/README.md +++ b/portals/developer-portal/distribution/README.md @@ -16,6 +16,7 @@ wso2apip-developer-portal-/ │ ├── config.toml # Unified active config — [developer_portal] + [platform_api] sections │ └── config-template.toml # Config reference — both active components, plus optional [ai_workspace] at the bottom └── resources/ + ├── roles.yaml # Platform API role-to-scope mapping (edit to change what a role grants) ├── developer-portal/ │ └── db-scripts/ # Developer Portal PostgreSQL schema (reference copy) ├── platform-api/ diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index fd0eb730d3..637aaff7b9 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -42,6 +42,9 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro + # Role-to-scope mapping named by auth.authorization.role_mappings. Mounted + # rather than baked into the image so it can be edited in place. + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-data:/app/data # Certs land under /app/data/certs to match the binary's compiled # default cert_file/key_file paths (./data/certs/{cert,key}.pem, diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index 0ad77f19ff..3f02da8331 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -25,6 +25,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-data:/app/data - ./resources/certificates:/app/data/certs:ro - ./resources/keys:/etc/platform-api/keys:ro From 20a5e2f56a13ef4901a71b04fdd2f3ea7146b3da Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 15:14:40 +0530 Subject: [PATCH 2/7] Refactor authentication configuration to enforce role-based access control --- .../templates/configmap.yaml | 13 +- .../templates/deployment.yaml | 9 + .../helm/platform-api-helm-chart/values.yaml | 49 +++- platform-api/README.md | 26 +-- platform-api/config/config-template.toml | 31 ++- platform-api/config/config.go | 31 ++- platform-api/config/config.toml | 8 +- platform-api/config/config_test.go | 31 +-- platform-api/internal/handler/auth_login.go | 40 ++-- .../internal/handler/auth_login_test.go | 44 ++-- .../internal/middleware/role_scope_map.go | 28 ++- .../middleware/role_scope_map_test.go | 13 ++ .../internal/server/role_scope_map_test.go | 7 +- platform-api/resources/roles.yaml | 11 +- portals/ai-workspace/distribution/README.md | 3 +- portals/developer-portal/README.md | 6 +- .../developer-portal/distribution/README.md | 3 +- .../it/configs/config-platform-api-it.toml | 11 +- .../it/configs/roles-platform-api-it.yaml | 212 ++++++++++++++++++ .../it/docker-compose.test.postgres.yaml | 1 + .../it/docker-compose.test.yaml | 1 + .../docker-compose.sqlite.yaml | 3 + .../docker-compose.sqlserver.yaml | 3 + tests/integration-e2e/docker-compose.yaml | 3 + .../integration-e2e/platform-api-config.toml | 9 +- tests/integration-e2e/suite_test.go | 13 +- 26 files changed, 457 insertions(+), 152 deletions(-) create mode 100644 portals/developer-portal/it/configs/roles-platform-api-it.yaml diff --git a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml index af8ad08f47..231c89d48c 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml @@ -111,10 +111,7 @@ data: # provisions a generated username and a bcrypt password hash. username = {{ `'{{ env "APIP_CP_ADMIN_USERNAME" }}'` }} password_hash = {{ `'{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}'` }} - {{- with $auth.file.admin.role }} - role = {{ . | quote }} - {{- end }} - scopes = {{ $auth.file.admin.scopes | quote }} + role = {{ required "config.auth.file.admin.role is required when auth.mode is \"file\"" $auth.file.admin.role | quote }} {{- end }} [platform_api.server.http] @@ -171,4 +168,12 @@ data: {{ . | nindent 4 | trim }} {{- end }} + {{- with $auth.authorization.roles }} + # Role→scope mapping named by auth.authorization.role_mappings. Mounted as a + # file rather than folded into the TOML above because the Platform API reads it + # separately and re-reads it only on restart. + roles.yaml: | + roles: + {{- toYaml . | nindent 6 }} + {{- end }} {{- end }} diff --git a/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml b/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml index a28018a997..3963e3b348 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml @@ -159,6 +159,11 @@ spec: - name: config mountPath: /etc/platform-api/config-platform-api.toml subPath: config-platform-api.toml + {{- if $pa.config.auth.authorization.roles }} + - name: config + mountPath: {{ $pa.config.auth.authorization.roleMappings }} + subPath: roles.yaml + {{- end }} - name: jwt-keys mountPath: {{ $jwtKeyDir }} readOnly: true @@ -184,6 +189,10 @@ spec: items: - key: config-platform-api.toml path: config-platform-api.toml + {{- if $pa.config.auth.authorization.roles }} + - key: roles.yaml + path: roles.yaml + {{- end }} # RS256 JWT keys mounted as PEM files from the external Secret. The # public key verifies tokens (every mode); the private key signs # file-mode login tokens (mounted only in file mode). diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index 6303f95c11..3d41fe6216 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -119,11 +119,37 @@ config: # Enforce per-endpoint OAuth2 scopes on validated tokens. enabled: true mode: scope # scope | role - # Path to a role→scope mapping YAML. Required when mode=role, and also when - # auth.file.admin.role below is set. The chart mounts no such file by default — - # supply one (ConfigMap volume via extraVolumes/extraVolumeMounts) and point - # this at it; platform-api/resources/roles.yaml is the shipped sample. - roleMappings: "" + # Path to the role→scope mapping YAML. Required when mode=role, and in file + # mode (auth.file.admin.role is a user's whole grant). The chart renders + # `roles` below into its config ConfigMap and mounts it here; point this + # elsewhere only if you supply your own file via extraVolumes/extraVolumeMounts. + roleMappings: /etc/platform-api/roles.yaml + # Roles the mapping file defines, each a name and the scopes it grants. + # Only ap_admin is shipped here — the file-mode admin below names it. + # platform-api/resources/roles.yaml is the full sample set (ap_admin, + # ap_operator, ap_publisher, ap_subscriber, ap_viewer); copy the entries you + # need from it. An ap: scope the Platform API's OpenAPI spec does not declare + # fails startup; dp: scopes (Developer Portal) are checked for shape only. + roles: + - name: ap_admin + scopes: + - ap:organization:manage + - ap:project:manage + - ap:gateway:manage + - ap:gateway_custom_policy:manage + - ap:rest_api:manage + - ap:llm_provider:manage + - ap:llm_proxy:manage + - ap:llm_template:manage + - ap:mcp_proxy:manage + - ap:application:manage + - ap:subscription:manage + - ap:subscription_plan:manage + - ap:secret:manage + - ap:api_key:read + - ap:api_key:all:manage + - ap:devportal:manage + - ap:git:read # Claim-name mappings shared by all modes. claimMappings: organization: organization @@ -158,14 +184,13 @@ config: # APIP_CP_ADMIN_PASSWORD_HASH (secrets.keys.adminUsername / adminPasswordHash), # which generate-secrets.sh provisions with a generated username and a bcrypt # hash. There is no admin/admin default: startup fails closed if unset. Only - # the granted scopes are configured here (add more users via configToml). + # the granted role is configured here (add more users via configToml). admin: - # Optional: a role from the roleMappings file, expanded into the token's - # scopes at login (e.g. "ap_admin"). Requires that file to be mounted and - # roleMappings to point at it; leave empty to grant scopes only. A role - # named here but absent from the file fails startup. - role: "" - scopes: "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:api_key:all:manage ap:secret:manage" + # REQUIRED in file mode — a role from the roleMappings file, expanded into + # the token's scopes at login, and this user's entire grant (there is no + # per-user scope list). Requires that file to be mounted and roleMappings + # above to point at it; a role absent from the file fails startup. + role: ap_admin # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl is # required in that mode. idp: diff --git a/platform-api/README.md b/platform-api/README.md index 563cd3af0c..c088592c4c 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -25,9 +25,8 @@ go run ./cmd/main.go AI Workspace and Developer Portal quickstarts use. It's the one Platform API config shared by every quickstart (both docker-compose setups mount it directly), so its admin user is granted the `ap_admin` role from the mounted [`resources/roles.yaml`](resources/roles.yaml), which covers both -the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces. Override the role with -`APIP_CP_ADMIN_ROLE`; the small `scopes` list alongside it carries the few scopes `roles.yaml` -cannot name (see the comment there). +the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces. That role is the whole grant — +override it with `APIP_CP_ADMIN_ROLE`, or edit what it grants in `roles.yaml`. There is no default admin credential: `APIP_CP_ADMIN_USERNAME` and `APIP_CP_ADMIN_PASSWORD_HASH` are **required** in this mode, and startup fails closed if either is unset or empty. `portals/scripts/setup.sh` @@ -358,13 +357,15 @@ sample (`resources/roles.yaml`) at `/etc/platform-api/roles.yaml`. Validation of that file is namespace-scoped. An `ap:` scope must be declared in this server's OpenAPI spec (plus any its compiled-in plugins declare) — an unknown one fails startup rather than silently -denying requests later. A scope in another component's namespace (`dp:*`) is checked only for shape: -this server mints it into the token but never enforces it, so it can neither confirm nor deny that it -exists. That is what lets one role describe a persona across the whole platform. +denying requests later. The exceptions are `ap:devportal:*` and `ap:git:read`, which this server mints +for the AI Workspace BFF to enforce and therefore allowlists rather than spec-checks. A scope in +another component's namespace (`dp:*`) is checked only for shape: this server mints it into the token +but never enforces it, so it can neither confirm nor deny that it exists. That is what lets one role +describe a persona across the whole platform — and what makes a per-user scope list unnecessary. ##### Granting a file-mode user a role -A `file`-mode user is granted a role rather than a hand-maintained scope list. The login endpoint +A `file`-mode user is granted **only** a role — there is no per-user scope list. The login endpoint expands that role through the same `role_mappings` file when it mints the token: ```toml @@ -372,7 +373,6 @@ expands that role through the same `role_mappings` file when it mints the token: username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' role = "ap_admin" # expanded via auth.authorization.role_mappings -# scopes = "" # optional: unioned with the role's scopes ``` The issued token carries **both**: the expanded scopes as the `scope` claim, and the role name as the @@ -381,11 +381,11 @@ the expanded claim, and flipping `auth.authorization.mode = "role"` re-expands t `roles.yaml` on every request instead. `claim_mappings.roles` defaults to the flat `roles` claim the login endpoint signs, so that switch needs no extra claim wiring. -A role is normally the whole grant — the mapping file may name scopes in any namespace, so there is -usually nothing left to add. `scopes` remains available for anything the file cannot name (an `ap:` -scope this server's spec doesn't declare, which `roles.yaml` would reject) or on its own instead of a -role; the two are unioned, and one of them is required — a user granted neither would authenticate -and then be denied every route. A role absent from the mapping file fails startup. +The role is required, and startup fails if a user has none or names one the mapping file doesn't +define — either way that user would authenticate successfully and then be denied every route. Because +the mapping is the only place a grant is expressed, no user can drift out of step with the role it +names, and widening or narrowing a persona is one edit in one file. To grant something no shipped +role covers, add a role to `roles.yaml`. This is how the shipped `config/config.toml` grants its admin user: `role = "ap_admin"` and nothing else. Changing what that user can do means editing the mounted `roles.yaml`, which makes that file the diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 6ada12f344..313b1aaff4 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -210,31 +210,28 @@ uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # starting with a blank or guessable credential. username = "" password_hash = "" -# One of role / scopes is REQUIRED — a user with neither authenticates and is then -# authorized for nothing. +# REQUIRED — a role from the auth.authorization.role_mappings file above, and this +# user's entire grant. The login endpoint expands it into the token's scope claim +# and also emits the role itself as the roles claim, so the same token works +# whether auth.authorization.mode is "scope" (default) or "role". A user with no +# role, or one naming a role the mapping file doesn't define, fails startup rather +# than logging in successfully and then being denied every request. # -# A role from auth.authorization.role_mappings is the normal way to grant a -# file-mode user. The login endpoint expands it into the token's scope claim and -# also emits the role itself as the roles claim, so the same token works whether -# auth.authorization.mode is "scope" (default) or "role". It is commented out here -# because role_mappings above is empty in this template: naming a role without a -# mapping file fails startup. Set role_mappings first, then uncomment. -# role = "ap_admin" # see resources/roles.yaml for the shipped roles +# There is no per-user scope list: what a role grants is defined once, in the +# mapping file, so no user can drift out of step with the role it names. To grant +# something no shipped role covers, add a role to that file (see +# resources/roles.yaml for the shipped ones and the scope namespaces it may use). # -# Space-separated scope list, unioned with whatever the role grants. Use it -# alongside a role to grant something the mapping file cannot name — an ap: scope -# this server's OpenAPI spec doesn't declare is rejected there, which is why the -# shipped config.toml lists the AI Workspace (ap:devportal:*, ap:git:read) and -# event-gateway plugin scopes here rather than in roles.yaml — or on its own -# instead of a role, as below. -scopes = "ap:organization:manage ap:project:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:llm_template:manage ap:mcp_proxy:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:secret:manage ap:api_key:read ap:api_key:all:manage ap:devportal:manage ap:git:read" +# Left empty here because role_mappings above is empty in this template — set that +# first, then name a role. +role = "" # Additional users — uncomment the WHOLE block (including the [[...]] header) # and replace the placeholder hash with a real bcrypt hash before use. # [[platform_api.auth.file.users]] # username = "readonly" # password_hash = "$2a$12$" -# scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" +# role = "ap_viewer" # JWT (local RS256) — used by "internal_token" and "file" modes. Tokens are # signed asymmetrically: "internal_token" only verifies tokens minted elsewhere diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 2590317b0e..232dc207c2 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -45,17 +45,13 @@ import ( type FileBasedUser struct { Username string `json:"username" koanf:"username"` PasswordHash string `json:"password_hash" koanf:"password_hash"` - // Role names one of the roles in the auth.authorization.role_mappings file. - // The login endpoint expands it into that role's scopes, so a user's grants - // can be expressed the same way an IDP expresses them — as a role — instead - // of a hand-maintained scope string. + // Role names one of the roles in the auth.authorization.role_mappings file + // and is the user's entire grant: the login endpoint expands it into the + // scope claim of the token it issues. It is the only way to grant a file-mode + // user, so a user's privileges are expressed exactly the way an IDP expresses + // them — as a role — and changing what a role grants is a single edit to the + // mapping file rather than a per-user scope string to keep in sync. Role string `json:"role" koanf:"role"` - // Scopes is a space-separated scope list granted directly, unioned with - // whatever Role expands to. It is still needed for scopes this server does - // not itself declare (Developer Portal "dp:*" scopes, for example): the role - // mapping is validated against this server's OpenAPI spec, so it can only - // name scopes this server knows. - Scopes string `json:"scopes" koanf:"scopes"` } // FileBasedUsers is a slice of FileBasedUser that can be decoded from a JSON string (env var) @@ -950,15 +946,16 @@ func validateFileBasedConfig(cfg *FileBased, authz *Authorization) error { if u.PasswordHash == "" { return fmt.Errorf("auth.file.users[%d] (%s): password_hash is required (set it in config via {{ env }}/{{ file }})", i, u.Username) } - // A user with neither is authenticated but authorized for nothing — a - // login that succeeds and then fails every request. Reject it at startup - // instead of shipping a token with an empty scope claim. - if u.Role == "" && u.Scopes == "" { - return fmt.Errorf("auth.file.users[%d] (%s): one of role or scopes is required", i, u.Username) + // A role is the user's whole grant, so a user without one is authenticated + // and then authorized for nothing — a login that succeeds and then fails + // every request. Reject it at startup instead of issuing a token with an + // empty scope claim. + if u.Role == "" { + return fmt.Errorf("auth.file.users[%d] (%s): role is required — name a role from auth.authorization.role_mappings", i, u.Username) } // The role is expanded from the mapping file at login, so without the - // file the role silently grants nothing. - if u.Role != "" && authz.RoleMappings == "" { + // file the role grants nothing. + if authz.RoleMappings == "" { return fmt.Errorf("auth.file.users[%d] (%s): role %q requires auth.authorization.role_mappings to be configured", i, u.Username, u.Role) } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 3f75c8f143..37cc436570 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -43,10 +43,6 @@ region = "us" [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' +# The role is the whole grant — edit its entry in the mounted roles.yaml to change +# what this user may do. role = '{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}' -# Unioned with whatever the role grants. These four live here rather than in -# roles.yaml because that file's ap: scopes are validated against this server's -# OpenAPI spec: ap:devportal:manage / ap:git:read are AI Workspace BFF scopes this -# server only mints, and the event-gateway scopes are declared only on a build -# that compiles in that plugin — naming any of them in roles.yaml fails startup. -scopes = "ap:devportal:manage ap:git:read ap:websub_api:manage ap:webbroker_api:manage" diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 918a363fa6..e908651f20 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -363,31 +363,33 @@ func TestValidateAuthConfig(t *testing.T) { { name: "file mode fully configured", auth: Auth{ - Mode: AuthModeFile, - JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleMappings: "/etc/platform-api/roles.yaml"}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, - Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Scopes: "ap:organization:manage"}}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, }, }, }, { - // A user granted nothing authenticates successfully and is then - // denied every route — reject the config instead. - name: "file mode user with neither role nor scopes", + // A role is the whole grant, so a user without one authenticates + // successfully and is then denied every route — reject the config. + name: "file mode user without a role", auth: Auth{ - Mode: AuthModeFile, - JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleMappings: "/etc/platform-api/roles.yaml"}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, }, }, - wantErr: "one of role or scopes is required", + wantErr: "role is required", }, { // The role is expanded from the mapping file at login, so without the - // file it would silently grant nothing. + // file it would grant nothing. name: "file mode user with a role but no mapping file", auth: Auth{ Mode: AuthModeFile, @@ -401,8 +403,8 @@ func TestValidateAuthConfig(t *testing.T) { wantErr: "auth.authorization.role_mappings", }, { - // A file-mode user may name a role while authorization itself runs in - // scope mode: the login endpoint expands the role into the scope claim. + // A file-mode user's role is expanded into the scope claim at login, so + // it works while authorization itself runs in the default scope mode. name: "file mode user with a role in scope authorization mode", auth: Auth{ Mode: AuthModeFile, @@ -672,6 +674,9 @@ encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd [platform_api.auth] mode = "file" +[platform_api.auth.authorization] +role_mappings = "/etc/platform-api/roles.yaml" + [platform_api.auth.jwt] public_key_file = "` + validJWTPublicKeyFile + `" private_key_file = "` + validJWTPrivateKeyFile + `" @@ -684,7 +689,7 @@ display_name = "Default" [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -scopes = "ap:api_key:all:manage" +role = "ap_admin" ` require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 5d754d597d..c3b8036a92 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -46,9 +46,9 @@ type loginResponse struct { type AuthLoginHandler struct { cfg *config.Server // roleScopeMap is the role-to-scope mapping from auth.authorization.role_mappings, - // used to expand a user's configured role into the scopes its token carries. - // Nil when no mapping file is configured, in which case no user may name a - // role (config validation enforces that pairing). + // used to expand each user's role into the scopes its token carries. In file + // mode it is always populated: config validation requires the mapping file, and + // startup checks every user's role against it. roleScopeMap map[string][]string slogger *slog.Logger } @@ -115,10 +115,9 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { // The role travels in the token as well as the scopes it expanded to, so a // consumer configured for role-based authorization reads the same identity // this endpoint authorized — the claim is a list, matching the shape IDPs - // emit and the shape the roles claim is read back in. - if matched.Role != "" { - claims[claimKey(cm.Roles, "roles")] = []string{matched.Role} - } + // emit and the shape the roles claim is read back in. Config validation + // guarantees the role is set, so this is unconditional. + claims[claimKey(cm.Roles, "roles")] = []string{matched.Role} // Sign asymmetrically with RS256 using the configured RSA private key, // read fresh from its mounted file. Config validation (validateJWTConfig) @@ -142,26 +141,19 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { } // effectiveScopes returns the space-separated scope claim for a user: the scopes -// its configured role grants, unioned with the scopes granted directly. A role is -// normally the whole grant — the mapping file may name scopes in any component's -// namespace — and the direct list is for anything beyond it, such as a scope only -// a plugin build declares. +// its role grants, per the mapping file. The role is the user's whole grant — +// there is no per-user scope list to drift out of sync with it — so widening or +// narrowing what a user may do is an edit to the role's entry in that one file. // -// Authorization is still enforced against this scope claim — expanding the role -// at issue time is what lets a role-shaped configuration be checked by the -// scope-mode enforcer, rather than requiring authorization to run in role mode. +// Authorization is still enforced against this scope claim; expanding the role at +// issue time is what lets a role-shaped configuration be checked by the scope-mode +// enforcer, rather than requiring authorization to run in role mode. Duplicates +// are dropped so a role that lists a scope twice doesn't repeat it in the claim. func (h *AuthLoginHandler) effectiveScopes(user *config.FileBasedUser) string { - direct := strings.Fields(user.Scopes) - if user.Role == "" { - return strings.Join(direct, " ") - } - - // Role scopes come first so the claim reads role-then-extras; seen dedupes - // an extra that the role already grants. fromRole := h.roleScopeMap[user.Role] - scopes := make([]string, 0, len(fromRole)+len(direct)) - seen := make(map[string]struct{}, len(fromRole)+len(direct)) - for _, s := range append(append([]string{}, fromRole...), direct...) { + scopes := make([]string, 0, len(fromRole)) + seen := make(map[string]struct{}, len(fromRole)) + for _, s := range fromRole { if _, dup := seen[s]; dup { continue } diff --git a/platform-api/internal/handler/auth_login_test.go b/platform-api/internal/handler/auth_login_test.go index ca2f27f549..a547b7f108 100644 --- a/platform-api/internal/handler/auth_login_test.go +++ b/platform-api/internal/handler/auth_login_test.go @@ -25,14 +25,16 @@ import ( "github.com/wso2/api-platform/platform-api/config" ) -// A file-mode user's scope claim is the union of the scopes its role grants and -// the scopes granted directly. The role covers the platform scopes (validated -// against the OpenAPI spec at startup); the direct list carries scopes this -// server does not declare, such as the Developer Portal's "dp:*" scopes. +// A file-mode user's scope claim is exactly what its role grants — the role is +// the whole grant, so the mapping file is the only place the user's privileges +// are defined. The mapping may name scopes in any component's namespace (the +// Developer Portal's "dp:*", for example), which is what makes a per-user scope +// list unnecessary. func TestEffectiveScopes(t *testing.T) { h := NewAuthLoginHandler(&config.Server{}, map[string][]string{ - "ap_admin": {"ap:organization:manage", "ap:rest_api:manage"}, + "ap_admin": {"ap:organization:manage", "ap:rest_api:manage", "dp:org_manage"}, "ap_viewer": {"ap:organization:read"}, + "ap_dupes": {"ap:rest_api:manage", "ap:organization:read", "ap:rest_api:manage"}, }) tests := []struct { @@ -41,33 +43,29 @@ func TestEffectiveScopes(t *testing.T) { want string }{ { - name: "role only", + name: "role expands to its scopes, in order", user: config.FileBasedUser{Role: "ap_viewer"}, want: "ap:organization:read", }, { - name: "scopes only", - user: config.FileBasedUser{Scopes: "dp:org_manage ap:devportal:manage"}, - want: "dp:org_manage ap:devportal:manage", - }, - { - // Role scopes first, then the extras the mapping cannot name. - name: "role unioned with direct scopes", - user: config.FileBasedUser{Role: "ap_admin", Scopes: "dp:org_manage"}, + // The mapping carries foreign-namespace scopes too, so nothing has to + // be granted outside it. + name: "role spanning multiple namespaces", + user: config.FileBasedUser{Role: "ap_admin"}, want: "ap:organization:manage ap:rest_api:manage dp:org_manage", }, { - // A direct scope the role already grants must not appear twice. - name: "overlapping scope is deduped", - user: config.FileBasedUser{Role: "ap_admin", Scopes: "ap:rest_api:manage dp:org_manage"}, - want: "ap:organization:manage ap:rest_api:manage dp:org_manage", + // A role listing the same scope twice must not repeat it in the claim. + name: "duplicate scope in a role is deduped", + user: config.FileBasedUser{Role: "ap_dupes"}, + want: "ap:rest_api:manage ap:organization:read", }, { - // validateFileUserRoles rejects this at startup; if it ever reaches - // here the direct scopes must still be honored rather than dropped. - name: "unknown role falls back to direct scopes", - user: config.FileBasedUser{Role: "no-such-role", Scopes: "dp:org_manage"}, - want: "dp:org_manage", + // validateFileUserRoles rejects this at startup; if it ever reached + // here the token must carry no scopes rather than a guessed grant. + name: "unknown role grants nothing", + user: config.FileBasedUser{Role: "no-such-role"}, + want: "", }, } for _, tt := range tests { diff --git a/platform-api/internal/middleware/role_scope_map.go b/platform-api/internal/middleware/role_scope_map.go index ec83fb5ff2..ac57130419 100644 --- a/platform-api/internal/middleware/role_scope_map.go +++ b/platform-api/internal/middleware/role_scope_map.go @@ -71,6 +71,21 @@ const PlatformScopePrefix = "ap:" // which is the only error class detectable without that component's spec. var wellFormedScope = regexp.MustCompile(`^[a-z0-9_]+:[a-z0-9_:*]+$`) +// mintedPlatformScopes are scopes in this server's own namespace that it issues +// into tokens but does not itself declare or enforce — they gate endpoints on a +// sibling component (the AI Workspace BFF) that trusts the same token. They +// cannot be checked against the OpenAPI spec for that reason, but they must be +// nameable in roles.yaml: a role is a file-mode user's entire grant, so a scope +// that can't be named in the mapping can't be granted at all. +var mintedPlatformScopes = map[string]bool{ + "ap:devportal:read": true, + "ap:devportal:create": true, + "ap:devportal:update": true, + "ap:devportal:delete": true, + "ap:devportal:manage": true, + "ap:git:read": true, +} + // ValidateRoleScopeMap checks the scopes referenced in the map, failing fast at // startup rather than at request time — an unrecognized scope name is almost // certainly a typo that would otherwise surface as a silent 403. @@ -78,10 +93,12 @@ var wellFormedScope = regexp.MustCompile(`^[a-z0-9_]+:[a-z0-9_:*]+$`) // Validation is namespace-scoped. A scope in this server's own namespace // (PlatformScopePrefix) must be declared in the OpenAPI spec — that includes // scopes contributed by compiled-in plugins, so this must run after the plugin -// specs are merged. A scope in another component's namespace is checked only for -// well-formedness: the roles file is the natural place to describe what a role -// grants across the platform, and refusing scopes this server doesn't declare -// would push those grants back into per-user scope lists. +// specs are merged — unless it is one of the mintedPlatformScopes this server +// issues on a sibling component's behalf. A scope in another component's +// namespace is checked only for well-formedness: the roles file is where a role's +// grants across the whole platform are described, and it is the only place a +// file-mode user's grants can be expressed, so refusing scopes this server does +// not declare would make them ungrantable. func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error { known := registry.AllScopes() for role, scopes := range m { @@ -93,6 +110,9 @@ func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error if !strings.HasPrefix(s, PlatformScopePrefix) { continue // another component's namespace — not ours to validate } + if mintedPlatformScopes[s] { + continue // ours to mint, a sibling component's to enforce + } if _, ok := known[s]; !ok { return fmt.Errorf("roles.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) } diff --git a/platform-api/internal/middleware/role_scope_map_test.go b/platform-api/internal/middleware/role_scope_map_test.go index a5fca2183b..fd6b727bb3 100644 --- a/platform-api/internal/middleware/role_scope_map_test.go +++ b/platform-api/internal/middleware/role_scope_map_test.go @@ -70,6 +70,19 @@ paths: scopes: []string{"DP:org_manage"}, wantErr: "malformed scope", }, + { + // This server mints these for the AI Workspace BFF to enforce, so they + // appear in no spec it can check. A role must still be able to name + // them — it is a file-mode user's only grant. + name: "minted platform scope passes without being declared here", + scopes: []string{"ap:devportal:manage", "ap:git:read"}, + }, + { + // The allowlist is exact, not a prefix: a typo inside it still fails. + name: "typo in a minted scope is still rejected", + scopes: []string{"ap:devportal:mange"}, + wantErr: "unknown scope", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/platform-api/internal/server/role_scope_map_test.go b/platform-api/internal/server/role_scope_map_test.go index 4eb6c8053a..0f7ad92a62 100644 --- a/platform-api/internal/server/role_scope_map_test.go +++ b/platform-api/internal/server/role_scope_map_test.go @@ -150,10 +150,11 @@ func TestValidateFileUserRoles(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - // A user granted scopes directly names no role, so there is nothing to check. - cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Scopes: "ap:organization:manage"}} + // Only file mode has users to check — another mode's config carries none. + cfg.Auth.Mode = config.AuthModeIDP + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Role: "ap_admn"}} if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { - t.Fatalf("unexpected error for a user with no role: %v", err) + t.Fatalf("unexpected error outside file mode: %v", err) } } diff --git a/platform-api/resources/roles.yaml b/platform-api/resources/roles.yaml index 88d838152a..e9cc69e7f5 100644 --- a/platform-api/resources/roles.yaml +++ b/platform-api/resources/roles.yaml @@ -6,7 +6,9 @@ # * role authorization (auth.authorization.mode = "role") — the roles claim of # an incoming token is expanded through this file on every request. # * file-mode users (auth.file.users[].role) — the login endpoint expands the -# role once, into the scope claim of the token it issues. +# role once, into the scope claim of the token it issues. A role is such a +# user's entire grant, so this file is the only place their privileges are +# defined; there is no per-user scope list to keep in sync with it. # # When a token carries multiple roles the effective scopes are the union of all # matching entries — most-permissive wins. @@ -23,7 +25,9 @@ # spec (plus any its compiled-in plugins declare) — an unknown ap: scope # fails startup rather than silently denying requests later. The # event-gateway scopes below are commented out for that reason: uncomment -# them on a build that includes that plugin. +# them on a build that includes that plugin. The two exceptions are +# ap:devportal:* and ap:git:read, which this server mints for the AI +# Workspace BFF to enforce; they are allowlisted, not spec-checked. # dp:* Developer Portal scopes. This server mints them into the token but never # enforces them, so it validates only their shape, not their existence. # @@ -72,6 +76,9 @@ roles: - ap:api_key:all:manage # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only + # AI Workspace — minted by the Platform API, enforced by the BFF. + - ap:devportal:manage + - ap:git:read # Developer Portal - dp:org_manage - dp:org_content_manage diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index ccfdcd102e..9f4c8e94fb 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -127,7 +127,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.auth].mode` | `file` (quickstart default), `internal_token`, or `idp` — selects exactly one auth mode | | `[platform_api.auth.jwt].public_key_file` / `private_key_file` | RS256 (asymmetric) PEM keys; `public_key_file` verifies every token, `private_key_file` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | | `[platform_api.auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | -| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing) | +| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing). Each user names a `role` from `resources/roles.yaml` — that role is the whole grant | +| `[platform_api.auth.authorization].role_mappings` | Path to the mounted `resources/roles.yaml` — edit that file to change what a role grants | | `[platform_api.server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | Each key's default value is written inline in `configs/config-template.toml` — a diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 5d59025c8f..c69646496e 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -249,15 +249,17 @@ The full annotated list of settings is in [`configs/config-template.toml`](confi ### Local auth -For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users, bcrypt-hashed passwords, and `dp:*` scopes are defined there, under `[[platform_api.auth.file.users]]`: +For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users and bcrypt-hashed passwords are defined there, under `[[platform_api.auth.file.users]]`; each names a role from the [`roles.yaml`](../../platform-api/resources/roles.yaml) mounted alongside it, and that role is where the `dp:*` scopes the portal enforces come from: ```toml [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$..." # bcrypt hash — generate with: htpasswd -bnBC 12 "" | tr -d ':\n' -scopes = "dp:org_manage dp:api_manage ..." +role = "ap_admin" # grants dp:org_manage, dp:api_manage, … — see roles.yaml ``` +To change what a portal user may do, edit that role's entry in `roles.yaml` rather than the user block. + The portal config (or `APIP_DP_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: ```toml diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md index 6032826574..8bddfb7f9a 100644 --- a/portals/developer-portal/distribution/README.md +++ b/portals/developer-portal/distribution/README.md @@ -135,7 +135,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.database].driver` | `sqlite3` or `postgres` | `sqlite3` | | `[platform_api.auth.jwt].public_key_file` / `.private_key_file` | RS256 keypair — platform-api signs login JWTs with the private key; the portal verifies with the public one | _(from `setup.sh`)_ | | `[platform_api.auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode | disabled | -| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars, `scopes` is a plain literal | admin, generated by `setup.sh` | +| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `role` names an entry in `resources/roles.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | +| `[platform_api.auth.authorization].role_mappings` | Path to the mounted `resources/roles.yaml` — edit that file to change what a role grants | `/etc/platform-api/roles.yaml` | See `configs/config-template.toml` for a fully-commented reference of every available setting across both active components (plus the optional `[ai_workspace]` section at the bottom). diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml index 86764acd6e..67391dd1a1 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -39,6 +39,11 @@ path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform-api-it.db" }}' [platform_api.auth] mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' +# Each user below names a role from this file — a role is a file-mode user's +# entire grant, so the scope lists live there, not here. +[platform_api.auth.authorization] +role_mappings = "/etc/platform-api/roles.yaml" + [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' # RS256 keypair generated by `make ensure-certs` (it/Makefile) and bind-mounted @@ -56,7 +61,7 @@ region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -scopes = "dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_delete dp:org_content_read dp:org_content_write dp:org_content_manage dp:org_content_delete dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read dp:delivery_manage" +role = "dp_admin_it" # Content/config manager — APIs, MCP servers, key managers, subscription # plans, views/labels, webhook subscribers, API workflows. No org management, @@ -64,11 +69,11 @@ scopes = "dp:org_read dp:org_create dp:org_update dp:org_manage dp:org_de [[platform_api.auth.file.users]] username = "publisher" password_hash = "$2y$10$BN9I5oPs34clNmhlO0CX0uDMKMnh9xkczGGmuLiInXSe/KOF5wqFW" -scopes = "dp:org_read dp:api_read dp:api_create dp:api_update dp:api_manage dp:api_delete dp:api_content_read dp:api_content_write dp:api_content_manage dp:api_content_delete dp:mcp_read dp:mcp_create dp:mcp_update dp:mcp_manage dp:mcp_delete dp:mcp_content_read dp:mcp_content_create dp:mcp_content_update dp:mcp_content_manage dp:mcp_content_delete dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:mcp_key_create dp:mcp_key_read dp:mcp_key_update dp:mcp_key_manage dp:mcp_key_revoke dp:api_workflow_create dp:api_workflow_read dp:api_workflow_update dp:api_workflow_delete dp:api_workflow_manage dp:sub_plan_create dp:sub_plan_read dp:sub_plan_update dp:sub_plan_manage dp:sub_plan_delete dp:km_create dp:km_read dp:km_update dp:km_manage dp:km_delete dp:view_create dp:view_read dp:view_update dp:view_manage dp:view_delete dp:label_create dp:label_read dp:label_update dp:label_manage dp:label_delete dp:webhook_subscriber_create dp:webhook_subscriber_read dp:webhook_subscriber_update dp:webhook_subscriber_delete dp:webhook_subscriber_manage dp:event_read" +role = "dp_publisher_it" # Portal end-user — read APIs/MCP servers, own applications, subscriptions, # and API keys. No org, key-manager, view/label, or webhook-subscriber management. [[platform_api.auth.file.users]] username = "developer" password_hash = "$2y$10$jX3o2E5jF4i3EOgoyJ0k.uegbDYmsmFNDfIxnvcZgTNJifAPjgKKK" -scopes = "dp:org_read dp:api_read dp:api_content_read dp:mcp_read dp:mcp_content_read dp:api_key_create dp:api_key_read dp:api_key_update dp:api_key_manage dp:api_key_revoke dp:app_create dp:app_read dp:app_update dp:app_manage dp:app_delete dp:app_key_create dp:app_key_manage dp:app_key_revoke dp:app_key_mapping_read dp:app_key_mapping_write dp:app_key_mapping_manage dp:subscription_create dp:subscription_read dp:subscription_update dp:subscription_manage dp:subscription_delete dp:sub_plan_read dp:view_read dp:label_read" +role = "dp_developer_it" diff --git a/portals/developer-portal/it/configs/roles-platform-api-it.yaml b/portals/developer-portal/it/configs/roles-platform-api-it.yaml new file mode 100644 index 0000000000..95e5e4f65c --- /dev/null +++ b/portals/developer-portal/it/configs/roles-platform-api-it.yaml @@ -0,0 +1,212 @@ +# -------------------------------------------------------------------- +# 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 +# -------------------------------------------------------------------- +# +# Role-to-scope mapping for the developer-portal backend IT suite, named by +# auth.authorization.role_mappings in config-platform-api-it.toml. A file-mode +# user's role is its entire grant, so the three IT accounts (admin / publisher / +# developer) get one role each, defined here rather than as per-user scope lists. +# +# These are Developer Portal ("dp:") scopes throughout: the Platform API mints +# them into the tokens it issues but does not enforce them, so it checks only +# their shape. The suite's own authorization assertions are what exercise them. +# -------------------------------------------------------------------- + +roles: + # Full access — organization management plus every resource area. + - name: dp_admin_it + scopes: + - dp:org_read + - dp:org_create + - dp:org_update + - dp:org_manage + - dp:org_delete + - dp:org_content_read + - dp:org_content_write + - dp:org_content_manage + - dp:org_content_delete + - dp:api_read + - dp:api_create + - dp:api_update + - dp:api_manage + - dp:api_delete + - dp:api_content_read + - dp:api_content_write + - dp:api_content_manage + - dp:api_content_delete + - dp:mcp_read + - dp:mcp_create + - dp:mcp_update + - dp:mcp_manage + - dp:mcp_delete + - dp:mcp_content_read + - dp:mcp_content_create + - dp:mcp_content_update + - dp:mcp_content_manage + - dp:mcp_content_delete + - dp:api_key_create + - dp:api_key_read + - dp:api_key_update + - dp:api_key_manage + - dp:api_key_revoke + - dp:mcp_key_create + - dp:mcp_key_read + - dp:mcp_key_update + - dp:mcp_key_manage + - dp:mcp_key_revoke + - dp:api_workflow_create + - dp:api_workflow_read + - dp:api_workflow_update + - dp:api_workflow_delete + - dp:api_workflow_manage + - dp:app_create + - dp:app_read + - dp:app_update + - dp:app_manage + - dp:app_delete + - dp:app_key_create + - dp:app_key_manage + - dp:app_key_revoke + - dp:app_key_mapping_read + - dp:app_key_mapping_write + - dp:app_key_mapping_manage + - dp:subscription_create + - dp:subscription_read + - dp:subscription_update + - dp:subscription_manage + - dp:subscription_delete + - dp:sub_plan_create + - dp:sub_plan_read + - dp:sub_plan_update + - dp:sub_plan_manage + - dp:sub_plan_delete + - dp:km_create + - dp:km_read + - dp:km_update + - dp:km_manage + - dp:km_delete + - dp:view_create + - dp:view_read + - dp:view_update + - dp:view_manage + - dp:view_delete + - dp:label_create + - dp:label_read + - dp:label_update + - dp:label_manage + - dp:label_delete + - dp:webhook_subscriber_create + - dp:webhook_subscriber_read + - dp:webhook_subscriber_update + - dp:webhook_subscriber_delete + - dp:webhook_subscriber_manage + - dp:event_read + - dp:delivery_manage + + # Content/config manager — APIs, MCP servers, key managers, subscription + # plans, views/labels, webhook subscribers, API workflows. No org management, + # no application ownership (that's the developer's). + - name: dp_publisher_it + scopes: + - dp:org_read + - dp:api_read + - dp:api_create + - dp:api_update + - dp:api_manage + - dp:api_delete + - dp:api_content_read + - dp:api_content_write + - dp:api_content_manage + - dp:api_content_delete + - dp:mcp_read + - dp:mcp_create + - dp:mcp_update + - dp:mcp_manage + - dp:mcp_delete + - dp:mcp_content_read + - dp:mcp_content_create + - dp:mcp_content_update + - dp:mcp_content_manage + - dp:mcp_content_delete + - dp:api_key_create + - dp:api_key_read + - dp:api_key_update + - dp:api_key_manage + - dp:api_key_revoke + - dp:mcp_key_create + - dp:mcp_key_read + - dp:mcp_key_update + - dp:mcp_key_manage + - dp:mcp_key_revoke + - dp:api_workflow_create + - dp:api_workflow_read + - dp:api_workflow_update + - dp:api_workflow_delete + - dp:api_workflow_manage + - dp:sub_plan_create + - dp:sub_plan_read + - dp:sub_plan_update + - dp:sub_plan_manage + - dp:sub_plan_delete + - dp:km_create + - dp:km_read + - dp:km_update + - dp:km_manage + - dp:km_delete + - dp:view_create + - dp:view_read + - dp:view_update + - dp:view_manage + - dp:view_delete + - dp:label_create + - dp:label_read + - dp:label_update + - dp:label_manage + - dp:label_delete + - dp:webhook_subscriber_create + - dp:webhook_subscriber_read + - dp:webhook_subscriber_update + - dp:webhook_subscriber_delete + - dp:webhook_subscriber_manage + - dp:event_read + + # Portal end-user — read APIs/MCP servers, own applications, subscriptions, + # and API keys. No org, key-manager, view/label, or webhook-subscriber management. + - name: dp_developer_it + scopes: + - dp:org_read + - dp:api_read + - dp:api_content_read + - dp:mcp_read + - dp:mcp_content_read + - dp:api_key_create + - dp:api_key_read + - dp:api_key_update + - dp:api_key_manage + - dp:api_key_revoke + - dp:app_create + - dp:app_read + - dp:app_update + - dp:app_manage + - dp:app_delete + - dp:app_key_create + - dp:app_key_manage + - dp:app_key_revoke + - dp:app_key_mapping_read + - dp:app_key_mapping_write + - dp:app_key_mapping_manage + - dp:subscription_create + - dp:subscription_read + - dp:subscription_update + - dp:subscription_manage + - dp:subscription_delete + - dp:sub_plan_read + - dp:view_read + - dp:label_read diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index 24c5319f1c..e8d72229b8 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -57,6 +57,7 @@ services: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro + - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles.yaml:ro - platform-api-data:/app/data # TLS pair + RS256 JWT keypair generated once, host-side, by `make # ensure-certs` (mirrors ../../scripts/setup.sh's approach for the production diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 96e3e9807f..1ec8f42cc4 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -36,6 +36,7 @@ services: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro + - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles.yaml:ro - platform-api-data:/app/data # TLS pair + RS256 JWT keypair generated once, host-side, by `make # ensure-certs` (mirrors ../../scripts/setup.sh's approach for the production diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 9b65c37f5d..4b9ef2f5bc 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -63,6 +63,9 @@ services: - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro + # Role-to-scope mapping named by auth.authorization.role_mappings — the + # shipped file, so the suite runs against the same grants operators get. + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 6bc853e7d7..e482b829fd 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -109,6 +109,9 @@ services: - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro + # Role-to-scope mapping named by auth.authorization.role_mappings — the + # shipped file, so the suite runs against the same grants operators get. + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 6faeaedb98..79f84bb156 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -122,6 +122,9 @@ services: - APIP_CP_WEBHOOK_GATEWAY_TYPE=wso2/api-platform volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro + # Role-to-scope mapping named by auth.authorization.role_mappings — the + # shipped file, so the suite runs against the same grants operators get. + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 62b3b01998..e0a9302734 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -23,6 +23,11 @@ ssl_mode = '{{ env "APIP_CP_DATABASE_SSL_MODE" "disable" }}' [platform_api.auth] mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' +# The admin below names a role from the shipped mapping, bind-mounted at this +# path — a role is a file-mode user's entire grant. +[platform_api.auth.authorization] +role_mappings = "/etc/platform-api/roles.yaml" + [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' # RS256 keypair generated by the platform-api-jwtkeygen init container and @@ -41,7 +46,9 @@ uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8 [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:api_key:all:manage ap:secret:manage dp:org_manage dp:api_manage dp:sub_plan_manage dp:app_manage dp:subscription_manage dp:api_key_manage dp:webhook_subscriber_manage" +# ap_admin from the shipped roles.yaml — every Platform API scope this build +# declares, plus the Developer Portal scopes the devportal e2e stack asserts on. +role = "ap_admin" [platform_api.webhook] enabled = '{{ env "APIP_CP_WEBHOOK_ENABLED" "false" }}' diff --git a/tests/integration-e2e/suite_test.go b/tests/integration-e2e/suite_test.go index 4fd2bd9911..ef49f78f99 100644 --- a/tests/integration-e2e/suite_test.go +++ b/tests/integration-e2e/suite_test.go @@ -49,11 +49,12 @@ const ( pollTimeout = 120 * time.Second // Admin user injected via AUTH_FILE_BASED_USERS on the @devportal stack. It - // carries both the platform-api ap:* scopes and the dp:*_manage scopes the - // developer portal requires, so the same admin JWT authorizes both products. - // (A mounted config's users are ignored — the built-in default admin wins — - // but the AUTH_FILE_BASED_USERS env var does override it.) - fileBasedAdminUsers = `[{"username":"admin","password_hash":"$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ.","scopes":"ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:api_key:all:manage ap:secret:manage dp:org_manage dp:api_manage dp:sub_plan_manage dp:app_manage dp:subscription_manage dp:api_key_manage dp:webhook_subscriber_manage"}]` + // names ap_admin from the mounted roles.yaml, which carries both the + // platform-api ap:* scopes and the dp:*_manage scopes the developer portal + // requires, so the same admin JWT authorizes both products. (A mounted + // config's users are ignored — the built-in default admin wins — but the + // AUTH_FILE_BASED_USERS env var does override it.) + fileBasedAdminUsers = `[{"username":"admin","password_hash":"$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ.","role":"ap_admin"}]` ) // Host-side endpoints. Ports are overridable so the suite can run alongside @@ -176,7 +177,7 @@ func bringUpStack() error { } if suite.db == "postgres" { - // Give the admin JWT the dp:* scopes the developer portal enforces. + // Give the admin JWT the role whose dp:* scopes the developer portal enforces. if err := os.Setenv("AUTH_FILE_BASED_USERS", fileBasedAdminUsers); err != nil { return err } From 3202b559bfe41f5bcee84eaa9842f44317afc721 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 15:45:38 +0530 Subject: [PATCH 3/7] Update authentication configuration to require audience in IDP mode and enforce flat claim mappings in file mode --- .../templates/configmap.yaml | 3 + .../helm/platform-api-helm-chart/values.yaml | 10 +-- platform-api/README.md | 9 +-- platform-api/config/config-template.toml | 11 ++- platform-api/config/config.go | 39 +++++++++ platform-api/config/config_test.go | 30 ++++++- platform-api/internal/handler/auth_login.go | 4 +- .../internal/middleware/role_scope_map.go | 40 +++------- .../middleware/role_scope_map_test.go | 17 ++-- platform-api/resources/roles.yaml | 7 +- portals/ai-workspace/README.md | 2 +- .../bff/internal/config/config.go | 4 +- .../ai-workspace/configs/config-template.toml | 2 +- portals/ai-workspace/production/README.md | 4 +- portals/ai-workspace/src/auth/permissions.ts | 7 -- portals/ai-workspace/src/config.env.ts | 1 - .../ai-workspace-cli-e2e/docker-compose.yaml | 80 ++++++++++++++++--- .../platform-api-config.toml | 61 ++++++++++---- tests/integration-e2e/README.md | 7 +- .../docker-compose.sqlite.yaml | 12 +-- .../docker-compose.sqlserver.yaml | 12 +-- tests/integration-e2e/docker-compose.yaml | 19 ++--- tests/integration-e2e/suite_test.go | 15 ---- 23 files changed, 251 insertions(+), 145 deletions(-) diff --git a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml index 231c89d48c..ebba7d3d6b 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml @@ -93,6 +93,9 @@ data: name = {{ $auth.idp.name | quote }} jwks_url = {{ required "config.auth.idp.jwksUrl is required when auth.mode is \"idp\"" $auth.idp.jwksUrl | quote }} issuer = {{ toJson $auth.idp.issuer }} + {{- if empty $auth.idp.audience }} + {{- fail "config.auth.idp.audience is required when auth.mode is \"idp\": without it, a token minted for any other client of the same IDP is accepted here on signature and issuer alone" }} + {{- end }} audience = {{ toJson $auth.idp.audience }} {{- end }} diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index 3d41fe6216..67e0754932 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -148,8 +148,6 @@ config: - ap:secret:manage - ap:api_key:read - ap:api_key:all:manage - - ap:devportal:manage - - ap:git:read # Claim-name mappings shared by all modes. claimMappings: organization: organization @@ -191,13 +189,15 @@ config: # per-user scope list). Requires that file to be mounted and roleMappings # above to point at it; a role absent from the file fails startup. role: ap_admin - # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl is - # required in that mode. + # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl, + # issuer, and audience are all required in that mode. idp: name: "" jwksUrl: "" issuer: [] # accepted token issuers - audience: [] # accepted audiences; empty = don't check + # Accepted audiences. Required: without it, a token the same IDP minted for + # any of its other clients verifies here on signature and issuer alone. + audience: [] # --- Server listeners --- server: diff --git a/platform-api/README.md b/platform-api/README.md index c088592c4c..dcfa3077a3 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -283,7 +283,7 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | | `[platform_api.auth]` | `mode` — one of `internal_token`, `file`, or `idp` | | `[platform_api.auth.authorization]` | `enabled`, `mode` (`scope` / `role`), `role_mappings` — applies in every auth mode | -| `[platform_api.auth.jwt]` | Asymmetric (RS256) token settings: `issuer`, `public_key` (**required** — PEM RSA public key, verifies tokens), `private_key` (**required in `file` mode** — PEM RSA private key, signs login tokens), `token_ttl` | +| `[platform_api.auth.jwt]` | Asymmetric (RS256) token settings: `issuer`, `public_key_file` (**required** — path to a PEM RSA public key, verifies tokens), `private_key_file` (**required in `file` mode** — path to a PEM RSA private key, signs login tokens), `token_ttl` | | `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint and issuer/audience for `idp` mode; JWT claim-name mappings (all modes) | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | | `[platform_api.server.http]` / `[platform_api.server.https]` | Listener enablement, ports, and (HTTPS) `cert_file` / `key_file` paths (certificates are always required for HTTPS — no self-signed fallback) | @@ -299,8 +299,8 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections `platform_api.auth.mode` selects exactly one mode; only that mode's section is read: -- **`internal_token`** — verify asymmetrically-signed (RS256) JWTs (`[platform_api.auth.jwt]`); tokens are minted by another trusted platform component and signed with the matching RSA private key, verified here against `public_key`. Symmetric (HMAC) and unsigned (`none`) tokens are rejected. -- **`file`** — `internal_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues RS256 JWTs signed with `[platform_api.auth.jwt].private_key`, verified with the matching `public_key`. Used by the AI Workspace and Developer Portal quickstarts. +- **`internal_token`** — verify asymmetrically-signed (RS256) JWTs (`[platform_api.auth.jwt]`); tokens are minted by another trusted platform component and signed with the matching RSA private key, verified here against `public_key_file`. Symmetric (HMAC) and unsigned (`none`) tokens are rejected. +- **`file`** — `internal_token` plus local username/password login: the login endpoint authenticates against `[platform_api.auth.file]` and issues RS256 JWTs signed with `[platform_api.auth.jwt].private_key_file`, verified with the matching `public_key_file`. Used by the AI Workspace and Developer Portal quickstarts. - **`idp`** — validate tokens against an external IDP's JWKS endpoint (Thunder, Asgardeo, Keycloak, Azure AD, Okta, etc.) via `[platform_api.auth.idp]`; `jwks_url` and `issuer` are required. The paths that bypass authentication and scope enforcement — health/metrics probes, the login @@ -357,8 +357,7 @@ sample (`resources/roles.yaml`) at `/etc/platform-api/roles.yaml`. Validation of that file is namespace-scoped. An `ap:` scope must be declared in this server's OpenAPI spec (plus any its compiled-in plugins declare) — an unknown one fails startup rather than silently -denying requests later. The exceptions are `ap:devportal:*` and `ap:git:read`, which this server mints -for the AI Workspace BFF to enforce and therefore allowlists rather than spec-checks. A scope in +denying requests later. A scope in another component's namespace (`dp:*`) is checked only for shape: this server mints it into the token but never enforces it, so it can neither confirm nor deny that it exists. That is what lets one role describe a persona across the whole platform — and what makes a per-user scope list unnecessary. diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 313b1aaff4..92af8d02ec 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -181,16 +181,21 @@ email = "email" scope = "scope" # space-separated scope string # Claim carrying the user's roles. Read in role authorization mode, and it is the # claim the file-mode login endpoint signs the user's role into. Default suits -# Asgardeo/Entra ID; Keycloak nests it: "realm_access.roles". +# Asgardeo/Entra ID; Keycloak nests it: "realm_access.roles". A dotted path is a +# read-side construct only — the login endpoint signs flat claims, so mode = +# "file" rejects a dotted mapping at startup rather than issue a token whose +# nested claim reads back empty. roles = "roles" # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). -# jwks_url and issuer are required in that mode. +# jwks_url, issuer, and audience are all required in that mode. [platform_api.auth.idp] name = "asgardeo" # friendly name for logging jwks_url = "https://accounts.example.com/oauth2/jwks" issuer = ["https://accounts.example.com"] # list of accepted issuers -audience = [] # accepted "aud" values; empty = skip audience check +# Accepted "aud" values — required. Without it, a token the same IDP minted for +# any of its other clients verifies here on signature and issuer alone. +audience = ["platform-api"] # File auth — local username/password login, used when mode = "file". Ideal # for initial / air-gapped setup; not recommended for production — prefer diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 232dc207c2..b38c203576 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -725,6 +725,9 @@ func validateAuthModeConfig(auth *Auth) error { return fmt.Errorf("Auth.JWT.TokenTTL must be a positive duration when auth.mode is %q "+ "(set auth.jwt.token_ttl, e.g. \"8h\")", AuthModeFile) } + if err := validateFileModeClaimMappings(&auth.ClaimMappings); err != nil { + return err + } return validateFileBasedConfig(&auth.File, &auth.Authorization) case AuthModeIDP: return validateIDPConfig(&auth.IDP) @@ -900,6 +903,13 @@ func validateIDPConfig(idp *IDP) error { if len(idp.Issuer) == 0 { return fmt.Errorf("auth.mode=%q requires auth.idp.issuer to be configured", AuthModeIDP) } + // Without an expected audience, any token the IDP minted for any of its + // clients verifies here — signature and issuer alone collapse "this IDP is + // trusted" into "every token it issues is valid for this server". + if len(idp.Audience) == 0 { + return fmt.Errorf("auth.mode=%q requires auth.idp.audience to be configured "+ + "(without it, a token minted for any other client of the same IDP is accepted)", AuthModeIDP) + } return nil } @@ -929,6 +939,35 @@ func validateAuthorizationConfig(authz *Authorization, claimMappings *ClaimMappi return nil } +// validateFileModeClaimMappings rejects a dot-separated claim mapping in file +// mode. A dotted mapping ("realm_access.roles") is a path into a nested claim, +// meaningful only when reading a token some IDP issued. The login endpoint signs +// flat claims, so it would emit a claim literally named "realm_access.roles" +// while the reader looks for a nested "realm_access" object and finds nothing — +// silently dropping the value. For the roles mapping that means a token whose +// role never arrives, so role-mode authorization denies every request from a +// user who logged in successfully. Reject at startup rather than mint tokens +// that read back empty. +func validateFileModeClaimMappings(cm *ClaimMappings) error { + for _, m := range []struct{ key, value string }{ + {"organization", cm.Organization}, + {"org_name", cm.OrgName}, + {"org_handle", cm.OrgHandle}, + {"user_id", cm.UserID}, + {"username", cm.Username}, + {"email", cm.Email}, + {"scope", cm.Scope}, + {"roles", cm.Roles}, + } { + if strings.Contains(m.value, ".") { + return fmt.Errorf("auth.claim_mappings.%s must be a flat claim name in auth.mode=%q (got %q): "+ + "the login endpoint signs flat claims, so a nested path would be issued as a literal claim name and read back empty", + m.key, AuthModeFile, m.value) + } + } + return nil +} + func validateFileBasedConfig(cfg *FileBased, authz *Authorization) error { if cfg.Organization.ID == "" { return fmt.Errorf("auth.mode=%q requires auth.file.organization.id to be configured", AuthModeFile) diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index e908651f20..1bf194d083 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -420,17 +420,45 @@ func TestValidateAuthConfig(t *testing.T) { }, }, }, + { + // The login endpoint signs flat claims, so a nested path would be + // issued as a literal claim name and read back empty — for the roles + // mapping, a user who logs in successfully and is then denied everything. + name: "file mode rejects a dotted claim mapping", + auth: Auth{ + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleMappings: "/etc/platform-api/roles.yaml"}, + ClaimMappings: ClaimMappings{Roles: "realm_access.roles"}, + File: FileBased{ + Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, + }, + }, + wantErr: "auth.claim_mappings.roles", + }, { name: "idp mode requires jwks_url", auth: Auth{Mode: AuthModeIDP}, wantErr: "auth.idp.jwks_url", }, { - name: "idp mode fully configured", + // Signature and issuer alone would accept a token the same IDP minted + // for any other client, so an expected audience is required too. + name: "idp mode without an audience rejected", auth: Auth{Mode: AuthModeIDP, IDP: IDP{ JWKSUrl: "https://idp.example.com/jwks", Issuer: []string{"https://idp.example.com"}, }}, + wantErr: "auth.idp.audience", + }, + { + name: "idp mode fully configured", + auth: Auth{Mode: AuthModeIDP, IDP: IDP{ + JWKSUrl: "https://idp.example.com/jwks", + Issuer: []string{"https://idp.example.com"}, + Audience: []string{"platform-api"}, + }}, }, { name: "unknown mode rejected", diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index c3b8036a92..975726a313 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -97,8 +97,8 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { // validateLocalJWT (and by any other consumer configured against the same // mapping) without the two ever drifting apart. Mapped names are used as // flat claim keys here; a dot-separated nested path (meant for reading - // externally-issued tokens) is not meaningful to sign against and is used - // as a literal flat key if configured that way. + // externally-issued tokens) is not meaningful to sign against, so + // validateFileModeClaimMappings rejects one at startup in this mode. cm := h.cfg.Auth.ClaimMappings expiry := time.Now().Add(h.cfg.Auth.JWT.TokenTTL) claims := jwt.MapClaims{ diff --git a/platform-api/internal/middleware/role_scope_map.go b/platform-api/internal/middleware/role_scope_map.go index ac57130419..291903bfef 100644 --- a/platform-api/internal/middleware/role_scope_map.go +++ b/platform-api/internal/middleware/role_scope_map.go @@ -66,25 +66,13 @@ func LoadRoleScopeMap(path string) (map[string][]string, error) { // server only mints them, so it can neither confirm nor deny that they exist. const PlatformScopePrefix = "ap:" -// wellFormedScope matches ":" with an optional ":*" wildcard -// tail — enough to catch a missing or malformed namespace on a foreign scope, -// which is the only error class detectable without that component's spec. -var wellFormedScope = regexp.MustCompile(`^[a-z0-9_]+:[a-z0-9_:*]+$`) - -// mintedPlatformScopes are scopes in this server's own namespace that it issues -// into tokens but does not itself declare or enforce — they gate endpoints on a -// sibling component (the AI Workspace BFF) that trusts the same token. They -// cannot be checked against the OpenAPI spec for that reason, but they must be -// nameable in roles.yaml: a role is a file-mode user's entire grant, so a scope -// that can't be named in the mapping can't be granted at all. -var mintedPlatformScopes = map[string]bool{ - "ap:devportal:read": true, - "ap:devportal:create": true, - "ap:devportal:update": true, - "ap:devportal:delete": true, - "ap:devportal:manage": true, - "ap:git:read": true, -} +// wellFormedScope matches ":" — one or more colon-separated +// segments after the namespace, with "*" permitted only as the final segment. +// Segments allow hyphens as well as underscores, since a foreign namespace +// (the Developer Portal's "dp:", say) picks its own naming convention. This is +// enough to catch a missing or malformed namespace on a foreign scope, which is +// the only error class detectable without that component's spec. +var wellFormedScope = regexp.MustCompile(`^[a-z0-9_-]+(?::[a-z0-9_-]+)+(?::\*)?$`) // ValidateRoleScopeMap checks the scopes referenced in the map, failing fast at // startup rather than at request time — an unrecognized scope name is almost @@ -93,12 +81,11 @@ var mintedPlatformScopes = map[string]bool{ // Validation is namespace-scoped. A scope in this server's own namespace // (PlatformScopePrefix) must be declared in the OpenAPI spec — that includes // scopes contributed by compiled-in plugins, so this must run after the plugin -// specs are merged — unless it is one of the mintedPlatformScopes this server -// issues on a sibling component's behalf. A scope in another component's -// namespace is checked only for well-formedness: the roles file is where a role's -// grants across the whole platform are described, and it is the only place a -// file-mode user's grants can be expressed, so refusing scopes this server does -// not declare would make them ungrantable. +// specs are merged. A scope in another component's namespace is checked only for +// well-formedness: the roles file is where a role's grants across the whole +// platform are described, and it is the only place a file-mode user's grants can +// be expressed, so refusing scopes this server does not declare would make them +// ungrantable. func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error { known := registry.AllScopes() for role, scopes := range m { @@ -110,9 +97,6 @@ func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error if !strings.HasPrefix(s, PlatformScopePrefix) { continue // another component's namespace — not ours to validate } - if mintedPlatformScopes[s] { - continue // ours to mint, a sibling component's to enforce - } if _, ok := known[s]; !ok { return fmt.Errorf("roles.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) } diff --git a/platform-api/internal/middleware/role_scope_map_test.go b/platform-api/internal/middleware/role_scope_map_test.go index fd6b727bb3..da8ef0334f 100644 --- a/platform-api/internal/middleware/role_scope_map_test.go +++ b/platform-api/internal/middleware/role_scope_map_test.go @@ -71,17 +71,16 @@ paths: wantErr: "malformed scope", }, { - // This server mints these for the AI Workspace BFF to enforce, so they - // appear in no spec it can check. A role must still be able to name - // them — it is a file-mode user's only grant. - name: "minted platform scope passes without being declared here", - scopes: []string{"ap:devportal:manage", "ap:git:read"}, + // A foreign namespace picks its own naming convention, so a hyphen in a + // segment is well-formed even though this server's own scopes never use one. + name: "hyphenated foreign scope is accepted", + scopes: []string{"dp:api-key_read"}, }, { - // The allowlist is exact, not a prefix: a typo inside it still fails. - name: "typo in a minted scope is still rejected", - scopes: []string{"ap:devportal:mange"}, - wantErr: "unknown scope", + // "*" is a trailing segment, not a free-floating character. + name: "wildcard outside the final segment is rejected", + scopes: []string{"ap:rest_*:read"}, + wantErr: "malformed scope", }, } for _, tt := range tests { diff --git a/platform-api/resources/roles.yaml b/platform-api/resources/roles.yaml index e9cc69e7f5..e729f98d6d 100644 --- a/platform-api/resources/roles.yaml +++ b/platform-api/resources/roles.yaml @@ -25,9 +25,7 @@ # spec (plus any its compiled-in plugins declare) — an unknown ap: scope # fails startup rather than silently denying requests later. The # event-gateway scopes below are commented out for that reason: uncomment -# them on a build that includes that plugin. The two exceptions are -# ap:devportal:* and ap:git:read, which this server mints for the AI -# Workspace BFF to enforce; they are allowlisted, not spec-checked. +# them on a build that includes that plugin. # dp:* Developer Portal scopes. This server mints them into the token but never # enforces them, so it validates only their shape, not their existence. # @@ -76,9 +74,6 @@ roles: - ap:api_key:all:manage # - ap:websub_api:manage # event-gateway build only # - ap:webbroker_api:manage # event-gateway build only - # AI Workspace — minted by the Platform API, enforced by the BFF. - - ap:devportal:manage - - ap:git:read # Developer Portal - dp:org_manage - dp:org_content_manage diff --git a/portals/ai-workspace/README.md b/portals/ai-workspace/README.md index 510e172478..9b79858cde 100644 --- a/portals/ai-workspace/README.md +++ b/portals/ai-workspace/README.md @@ -374,7 +374,7 @@ failures, by symptom: | Symptom | Cause | Fix | |---|---|---| | `unauthorized_client` / *"not authorized to use the requested grant type"* | App registered as SPA, or Code/Refresh grant not enabled | Recreate as Standard-Based OIDC app; enable **Code** + **Refresh Token** (step 1) | -| Platform API exits at startup with *`auth.mode must be "internal_token", "file", or "idp"`* | `auth.mode` is unset or misspelled | Compose: set `APIP_CP_AUTH_MODE=idp` in `api-platform.env` (step 3, Option 1). Local: set `[auth] mode = "idp"` in `config.toml` (step 3, Option 2) | +| Platform API exits at startup with *`auth.mode must be "internal_token", "file", or "idp"`* | `auth.mode` is unset or misspelled | Compose: set `APIP_CP_AUTH_MODE=idp` in `api-platform.env` (step 3, Option 1). Local: set `[platform_api.auth] mode = "idp"` in `config.toml` (step 3, Option 2) | | `502` + `dial tcp: lookup platform-api: no such host` | BFF run locally but `[ai_workspace.control_plane] url` points at the compose hostname | Set `APIP_AIW_CONTROL_PLANE_URL=https://localhost:9243` (step 3, Option 2) | | Proxied calls return `authentication_failed` | Platform API still on local JWT/file-based, validating the IDP token with the wrong validator | Switch it to the IDP — compose: set the `APIP_CP_AUTH_IDP_*` keys in `api-platform.env` (step 3, Option 1); local: enable `[auth.idp]` (step 3, Option 2) | | Proxied calls return `authentication_failed`, Platform API logs `token contains an invalid number of segments` | IDP is issuing **opaque** access tokens — the BFF forwards the access token and the Platform API can only validate a **JWT** via JWKS | Set **Access Token Type = JWT** on the app's Protocol tab (step 1) and re-login | diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 7595437112..a40c671a43 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -212,7 +212,6 @@ const defaultOIDCScopes = "openid profile email offline_access" + " ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:deployment:undeploy ap:rest_api:deployment:restore" + " ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage" + " ap:rest_api:publication:read ap:rest_api:publication:create ap:rest_api:publication:delete" + - " ap:devportal:read ap:devportal:create ap:devportal:update ap:devportal:delete ap:devportal:manage" + " ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage" + " ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage" + " ap:llm_template:read ap:llm_template:create ap:llm_template:update ap:llm_template:delete ap:llm_template:manage" + @@ -232,8 +231,7 @@ const defaultOIDCScopes = "openid profile email offline_access" + " ap:webbroker_api:api_key:read ap:webbroker_api:api_key:create ap:webbroker_api:api_key:delete ap:webbroker_api:api_key:manage ap:webbroker_api:api_key:update" + " ap:webbroker_api:deployment:read ap:webbroker_api:deployment:create ap:webbroker_api:deployment:delete ap:webbroker_api:deployment:manage ap:webbroker_api:deployment:undeploy ap:webbroker_api:deployment:restore" + " ap:webbroker_api:publication:read ap:webbroker_api:publication:create ap:webbroker_api:publication:delete" + - " ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" + - " ap:git:read" + " ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" // Load resolves configuration from one or more config.toml files. At least one path // is required and each must exist and parse — there is no default path and no diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 040cad987f..afbcb6df70 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -250,7 +250,7 @@ post_logout_redirect_url = "https://localhost:9643/login" # Scopes requested at login (space-separated). Defaults to the full ap:* set the # Platform API authorizes against (recommended) — trim only what you need to # restrict, and always keep offline_access or token refresh breaks. -scope = "openid profile email offline_access ap:organization:read ap:organization:manage ap:organization:subscription:read ap:project:read ap:project:create ap:project:update ap:project:delete ap:project:manage ap:application:read ap:application:create ap:application:update ap:application:delete ap:application:manage ap:application:api_key:read ap:application:api_key:create ap:application:api_key:delete ap:application:api_key:manage ap:application:association:read ap:application:association:create ap:application:association:delete ap:application:association:manage ap:application:association:api_key:read ap:gateway:read ap:gateway:create ap:gateway:update ap:gateway:delete ap:gateway:manage ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage ap:gateway_custom_policy:read ap:gateway_custom_policy:create ap:gateway_custom_policy:delete ap:gateway_custom_policy:manage ap:gateway:artifact:read ap:gateway:manifest:read ap:rest_api:read ap:rest_api:create ap:rest_api:update ap:rest_api:delete ap:rest_api:manage ap:rest_api:import ap:rest_api:gateway:read ap:rest_api:gateway:create ap:rest_api:gateway:manage ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:deployment:undeploy ap:rest_api:deployment:restore ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage ap:rest_api:publication:read ap:rest_api:publication:create ap:rest_api:publication:delete ap:devportal:read ap:devportal:create ap:devportal:update ap:devportal:delete ap:devportal:manage ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage ap:llm_template:read ap:llm_template:create ap:llm_template:update ap:llm_template:delete ap:llm_template:manage ap:llm_provider:read ap:llm_provider:create ap:llm_provider:update ap:llm_provider:delete ap:llm_provider:manage ap:llm_provider:api_key:read ap:llm_provider:api_key:create ap:llm_provider:api_key:delete ap:llm_provider:api_key:manage ap:llm_provider:deployment:read ap:llm_provider:deployment:create ap:llm_provider:deployment:delete ap:llm_provider:deployment:manage ap:llm_provider:deployment:undeploy ap:llm_provider:deployment:restore ap:llm_proxy:read ap:llm_proxy:create ap:llm_proxy:update ap:llm_proxy:delete ap:llm_proxy:manage ap:llm_proxy:api_key:read ap:llm_proxy:api_key:create ap:llm_proxy:api_key:delete ap:llm_proxy:api_key:manage ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:llm_proxy:deployment:undeploy ap:llm_proxy:deployment:restore ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:mcp_proxy:deployment:undeploy ap:mcp_proxy:deployment:restore ap:websub_api:read ap:websub_api:create ap:websub_api:update ap:websub_api:delete ap:websub_api:manage ap:websub_api:api_key:read ap:websub_api:api_key:create ap:websub_api:api_key:delete ap:websub_api:api_key:manage ap:websub_api:api_key:update ap:websub_api:deployment:read ap:websub_api:deployment:create ap:websub_api:deployment:delete ap:websub_api:deployment:manage ap:websub_api:deployment:undeploy ap:websub_api:deployment:restore ap:websub_api:publication:read ap:websub_api:publication:create ap:websub_api:publication:delete ap:webbroker_api:read ap:webbroker_api:create ap:webbroker_api:update ap:webbroker_api:delete ap:webbroker_api:manage ap:webbroker_api:api_key:read ap:webbroker_api:api_key:create ap:webbroker_api:api_key:delete ap:webbroker_api:api_key:manage ap:webbroker_api:api_key:update ap:webbroker_api:deployment:read ap:webbroker_api:deployment:create ap:webbroker_api:deployment:delete ap:webbroker_api:deployment:manage ap:webbroker_api:deployment:undeploy ap:webbroker_api:deployment:restore ap:webbroker_api:publication:read ap:webbroker_api:publication:create ap:webbroker_api:publication:delete ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage ap:git:read" +scope = "openid profile email offline_access ap:organization:read ap:organization:manage ap:organization:subscription:read ap:project:read ap:project:create ap:project:update ap:project:delete ap:project:manage ap:application:read ap:application:create ap:application:update ap:application:delete ap:application:manage ap:application:api_key:read ap:application:api_key:create ap:application:api_key:delete ap:application:api_key:manage ap:application:association:read ap:application:association:create ap:application:association:delete ap:application:association:manage ap:application:association:api_key:read ap:gateway:read ap:gateway:create ap:gateway:update ap:gateway:delete ap:gateway:manage ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage ap:gateway_custom_policy:read ap:gateway_custom_policy:create ap:gateway_custom_policy:delete ap:gateway_custom_policy:manage ap:gateway:artifact:read ap:gateway:manifest:read ap:rest_api:read ap:rest_api:create ap:rest_api:update ap:rest_api:delete ap:rest_api:manage ap:rest_api:import ap:rest_api:gateway:read ap:rest_api:gateway:create ap:rest_api:gateway:manage ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:deployment:undeploy ap:rest_api:deployment:restore ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage ap:rest_api:publication:read ap:rest_api:publication:create ap:rest_api:publication:delete ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage ap:llm_template:read ap:llm_template:create ap:llm_template:update ap:llm_template:delete ap:llm_template:manage ap:llm_provider:read ap:llm_provider:create ap:llm_provider:update ap:llm_provider:delete ap:llm_provider:manage ap:llm_provider:api_key:read ap:llm_provider:api_key:create ap:llm_provider:api_key:delete ap:llm_provider:api_key:manage ap:llm_provider:deployment:read ap:llm_provider:deployment:create ap:llm_provider:deployment:delete ap:llm_provider:deployment:manage ap:llm_provider:deployment:undeploy ap:llm_provider:deployment:restore ap:llm_proxy:read ap:llm_proxy:create ap:llm_proxy:update ap:llm_proxy:delete ap:llm_proxy:manage ap:llm_proxy:api_key:read ap:llm_proxy:api_key:create ap:llm_proxy:api_key:delete ap:llm_proxy:api_key:manage ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:llm_proxy:deployment:undeploy ap:llm_proxy:deployment:restore ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:mcp_proxy:deployment:undeploy ap:mcp_proxy:deployment:restore ap:websub_api:read ap:websub_api:create ap:websub_api:update ap:websub_api:delete ap:websub_api:manage ap:websub_api:api_key:read ap:websub_api:api_key:create ap:websub_api:api_key:delete ap:websub_api:api_key:manage ap:websub_api:api_key:update ap:websub_api:deployment:read ap:websub_api:deployment:create ap:websub_api:deployment:delete ap:websub_api:deployment:manage ap:websub_api:deployment:undeploy ap:websub_api:deployment:restore ap:websub_api:publication:read ap:websub_api:publication:create ap:websub_api:publication:delete ap:webbroker_api:read ap:webbroker_api:create ap:webbroker_api:update ap:webbroker_api:delete ap:webbroker_api:manage ap:webbroker_api:api_key:read ap:webbroker_api:api_key:create ap:webbroker_api:api_key:delete ap:webbroker_api:api_key:manage ap:webbroker_api:api_key:update ap:webbroker_api:deployment:read ap:webbroker_api:deployment:create ap:webbroker_api:deployment:delete ap:webbroker_api:deployment:manage ap:webbroker_api:deployment:undeploy ap:webbroker_api:deployment:restore ap:webbroker_api:publication:read ap:webbroker_api:publication:create ap:webbroker_api:publication:delete ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" # ==================================================================== diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 79d850dc4f..a7138bd1a6 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -113,11 +113,11 @@ org_handle = "org_handle" Optional overrides (defaults shown): ```toml -[auth.authorization] +[platform_api.auth.authorization] enabled = true mode = "scope" # or "role" for role-based auth (then set role_mappings) -[auth.claim_mappings] +[platform_api.auth.claim_mappings] user_id = "sub" username = "username" email = "email" diff --git a/portals/ai-workspace/src/auth/permissions.ts b/portals/ai-workspace/src/auth/permissions.ts index 7e51da62c4..a76c03f7da 100644 --- a/portals/ai-workspace/src/auth/permissions.ts +++ b/portals/ai-workspace/src/auth/permissions.ts @@ -90,13 +90,6 @@ export const SCOPES = { REST_API_API_KEY_MANAGE: 'ap:rest_api:api_key:manage', REST_API_PUBLICATION_READ: 'ap:rest_api:publication:read', - // DevPortals - DEVPORTAL_READ: 'ap:devportal:read', - DEVPORTAL_CREATE: 'ap:devportal:create', - DEVPORTAL_UPDATE: 'ap:devportal:update', - DEVPORTAL_DELETE: 'ap:devportal:delete', - DEVPORTAL_MANAGE: 'ap:devportal:manage', - // Subscriptions SUBSCRIPTION_READ: 'ap:subscription:read', SUBSCRIPTION_CREATE: 'ap:subscription:create', diff --git a/portals/ai-workspace/src/config.env.ts b/portals/ai-workspace/src/config.env.ts index 7d3e4f356d..7dda775ac9 100644 --- a/portals/ai-workspace/src/config.env.ts +++ b/portals/ai-workspace/src/config.env.ts @@ -82,7 +82,6 @@ export const OIDC_SCOPE = getEnvOrDefault( ' ap:rest_api:deployment:read ap:rest_api:deployment:create ap:rest_api:deployment:delete ap:rest_api:deployment:manage ap:rest_api:deployment:undeploy ap:rest_api:deployment:restore' + ' ap:rest_api:api_key:read ap:rest_api:api_key:create ap:rest_api:api_key:update ap:rest_api:api_key:delete ap:rest_api:api_key:manage' + ' ap:rest_api:publication:read ap:rest_api:publication:create ap:rest_api:publication:delete' + - ' ap:devportal:read ap:devportal:create ap:devportal:update ap:devportal:delete ap:devportal:manage' + ' ap:subscription:read ap:subscription:create ap:subscription:update ap:subscription:delete ap:subscription:manage' + ' ap:subscription_plan:read ap:subscription_plan:create ap:subscription_plan:update ap:subscription_plan:delete ap:subscription_plan:manage' + ' ap:llm_template:read ap:llm_template:create ap:llm_template:update ap:llm_template:delete ap:llm_template:manage' + diff --git a/tests/ai-workspace-cli-e2e/docker-compose.yaml b/tests/ai-workspace-cli-e2e/docker-compose.yaml index 4ec7dbc7dd..5a46941244 100644 --- a/tests/ai-workspace-cli-e2e/docker-compose.yaml +++ b/tests/ai-workspace-cli-e2e/docker-compose.yaml @@ -9,30 +9,84 @@ # platform-api:it-e2e from source and passes it through (same pattern the # platform-api-devportal-e2e / platform-api-gateway-e2e suites use). services: + # One-shot init container: generates the TLS pair the platform-api HTTPS + # listener requires (the server no longer generates a self-signed fallback). + platform-api-certgen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + [ -f /certs/cert.pem ] && [ -f /certs/key.pem ] && exit 0 + openssl req -x509 -newkey rsa:2048 -sha256 -days 365 -nodes \ + -keyout /certs/key.pem -out /certs/cert.pem \ + -subj "/O=WSO2 API Platform/CN=platform-api" \ + -addext "subjectAltName=DNS:localhost,DNS:platform-api,IP:127.0.0.1" + # certgen runs as root; platform-api runs as uid 10001, so the key must be + # owned by that uid, not made world-readable. + chown 10001 /certs/key.pem + chmod 0600 /certs/key.pem + volumes: + - platform-api-certs:/certs + networks: [aiwscli] + + # One-shot init container: generates the RS256 keypair platform-api's + # auth.jwt config requires. Login tokens are signed asymmetrically — there is + # no shared HMAC secret anymore. + platform-api-jwtkeygen: + image: alpine/openssl + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + [ -f /keys/jwt_private.pem ] && [ -f /keys/jwt_public.pem ] && exit 0 + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ + -out /keys/jwt_private.pem 2>/dev/null + openssl rsa -in /keys/jwt_private.pem -pubout \ + -out /keys/jwt_public.pem 2>/dev/null + chown 10001 /keys/jwt_private.pem + chmod 0600 /keys/jwt_private.pem + chmod 0644 /keys/jwt_public.pem + volumes: + - platform-api-jwt-keys:/keys + networks: [aiwscli] + platform-api: image: ${PLATFORM_API_IMAGE:-platform-api:it-e2e} - command: ["./platform-api", "-config", "/etc/platform-api/config.toml"] + command: ["-config", "/etc/platform-api/config.toml"] + # Every variable here is read through the matching {{ env }} token in + # platform-api-config.toml — platform-api has no env-override layer. environment: - - DATABASE_DRIVER=sqlite3 - - DATABASE_PATH=/app/data/platform.db - - DATABASE_EXECUTE_SCHEMA_DDL=true + - APIP_CP_DATABASE_DRIVER=sqlite3 + - APIP_CP_DATABASE_PATH=/app/data/platform.db # Single encryption key — must be 32 bytes (64 hex chars). Used for all # at-rest encryption (secrets, subscription tokens, HMAC). - - ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - - AUTH_FILE_BASED_ENABLED=true - - AUTH_FILE_BASED_ORGANIZATION_ID=default - - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - - AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - - AUTH_FILE_BASED_ORGANIZATION_REGION=us - # Signs login JWTs; must be 32 bytes (64 hex chars). Overrides the - # placeholder in platform-api-config.toml (env wins in koanf). - - AUTH_JWT_SECRET_KEY=fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + - APIP_CP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + # Stable org identity so the scenario's org handle/UUID are fixed across runs. + - APIP_CP_AUTH_FILE_ORGANIZATION_ID=default + - APIP_CP_AUTH_FILE_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 + - APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME=Default + - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro + # Role-to-scope mapping named by auth.authorization.role_mappings — the + # shipped file, so the suite runs against the same grants operators get. + - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - platform-api-certs:/app/data/certs + - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: - "${PA_HOST_PORT:-9243}:9243" + depends_on: + platform-api-certgen: + condition: service_completed_successfully + platform-api-jwtkeygen: + condition: service_completed_successfully networks: [aiwscli] +volumes: + platform-api-certs: + platform-api-jwt-keys: + networks: aiwscli: driver: bridge diff --git a/tests/ai-workspace-cli-e2e/platform-api-config.toml b/tests/ai-workspace-cli-e2e/platform-api-config.toml index 497457dde9..80b459ea4e 100644 --- a/tests/ai-workspace-cli-e2e/platform-api-config.toml +++ b/tests/ai-workspace-cli-e2e/platform-api-config.toml @@ -1,26 +1,53 @@ -# platform-api configuration for the combined e2e stack. +# platform-api configuration for the AI Workspace CLI e2e stack. +# # Database settings come from environment variables (see docker-compose.yaml); # this file supplies file-based auth so the scenario can log in (admin/admin). +# +# Every environment variable below reaches the server through the {{ env }} +# token that names it — platform-api has no env-override layer, so an env var +# not spelled in this file has no effect. -[auth.jwt] -enabled = true -issuer = "platform-api" -secret_key = "e2e-integration-secret-key-0123456789" +[platform_api] -[auth.file_based] -enabled = true +[platform_api.logging] +level = '{{ env "APIP_CP_LOGGING_LEVEL" "info" }}' -[auth.file_based.organization] -id = "default" # organization handle (URL-safe slug) -display_name = "Default" -region = "us" +[platform_api.security] +encryption_key = '{{ env "APIP_CP_ENCRYPTION_KEY" }}' + +[platform_api.database] +driver = '{{ env "APIP_CP_DATABASE_DRIVER" "sqlite3" }}' +path = '{{ env "APIP_CP_DATABASE_PATH" "/app/data/platform.db" }}' + +[platform_api.auth] +mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' + +# The admin below names a role from the shipped mapping, bind-mounted at this +# path — a role is a file-mode user's entire grant. +[platform_api.auth.authorization] +enabled = true +mode = "scope" +role_mappings = "/etc/platform-api/roles.yaml" + +[platform_api.auth.jwt] +issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' +# RS256 keypair generated by the platform-api-jwtkeygen init container and +# bind-mounted read-only at /etc/platform-api/keys. Tokens are signed +# asymmetrically — there is no shared HMAC secret. +public_key_file = '{{ env "APIP_CP_AUTH_JWT_PUBLIC_KEY_FILE" "/etc/platform-api/keys/jwt_public.pem" }}' +private_key_file = '{{ env "APIP_CP_AUTH_JWT_PRIVATE_KEY_FILE" "/etc/platform-api/keys/jwt_private.pem" }}' +token_ttl = "8h" + +[platform_api.auth.file.organization] +id = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_ID" "default" }}' +display_name = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME" "Default" }}' +region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' +uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8-a2f4-c8dfbb085295" }}' # Default login: admin / admin (bcrypt hash of "admin", reused from the shipped sample config). -[[auth.file_based.users]] +[[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -# NOTE: platform-api ignores file-based users from a mounted config (defaultConfig's -# admin wins in koanf's slice merge), so scopes here are not authoritative. The -# @devportal stack instead injects the admin (with dp:* scopes) via the -# AUTH_FILE_BASED_USERS env var, which does override the default. See suite_test.go. -scopes = "ap:organization:manage ap:gateway:manage ap:gateway_custom_policy:manage ap:rest_api:manage ap:llm_provider:manage ap:llm_proxy:manage ap:mcp_proxy:manage ap:webbroker_api:manage ap:websub_api:manage ap:application:manage ap:subscription:manage ap:subscription_plan:manage ap:project:manage ap:llm_template:manage ap:devportal:manage ap:api_key:read ap:api_key:all:manage ap:secret:manage" +# ap_admin from the shipped roles.yaml — every Platform API scope this build +# declares. A role is the user's whole grant; there is no per-user scope list. +role = "ap_admin" diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index f9fce381a0..d3c18749e4 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -156,10 +156,9 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. - Auth: the devportal accepts the platform-api admin JWT directly (verified against the RS256 `jwt_public.pem` shared via the `platform-api-jwt-keys` volume, org from the token's `org_handle` claim). The - admin must carry `dp:*` scopes, which platform-api's built-in admin lacks — - so the suite injects an admin (ap:* *and* dp:*) via the - `AUTH_FILE_BASED_USERS` env var (a mounted config's users are ignored; only - that env override wins). Bearer auth (not API-key mode) is used because the + admin must carry `dp:*` scopes, which it gets from the `ap_admin` role in the + mounted `roles.yaml` — that role spans both the `ap:*` and `dp:*` namespaces, + so the one admin JWT authorizes both products. Bearer auth (not API-key mode) is used because the write paths need a resolved user for `created_by`. - `BeforeSuite` generates the shared webhook secret (`prepareWebhookSecret`, exported as `E2E_WEBHOOK_SECRET` before the stack starts so compose interpolates the same diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 4b9ef2f5bc..57417cbf1b 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -56,11 +56,13 @@ services: # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest # encryption (secrets, subscription tokens, HMAC) - APIP_CP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - - APIP_CP_AUTH_FILE_BASED_ENABLED=true - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_ID=default - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us + # Stable org identity so the scenario's org handle/UUID are fixed across runs. + # These names are the {{ env }} tokens platform-api-config.toml reads; the + # server has no env-override layer, so a name not spelled in that file is inert. + - APIP_CP_AUTH_FILE_ORGANIZATION_ID=default + - APIP_CP_AUTH_FILE_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 + - APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME=Default + - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro # Role-to-scope mapping named by auth.authorization.role_mappings — the diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index e482b829fd..97e788c223 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -102,11 +102,13 @@ services: # Single encryption key — must be 32 bytes (64 hex chars). Used for all at-rest # encryption (secrets, subscription tokens, HMAC) - APIP_CP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - - APIP_CP_AUTH_FILE_BASED_ENABLED=true - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_ID=default - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us + # Stable org identity so the scenario's org handle/UUID are fixed across runs. + # These names are the {{ env }} tokens platform-api-config.toml reads; the + # server has no env-override layer, so a name not spelled in that file is inert. + - APIP_CP_AUTH_FILE_ORGANIZATION_ID=default + - APIP_CP_AUTH_FILE_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 + - APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME=Default + - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro # Role-to-scope mapping named by auth.authorization.role_mappings — the diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 79f84bb156..228bae0aa4 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -101,17 +101,13 @@ services: - APIP_CP_DATABASE_PASSWORD=apip - APIP_CP_DATABASE_SSL_MODE=disable - APIP_CP_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - # Force file-based auth + a stable org so the scenario can log in and the - # org is seeded (env overrides the mounted config file). - - APIP_CP_AUTH_FILE_BASED_ENABLED=true - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_ID=default - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_DISPLAY_NAME=Default - - APIP_CP_AUTH_FILE_BASED_ORGANIZATION_REGION=us - # The @devportal stack injects the admin user (with dp:* scopes) here so the - # issued JWT is authorized on the developer portal. Empty (the default) means - # platform-api uses its built-in admin (ap:* scopes only). - - APIP_CP_AUTH_FILE_BASED_USERS=${AUTH_FILE_BASED_USERS:-} + # Stable org identity so the scenario's org handle/UUID are fixed across runs. + # These names are the {{ env }} tokens platform-api-config.toml reads; the + # server has no env-override layer, so a name not spelled in that file is inert. + - APIP_CP_AUTH_FILE_ORGANIZATION_ID=default + - APIP_CP_AUTH_FILE_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 + - APIP_CP_AUTH_FILE_ORGANIZATION_DISPLAY_NAME=Default + - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us # Developer Portal webhook receiver (only the postgres stack runs the devportal). # Enabled via env so the sqlite/sqlserver stacks — which have no devportal — # leave it off. The secret both verifies request signatures and decrypts the @@ -119,7 +115,6 @@ services: # with this exact same value (see registerWebhookSubscriber). - APIP_CP_WEBHOOK_ENABLED=true - APIP_CP_WEBHOOK_SECRET=${E2E_WEBHOOK_SECRET:?set E2E_WEBHOOK_SECRET (the suite sets it automatically; export one to run compose by hand)} - - APIP_CP_WEBHOOK_GATEWAY_TYPE=wso2/api-platform volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro # Role-to-scope mapping named by auth.authorization.role_mappings — the diff --git a/tests/integration-e2e/suite_test.go b/tests/integration-e2e/suite_test.go index ef49f78f99..6aa2b783c1 100644 --- a/tests/integration-e2e/suite_test.go +++ b/tests/integration-e2e/suite_test.go @@ -47,14 +47,6 @@ const ( // run (postgres + two gateways + devportal, with controller restarts) tolerates // slower Envoy config propagation under load on constrained hosts. pollTimeout = 120 * time.Second - - // Admin user injected via AUTH_FILE_BASED_USERS on the @devportal stack. It - // names ap_admin from the mounted roles.yaml, which carries both the - // platform-api ap:* scopes and the dp:*_manage scopes the developer portal - // requires, so the same admin JWT authorizes both products. (A mounted - // config's users are ignored — the built-in default admin wins — but the - // AUTH_FILE_BASED_USERS env var does override it.) - fileBasedAdminUsers = `[{"username":"admin","password_hash":"$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ.","role":"ap_admin"}]` ) // Host-side endpoints. Ports are overridable so the suite can run alongside @@ -176,13 +168,6 @@ func bringUpStack() error { return err } - if suite.db == "postgres" { - // Give the admin JWT the role whose dp:* scopes the developer portal enforces. - if err := os.Setenv("AUTH_FILE_BASED_USERS", fileBasedAdminUsers); err != nil { - return err - } - } - // Phase 1: control plane + backend. phase1 := []string{"platform-api", "sample-backend"} if suite.db != "sqlite" { From c738aaebfc4f8205a5cb8982ec471434f99dd784 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 15:51:46 +0530 Subject: [PATCH 4/7] Enhance provider creation flow in LLM proxy secret management tests by ensuring organization-level context is set before accessing the Service Provider page. --- .../e2e/001-providers/003-llm-proxy-secret-management.cy.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js index 28512c8905..93603b4960 100644 --- a/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js +++ b/portals/ai-workspace/cypress/e2e/001-providers/003-llm-proxy-secret-management.cy.js @@ -93,6 +93,11 @@ function createProjectViaUI(projectName) { function createProviderViaUI(providerName) { cy.intercept('POST', /\/llm-providers(\?|$)/).as('createProviderForProxy'); + // Providers are an organization-level resource: with a project selected (which + // createProjectViaUI leaves behind), the Service Provider page renders the + // "available at the organization level" notice and no create button at all. + // currentProject is in-memory React state, so reloading the org root clears it. + cy.visitWorkspace(`/organizations/${Cypress.env('ORG_HANDLE')}`); cy.get('[data-cyid="nav-service-provider"]', { timeout: 30000 }).should('be.visible').click(); cy.get('[data-cyid="add-new-provider-button"]', { timeout: 30000 }).should('be.visible').click(); cy.get('[data-cyid="provider-template-openai-card"]', { timeout: 30000 }).should('be.visible').click(); From 831169b6a3794fef38802953a18066d007db6b8c Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 15:57:41 +0530 Subject: [PATCH 5/7] Refactor IDP authentication configuration to remove audience requirement and enhance claim mapping handling. Update related tests and documentation for clarity on nested claim paths. --- .../templates/configmap.yaml | 3 - .../helm/platform-api-helm-chart/values.yaml | 8 +-- platform-api/config/config-template.toml | 13 ++-- platform-api/config/config.go | 39 ----------- platform-api/config/config_test.go | 30 +-------- platform-api/internal/handler/auth_login.go | 64 +++++++++++++++---- .../internal/handler/auth_login_test.go | 47 ++++++++++++++ 7 files changed, 106 insertions(+), 98 deletions(-) diff --git a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml index ebba7d3d6b..231c89d48c 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml @@ -93,9 +93,6 @@ data: name = {{ $auth.idp.name | quote }} jwks_url = {{ required "config.auth.idp.jwksUrl is required when auth.mode is \"idp\"" $auth.idp.jwksUrl | quote }} issuer = {{ toJson $auth.idp.issuer }} - {{- if empty $auth.idp.audience }} - {{- fail "config.auth.idp.audience is required when auth.mode is \"idp\": without it, a token minted for any other client of the same IDP is accepted here on signature and issuer alone" }} - {{- end }} audience = {{ toJson $auth.idp.audience }} {{- end }} diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index 67e0754932..6e22ea4248 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -189,15 +189,13 @@ config: # per-user scope list). Requires that file to be mounted and roleMappings # above to point at it; a role absent from the file fails startup. role: ap_admin - # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl, - # issuer, and audience are all required in that mode. + # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl is + # required in that mode. idp: name: "" jwksUrl: "" issuer: [] # accepted token issuers - # Accepted audiences. Required: without it, a token the same IDP minted for - # any of its other clients verifies here on signature and issuer alone. - audience: [] + audience: [] # accepted audiences; empty = don't check # --- Server listeners --- server: diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 92af8d02ec..860cdb8d93 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -181,21 +181,18 @@ email = "email" scope = "scope" # space-separated scope string # Claim carrying the user's roles. Read in role authorization mode, and it is the # claim the file-mode login endpoint signs the user's role into. Default suits -# Asgardeo/Entra ID; Keycloak nests it: "realm_access.roles". A dotted path is a -# read-side construct only — the login endpoint signs flat claims, so mode = -# "file" rejects a dotted mapping at startup rather than issue a token whose -# nested claim reads back empty. +# Asgardeo/Entra ID; Keycloak nests it: "realm_access.roles". A dotted path works +# in both directions — file mode signs the nested object the reader resolves — so +# the same mapping serves whichever auth mode is active. roles = "roles" # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). -# jwks_url, issuer, and audience are all required in that mode. +# jwks_url and issuer are required in that mode. [platform_api.auth.idp] name = "asgardeo" # friendly name for logging jwks_url = "https://accounts.example.com/oauth2/jwks" issuer = ["https://accounts.example.com"] # list of accepted issuers -# Accepted "aud" values — required. Without it, a token the same IDP minted for -# any of its other clients verifies here on signature and issuer alone. -audience = ["platform-api"] +audience = [] # accepted "aud" values; empty = skip audience check # File auth — local username/password login, used when mode = "file". Ideal # for initial / air-gapped setup; not recommended for production — prefer diff --git a/platform-api/config/config.go b/platform-api/config/config.go index b38c203576..232dc207c2 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -725,9 +725,6 @@ func validateAuthModeConfig(auth *Auth) error { return fmt.Errorf("Auth.JWT.TokenTTL must be a positive duration when auth.mode is %q "+ "(set auth.jwt.token_ttl, e.g. \"8h\")", AuthModeFile) } - if err := validateFileModeClaimMappings(&auth.ClaimMappings); err != nil { - return err - } return validateFileBasedConfig(&auth.File, &auth.Authorization) case AuthModeIDP: return validateIDPConfig(&auth.IDP) @@ -903,13 +900,6 @@ func validateIDPConfig(idp *IDP) error { if len(idp.Issuer) == 0 { return fmt.Errorf("auth.mode=%q requires auth.idp.issuer to be configured", AuthModeIDP) } - // Without an expected audience, any token the IDP minted for any of its - // clients verifies here — signature and issuer alone collapse "this IDP is - // trusted" into "every token it issues is valid for this server". - if len(idp.Audience) == 0 { - return fmt.Errorf("auth.mode=%q requires auth.idp.audience to be configured "+ - "(without it, a token minted for any other client of the same IDP is accepted)", AuthModeIDP) - } return nil } @@ -939,35 +929,6 @@ func validateAuthorizationConfig(authz *Authorization, claimMappings *ClaimMappi return nil } -// validateFileModeClaimMappings rejects a dot-separated claim mapping in file -// mode. A dotted mapping ("realm_access.roles") is a path into a nested claim, -// meaningful only when reading a token some IDP issued. The login endpoint signs -// flat claims, so it would emit a claim literally named "realm_access.roles" -// while the reader looks for a nested "realm_access" object and finds nothing — -// silently dropping the value. For the roles mapping that means a token whose -// role never arrives, so role-mode authorization denies every request from a -// user who logged in successfully. Reject at startup rather than mint tokens -// that read back empty. -func validateFileModeClaimMappings(cm *ClaimMappings) error { - for _, m := range []struct{ key, value string }{ - {"organization", cm.Organization}, - {"org_name", cm.OrgName}, - {"org_handle", cm.OrgHandle}, - {"user_id", cm.UserID}, - {"username", cm.Username}, - {"email", cm.Email}, - {"scope", cm.Scope}, - {"roles", cm.Roles}, - } { - if strings.Contains(m.value, ".") { - return fmt.Errorf("auth.claim_mappings.%s must be a flat claim name in auth.mode=%q (got %q): "+ - "the login endpoint signs flat claims, so a nested path would be issued as a literal claim name and read back empty", - m.key, AuthModeFile, m.value) - } - } - return nil -} - func validateFileBasedConfig(cfg *FileBased, authz *Authorization) error { if cfg.Organization.ID == "" { return fmt.Errorf("auth.mode=%q requires auth.file.organization.id to be configured", AuthModeFile) diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 1bf194d083..e908651f20 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -420,45 +420,17 @@ func TestValidateAuthConfig(t *testing.T) { }, }, }, - { - // The login endpoint signs flat claims, so a nested path would be - // issued as a literal claim name and read back empty — for the roles - // mapping, a user who logs in successfully and is then denied everything. - name: "file mode rejects a dotted claim mapping", - auth: Auth{ - Mode: AuthModeFile, - JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleMappings: "/etc/platform-api/roles.yaml"}, - ClaimMappings: ClaimMappings{Roles: "realm_access.roles"}, - File: FileBased{ - Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, - Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, - }, - }, - wantErr: "auth.claim_mappings.roles", - }, { name: "idp mode requires jwks_url", auth: Auth{Mode: AuthModeIDP}, wantErr: "auth.idp.jwks_url", }, { - // Signature and issuer alone would accept a token the same IDP minted - // for any other client, so an expected audience is required too. - name: "idp mode without an audience rejected", + name: "idp mode fully configured", auth: Auth{Mode: AuthModeIDP, IDP: IDP{ JWKSUrl: "https://idp.example.com/jwks", Issuer: []string{"https://idp.example.com"}, }}, - wantErr: "auth.idp.audience", - }, - { - name: "idp mode fully configured", - auth: Auth{Mode: AuthModeIDP, IDP: IDP{ - JWKSUrl: "https://idp.example.com/jwks", - Issuer: []string{"https://idp.example.com"}, - Audience: []string{"platform-api"}, - }}, }, { name: "unknown mode rejected", diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 975726a313..4ed93de62d 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -95,29 +95,30 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { // Claim names come from auth.claim_mappings — the same mapping IDP mode // reads incoming claims by — so a token this endpoint signs is readable by // validateLocalJWT (and by any other consumer configured against the same - // mapping) without the two ever drifting apart. Mapped names are used as - // flat claim keys here; a dot-separated nested path (meant for reading - // externally-issued tokens) is not meaningful to sign against, so - // validateFileModeClaimMappings rejects one at startup in this mode. + // mapping) without the two ever drifting apart. A mapped name may be a + // dot-separated path ("realm_access.roles", the Keycloak shape): setClaim + // writes it as the nested object resolveClaimPath reads back, so the same + // mapping works in both directions and an operator can point file mode at + // the claim layout the rest of their estate already uses. cm := h.cfg.Auth.ClaimMappings expiry := time.Now().Add(h.cfg.Auth.JWT.TokenTTL) claims := jwt.MapClaims{ - "sub": matched.Username, - claimKey(cm.Username, "username"): matched.Username, - claimKey(cm.Scope, "scope"): h.effectiveScopes(matched), - claimKey(cm.Organization, "organization"): fileBasedAuth.Organization.UUID, - claimKey(cm.OrgName, "org_name"): fileBasedAuth.Organization.DisplayName, - claimKey(cm.OrgHandle, "org_handle"): fileBasedAuth.Organization.ID, - "iss": h.cfg.Auth.JWT.Issuer, - "exp": expiry.Unix(), - "iat": time.Now().Unix(), + "sub": matched.Username, + "iss": h.cfg.Auth.JWT.Issuer, + "exp": expiry.Unix(), + "iat": time.Now().Unix(), } + setClaim(claims, claimKey(cm.Username, "username"), matched.Username) + setClaim(claims, claimKey(cm.Scope, "scope"), h.effectiveScopes(matched)) + setClaim(claims, claimKey(cm.Organization, "organization"), fileBasedAuth.Organization.UUID) + setClaim(claims, claimKey(cm.OrgName, "org_name"), fileBasedAuth.Organization.DisplayName) + setClaim(claims, claimKey(cm.OrgHandle, "org_handle"), fileBasedAuth.Organization.ID) // The role travels in the token as well as the scopes it expanded to, so a // consumer configured for role-based authorization reads the same identity // this endpoint authorized — the claim is a list, matching the shape IDPs // emit and the shape the roles claim is read back in. Config validation // guarantees the role is set, so this is unconditional. - claims[claimKey(cm.Roles, "roles")] = []string{matched.Role} + setClaim(claims, claimKey(cm.Roles, "roles"), []string{matched.Role}) // Sign asymmetrically with RS256 using the configured RSA private key, // read fresh from its mounted file. Config validation (validateJWTConfig) @@ -171,3 +172,38 @@ func claimKey(name, def string) string { } return name } + +// setClaim writes value at path, where path is either a flat claim name +// ("roles") or a dot-separated path into nested claim objects +// ("realm_access.roles"). It is the write-side mirror of the middleware's +// resolveClaimPath, so a mapping configured for an IDP's nested layout reads +// back the same way from a token this endpoint signed. +// +// Intermediate objects are created as needed and merged into, never replaced, +// so two mappings sharing a prefix ("realm_access.roles" and +// "realm_access.org_id") both survive regardless of the order they are set. A +// prefix that already holds a non-object value is overwritten with an object: +// that only happens when one mapping is a strict prefix of another, which is a +// contradictory configuration either way, and the deeper path is the one an +// operator wrote deliberately. +func setClaim(claims jwt.MapClaims, path string, value interface{}) { + if path == "" { + return + } + parts := strings.Split(path, ".") + if len(parts) == 1 { + claims[path] = value + return + } + + current := map[string]interface{}(claims) + for _, part := range parts[:len(parts)-1] { + next, ok := current[part].(map[string]interface{}) + if !ok { + next = map[string]interface{}{} + current[part] = next + } + current = next + } + current[parts[len(parts)-1]] = value +} diff --git a/platform-api/internal/handler/auth_login_test.go b/platform-api/internal/handler/auth_login_test.go index a547b7f108..e75748d96a 100644 --- a/platform-api/internal/handler/auth_login_test.go +++ b/platform-api/internal/handler/auth_login_test.go @@ -20,6 +20,7 @@ package handler import ( "testing" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/wso2/api-platform/platform-api/config" @@ -74,3 +75,49 @@ func TestEffectiveScopes(t *testing.T) { }) } } + +// A claim mapping may be a dot-separated path into a nested claim object, the +// shape Keycloak and similar IDPs use. setClaim is the write-side mirror of the +// middleware's resolveClaimPath, so a mapping configured for that layout must +// round-trip: what this endpoint signs is what the reader finds. +func TestSetClaimNestedPaths(t *testing.T) { + t.Run("flat path writes a top-level claim", func(t *testing.T) { + claims := jwt.MapClaims{} + setClaim(claims, "roles", []string{"ap_admin"}) + assert.Equal(t, []string{"ap_admin"}, claims["roles"]) + }) + + t.Run("dotted path writes a nested object", func(t *testing.T) { + claims := jwt.MapClaims{} + setClaim(claims, "realm_access.roles", []string{"ap_admin"}) + + nested, ok := claims["realm_access"].(map[string]interface{}) + assert.True(t, ok, "realm_access should be a nested object, not a literal key") + assert.Equal(t, []string{"ap_admin"}, nested["roles"]) + assert.Nil(t, claims["realm_access.roles"], "the dotted name must not survive as a flat key") + }) + + t.Run("mappings sharing a prefix both survive", func(t *testing.T) { + claims := jwt.MapClaims{} + setClaim(claims, "realm_access.roles", []string{"ap_admin"}) + setClaim(claims, "realm_access.org_id", "acme") + + nested := claims["realm_access"].(map[string]interface{}) + assert.Equal(t, []string{"ap_admin"}, nested["roles"]) + assert.Equal(t, "acme", nested["org_id"]) + }) + + t.Run("deeper nesting is created for every intermediate level", func(t *testing.T) { + claims := jwt.MapClaims{} + setClaim(claims, "resource_access.platform-api.roles", []string{"ap_admin"}) + + client := claims["resource_access"].(map[string]interface{})["platform-api"].(map[string]interface{}) + assert.Equal(t, []string{"ap_admin"}, client["roles"]) + }) + + t.Run("empty path writes nothing", func(t *testing.T) { + claims := jwt.MapClaims{} + setClaim(claims, "", "value") + assert.Empty(t, claims) + }) +} From c715b941656e1a030dcc3ad680b3295b101f532a Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 16:45:28 +0530 Subject: [PATCH 6/7] Refactor authentication configuration to use roles-to-scope mapping. --- distribution/all-in-one/docker-compose.yaml | 4 +- .../templates/configmap.yaml | 8 +-- .../templates/deployment.yaml | 8 +-- .../helm/platform-api-helm-chart/values.yaml | 22 ++++--- platform-api/README.md | 50 ++++++++------- platform-api/config/config-template.toml | 30 ++++----- platform-api/config/config.go | 56 ++++++++++------- platform-api/config/config.toml | 11 ++-- platform-api/config/config_test.go | 62 ++++++++++++++----- platform-api/config/default_config.go | 2 +- platform-api/internal/handler/auth_login.go | 44 +++++++------ .../internal/handler/auth_login_test.go | 30 +++++++-- .../middleware/openapi_scope_registry.go | 2 +- .../internal/middleware/role_scope_map.go | 16 ++--- .../internal/server/role_scope_map_test.go | 30 ++++----- platform-api/internal/server/server.go | 26 ++++---- ...roles.yaml => roles_to_scope_mapping.yaml} | 14 ++--- portals/ai-workspace/Makefile | 8 +-- portals/ai-workspace/distribution/README.md | 6 +- portals/ai-workspace/docker-compose.yaml | 4 +- portals/ai-workspace/production/README.md | 2 +- portals/developer-portal/Makefile | 8 +-- portals/developer-portal/README.md | 6 +- .../developer-portal/distribution/README.md | 6 +- .../docker-compose.platform-api.yaml | 4 +- portals/developer-portal/docker-compose.yaml | 2 +- .../it/configs/config-platform-api-it.toml | 8 +-- .../it/configs/roles-platform-api-it.yaml | 4 +- .../it/docker-compose.test.postgres.yaml | 2 +- .../it/docker-compose.test.yaml | 2 +- .../ai-workspace-cli-e2e/docker-compose.yaml | 4 +- .../platform-api-config.toml | 8 +-- tests/integration-e2e/README.md | 2 +- .../docker-compose.sqlite.yaml | 4 +- .../docker-compose.sqlserver.yaml | 4 +- tests/integration-e2e/docker-compose.yaml | 4 +- .../integration-e2e/platform-api-config.toml | 6 +- 37 files changed, 290 insertions(+), 219 deletions(-) rename platform-api/resources/{roles.yaml => roles_to_scope_mapping.yaml} (94%) diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index a29d4eb070..14378e417f 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -137,11 +137,9 @@ services: - "9243:9243" volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-data:/api-platform/data - platform-api-certs:/app/data/certs - # RS256 JWT signing/verification keys — on the Platform API's {{ file }} - # allowlist (/etc/platform-api). - platform-api-jwt-keys:/etc/platform-api/keys:ro environment: - APIP_CP_DEFAULT_DEVPORTAL_API_URL=http://devportal:${APIP_DP_SERVER_PORT:-9543} diff --git a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml index 231c89d48c..3138a88179 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml @@ -69,7 +69,7 @@ data: [platform_api.auth.authorization] enabled = {{ $auth.authorization.enabled }} mode = {{ $auth.authorization.mode | quote }} - role_mappings = {{ $auth.authorization.roleMappings | quote }} + roles_to_scope_mapping = {{ $auth.authorization.rolesToScopeMapping | quote }} [platform_api.auth.claim_mappings] organization = {{ $auth.claimMappings.organization | quote }} @@ -111,7 +111,7 @@ data: # provisions a generated username and a bcrypt password hash. username = {{ `'{{ env "APIP_CP_ADMIN_USERNAME" }}'` }} password_hash = {{ `'{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}'` }} - role = {{ required "config.auth.file.admin.role is required when auth.mode is \"file\"" $auth.file.admin.role | quote }} + roles = {{ required "config.auth.file.admin.roles is required when auth.mode is \"file\"" $auth.file.admin.roles | toJson }} {{- end }} [platform_api.server.http] @@ -169,10 +169,10 @@ data: {{ . | nindent 4 | trim }} {{- end }} {{- with $auth.authorization.roles }} - # Role→scope mapping named by auth.authorization.role_mappings. Mounted as a + # Role→scope mapping named by auth.authorization.roles_to_scope_mapping. Mounted as a # file rather than folded into the TOML above because the Platform API reads it # separately and re-reads it only on restart. - roles.yaml: | + roles_to_scope_mapping.yaml: | roles: {{- toYaml . | nindent 6 }} {{- end }} diff --git a/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml b/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml index 3963e3b348..62e7d5752e 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml @@ -161,8 +161,8 @@ spec: subPath: config-platform-api.toml {{- if $pa.config.auth.authorization.roles }} - name: config - mountPath: {{ $pa.config.auth.authorization.roleMappings }} - subPath: roles.yaml + mountPath: {{ $pa.config.auth.authorization.rolesToScopeMapping }} + subPath: roles_to_scope_mapping.yaml {{- end }} - name: jwt-keys mountPath: {{ $jwtKeyDir }} @@ -190,8 +190,8 @@ spec: - key: config-platform-api.toml path: config-platform-api.toml {{- if $pa.config.auth.authorization.roles }} - - key: roles.yaml - path: roles.yaml + - key: roles_to_scope_mapping.yaml + path: roles_to_scope_mapping.yaml {{- end }} # RS256 JWT keys mounted as PEM files from the external Secret. The # public key verifies tokens (every mode); the private key signs diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index 6e22ea4248..8d8c3bc78b 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -120,13 +120,13 @@ config: enabled: true mode: scope # scope | role # Path to the role→scope mapping YAML. Required when mode=role, and in file - # mode (auth.file.admin.role is a user's whole grant). The chart renders + # mode (auth.file.admin.roles is a user's whole grant). The chart renders # `roles` below into its config ConfigMap and mounts it here; point this # elsewhere only if you supply your own file via extraVolumes/extraVolumeMounts. - roleMappings: /etc/platform-api/roles.yaml + rolesToScopeMapping: /etc/platform-api/roles_to_scope_mapping.yaml # Roles the mapping file defines, each a name and the scopes it grants. # Only ap_admin is shipped here — the file-mode admin below names it. - # platform-api/resources/roles.yaml is the full sample set (ap_admin, + # platform-api/resources/roles_to_scope_mapping.yaml is the full sample set (ap_admin, # ap_operator, ap_publisher, ap_subscriber, ap_viewer); copy the entries you # need from it. An ap: scope the Platform API's OpenAPI spec does not declare # fails startup; dp: scopes (Developer Portal) are checked for shape only. @@ -158,7 +158,7 @@ config: email: email scope: scope # Claim carrying the user's roles — read in role authorization mode, and the - # claim the file-mode login endpoint signs auth.file.admin.role into. + # claim the file-mode login endpoint signs auth.file.admin.roles into. roles: roles # Keycloak nests it: "realm_access.roles" # Local RS256 JWT keys. public_key_file verifies tokens (every mode); # private_key_file signs login tokens (file mode only). Both are mounted as @@ -182,13 +182,15 @@ config: # APIP_CP_ADMIN_PASSWORD_HASH (secrets.keys.adminUsername / adminPasswordHash), # which generate-secrets.sh provisions with a generated username and a bcrypt # hash. There is no admin/admin default: startup fails closed if unset. Only - # the granted role is configured here (add more users via configToml). + # the granted roles are configured here (add more users via configToml). admin: - # REQUIRED in file mode — a role from the roleMappings file, expanded into - # the token's scopes at login, and this user's entire grant (there is no - # per-user scope list). Requires that file to be mounted and roleMappings - # above to point at it; a role absent from the file fails startup. - role: ap_admin + # REQUIRED in file mode — one or more roles from the rolesToScopeMapping + # file, expanded into the token's scopes at login (the union of what each + # grants), and this user's entire grant (there is no per-user scope list). + # Requires that file to be mounted and rolesToScopeMapping above to point + # at it; a role absent from the file fails startup. + roles: + - ap_admin # idp mode — external OIDC provider (rendered only when mode=idp). jwksUrl is # required in that mode. idp: diff --git a/platform-api/README.md b/platform-api/README.md index dcfa3077a3..41b9427fd9 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -24,9 +24,10 @@ go run ./cmd/main.go (username/password login backed by the organization/user block in that file) — the same mode the AI Workspace and Developer Portal quickstarts use. It's the one Platform API config shared by every quickstart (both docker-compose setups mount it directly), so its admin user is granted the -`ap_admin` role from the mounted [`resources/roles.yaml`](resources/roles.yaml), which covers both -the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces. That role is the whole grant — -override it with `APIP_CP_ADMIN_ROLE`, or edit what it grants in `roles.yaml`. +`ap_admin` role from the mounted [`resources/roles_to_scope_mapping.yaml`](resources/roles_to_scope_mapping.yaml), which covers both +the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces. That one role is the whole grant — +override it with `APIP_CP_ADMIN_ROLE`, name more roles alongside it, or edit what it grants in +`roles_to_scope_mapping.yaml`. There is no default admin credential: `APIP_CP_ADMIN_USERNAME` and `APIP_CP_ADMIN_PASSWORD_HASH` are **required** in this mode, and startup fails closed if either is unset or empty. `portals/scripts/setup.sh` @@ -282,7 +283,7 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | `[platform_api.security.api_key]` | `hashing_algorithms` accepted for API key verification | | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | | `[platform_api.auth]` | `mode` — one of `internal_token`, `file`, or `idp` | -| `[platform_api.auth.authorization]` | `enabled`, `mode` (`scope` / `role`), `role_mappings` — applies in every auth mode | +| `[platform_api.auth.authorization]` | `enabled`, `mode` (`scope` / `role`), `roles_to_scope_mapping` — applies in every auth mode | | `[platform_api.auth.jwt]` | Asymmetric (RS256) token settings: `issuer`, `public_key_file` (**required** — path to a PEM RSA public key, verifies tokens), `private_key_file` (**required in `file` mode** — path to a PEM RSA private key, signs login tokens), `token_ttl` | | `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint and issuer/audience for `idp` mode; JWT claim-name mappings (all modes) | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | @@ -314,7 +315,7 @@ key silently ignored. #### Role-Based Access Control (RBAC) Per-route scope checks are enforced when `platform_api.auth.authorization.enabled = true`. The -shipped [`resources/roles.yaml`](resources/roles.yaml) defines five roles, each granting scopes in +shipped [`resources/roles_to_scope_mapping.yaml`](resources/roles_to_scope_mapping.yaml) defines five roles, each granting scopes in both the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces — one role covers a persona across both components: @@ -344,16 +345,16 @@ local public key: [platform_api.auth.authorization] enabled = true mode = "role" # "scope" (default) or "role" -role_mappings = "/etc/platform-api/roles.yaml" # required when mode = "role" +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" # required when mode = "role" ``` `mode = "scope"` authorizes from the scope claim directly. `mode = "role"` expands the roles claim -named by `claim_mappings.roles` into platform scopes via the `role_mappings` YAML file; both that +named by `claim_mappings.roles` into platform scopes via the `roles_to_scope_mapping` YAML file; both that claim mapping and the file path are required in role mode, so startup fails rather than falling back to using role names verbatim as scopes. The mapping file is operator-owned config, not part of the image: the packs mount their editable -sample (`resources/roles.yaml`) at `/etc/platform-api/roles.yaml`. +sample (`resources/roles_to_scope_mapping.yaml`) at `/etc/platform-api/roles_to_scope_mapping.yaml`. Validation of that file is namespace-scoped. An `ap:` scope must be declared in this server's OpenAPI spec (plus any its compiled-in plugins declare) — an unknown one fails startup rather than silently @@ -362,32 +363,37 @@ another component's namespace (`dp:*`) is checked only for shape: this server mi but never enforces it, so it can neither confirm nor deny that it exists. That is what lets one role describe a persona across the whole platform — and what makes a per-user scope list unnecessary. -##### Granting a file-mode user a role +##### Granting a file-mode user roles -A `file`-mode user is granted **only** a role — there is no per-user scope list. The login endpoint -expands that role through the same `role_mappings` file when it mints the token: +A `file`-mode user is granted **only** roles — there is no per-user scope list. The login endpoint +expands them through the same `roles_to_scope_mapping` file when it mints the token, unioning what +each grants: ```toml [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -role = "ap_admin" # expanded via auth.authorization.role_mappings +roles = ["ap_admin"] # expanded via auth.authorization.roles_to_scope_mapping ``` -The issued token carries **both**: the expanded scopes as the `scope` claim, and the role name as the +`roles` is a list, so a user whose persona spans two shipped roles names both rather than needing a +sixth role defined for the combination — `roles = ["ap_publisher", "ap_subscriber"]` grants the union +of the two, most-permissive wins, with duplicate scopes collapsed. + +The issued token carries **both**: the expanded scopes as the `scope` claim, and the role names as the `roles` claim. So the same login works under either authorization mode — `scope` (the default) checks -the expanded claim, and flipping `auth.authorization.mode = "role"` re-expands the role from the same -`roles.yaml` on every request instead. `claim_mappings.roles` defaults to the flat `roles` claim the +the expanded claim, and flipping `auth.authorization.mode = "role"` re-expands the roles from the same +`roles_to_scope_mapping.yaml` on every request instead. `claim_mappings.roles` defaults to the flat `roles` claim the login endpoint signs, so that switch needs no extra claim wiring. -The role is required, and startup fails if a user has none or names one the mapping file doesn't -define — either way that user would authenticate successfully and then be denied every route. Because -the mapping is the only place a grant is expressed, no user can drift out of step with the role it -names, and widening or narrowing a persona is one edit in one file. To grant something no shipped -role covers, add a role to `roles.yaml`. +At least one role is required, and startup fails if a user has none or names one the mapping file +doesn't define — either way that user would authenticate successfully and then be denied every route. +Because the mapping is the only place a grant is expressed, no user can drift out of step with the +roles it names, and widening or narrowing a persona is one edit in one file. To grant something no +combination of shipped roles covers, add a role to `roles_to_scope_mapping.yaml`. -This is how the shipped `config/config.toml` grants its admin user: `role = "ap_admin"` and nothing -else. Changing what that user can do means editing the mounted `roles.yaml`, which makes that file the +This is how the shipped `config/config.toml` grants its admin user: `roles = ["ap_admin"]` and nothing +else. Changing what that user can do means editing the mounted `roles_to_scope_mapping.yaml`, which makes that file the security-relevant one to review in a pack. ### Providing secrets via the config file diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 860cdb8d93..65a05e5839 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -155,14 +155,14 @@ mode = "file" enabled = true # "scope" (default) checks the scope claim; "role" checks the roles claim -# configured below at claim_mappings.roles, expanding each role via role_mappings. +# configured below at claim_mappings.roles, expanding each role via roles_to_scope_mapping. mode = "scope" # Path to a YAML file mapping role names to platform scopes. Required when # mode = "role" (startup fails if unset), and also when any file-mode user below # names a role — the login endpoint expands that role through this same file. -# The packs mount their editable sample at /etc/platform-api/roles.yaml. -role_mappings = "" +# The packs mount their editable sample at /etc/platform-api/roles_to_scope_mapping.yaml. +roles_to_scope_mapping = "" # JWT claim name mappings — shared by all three auth modes ("idp" reads # incoming claims by these names; "file" mode's login endpoint signs tokens @@ -180,7 +180,7 @@ username = "username" email = "email" scope = "scope" # space-separated scope string # Claim carrying the user's roles. Read in role authorization mode, and it is the -# claim the file-mode login endpoint signs the user's role into. Default suits +# claim the file-mode login endpoint signs the user's roles into. Default suits # Asgardeo/Entra ID; Keycloak nests it: "realm_access.roles". A dotted path works # in both directions — file mode signs the nested object the reader resolves — so # the same mapping serves whichever auth mode is active. @@ -212,28 +212,30 @@ uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # starting with a blank or guessable credential. username = "" password_hash = "" -# REQUIRED — a role from the auth.authorization.role_mappings file above, and this -# user's entire grant. The login endpoint expands it into the token's scope claim -# and also emits the role itself as the roles claim, so the same token works +# REQUIRED — one or more roles from the auth.authorization.roles_to_scope_mapping +# file above, and this user's entire grant. The login endpoint expands them into +# the token's scope claim (the union of what each grants — most-permissive wins) +# and also emits the roles themselves as the roles claim, so the same token works # whether auth.authorization.mode is "scope" (default) or "role". A user with no -# role, or one naming a role the mapping file doesn't define, fails startup rather +# roles, or one naming a role the mapping file doesn't define, fails startup rather # than logging in successfully and then being denied every request. # # There is no per-user scope list: what a role grants is defined once, in the -# mapping file, so no user can drift out of step with the role it names. To grant -# something no shipped role covers, add a role to that file (see -# resources/roles.yaml for the shipped ones and the scope namespaces it may use). +# mapping file, so no user can drift out of step with the roles it names. To grant +# something no shipped role covers, name several roles, or add a role to that file +# (see resources/roles_to_scope_mapping.yaml for the shipped ones and the scope +# namespaces it may use). # -# Left empty here because role_mappings above is empty in this template — set that +# Left empty here because roles_to_scope_mapping above is empty in this template — set that # first, then name a role. -role = "" +roles = [] # Additional users — uncomment the WHOLE block (including the [[...]] header) # and replace the placeholder hash with a real bcrypt hash before use. # [[platform_api.auth.file.users]] # username = "readonly" # password_hash = "$2a$12$" -# role = "ap_viewer" +# roles = ["ap_viewer"] # JWT (local RS256) — used by "internal_token" and "file" modes. Tokens are # signed asymmetrically: "internal_token" only verifies tokens minted elsewhere diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 232dc207c2..786ef3aea3 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -45,13 +45,16 @@ import ( type FileBasedUser struct { Username string `json:"username" koanf:"username"` PasswordHash string `json:"password_hash" koanf:"password_hash"` - // Role names one of the roles in the auth.authorization.role_mappings file - // and is the user's entire grant: the login endpoint expands it into the - // scope claim of the token it issues. It is the only way to grant a file-mode - // user, so a user's privileges are expressed exactly the way an IDP expresses - // them — as a role — and changing what a role grants is a single edit to the - // mapping file rather than a per-user scope string to keep in sync. - Role string `json:"role" koanf:"role"` + // Roles names one or more of the roles in the + // auth.authorization.roles_to_scope_mapping file and is the user's entire + // grant: the login endpoint expands them into the scope claim of the token it + // issues, unioning what each role grants — most-permissive wins, the same way + // a token carrying several roles is expanded in role authorization mode. It is + // the only way to grant a file-mode user, so a user's privileges are expressed + // exactly the way an IDP expresses them — as roles — and changing what a role + // grants is a single edit to the mapping file rather than a per-user scope + // string to keep in sync. + Roles []string `json:"roles" koanf:"roles"` } // FileBasedUsers is a slice of FileBasedUser that can be decoded from a JSON string (env var) @@ -166,7 +169,7 @@ const ( // AuthzModeScope authorizes using the JWT scope claim directly. AuthzModeScope = "scope" // AuthzModeRole authorizes by expanding the token's roles claim into - // platform scopes via the auth.authorization.role_mappings file. + // platform scopes via the auth.authorization.roles_to_scope_mapping file. AuthzModeRole = "role" ) @@ -181,10 +184,10 @@ type Authorization struct { Enabled bool `koanf:"enabled"` // Mode selects how authorization is enforced: "scope" (default) or "role". Mode string `koanf:"mode"` - // RoleMappings is the path to a YAML file mapping IDP roles to platform + // RolesToScopeMapping is the path to a YAML file mapping IDP roles to platform // scopes. Required in "role" mode (validateAuthorizationConfig rejects an // empty path there); unused in "scope" mode. - RoleMappings string `koanf:"role_mappings"` + RolesToScopeMapping string `koanf:"roles_to_scope_mapping"` } // ClaimMappings holds JWT claim name mappings, shared across all auth modes. @@ -922,8 +925,8 @@ func validateAuthorizationConfig(authz *Authorization, claimMappings *ClaimMappi // exactly like a platform scope, so silently accepting an empty path // means authorization that denies everything (or, for a role named after // a scope, grants unintentionally). Require the mapping explicitly. - if authz.RoleMappings == "" { - return fmt.Errorf("auth.authorization.mode=%s requires auth.authorization.role_mappings to be configured", AuthzModeRole) + if authz.RolesToScopeMapping == "" { + return fmt.Errorf("auth.authorization.mode=%s requires auth.authorization.roles_to_scope_mapping to be configured", AuthzModeRole) } } return nil @@ -946,18 +949,25 @@ func validateFileBasedConfig(cfg *FileBased, authz *Authorization) error { if u.PasswordHash == "" { return fmt.Errorf("auth.file.users[%d] (%s): password_hash is required (set it in config via {{ env }}/{{ file }})", i, u.Username) } - // A role is the user's whole grant, so a user without one is authenticated - // and then authorized for nothing — a login that succeeds and then fails - // every request. Reject it at startup instead of issuing a token with an - // empty scope claim. - if u.Role == "" { - return fmt.Errorf("auth.file.users[%d] (%s): role is required — name a role from auth.authorization.role_mappings", i, u.Username) + // The roles are the user's whole grant, so a user without any is + // authenticated and then authorized for nothing — a login that succeeds and + // then fails every request. Reject it at startup instead of issuing a token + // with an empty scope claim. An entry that is present but blank is the same + // mistake spelled differently, so reject it here rather than letting it + // expand to nothing later. + if len(u.Roles) == 0 { + return fmt.Errorf("auth.file.users[%d] (%s): roles is required — name at least one role from auth.authorization.roles_to_scope_mapping", i, u.Username) } - // The role is expanded from the mapping file at login, so without the - // file the role grants nothing. - if authz.RoleMappings == "" { - return fmt.Errorf("auth.file.users[%d] (%s): role %q requires auth.authorization.role_mappings to be configured", - i, u.Username, u.Role) + for j, role := range u.Roles { + if role == "" { + return fmt.Errorf("auth.file.users[%d] (%s): roles[%d] is empty — name a role from auth.authorization.roles_to_scope_mapping", i, u.Username, j) + } + } + // The roles are expanded from the mapping file at login, so without the + // file they grant nothing. + if authz.RolesToScopeMapping == "" { + return fmt.Errorf("auth.file.users[%d] (%s): roles %v require auth.authorization.roles_to_scope_mapping to be configured", + i, u.Username, u.Roles) } } return nil diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 37cc436570..79786bd72b 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -25,10 +25,10 @@ mode = "file" [platform_api.auth.authorization] enabled = true mode = "scope" -# Mounted role-to-scope mapping (resources/roles.yaml in this pack). Edit it to +# Mounted role-to-scope mapping (resources/roles_to_scope_mapping.yaml in this pack). Edit it to # change what each role grants — it is config, not part of the image. The admin # user below names a role from this file instead of listing platform scopes. -role_mappings = "/etc/platform-api/roles.yaml" +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" [platform_api.auth.jwt] issuer = "platform-api" @@ -43,6 +43,7 @@ region = "us" [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -# The role is the whole grant — edit its entry in the mounted roles.yaml to change -# what this user may do. -role = '{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}' +# The roles are the whole grant — name more than one to union what they grant, or +# edit an entry in the mounted roles_to_scope_mapping.yaml to change what this +# user may do. +roles = ['{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}'] diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index e908651f20..35099ced5b 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -365,27 +365,27 @@ func TestValidateAuthConfig(t *testing.T) { auth: Auth{ Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleMappings: "/etc/platform-api/roles.yaml"}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, - Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Roles: []string{"ap_admin"}}}, }, }, }, { - // A role is the whole grant, so a user without one authenticates + // The roles are the whole grant, so a user without any authenticates // successfully and is then denied every route — reject the config. name: "file mode user without a role", auth: Auth{ Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleMappings: "/etc/platform-api/roles.yaml"}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, }, }, - wantErr: "role is required", + wantErr: "roles is required", }, { // The role is expanded from the mapping file at login, so without the @@ -397,10 +397,10 @@ func TestValidateAuthConfig(t *testing.T) { Authorization: Authorization{Enabled: true, Mode: AuthzModeScope}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, - Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Roles: []string{"ap_admin"}}}, }, }, - wantErr: "auth.authorization.role_mappings", + wantErr: "auth.authorization.roles_to_scope_mapping", }, { // A file-mode user's role is expanded into the scope claim at login, so @@ -412,11 +412,11 @@ func TestValidateAuthConfig(t *testing.T) { Authorization: Authorization{ Enabled: true, Mode: AuthzModeScope, - RoleMappings: "/etc/platform-api/roles.yaml", + RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml", }, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, - Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Role: "ap_admin"}}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Roles: []string{"ap_admin"}}}, }, }, }, @@ -479,19 +479,19 @@ func TestValidateAuthorizationConfig(t *testing.T) { }, { name: "role mode fully configured", - authz: Authorization{Enabled: true, Mode: AuthzModeRole, RoleMappings: "/etc/platform-api/roles.yaml"}, + authz: Authorization{Enabled: true, Mode: AuthzModeRole, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, claims: ClaimMappings{Roles: "realm_access.roles"}, }, { name: "role mode without roles claim mapping", - authz: Authorization{Enabled: true, Mode: AuthzModeRole, RoleMappings: "/etc/platform-api/roles.yaml"}, + authz: Authorization{Enabled: true, Mode: AuthzModeRole, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, wantErr: "auth.claim_mappings.roles", }, { - name: "role mode without role_mappings file", + name: "role mode without roles_to_scope_mapping file", authz: Authorization{Enabled: true, Mode: AuthzModeRole}, claims: ClaimMappings{Roles: "roles"}, - wantErr: "auth.authorization.role_mappings", + wantErr: "auth.authorization.roles_to_scope_mapping", }, { name: "unknown mode rejected", @@ -531,7 +531,7 @@ func TestValidateAuthConfig_RoleAuthorizationInInternalTokenMode(t *testing.T) { auth := Auth{ Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeRole, RoleMappings: "/etc/platform-api/roles.yaml"}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeRole, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, ClaimMappings: ClaimMappings{Roles: "roles"}, } assert.NoError(t, validateAuthConfig(&auth)) @@ -675,7 +675,7 @@ encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd mode = "file" [platform_api.auth.authorization] -role_mappings = "/etc/platform-api/roles.yaml" +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" [platform_api.auth.jwt] public_key_file = "` + validJWTPublicKeyFile + `" @@ -689,7 +689,7 @@ display_name = "Default" [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -role = "ap_admin" +roles = ["ap_admin"] ` require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) @@ -721,3 +721,33 @@ role = "ap_admin" assert.Equal(t, "generated-admin", cfg.Auth.File.Users[0].Username) }) } + +// auth.file.users[].roles is a list, so a user whose persona spans two shipped +// roles names both rather than needing a role defined for the combination. The +// shipped config.toml writes that list with an {{ env }} token inside it, so this +// also pins that interpolation reaches into array elements — a regression there +// would silently hand the raw "{{ env ... }}" string to the role lookup and grant +// the user nothing. +func TestLoadConfig_FileUserRolesList(t *testing.T) { + t.Setenv("APIP_CP_ADMIN_ROLE", "ap_operator") + cfg, err := loadWithKeys(t, `private_key_file = "`+validJWTPrivateKeyFile+`" + +[platform_api.auth] +mode = "file" + +[platform_api.auth.authorization] +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" + +[platform_api.auth.file.organization] +id = "default" +display_name = "Default" + +[[platform_api.auth.file.users]] +username = "admin" +password_hash = "$2a$12$hash" +roles = ['{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}', "ap_viewer"] +`) + require.NoError(t, err) + require.Len(t, cfg.Auth.File.Users, 1) + assert.Equal(t, []string{"ap_operator", "ap_viewer"}, cfg.Auth.File.Users[0].Roles) +} diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 854f45334f..8c7009f7e6 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -47,7 +47,7 @@ func defaultConfig() *Server { Authorization: Authorization{ Enabled: true, Mode: AuthzModeScope, - // RoleMappings is left empty on purpose: the mapping file is + // RolesToScopeMapping is left empty on purpose: the mapping file is // operator-owned and mounted (the packs ship a sample), so a // built-in path would make startup depend on a file the image // does not carry. The shipped config.toml points at the mount. diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index 4ed93de62d..a3dec98ac2 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -20,6 +20,7 @@ package handler import ( "log/slog" "net/http" + "slices" "strings" "time" @@ -45,10 +46,10 @@ type loginResponse struct { // AuthLoginHandler issues JWT tokens for locally-configured users (file-based auth mode). type AuthLoginHandler struct { cfg *config.Server - // roleScopeMap is the role-to-scope mapping from auth.authorization.role_mappings, - // used to expand each user's role into the scopes its token carries. In file + // roleScopeMap is the role-to-scope mapping from auth.authorization.roles_to_scope_mapping, + // used to expand each user's roles into the scopes its token carries. In file // mode it is always populated: config validation requires the mapping file, and - // startup checks every user's role against it. + // startup checks every role every user names against it. roleScopeMap map[string][]string slogger *slog.Logger } @@ -113,12 +114,12 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { setClaim(claims, claimKey(cm.Organization, "organization"), fileBasedAuth.Organization.UUID) setClaim(claims, claimKey(cm.OrgName, "org_name"), fileBasedAuth.Organization.DisplayName) setClaim(claims, claimKey(cm.OrgHandle, "org_handle"), fileBasedAuth.Organization.ID) - // The role travels in the token as well as the scopes it expanded to, so a + // The roles travel in the token as well as the scopes they expanded to, so a // consumer configured for role-based authorization reads the same identity // this endpoint authorized — the claim is a list, matching the shape IDPs // emit and the shape the roles claim is read back in. Config validation - // guarantees the role is set, so this is unconditional. - setClaim(claims, claimKey(cm.Roles, "roles"), []string{matched.Role}) + // guarantees at least one role is set, so this is unconditional. + setClaim(claims, claimKey(cm.Roles, "roles"), slices.Clone(matched.Roles)) // Sign asymmetrically with RS256 using the configured RSA private key, // read fresh from its mounted file. Config validation (validateJWTConfig) @@ -141,25 +142,28 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { return nil } -// effectiveScopes returns the space-separated scope claim for a user: the scopes -// its role grants, per the mapping file. The role is the user's whole grant — -// there is no per-user scope list to drift out of sync with it — so widening or -// narrowing what a user may do is an edit to the role's entry in that one file. +// effectiveScopes returns the space-separated scope claim for a user: the union +// of the scopes its roles grant, per the mapping file. The roles are the user's +// whole grant — there is no per-user scope list to drift out of sync with them — +// so widening or narrowing what a user may do is an edit to a role's entry in +// that one file, or naming a different set of roles. // -// Authorization is still enforced against this scope claim; expanding the role at +// Authorization is still enforced against this scope claim; expanding the roles at // issue time is what lets a role-shaped configuration be checked by the scope-mode // enforcer, rather than requiring authorization to run in role mode. Duplicates -// are dropped so a role that lists a scope twice doesn't repeat it in the claim. +// are dropped, so a scope two of the user's roles both grant — or one role lists +// twice — appears once in the claim. func (h *AuthLoginHandler) effectiveScopes(user *config.FileBasedUser) string { - fromRole := h.roleScopeMap[user.Role] - scopes := make([]string, 0, len(fromRole)) - seen := make(map[string]struct{}, len(fromRole)) - for _, s := range fromRole { - if _, dup := seen[s]; dup { - continue + scopes := make([]string, 0, len(user.Roles)) + seen := make(map[string]struct{}, len(user.Roles)) + for _, role := range user.Roles { + for _, s := range h.roleScopeMap[role] { + if _, dup := seen[s]; dup { + continue + } + seen[s] = struct{}{} + scopes = append(scopes, s) } - seen[s] = struct{}{} - scopes = append(scopes, s) } return strings.Join(scopes, " ") } diff --git a/platform-api/internal/handler/auth_login_test.go b/platform-api/internal/handler/auth_login_test.go index e75748d96a..941137bf40 100644 --- a/platform-api/internal/handler/auth_login_test.go +++ b/platform-api/internal/handler/auth_login_test.go @@ -26,7 +26,7 @@ import ( "github.com/wso2/api-platform/platform-api/config" ) -// A file-mode user's scope claim is exactly what its role grants — the role is +// A file-mode user's scope claim is exactly what its roles grant — the roles are // the whole grant, so the mapping file is the only place the user's privileges // are defined. The mapping may name scopes in any component's namespace (the // Developer Portal's "dp:*", for example), which is what makes a per-user scope @@ -45,27 +45,47 @@ func TestEffectiveScopes(t *testing.T) { }{ { name: "role expands to its scopes, in order", - user: config.FileBasedUser{Role: "ap_viewer"}, + user: config.FileBasedUser{Roles: []string{"ap_viewer"}}, want: "ap:organization:read", }, { // The mapping carries foreign-namespace scopes too, so nothing has to // be granted outside it. name: "role spanning multiple namespaces", - user: config.FileBasedUser{Role: "ap_admin"}, + user: config.FileBasedUser{Roles: []string{"ap_admin"}}, want: "ap:organization:manage ap:rest_api:manage dp:org_manage", }, { // A role listing the same scope twice must not repeat it in the claim. name: "duplicate scope in a role is deduped", - user: config.FileBasedUser{Role: "ap_dupes"}, + user: config.FileBasedUser{Roles: []string{"ap_dupes"}}, want: "ap:rest_api:manage ap:organization:read", }, + { + // Several roles union — most-permissive wins, matching how a token + // carrying several roles is expanded in role authorization mode. + name: "multiple roles union their scopes", + user: config.FileBasedUser{Roles: []string{"ap_viewer", "ap_admin"}}, + want: "ap:organization:read ap:organization:manage ap:rest_api:manage dp:org_manage", + }, + { + // A scope two of the user's roles both grant appears once. + name: "scope granted by two roles is deduped", + user: config.FileBasedUser{Roles: []string{"ap_admin", "ap_dupes"}}, + want: "ap:organization:manage ap:rest_api:manage dp:org_manage ap:organization:read", + }, { // validateFileUserRoles rejects this at startup; if it ever reached // here the token must carry no scopes rather than a guessed grant. name: "unknown role grants nothing", - user: config.FileBasedUser{Role: "no-such-role"}, + user: config.FileBasedUser{Roles: []string{"no-such-role"}}, + want: "", + }, + { + // Config validation rejects this at startup; the token must carry no + // scopes rather than a guessed grant if it ever reached here. + name: "no roles grants nothing", + user: config.FileBasedUser{}, want: "", }, } diff --git a/platform-api/internal/middleware/openapi_scope_registry.go b/platform-api/internal/middleware/openapi_scope_registry.go index 0d145fd82b..a1774bee44 100644 --- a/platform-api/internal/middleware/openapi_scope_registry.go +++ b/platform-api/internal/middleware/openapi_scope_registry.go @@ -96,7 +96,7 @@ func (r *ScopeRegistry) Operations() []Operation { } // AllScopes returns the set of every scope name declared across all operations. -// Used at startup to validate that roles.yaml only references known scopes. +// Used at startup to validate that roles_to_scope_mapping.yaml only references known scopes. func (r *ScopeRegistry) AllScopes() map[string]struct{} { known := make(map[string]struct{}) for _, scopes := range r.scopes { diff --git a/platform-api/internal/middleware/role_scope_map.go b/platform-api/internal/middleware/role_scope_map.go index 291903bfef..0c4c5ddace 100644 --- a/platform-api/internal/middleware/role_scope_map.go +++ b/platform-api/internal/middleware/role_scope_map.go @@ -26,34 +26,34 @@ import ( "gopkg.in/yaml.v3" ) -// roleScopeEntry is a single entry in roles.yaml: an IDP role name and the +// roleScopeEntry is a single entry in roles_to_scope_mapping.yaml: an IDP role name and the // platform scopes it grants. type roleScopeEntry struct { Name string `yaml:"name"` Scopes []string `yaml:"scopes"` } -// roleScopeConfig is the top-level structure of roles.yaml. +// roleScopeConfig is the top-level structure of roles_to_scope_mapping.yaml. type roleScopeConfig struct { Roles []roleScopeEntry `yaml:"roles"` } -// LoadRoleScopeMap reads a roles.yaml file and returns a map from IDP role name +// LoadRoleScopeMap reads a roles_to_scope_mapping.yaml file and returns a map from IDP role name // to the list of platform scopes that role grants. Each user token may carry // multiple roles; the caller is expected to union the scope lists at request time. func LoadRoleScopeMap(path string) (map[string][]string, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("roles.yaml: read %q: %w", path, err) + return nil, fmt.Errorf("roles_to_scope_mapping.yaml: read %q: %w", path, err) } var cfg roleScopeConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("roles.yaml: parse %q: %w", path, err) + return nil, fmt.Errorf("roles_to_scope_mapping.yaml: parse %q: %w", path, err) } m := make(map[string][]string, len(cfg.Roles)) for _, entry := range cfg.Roles { if entry.Name == "" { - return nil, fmt.Errorf("roles.yaml: entry missing required 'name' field in %q", path) + return nil, fmt.Errorf("roles_to_scope_mapping.yaml: entry missing required 'name' field in %q", path) } m[entry.Name] = entry.Scopes } @@ -91,14 +91,14 @@ func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error for role, scopes := range m { for _, s := range scopes { if !wellFormedScope.MatchString(s) { - return fmt.Errorf("roles.yaml: role %q references malformed scope %q — expected \":\", e.g. %sorganization:manage", + return fmt.Errorf("roles_to_scope_mapping.yaml: role %q references malformed scope %q — expected \":\", e.g. %sorganization:manage", role, s, PlatformScopePrefix) } if !strings.HasPrefix(s, PlatformScopePrefix) { continue // another component's namespace — not ours to validate } if _, ok := known[s]; !ok { - return fmt.Errorf("roles.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) + return fmt.Errorf("roles_to_scope_mapping.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) } } } diff --git a/platform-api/internal/server/role_scope_map_test.go b/platform-api/internal/server/role_scope_map_test.go index 0f7ad92a62..b5faae9c23 100644 --- a/platform-api/internal/server/role_scope_map_test.go +++ b/platform-api/internal/server/role_scope_map_test.go @@ -28,7 +28,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/middleware" ) -// writeRolesFile writes a roles.yaml mapping one role to the given scopes and +// writeRolesFile writes a roles_to_scope_mapping.yaml mapping one role to the given scopes and // returns its path. func writeRolesFile(t *testing.T, role string, scopes ...string) string { t.Helper() @@ -37,9 +37,9 @@ func writeRolesFile(t *testing.T, role string, scopes ...string) string { for _, s := range scopes { fmt.Fprintf(&b, " - %s\n", s) } - path := filepath.Join(t.TempDir(), "roles.yaml") + path := filepath.Join(t.TempDir(), "roles_to_scope_mapping.yaml") if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { - t.Fatalf("writing roles.yaml: %v", err) + t.Fatalf("writing roles_to_scope_mapping.yaml: %v", err) } return path } @@ -50,7 +50,7 @@ func writeRolesFile(t *testing.T, role string, scopes ...string) string { func roleModeConfig(path string) *config.Server { cfg := &config.Server{} cfg.Auth.Authorization.Mode = config.AuthzModeRole - cfg.Auth.Authorization.RoleMappings = path + cfg.Auth.Authorization.RolesToScopeMapping = path return cfg } @@ -76,7 +76,7 @@ func TestLoadRoleScopeMap_AcceptsPluginScopeAfterMerge(t *testing.T) { } // The pre-merge registry knows nothing of plugin scopes, so validating against -// it rejects the same roles.yaml. This is the failure the ordering above avoids; +// it rejects the same roles_to_scope_mapping.yaml. This is the failure the ordering above avoids; // if someone moves loadRoleScopeMap back before initPlugins, the test above // starts failing with exactly this error. func TestLoadRoleScopeMap_RejectsPluginScopeBeforeMerge(t *testing.T) { @@ -94,7 +94,7 @@ func TestLoadRoleScopeMap_RejectsPluginScopeBeforeMerge(t *testing.T) { // The mapping is loaded whenever it is configured, including in scope // authorization mode — file-mode users name a role from this same file to -// inherit its scopes, so the login endpoint needs it there too. A bad roles.yaml +// inherit its scopes, so the login endpoint needs it there too. A bad roles_to_scope_mapping.yaml // therefore still fails startup in scope mode. func TestLoadRoleScopeMap_LoadedInScopeMode(t *testing.T) { reg := emptyRegistry(t) @@ -123,7 +123,7 @@ func TestLoadRoleScopeMap_SkippedWhenUnconfigured(t *testing.T) { t.Fatalf("loadRoleScopeMap: unexpected error: %v", err) } if m != nil { - t.Fatalf("expected no mapping when role_mappings is unset, got %v", m) + t.Fatalf("expected no mapping when roles_to_scope_mapping is unset, got %v", m) } } @@ -135,13 +135,13 @@ func TestValidateFileUserRoles(t *testing.T) { cfg := &config.Server{} cfg.Auth.Mode = config.AuthModeFile - cfg.Auth.Authorization.RoleMappings = "/etc/platform-api/roles.yaml" - cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Role: "ap_admin"}} + cfg.Auth.Authorization.RolesToScopeMapping = "/etc/platform-api/roles_to_scope_mapping.yaml" + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Roles: []string{"ap_admin"}}} if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { t.Fatalf("unexpected error for a defined role: %v", err) } - cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Role: "ap_admn"}} + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Roles: []string{"ap_admn"}}} err := validateFileUserRoles(cfg, roleScopeMap) if err == nil { t.Fatal("expected an error for a role missing from the mapping, got nil") @@ -152,7 +152,7 @@ func TestValidateFileUserRoles(t *testing.T) { // Only file mode has users to check — another mode's config carries none. cfg.Auth.Mode = config.AuthModeIDP - cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Role: "ap_admn"}} + cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Roles: []string{"ap_admn"}}} if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { t.Fatalf("unexpected error outside file mode: %v", err) } @@ -168,19 +168,19 @@ func TestShippedSampleRolesValidateAgainstShippedSpec(t *testing.T) { t.Fatalf("loading the shipped OpenAPI spec: %v", err) } - m, err := middleware.LoadRoleScopeMap("../../resources/roles.yaml") + m, err := middleware.LoadRoleScopeMap("../../resources/roles_to_scope_mapping.yaml") if err != nil { - t.Fatalf("loading the shipped roles.yaml: %v", err) + t.Fatalf("loading the shipped roles_to_scope_mapping.yaml: %v", err) } if err := middleware.ValidateRoleScopeMap(m, reg); err != nil { - t.Fatalf("shipped roles.yaml is not valid against the shipped spec: %v", err) + t.Fatalf("shipped roles_to_scope_mapping.yaml is not valid against the shipped spec: %v", err) } // The documented role set. ap_admin in particular is what the shipped // config.toml grants its admin user, so a rename here breaks every pack. for _, role := range []string{"ap_admin", "ap_operator", "ap_publisher", "ap_subscriber", "ap_viewer"} { if _, ok := m[role]; !ok { - t.Fatalf("shipped roles.yaml does not declare %q", role) + t.Fatalf("shipped roles_to_scope_mapping.yaml does not declare %q", role) } } diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 8cf76f6407..1a1fb5cf02 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -448,9 +448,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, } }() - // Load and validate the role-to-scope map when roles.yaml is configured. + // Load and validate the role-to-scope map when roles_to_scope_mapping.yaml is configured. // Runs after initPlugins so a role may map to a plugin-declared scope, and - // after the defer above so a bad roles.yaml still stops the plugins. + // after the defer above so a bad roles_to_scope_mapping.yaml still stops the plugins. roleScopeMap, err := loadRoleScopeMap(cfg, scopeRegistry, slogger) if err != nil { return nil, err @@ -713,18 +713,18 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m // requires the path wherever it is needed, so an empty path here means nothing // consumes the mapping. func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, slogger *slog.Logger) (map[string][]string, error) { - if cfg.Auth.Authorization.RoleMappings == "" { + if cfg.Auth.Authorization.RolesToScopeMapping == "" { return nil, nil } - m, err := middleware.LoadRoleScopeMap(cfg.Auth.Authorization.RoleMappings) + m, err := middleware.LoadRoleScopeMap(cfg.Auth.Authorization.RolesToScopeMapping) if err != nil { return nil, fmt.Errorf("failed to load role mappings file: %w", err) } if err := middleware.ValidateRoleScopeMap(m, registry); err != nil { - return nil, fmt.Errorf("invalid roles.yaml: %w", err) + return nil, fmt.Errorf("invalid roles_to_scope_mapping.yaml: %w", err) } - slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.Authorization.RoleMappings, "roles", len(m)) + slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.Authorization.RolesToScopeMapping, "roles", len(m)) return m, nil } @@ -740,12 +740,14 @@ func validateFileUserRoles(cfg *config.Server, roleScopeMap map[string][]string) return nil } for i, u := range cfg.Auth.File.Users { - if u.Role == "" { - continue - } - if _, ok := roleScopeMap[u.Role]; !ok { - return fmt.Errorf("auth.file.users[%d]: role %q is not defined in %s", - i, u.Role, cfg.Auth.Authorization.RoleMappings) + for j, role := range u.Roles { + if role == "" { + continue + } + if _, ok := roleScopeMap[role]; !ok { + return fmt.Errorf("auth.file.users[%d]: roles[%d] %q is not defined in %s", + i, j, role, cfg.Auth.Authorization.RolesToScopeMapping) + } } } return nil diff --git a/platform-api/resources/roles.yaml b/platform-api/resources/roles_to_scope_mapping.yaml similarity index 94% rename from platform-api/resources/roles.yaml rename to platform-api/resources/roles_to_scope_mapping.yaml index e729f98d6d..3604f6dfd7 100644 --- a/platform-api/resources/roles.yaml +++ b/platform-api/resources/roles_to_scope_mapping.yaml @@ -1,17 +1,17 @@ -# Role-to-scope mapping used by the Platform API (auth.authorization.role_mappings). +# Role-to-scope mapping used by the Platform API (auth.authorization.roles_to_scope_mapping). # # Each entry maps a role name to the scopes that role grants. Two consumers read # this file: # # * role authorization (auth.authorization.mode = "role") — the roles claim of # an incoming token is expanded through this file on every request. -# * file-mode users (auth.file.users[].role) — the login endpoint expands the -# role once, into the scope claim of the token it issues. A role is such a -# user's entire grant, so this file is the only place their privileges are -# defined; there is no per-user scope list to keep in sync with it. +# * file-mode users (auth.file.users[].roles) — the login endpoint expands the +# roles once, into the scope claim of the token it issues. Those roles are +# such a user's entire grant, so this file is the only place their privileges +# are defined; there is no per-user scope list to keep in sync with it. # -# When a token carries multiple roles the effective scopes are the union of all -# matching entries — most-permissive wins. +# When a token — or a file-mode user's roles list — carries multiple roles, the +# effective scopes are the union of all matching entries; most-permissive wins. # # Naming: roles are named after the platform ("ap_") rather than after any one # IDP's convention, because the same file serves every auth mode. Map your IDP's diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 052cc2ce85..8f95caf4a3 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -341,13 +341,13 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) > $(DIST_DIR)/configs/.pa-config-template.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config.toml" \ > $(DIST_DIR)/configs/.pa-config.toml - @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles.yaml" \ - > $(DIST_DIR)/resources/roles.yaml + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles_to_scope_mapping.yaml" \ + > $(DIST_DIR)/resources/roles_to_scope_mapping.yaml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-config.toml - @cp ../../platform-api/resources/roles.yaml $(DIST_DIR)/resources/roles.yaml + @cp ../../platform-api/resources/roles_to_scope_mapping.yaml $(DIST_DIR)/resources/roles_to_scope_mapping.yaml endif # Require a [platform_api] root table — pre-unified configs would merge into a broken file. @if ! grep -q '^\[platform_api' $(DIST_DIR)/configs/.pa-config.toml; then \ @@ -393,7 +393,7 @@ endif @rm -f $(DIST_DIR)/scripts/setup.ps1.bak # Point the platform-api mount at the merged config so both containers share one file. @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ - -e 's#\.\./\.\./platform-api/resources/roles\.yaml:#./resources/roles.yaml:#' \ + -e 's#\.\./\.\./platform-api/resources/roles_to_scope_mapping\.yaml:#./resources/roles_to_scope_mapping.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @sed -i.bak -E \ diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 9f4c8e94fb..2d0d182473 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -18,7 +18,7 @@ wso2apip-ai-workspace-/ │ └── config-template.toml # Full configuration reference for both, │ # plus optional [developer_portal] at the bottom └── resources/ - ├── roles.yaml # Platform API role-to-scope mapping (edit to change what a role grants) + ├── roles_to_scope_mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) └── platform-api/ └── db-scripts/ # Platform API schema scripts (schema.*.sql) ``` @@ -127,8 +127,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.auth].mode` | `file` (quickstart default), `internal_token`, or `idp` — selects exactly one auth mode | | `[platform_api.auth.jwt].public_key_file` / `private_key_file` | RS256 (asymmetric) PEM keys; `public_key_file` verifies every token, `private_key_file` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | | `[platform_api.auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | -| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing). Each user names a `role` from `resources/roles.yaml` — that role is the whole grant | -| `[platform_api.auth.authorization].role_mappings` | Path to the mounted `resources/roles.yaml` — edit that file to change what a role grants | +| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing). Each user names one or more `roles` from `resources/roles_to_scope_mapping.yaml` — those roles are the whole grant, unioned | +| `[platform_api.auth.authorization].roles_to_scope_mapping` | Path to the mounted `resources/roles_to_scope_mapping.yaml` — edit that file to change what a role grants | | `[platform_api.server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | Each key's default value is written inline in `configs/config-template.toml` — a diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 9054be8645..4bf27b0d0d 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -26,9 +26,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.role_mappings. Mounted - # rather than baked into the image so it can be edited in place. - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-data:/app/data - ./resources/certificates:/app/data/certs:ro - ./resources/keys:/etc/platform-api/keys:ro diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index a7138bd1a6..2eda1b1e65 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -115,7 +115,7 @@ Optional overrides (defaults shown): ```toml [platform_api.auth.authorization] enabled = true -mode = "scope" # or "role" for role-based auth (then set role_mappings) +mode = "scope" # or "role" for role-based auth (then set roles_to_scope_mapping) [platform_api.auth.claim_mappings] user_id = "sub" diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index c3eb499f76..bd3a560cc3 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -284,13 +284,13 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) > $(DIST_DIR)/configs/.pa-config.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config-template.toml" \ > $(DIST_DIR)/configs/.pa-config-template.toml - @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles.yaml" \ - > $(DIST_DIR)/resources/roles.yaml + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles_to_scope_mapping.yaml" \ + > $(DIST_DIR)/resources/roles_to_scope_mapping.yaml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-config.toml @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml - @cp ../../platform-api/resources/roles.yaml $(DIST_DIR)/resources/roles.yaml + @cp ../../platform-api/resources/roles_to_scope_mapping.yaml $(DIST_DIR)/resources/roles_to_scope_mapping.yaml endif # Require a [platform_api] root table — pre-unified configs would merge into a broken file. @if ! grep -q '^\[platform_api' $(DIST_DIR)/configs/.pa-config.toml; then \ @@ -311,7 +311,7 @@ endif @rm -f $(DIST_DIR)/configs/.pa-config.toml $(DIST_DIR)/configs/.pa-config-template.toml $(DIST_DIR)/configs/.aiw-config-template.toml # Point the platform-api mount at the merged config so both containers share one file. @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ - -e 's#\.\./\.\./platform-api/resources/roles\.yaml:#./resources/roles.yaml:#' \ + -e 's#\.\./\.\./platform-api/resources/roles_to_scope_mapping\.yaml:#./resources/roles_to_scope_mapping.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index c69646496e..54c43e1027 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -249,16 +249,16 @@ The full annotated list of settings is in [`configs/config-template.toml`](confi ### Local auth -For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users and bcrypt-hashed passwords are defined there, under `[[platform_api.auth.file.users]]`; each names a role from the [`roles.yaml`](../../platform-api/resources/roles.yaml) mounted alongside it, and that role is where the `dp:*` scopes the portal enforces come from: +For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users and bcrypt-hashed passwords are defined there, under `[[platform_api.auth.file.users]]`; each names one or more roles from the [`roles_to_scope_mapping.yaml`](../../platform-api/resources/roles_to_scope_mapping.yaml) mounted alongside it, and those roles are where the `dp:*` scopes the portal enforces come from: ```toml [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$..." # bcrypt hash — generate with: htpasswd -bnBC 12 "" | tr -d ':\n' -role = "ap_admin" # grants dp:org_manage, dp:api_manage, … — see roles.yaml +roles = ["ap_admin"] # grants dp:org_manage, dp:api_manage, … — see roles_to_scope_mapping.yaml ``` -To change what a portal user may do, edit that role's entry in `roles.yaml` rather than the user block. +To change what a portal user may do, edit that role's entry in `roles_to_scope_mapping.yaml` — or name a second role alongside it — rather than listing scopes on the user block. The portal config (or `APIP_DP_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: diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md index 8bddfb7f9a..fc2252fa76 100644 --- a/portals/developer-portal/distribution/README.md +++ b/portals/developer-portal/distribution/README.md @@ -16,7 +16,7 @@ wso2apip-developer-portal-/ │ ├── config.toml # Unified active config — [developer_portal] + [platform_api] sections │ └── config-template.toml # Config reference — both active components, plus optional [ai_workspace] at the bottom └── resources/ - ├── roles.yaml # Platform API role-to-scope mapping (edit to change what a role grants) + ├── roles_to_scope_mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) ├── developer-portal/ │ └── db-scripts/ # Developer Portal PostgreSQL schema (reference copy) ├── platform-api/ @@ -135,8 +135,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.database].driver` | `sqlite3` or `postgres` | `sqlite3` | | `[platform_api.auth.jwt].public_key_file` / `.private_key_file` | RS256 keypair — platform-api signs login JWTs with the private key; the portal verifies with the public one | _(from `setup.sh`)_ | | `[platform_api.auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode | disabled | -| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `role` names an entry in `resources/roles.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | -| `[platform_api.auth.authorization].role_mappings` | Path to the mounted `resources/roles.yaml` — edit that file to change what a role grants | `/etc/platform-api/roles.yaml` | +| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `roles` names one or more entries in `resources/roles_to_scope_mapping.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | +| `[platform_api.auth.authorization].roles_to_scope_mapping` | Path to the mounted `resources/roles_to_scope_mapping.yaml` — edit that file to change what a role grants | `/etc/platform-api/roles_to_scope_mapping.yaml` | See `configs/config-template.toml` for a fully-commented reference of every available setting across both active components (plus the optional `[ai_workspace]` section at the bottom). diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 637aaff7b9..6cbcc0d76d 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -42,9 +42,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.role_mappings. Mounted - # rather than baked into the image so it can be edited in place. - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-data:/app/data # Certs land under /app/data/certs to match the binary's compiled # default cert_file/key_file paths (./data/certs/{cert,key}.pem, diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index 3f02da8331..dcb0dca56d 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -25,7 +25,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-data:/app/data - ./resources/certificates:/app/data/certs:ro - ./resources/keys:/etc/platform-api/keys:ro diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml index 67391dd1a1..882d12782a 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -42,7 +42,7 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' # Each user below names a role from this file — a role is a file-mode user's # entire grant, so the scope lists live there, not here. [platform_api.auth.authorization] -role_mappings = "/etc/platform-api/roles.yaml" +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' @@ -61,7 +61,7 @@ region = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_REGION" "us" }}' [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -role = "dp_admin_it" +roles = ["dp_admin_it"] # Content/config manager — APIs, MCP servers, key managers, subscription # plans, views/labels, webhook subscribers, API workflows. No org management, @@ -69,11 +69,11 @@ role = "dp_admin_it" [[platform_api.auth.file.users]] username = "publisher" password_hash = "$2y$10$BN9I5oPs34clNmhlO0CX0uDMKMnh9xkczGGmuLiInXSe/KOF5wqFW" -role = "dp_publisher_it" +roles = ["dp_publisher_it"] # Portal end-user — read APIs/MCP servers, own applications, subscriptions, # and API keys. No org, key-manager, view/label, or webhook-subscriber management. [[platform_api.auth.file.users]] username = "developer" password_hash = "$2y$10$jX3o2E5jF4i3EOgoyJ0k.uegbDYmsmFNDfIxnvcZgTNJifAPjgKKK" -role = "dp_developer_it" +roles = ["dp_developer_it"] diff --git a/portals/developer-portal/it/configs/roles-platform-api-it.yaml b/portals/developer-portal/it/configs/roles-platform-api-it.yaml index 95e5e4f65c..44e7244260 100644 --- a/portals/developer-portal/it/configs/roles-platform-api-it.yaml +++ b/portals/developer-portal/it/configs/roles-platform-api-it.yaml @@ -10,8 +10,8 @@ # -------------------------------------------------------------------- # # Role-to-scope mapping for the developer-portal backend IT suite, named by -# auth.authorization.role_mappings in config-platform-api-it.toml. A file-mode -# user's role is its entire grant, so the three IT accounts (admin / publisher / +# auth.authorization.roles_to_scope_mapping in config-platform-api-it.toml. A file-mode +# user's roles are its entire grant, so the three IT accounts (admin / publisher / # developer) get one role each, defined here rather than as per-user scope lists. # # These are Developer Portal ("dp:") scopes throughout: the Platform API mints diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index e8d72229b8..1cab0a2b31 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -57,7 +57,7 @@ services: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles.yaml:ro + - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-data:/app/data # TLS pair + RS256 JWT keypair generated once, host-side, by `make # ensure-certs` (mirrors ../../scripts/setup.sh's approach for the production diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index 1ec8f42cc4..bad615f531 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -36,7 +36,7 @@ services: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles.yaml:ro + - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-data:/app/data # TLS pair + RS256 JWT keypair generated once, host-side, by `make # ensure-certs` (mirrors ../../scripts/setup.sh's approach for the production diff --git a/tests/ai-workspace-cli-e2e/docker-compose.yaml b/tests/ai-workspace-cli-e2e/docker-compose.yaml index 5a46941244..fe050e47f6 100644 --- a/tests/ai-workspace-cli-e2e/docker-compose.yaml +++ b/tests/ai-workspace-cli-e2e/docker-compose.yaml @@ -69,9 +69,9 @@ services: - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.role_mappings — the + # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/ai-workspace-cli-e2e/platform-api-config.toml b/tests/ai-workspace-cli-e2e/platform-api-config.toml index 80b459ea4e..0ecf4385ca 100644 --- a/tests/ai-workspace-cli-e2e/platform-api-config.toml +++ b/tests/ai-workspace-cli-e2e/platform-api-config.toml @@ -27,7 +27,7 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' [platform_api.auth.authorization] enabled = true mode = "scope" -role_mappings = "/etc/platform-api/roles.yaml" +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' @@ -48,6 +48,6 @@ uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8 [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -# ap_admin from the shipped roles.yaml — every Platform API scope this build -# declares. A role is the user's whole grant; there is no per-user scope list. -role = "ap_admin" +# ap_admin from the shipped roles_to_scope_mapping.yaml — every Platform API scope this build +# declares. The roles list is the user's whole grant; there is no per-user scope list. +roles = ["ap_admin"] diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index d3c18749e4..c0bd8bc3e3 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -157,7 +157,7 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. against the RS256 `jwt_public.pem` shared via the `platform-api-jwt-keys` volume, org from the token's `org_handle` claim). The admin must carry `dp:*` scopes, which it gets from the `ap_admin` role in the - mounted `roles.yaml` — that role spans both the `ap:*` and `dp:*` namespaces, + mounted `roles_to_scope_mapping.yaml` — that role spans both the `ap:*` and `dp:*` namespaces, so the one admin JWT authorizes both products. Bearer auth (not API-key mode) is used because the write paths need a resolved user for `created_by`. - `BeforeSuite` generates the shared webhook secret (`prepareWebhookSecret`, exported diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 57417cbf1b..e6a6eab74c 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -65,9 +65,9 @@ services: - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.role_mappings — the + # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 97e788c223..6fae6762b6 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -111,9 +111,9 @@ services: - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.role_mappings — the + # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 228bae0aa4..6218655dd3 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -117,9 +117,9 @@ services: - APIP_CP_WEBHOOK_SECRET=${E2E_WEBHOOK_SECRET:?set E2E_WEBHOOK_SECRET (the suite sets it automatically; export one to run compose by hand)} volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.role_mappings — the + # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles.yaml:/etc/platform-api/roles.yaml:ro + - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index e0a9302734..6b0ae14e41 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -26,7 +26,7 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' # The admin below names a role from the shipped mapping, bind-mounted at this # path — a role is a file-mode user's entire grant. [platform_api.auth.authorization] -role_mappings = "/etc/platform-api/roles.yaml" +roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' @@ -46,9 +46,9 @@ uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8 [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -# ap_admin from the shipped roles.yaml — every Platform API scope this build +# ap_admin from the shipped roles_to_scope_mapping.yaml — every Platform API scope this build # declares, plus the Developer Portal scopes the devportal e2e stack asserts on. -role = "ap_admin" +roles = ["ap_admin"] [platform_api.webhook] enabled = '{{ env "APIP_CP_WEBHOOK_ENABLED" "false" }}' From a7fda3068a49f92aa2d8afda614c5dbecc3996fa Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Thu, 30 Jul 2026 17:14:03 +0530 Subject: [PATCH 7/7] Rename roles_to_scope_mapping to role-to-scope-mapping across configuration files and update references in documentation and tests for consistency. --- distribution/all-in-one/docker-compose.yaml | 2 +- .../templates/configmap.yaml | 6 ++-- .../templates/deployment.yaml | 8 ++--- .../helm/platform-api-helm-chart/values.yaml | 8 ++--- platform-api/README.md | 26 +++++++------- platform-api/config/config-template.toml | 12 +++---- platform-api/config/config.go | 20 +++++------ platform-api/config/config.toml | 8 ++--- platform-api/config/config_test.go | 34 +++++++++---------- platform-api/config/default_config.go | 2 +- platform-api/internal/handler/auth_login.go | 2 +- .../middleware/openapi_scope_registry.go | 2 +- .../internal/middleware/role_scope_map.go | 16 ++++----- .../internal/server/role_scope_map_test.go | 24 ++++++------- platform-api/internal/server/server.go | 14 ++++---- ...apping.yaml => role-to-scope-mapping.yaml} | 2 +- portals/ai-workspace/Makefile | 8 ++--- portals/ai-workspace/distribution/README.md | 6 ++-- portals/ai-workspace/docker-compose.yaml | 2 +- portals/ai-workspace/production/README.md | 2 +- portals/developer-portal/Makefile | 8 ++--- portals/developer-portal/README.md | 6 ++-- .../developer-portal/distribution/README.md | 6 ++-- .../docker-compose.platform-api.yaml | 2 +- portals/developer-portal/docker-compose.yaml | 2 +- .../it/configs/config-platform-api-it.toml | 2 +- .../it/configs/roles-platform-api-it.yaml | 2 +- .../it/docker-compose.test.postgres.yaml | 2 +- .../it/docker-compose.test.yaml | 2 +- .../ai-workspace-cli-e2e/docker-compose.yaml | 4 +-- .../platform-api-config.toml | 4 +-- tests/integration-e2e/README.md | 2 +- .../docker-compose.sqlite.yaml | 4 +-- .../docker-compose.sqlserver.yaml | 4 +-- tests/integration-e2e/docker-compose.yaml | 4 +-- .../integration-e2e/platform-api-config.toml | 4 +-- 36 files changed, 131 insertions(+), 131 deletions(-) rename platform-api/resources/{roles_to_scope_mapping.yaml => role-to-scope-mapping.yaml} (99%) diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index 14378e417f..635f8e49f6 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -137,7 +137,7 @@ services: - "9243:9243" volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-data:/api-platform/data - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro diff --git a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml index 3138a88179..c4c19390dc 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/configmap.yaml @@ -69,7 +69,7 @@ data: [platform_api.auth.authorization] enabled = {{ $auth.authorization.enabled }} mode = {{ $auth.authorization.mode | quote }} - roles_to_scope_mapping = {{ $auth.authorization.rolesToScopeMapping | quote }} + role_to_scope_mapping = {{ $auth.authorization.roleToScopeMapping | quote }} [platform_api.auth.claim_mappings] organization = {{ $auth.claimMappings.organization | quote }} @@ -169,10 +169,10 @@ data: {{ . | nindent 4 | trim }} {{- end }} {{- with $auth.authorization.roles }} - # Role→scope mapping named by auth.authorization.roles_to_scope_mapping. Mounted as a + # Role→scope mapping named by auth.authorization.role_to_scope_mapping. Mounted as a # file rather than folded into the TOML above because the Platform API reads it # separately and re-reads it only on restart. - roles_to_scope_mapping.yaml: | + role-to-scope-mapping.yaml: | roles: {{- toYaml . | nindent 6 }} {{- end }} diff --git a/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml b/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml index 62e7d5752e..8df1b253a1 100644 --- a/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml +++ b/kubernetes/helm/platform-api-helm-chart/templates/deployment.yaml @@ -161,8 +161,8 @@ spec: subPath: config-platform-api.toml {{- if $pa.config.auth.authorization.roles }} - name: config - mountPath: {{ $pa.config.auth.authorization.rolesToScopeMapping }} - subPath: roles_to_scope_mapping.yaml + mountPath: {{ $pa.config.auth.authorization.roleToScopeMapping }} + subPath: role-to-scope-mapping.yaml {{- end }} - name: jwt-keys mountPath: {{ $jwtKeyDir }} @@ -190,8 +190,8 @@ spec: - key: config-platform-api.toml path: config-platform-api.toml {{- if $pa.config.auth.authorization.roles }} - - key: roles_to_scope_mapping.yaml - path: roles_to_scope_mapping.yaml + - key: role-to-scope-mapping.yaml + path: role-to-scope-mapping.yaml {{- end }} # RS256 JWT keys mounted as PEM files from the external Secret. The # public key verifies tokens (every mode); the private key signs diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index 8d8c3bc78b..34a184323c 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -123,10 +123,10 @@ config: # mode (auth.file.admin.roles is a user's whole grant). The chart renders # `roles` below into its config ConfigMap and mounts it here; point this # elsewhere only if you supply your own file via extraVolumes/extraVolumeMounts. - rolesToScopeMapping: /etc/platform-api/roles_to_scope_mapping.yaml + roleToScopeMapping: /etc/platform-api/role-to-scope-mapping.yaml # Roles the mapping file defines, each a name and the scopes it grants. # Only ap_admin is shipped here — the file-mode admin below names it. - # platform-api/resources/roles_to_scope_mapping.yaml is the full sample set (ap_admin, + # platform-api/resources/role-to-scope-mapping.yaml is the full sample set (ap_admin, # ap_operator, ap_publisher, ap_subscriber, ap_viewer); copy the entries you # need from it. An ap: scope the Platform API's OpenAPI spec does not declare # fails startup; dp: scopes (Developer Portal) are checked for shape only. @@ -184,10 +184,10 @@ config: # hash. There is no admin/admin default: startup fails closed if unset. Only # the granted roles are configured here (add more users via configToml). admin: - # REQUIRED in file mode — one or more roles from the rolesToScopeMapping + # REQUIRED in file mode — one or more roles from the roleToScopeMapping # file, expanded into the token's scopes at login (the union of what each # grants), and this user's entire grant (there is no per-user scope list). - # Requires that file to be mounted and rolesToScopeMapping above to point + # Requires that file to be mounted and roleToScopeMapping above to point # at it; a role absent from the file fails startup. roles: - ap_admin diff --git a/platform-api/README.md b/platform-api/README.md index 41b9427fd9..8aabbb4c99 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -24,10 +24,10 @@ go run ./cmd/main.go (username/password login backed by the organization/user block in that file) — the same mode the AI Workspace and Developer Portal quickstarts use. It's the one Platform API config shared by every quickstart (both docker-compose setups mount it directly), so its admin user is granted the -`ap_admin` role from the mounted [`resources/roles_to_scope_mapping.yaml`](resources/roles_to_scope_mapping.yaml), which covers both +`ap_admin` role from the mounted [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml), which covers both the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces. That one role is the whole grant — -override it with `APIP_CP_ADMIN_ROLE`, name more roles alongside it, or edit what it grants in -`roles_to_scope_mapping.yaml`. +replace it, name more roles alongside it, or edit what it grants in +`role-to-scope-mapping.yaml`. There is no default admin credential: `APIP_CP_ADMIN_USERNAME` and `APIP_CP_ADMIN_PASSWORD_HASH` are **required** in this mode, and startup fails closed if either is unset or empty. `portals/scripts/setup.sh` @@ -283,7 +283,7 @@ All settings live under `[platform_api]` / `[platform_api.*]`. The main sections | `[platform_api.security.api_key]` | `hashing_algorithms` accepted for API key verification | | `[platform_api.database]` | `driver` (`sqlite3` / `postgres` / `sqlserver`), connection fields, pool sizing | | `[platform_api.auth]` | `mode` — one of `internal_token`, `file`, or `idp` | -| `[platform_api.auth.authorization]` | `enabled`, `mode` (`scope` / `role`), `roles_to_scope_mapping` — applies in every auth mode | +| `[platform_api.auth.authorization]` | `enabled`, `mode` (`scope` / `role`), `role_to_scope_mapping` — applies in every auth mode | | `[platform_api.auth.jwt]` | Asymmetric (RS256) token settings: `issuer`, `public_key_file` (**required** — path to a PEM RSA public key, verifies tokens), `private_key_file` (**required in `file` mode** — path to a PEM RSA private key, signs login tokens), `token_ttl` | | `[platform_api.auth.idp]` / `[platform_api.auth.claim_mappings]` | JWKS endpoint and issuer/audience for `idp` mode; JWT claim-name mappings (all modes) | | `[platform_api.auth.file.organization]` / `[[platform_api.auth.file.users]]` | Local org + username/password/scope entries for `file` mode | @@ -315,7 +315,7 @@ key silently ignored. #### Role-Based Access Control (RBAC) Per-route scope checks are enforced when `platform_api.auth.authorization.enabled = true`. The -shipped [`resources/roles_to_scope_mapping.yaml`](resources/roles_to_scope_mapping.yaml) defines five roles, each granting scopes in +shipped [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml) defines five roles, each granting scopes in both the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces — one role covers a persona across both components: @@ -345,16 +345,16 @@ local public key: [platform_api.auth.authorization] enabled = true mode = "role" # "scope" (default) or "role" -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" # required when mode = "role" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" # required when mode = "role" ``` `mode = "scope"` authorizes from the scope claim directly. `mode = "role"` expands the roles claim -named by `claim_mappings.roles` into platform scopes via the `roles_to_scope_mapping` YAML file; both that +named by `claim_mappings.roles` into platform scopes via the `role_to_scope_mapping` YAML file; both that claim mapping and the file path are required in role mode, so startup fails rather than falling back to using role names verbatim as scopes. The mapping file is operator-owned config, not part of the image: the packs mount their editable -sample (`resources/roles_to_scope_mapping.yaml`) at `/etc/platform-api/roles_to_scope_mapping.yaml`. +sample (`resources/role-to-scope-mapping.yaml`) at `/etc/platform-api/role-to-scope-mapping.yaml`. Validation of that file is namespace-scoped. An `ap:` scope must be declared in this server's OpenAPI spec (plus any its compiled-in plugins declare) — an unknown one fails startup rather than silently @@ -366,14 +366,14 @@ describe a persona across the whole platform — and what makes a per-user scope ##### Granting a file-mode user roles A `file`-mode user is granted **only** roles — there is no per-user scope list. The login endpoint -expands them through the same `roles_to_scope_mapping` file when it mints the token, unioning what +expands them through the same `role_to_scope_mapping` file when it mints the token, unioning what each grants: ```toml [[platform_api.auth.file.users]] username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' -roles = ["ap_admin"] # expanded via auth.authorization.roles_to_scope_mapping +roles = ["ap_admin"] # expanded via auth.authorization.role_to_scope_mapping ``` `roles` is a list, so a user whose persona spans two shipped roles names both rather than needing a @@ -383,17 +383,17 @@ of the two, most-permissive wins, with duplicate scopes collapsed. The issued token carries **both**: the expanded scopes as the `scope` claim, and the role names as the `roles` claim. So the same login works under either authorization mode — `scope` (the default) checks the expanded claim, and flipping `auth.authorization.mode = "role"` re-expands the roles from the same -`roles_to_scope_mapping.yaml` on every request instead. `claim_mappings.roles` defaults to the flat `roles` claim the +`role-to-scope-mapping.yaml` on every request instead. `claim_mappings.roles` defaults to the flat `roles` claim the login endpoint signs, so that switch needs no extra claim wiring. At least one role is required, and startup fails if a user has none or names one the mapping file doesn't define — either way that user would authenticate successfully and then be denied every route. Because the mapping is the only place a grant is expressed, no user can drift out of step with the roles it names, and widening or narrowing a persona is one edit in one file. To grant something no -combination of shipped roles covers, add a role to `roles_to_scope_mapping.yaml`. +combination of shipped roles covers, add a role to `role-to-scope-mapping.yaml`. This is how the shipped `config/config.toml` grants its admin user: `roles = ["ap_admin"]` and nothing -else. Changing what that user can do means editing the mounted `roles_to_scope_mapping.yaml`, which makes that file the +else. Changing what that user can do means editing the mounted `role-to-scope-mapping.yaml`, which makes that file the security-relevant one to review in a pack. ### Providing secrets via the config file diff --git a/platform-api/config/config-template.toml b/platform-api/config/config-template.toml index 65a05e5839..b3042b6a83 100644 --- a/platform-api/config/config-template.toml +++ b/platform-api/config/config-template.toml @@ -155,14 +155,14 @@ mode = "file" enabled = true # "scope" (default) checks the scope claim; "role" checks the roles claim -# configured below at claim_mappings.roles, expanding each role via roles_to_scope_mapping. +# configured below at claim_mappings.roles, expanding each role via role_to_scope_mapping. mode = "scope" # Path to a YAML file mapping role names to platform scopes. Required when # mode = "role" (startup fails if unset), and also when any file-mode user below # names a role — the login endpoint expands that role through this same file. -# The packs mount their editable sample at /etc/platform-api/roles_to_scope_mapping.yaml. -roles_to_scope_mapping = "" +# The packs mount their editable sample at /etc/platform-api/role-to-scope-mapping.yaml. +role_to_scope_mapping = "" # JWT claim name mappings — shared by all three auth modes ("idp" reads # incoming claims by these names; "file" mode's login endpoint signs tokens @@ -212,7 +212,7 @@ uuid = "99089a17-72e0-4dd8-a2f4-c8dfbb085295" # starting with a blank or guessable credential. username = "" password_hash = "" -# REQUIRED — one or more roles from the auth.authorization.roles_to_scope_mapping +# REQUIRED — one or more roles from the auth.authorization.role_to_scope_mapping # file above, and this user's entire grant. The login endpoint expands them into # the token's scope claim (the union of what each grants — most-permissive wins) # and also emits the roles themselves as the roles claim, so the same token works @@ -223,10 +223,10 @@ password_hash = "" # There is no per-user scope list: what a role grants is defined once, in the # mapping file, so no user can drift out of step with the roles it names. To grant # something no shipped role covers, name several roles, or add a role to that file -# (see resources/roles_to_scope_mapping.yaml for the shipped ones and the scope +# (see resources/role-to-scope-mapping.yaml for the shipped ones and the scope # namespaces it may use). # -# Left empty here because roles_to_scope_mapping above is empty in this template — set that +# Left empty here because role_to_scope_mapping above is empty in this template — set that # first, then name a role. roles = [] diff --git a/platform-api/config/config.go b/platform-api/config/config.go index 786ef3aea3..6bb4fb3c73 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -46,7 +46,7 @@ type FileBasedUser struct { Username string `json:"username" koanf:"username"` PasswordHash string `json:"password_hash" koanf:"password_hash"` // Roles names one or more of the roles in the - // auth.authorization.roles_to_scope_mapping file and is the user's entire + // auth.authorization.role_to_scope_mapping file and is the user's entire // grant: the login endpoint expands them into the scope claim of the token it // issues, unioning what each role grants — most-permissive wins, the same way // a token carrying several roles is expanded in role authorization mode. It is @@ -169,7 +169,7 @@ const ( // AuthzModeScope authorizes using the JWT scope claim directly. AuthzModeScope = "scope" // AuthzModeRole authorizes by expanding the token's roles claim into - // platform scopes via the auth.authorization.roles_to_scope_mapping file. + // platform scopes via the auth.authorization.role_to_scope_mapping file. AuthzModeRole = "role" ) @@ -184,10 +184,10 @@ type Authorization struct { Enabled bool `koanf:"enabled"` // Mode selects how authorization is enforced: "scope" (default) or "role". Mode string `koanf:"mode"` - // RolesToScopeMapping is the path to a YAML file mapping IDP roles to platform + // RoleToScopeMapping is the path to a YAML file mapping IDP roles to platform // scopes. Required in "role" mode (validateAuthorizationConfig rejects an // empty path there); unused in "scope" mode. - RolesToScopeMapping string `koanf:"roles_to_scope_mapping"` + RoleToScopeMapping string `koanf:"role_to_scope_mapping"` } // ClaimMappings holds JWT claim name mappings, shared across all auth modes. @@ -925,8 +925,8 @@ func validateAuthorizationConfig(authz *Authorization, claimMappings *ClaimMappi // exactly like a platform scope, so silently accepting an empty path // means authorization that denies everything (or, for a role named after // a scope, grants unintentionally). Require the mapping explicitly. - if authz.RolesToScopeMapping == "" { - return fmt.Errorf("auth.authorization.mode=%s requires auth.authorization.roles_to_scope_mapping to be configured", AuthzModeRole) + if authz.RoleToScopeMapping == "" { + return fmt.Errorf("auth.authorization.mode=%s requires auth.authorization.role_to_scope_mapping to be configured", AuthzModeRole) } } return nil @@ -956,17 +956,17 @@ func validateFileBasedConfig(cfg *FileBased, authz *Authorization) error { // mistake spelled differently, so reject it here rather than letting it // expand to nothing later. if len(u.Roles) == 0 { - return fmt.Errorf("auth.file.users[%d] (%s): roles is required — name at least one role from auth.authorization.roles_to_scope_mapping", i, u.Username) + return fmt.Errorf("auth.file.users[%d] (%s): roles is required — name at least one role from auth.authorization.role_to_scope_mapping", i, u.Username) } for j, role := range u.Roles { if role == "" { - return fmt.Errorf("auth.file.users[%d] (%s): roles[%d] is empty — name a role from auth.authorization.roles_to_scope_mapping", i, u.Username, j) + return fmt.Errorf("auth.file.users[%d] (%s): roles[%d] is empty — name a role from auth.authorization.role_to_scope_mapping", i, u.Username, j) } } // The roles are expanded from the mapping file at login, so without the // file they grant nothing. - if authz.RolesToScopeMapping == "" { - return fmt.Errorf("auth.file.users[%d] (%s): roles %v require auth.authorization.roles_to_scope_mapping to be configured", + if authz.RoleToScopeMapping == "" { + return fmt.Errorf("auth.file.users[%d] (%s): roles %v require auth.authorization.role_to_scope_mapping to be configured", i, u.Username, u.Roles) } } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 79786bd72b..0569ef4ecc 100644 --- a/platform-api/config/config.toml +++ b/platform-api/config/config.toml @@ -25,10 +25,10 @@ mode = "file" [platform_api.auth.authorization] enabled = true mode = "scope" -# Mounted role-to-scope mapping (resources/roles_to_scope_mapping.yaml in this pack). Edit it to +# Mounted role-to-scope mapping (resources/role-to-scope-mapping.yaml in this pack). Edit it to # change what each role grants — it is config, not part of the image. The admin # user below names a role from this file instead of listing platform scopes. -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.jwt] issuer = "platform-api" @@ -44,6 +44,6 @@ region = "us" username = '{{ env "APIP_CP_ADMIN_USERNAME" }}' password_hash = '{{ env "APIP_CP_ADMIN_PASSWORD_HASH" }}' # The roles are the whole grant — name more than one to union what they grant, or -# edit an entry in the mounted roles_to_scope_mapping.yaml to change what this +# edit an entry in the mounted role-to-scope-mapping.yaml to change what this # user may do. -roles = ['{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}'] +roles = ["ap_admin"] diff --git a/platform-api/config/config_test.go b/platform-api/config/config_test.go index 35099ced5b..5bc5d3de98 100644 --- a/platform-api/config/config_test.go +++ b/platform-api/config/config_test.go @@ -365,7 +365,7 @@ func TestValidateAuthConfig(t *testing.T) { auth: Auth{ Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml"}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Roles: []string{"ap_admin"}}}, @@ -379,7 +379,7 @@ func TestValidateAuthConfig(t *testing.T) { auth: Auth{ Mode: AuthModeFile, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeScope, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml"}, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash"}}, @@ -400,7 +400,7 @@ func TestValidateAuthConfig(t *testing.T) { Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Roles: []string{"ap_admin"}}}, }, }, - wantErr: "auth.authorization.roles_to_scope_mapping", + wantErr: "auth.authorization.role_to_scope_mapping", }, { // A file-mode user's role is expanded into the scope claim at login, so @@ -412,7 +412,7 @@ func TestValidateAuthConfig(t *testing.T) { Authorization: Authorization{ Enabled: true, Mode: AuthzModeScope, - RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml", + RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml", }, File: FileBased{ Organization: FileBasedOrg{ID: "default", DisplayName: "Default"}, @@ -479,19 +479,19 @@ func TestValidateAuthorizationConfig(t *testing.T) { }, { name: "role mode fully configured", - authz: Authorization{Enabled: true, Mode: AuthzModeRole, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, + authz: Authorization{Enabled: true, Mode: AuthzModeRole, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml"}, claims: ClaimMappings{Roles: "realm_access.roles"}, }, { name: "role mode without roles claim mapping", - authz: Authorization{Enabled: true, Mode: AuthzModeRole, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, + authz: Authorization{Enabled: true, Mode: AuthzModeRole, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml"}, wantErr: "auth.claim_mappings.roles", }, { - name: "role mode without roles_to_scope_mapping file", + name: "role mode without role_to_scope_mapping file", authz: Authorization{Enabled: true, Mode: AuthzModeRole}, claims: ClaimMappings{Roles: "roles"}, - wantErr: "auth.authorization.roles_to_scope_mapping", + wantErr: "auth.authorization.role_to_scope_mapping", }, { name: "unknown mode rejected", @@ -531,7 +531,7 @@ func TestValidateAuthConfig_RoleAuthorizationInInternalTokenMode(t *testing.T) { auth := Auth{ Mode: AuthModeInternalToken, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile}, - Authorization: Authorization{Enabled: true, Mode: AuthzModeRole, RolesToScopeMapping: "/etc/platform-api/roles_to_scope_mapping.yaml"}, + Authorization: Authorization{Enabled: true, Mode: AuthzModeRole, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml"}, ClaimMappings: ClaimMappings{Roles: "roles"}, } assert.NoError(t, validateAuthConfig(&auth)) @@ -675,7 +675,7 @@ encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd mode = "file" [platform_api.auth.authorization] -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.jwt] public_key_file = "` + validJWTPublicKeyFile + `" @@ -724,19 +724,19 @@ roles = ["ap_admin"] // auth.file.users[].roles is a list, so a user whose persona spans two shipped // roles names both rather than needing a role defined for the combination. The -// shipped config.toml writes that list with an {{ env }} token inside it, so this -// also pins that interpolation reaches into array elements — a regression there -// would silently hand the raw "{{ env ... }}" string to the role lookup and grant -// the user nothing. +// list is also written with an {{ env }} token in one element, pinning that +// interpolation reaches into array elements and not just scalars — a regression +// there would silently hand the raw "{{ env ... }}" string to the role lookup +// and grant the user nothing. func TestLoadConfig_FileUserRolesList(t *testing.T) { - t.Setenv("APIP_CP_ADMIN_ROLE", "ap_operator") + t.Setenv("APIP_CP_USER_ROLE", "ap_operator") cfg, err := loadWithKeys(t, `private_key_file = "`+validJWTPrivateKeyFile+`" [platform_api.auth] mode = "file" [platform_api.auth.authorization] -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.file.organization] id = "default" @@ -745,7 +745,7 @@ display_name = "Default" [[platform_api.auth.file.users]] username = "admin" password_hash = "$2a$12$hash" -roles = ['{{ env "APIP_CP_ADMIN_ROLE" "ap_admin" }}', "ap_viewer"] +roles = ['{{ env "APIP_CP_USER_ROLE" "ap_admin" }}', "ap_viewer"] `) require.NoError(t, err) require.Len(t, cfg.Auth.File.Users, 1) diff --git a/platform-api/config/default_config.go b/platform-api/config/default_config.go index 8c7009f7e6..f910ea767c 100644 --- a/platform-api/config/default_config.go +++ b/platform-api/config/default_config.go @@ -47,7 +47,7 @@ func defaultConfig() *Server { Authorization: Authorization{ Enabled: true, Mode: AuthzModeScope, - // RolesToScopeMapping is left empty on purpose: the mapping file is + // RoleToScopeMapping is left empty on purpose: the mapping file is // operator-owned and mounted (the packs ship a sample), so a // built-in path would make startup depend on a file the image // does not carry. The shipped config.toml points at the mount. diff --git a/platform-api/internal/handler/auth_login.go b/platform-api/internal/handler/auth_login.go index a3dec98ac2..5cd294958e 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -46,7 +46,7 @@ type loginResponse struct { // AuthLoginHandler issues JWT tokens for locally-configured users (file-based auth mode). type AuthLoginHandler struct { cfg *config.Server - // roleScopeMap is the role-to-scope mapping from auth.authorization.roles_to_scope_mapping, + // roleScopeMap is the role-to-scope mapping from auth.authorization.role_to_scope_mapping, // used to expand each user's roles into the scopes its token carries. In file // mode it is always populated: config validation requires the mapping file, and // startup checks every role every user names against it. diff --git a/platform-api/internal/middleware/openapi_scope_registry.go b/platform-api/internal/middleware/openapi_scope_registry.go index a1774bee44..066a2d20df 100644 --- a/platform-api/internal/middleware/openapi_scope_registry.go +++ b/platform-api/internal/middleware/openapi_scope_registry.go @@ -96,7 +96,7 @@ func (r *ScopeRegistry) Operations() []Operation { } // AllScopes returns the set of every scope name declared across all operations. -// Used at startup to validate that roles_to_scope_mapping.yaml only references known scopes. +// Used at startup to validate that role-to-scope-mapping.yaml only references known scopes. func (r *ScopeRegistry) AllScopes() map[string]struct{} { known := make(map[string]struct{}) for _, scopes := range r.scopes { diff --git a/platform-api/internal/middleware/role_scope_map.go b/platform-api/internal/middleware/role_scope_map.go index 0c4c5ddace..18eee79404 100644 --- a/platform-api/internal/middleware/role_scope_map.go +++ b/platform-api/internal/middleware/role_scope_map.go @@ -26,34 +26,34 @@ import ( "gopkg.in/yaml.v3" ) -// roleScopeEntry is a single entry in roles_to_scope_mapping.yaml: an IDP role name and the +// roleScopeEntry is a single entry in role-to-scope-mapping.yaml: an IDP role name and the // platform scopes it grants. type roleScopeEntry struct { Name string `yaml:"name"` Scopes []string `yaml:"scopes"` } -// roleScopeConfig is the top-level structure of roles_to_scope_mapping.yaml. +// roleScopeConfig is the top-level structure of role-to-scope-mapping.yaml. type roleScopeConfig struct { Roles []roleScopeEntry `yaml:"roles"` } -// LoadRoleScopeMap reads a roles_to_scope_mapping.yaml file and returns a map from IDP role name +// LoadRoleScopeMap reads a role-to-scope-mapping.yaml file and returns a map from IDP role name // to the list of platform scopes that role grants. Each user token may carry // multiple roles; the caller is expected to union the scope lists at request time. func LoadRoleScopeMap(path string) (map[string][]string, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("roles_to_scope_mapping.yaml: read %q: %w", path, err) + return nil, fmt.Errorf("role-to-scope-mapping.yaml: read %q: %w", path, err) } var cfg roleScopeConfig if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("roles_to_scope_mapping.yaml: parse %q: %w", path, err) + return nil, fmt.Errorf("role-to-scope-mapping.yaml: parse %q: %w", path, err) } m := make(map[string][]string, len(cfg.Roles)) for _, entry := range cfg.Roles { if entry.Name == "" { - return nil, fmt.Errorf("roles_to_scope_mapping.yaml: entry missing required 'name' field in %q", path) + return nil, fmt.Errorf("role-to-scope-mapping.yaml: entry missing required 'name' field in %q", path) } m[entry.Name] = entry.Scopes } @@ -91,14 +91,14 @@ func ValidateRoleScopeMap(m map[string][]string, registry *ScopeRegistry) error for role, scopes := range m { for _, s := range scopes { if !wellFormedScope.MatchString(s) { - return fmt.Errorf("roles_to_scope_mapping.yaml: role %q references malformed scope %q — expected \":\", e.g. %sorganization:manage", + return fmt.Errorf("role-to-scope-mapping.yaml: role %q references malformed scope %q — expected \":\", e.g. %sorganization:manage", role, s, PlatformScopePrefix) } if !strings.HasPrefix(s, PlatformScopePrefix) { continue // another component's namespace — not ours to validate } if _, ok := known[s]; !ok { - return fmt.Errorf("roles_to_scope_mapping.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) + return fmt.Errorf("role-to-scope-mapping.yaml: role %q references unknown scope %q — check the OpenAPI spec for valid scope names", role, s) } } } diff --git a/platform-api/internal/server/role_scope_map_test.go b/platform-api/internal/server/role_scope_map_test.go index b5faae9c23..018be2d157 100644 --- a/platform-api/internal/server/role_scope_map_test.go +++ b/platform-api/internal/server/role_scope_map_test.go @@ -28,7 +28,7 @@ import ( "github.com/wso2/api-platform/platform-api/internal/middleware" ) -// writeRolesFile writes a roles_to_scope_mapping.yaml mapping one role to the given scopes and +// writeRolesFile writes a role-to-scope-mapping.yaml mapping one role to the given scopes and // returns its path. func writeRolesFile(t *testing.T, role string, scopes ...string) string { t.Helper() @@ -37,9 +37,9 @@ func writeRolesFile(t *testing.T, role string, scopes ...string) string { for _, s := range scopes { fmt.Fprintf(&b, " - %s\n", s) } - path := filepath.Join(t.TempDir(), "roles_to_scope_mapping.yaml") + path := filepath.Join(t.TempDir(), "role-to-scope-mapping.yaml") if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { - t.Fatalf("writing roles_to_scope_mapping.yaml: %v", err) + t.Fatalf("writing role-to-scope-mapping.yaml: %v", err) } return path } @@ -50,7 +50,7 @@ func writeRolesFile(t *testing.T, role string, scopes ...string) string { func roleModeConfig(path string) *config.Server { cfg := &config.Server{} cfg.Auth.Authorization.Mode = config.AuthzModeRole - cfg.Auth.Authorization.RolesToScopeMapping = path + cfg.Auth.Authorization.RoleToScopeMapping = path return cfg } @@ -76,7 +76,7 @@ func TestLoadRoleScopeMap_AcceptsPluginScopeAfterMerge(t *testing.T) { } // The pre-merge registry knows nothing of plugin scopes, so validating against -// it rejects the same roles_to_scope_mapping.yaml. This is the failure the ordering above avoids; +// it rejects the same role-to-scope-mapping.yaml. This is the failure the ordering above avoids; // if someone moves loadRoleScopeMap back before initPlugins, the test above // starts failing with exactly this error. func TestLoadRoleScopeMap_RejectsPluginScopeBeforeMerge(t *testing.T) { @@ -94,7 +94,7 @@ func TestLoadRoleScopeMap_RejectsPluginScopeBeforeMerge(t *testing.T) { // The mapping is loaded whenever it is configured, including in scope // authorization mode — file-mode users name a role from this same file to -// inherit its scopes, so the login endpoint needs it there too. A bad roles_to_scope_mapping.yaml +// inherit its scopes, so the login endpoint needs it there too. A bad role-to-scope-mapping.yaml // therefore still fails startup in scope mode. func TestLoadRoleScopeMap_LoadedInScopeMode(t *testing.T) { reg := emptyRegistry(t) @@ -123,7 +123,7 @@ func TestLoadRoleScopeMap_SkippedWhenUnconfigured(t *testing.T) { t.Fatalf("loadRoleScopeMap: unexpected error: %v", err) } if m != nil { - t.Fatalf("expected no mapping when roles_to_scope_mapping is unset, got %v", m) + t.Fatalf("expected no mapping when role_to_scope_mapping is unset, got %v", m) } } @@ -135,7 +135,7 @@ func TestValidateFileUserRoles(t *testing.T) { cfg := &config.Server{} cfg.Auth.Mode = config.AuthModeFile - cfg.Auth.Authorization.RolesToScopeMapping = "/etc/platform-api/roles_to_scope_mapping.yaml" + cfg.Auth.Authorization.RoleToScopeMapping = "/etc/platform-api/role-to-scope-mapping.yaml" cfg.Auth.File.Users = config.FileBasedUsers{{Username: "admin", Roles: []string{"ap_admin"}}} if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { t.Fatalf("unexpected error for a defined role: %v", err) @@ -168,19 +168,19 @@ func TestShippedSampleRolesValidateAgainstShippedSpec(t *testing.T) { t.Fatalf("loading the shipped OpenAPI spec: %v", err) } - m, err := middleware.LoadRoleScopeMap("../../resources/roles_to_scope_mapping.yaml") + m, err := middleware.LoadRoleScopeMap("../../resources/role-to-scope-mapping.yaml") if err != nil { - t.Fatalf("loading the shipped roles_to_scope_mapping.yaml: %v", err) + t.Fatalf("loading the shipped role-to-scope-mapping.yaml: %v", err) } if err := middleware.ValidateRoleScopeMap(m, reg); err != nil { - t.Fatalf("shipped roles_to_scope_mapping.yaml is not valid against the shipped spec: %v", err) + t.Fatalf("shipped role-to-scope-mapping.yaml is not valid against the shipped spec: %v", err) } // The documented role set. ap_admin in particular is what the shipped // config.toml grants its admin user, so a rename here breaks every pack. for _, role := range []string{"ap_admin", "ap_operator", "ap_publisher", "ap_subscriber", "ap_viewer"} { if _, ok := m[role]; !ok { - t.Fatalf("shipped roles_to_scope_mapping.yaml does not declare %q", role) + t.Fatalf("shipped role-to-scope-mapping.yaml does not declare %q", role) } } diff --git a/platform-api/internal/server/server.go b/platform-api/internal/server/server.go index 1a1fb5cf02..c6840e4cbb 100644 --- a/platform-api/internal/server/server.go +++ b/platform-api/internal/server/server.go @@ -448,9 +448,9 @@ func StartPlatformAPIServer(cfg *config.Server, slogger *slog.Logger, } }() - // Load and validate the role-to-scope map when roles_to_scope_mapping.yaml is configured. + // Load and validate the role-to-scope map when role-to-scope-mapping.yaml is configured. // Runs after initPlugins so a role may map to a plugin-declared scope, and - // after the defer above so a bad roles_to_scope_mapping.yaml still stops the plugins. + // after the defer above so a bad role-to-scope-mapping.yaml still stops the plugins. roleScopeMap, err := loadRoleScopeMap(cfg, scopeRegistry, slogger) if err != nil { return nil, err @@ -713,18 +713,18 @@ func buildAuthenticator(cfg *config.Server, slogger *slog.Logger, roleScopeMap m // requires the path wherever it is needed, so an empty path here means nothing // consumes the mapping. func loadRoleScopeMap(cfg *config.Server, registry *middleware.ScopeRegistry, slogger *slog.Logger) (map[string][]string, error) { - if cfg.Auth.Authorization.RolesToScopeMapping == "" { + if cfg.Auth.Authorization.RoleToScopeMapping == "" { return nil, nil } - m, err := middleware.LoadRoleScopeMap(cfg.Auth.Authorization.RolesToScopeMapping) + m, err := middleware.LoadRoleScopeMap(cfg.Auth.Authorization.RoleToScopeMapping) if err != nil { return nil, fmt.Errorf("failed to load role mappings file: %w", err) } if err := middleware.ValidateRoleScopeMap(m, registry); err != nil { - return nil, fmt.Errorf("invalid roles_to_scope_mapping.yaml: %w", err) + return nil, fmt.Errorf("invalid role-to-scope-mapping.yaml: %w", err) } - slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.Authorization.RolesToScopeMapping, "roles", len(m)) + slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.Authorization.RoleToScopeMapping, "roles", len(m)) return m, nil } @@ -746,7 +746,7 @@ func validateFileUserRoles(cfg *config.Server, roleScopeMap map[string][]string) } if _, ok := roleScopeMap[role]; !ok { return fmt.Errorf("auth.file.users[%d]: roles[%d] %q is not defined in %s", - i, j, role, cfg.Auth.Authorization.RolesToScopeMapping) + i, j, role, cfg.Auth.Authorization.RoleToScopeMapping) } } } diff --git a/platform-api/resources/roles_to_scope_mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml similarity index 99% rename from platform-api/resources/roles_to_scope_mapping.yaml rename to platform-api/resources/role-to-scope-mapping.yaml index 3604f6dfd7..3d70a06a7b 100644 --- a/platform-api/resources/roles_to_scope_mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -1,4 +1,4 @@ -# Role-to-scope mapping used by the Platform API (auth.authorization.roles_to_scope_mapping). +# Role-to-scope mapping used by the Platform API (auth.authorization.role_to_scope_mapping). # # Each entry maps a role name to the scopes that role grants. Two consumers read # this file: diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 8f95caf4a3..22840a6ca2 100644 --- a/portals/ai-workspace/Makefile +++ b/portals/ai-workspace/Makefile @@ -341,13 +341,13 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) > $(DIST_DIR)/configs/.pa-config-template.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config.toml" \ > $(DIST_DIR)/configs/.pa-config.toml - @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles_to_scope_mapping.yaml" \ - > $(DIST_DIR)/resources/roles_to_scope_mapping.yaml + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/role-to-scope-mapping.yaml" \ + > $(DIST_DIR)/resources/role-to-scope-mapping.yaml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-config.toml - @cp ../../platform-api/resources/roles_to_scope_mapping.yaml $(DIST_DIR)/resources/roles_to_scope_mapping.yaml + @cp ../../platform-api/resources/role-to-scope-mapping.yaml $(DIST_DIR)/resources/role-to-scope-mapping.yaml endif # Require a [platform_api] root table — pre-unified configs would merge into a broken file. @if ! grep -q '^\[platform_api' $(DIST_DIR)/configs/.pa-config.toml; then \ @@ -393,7 +393,7 @@ endif @rm -f $(DIST_DIR)/scripts/setup.ps1.bak # Point the platform-api mount at the merged config so both containers share one file. @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ - -e 's#\.\./\.\./platform-api/resources/roles_to_scope_mapping\.yaml:#./resources/roles_to_scope_mapping.yaml:#' \ + -e 's#\.\./\.\./platform-api/resources/role-to-scope-mapping\.yaml:#./resources/role-to-scope-mapping.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @sed -i.bak -E \ diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index 2d0d182473..42afbcc653 100644 --- a/portals/ai-workspace/distribution/README.md +++ b/portals/ai-workspace/distribution/README.md @@ -18,7 +18,7 @@ wso2apip-ai-workspace-/ │ └── config-template.toml # Full configuration reference for both, │ # plus optional [developer_portal] at the bottom └── resources/ - ├── roles_to_scope_mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) + ├── role-to-scope-mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) └── platform-api/ └── db-scripts/ # Platform API schema scripts (schema.*.sql) ``` @@ -127,8 +127,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.auth].mode` | `file` (quickstart default), `internal_token`, or `idp` — selects exactly one auth mode | | `[platform_api.auth.jwt].public_key_file` / `private_key_file` | RS256 (asymmetric) PEM keys; `public_key_file` verifies every token, `private_key_file` signs login JWTs in `file` mode. Read via `{{ file }}` — HMAC and unsigned tokens are rejected | | `[platform_api.auth.idp]` | JWKS-based IDP auth — active when `mode = "idp"`; configure for Asgardeo, Keycloak, Auth0, etc. | -| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing). Each user names one or more `roles` from `resources/roles_to_scope_mapping.yaml` — those roles are the whole grant, unioned | -| `[platform_api.auth.authorization].roles_to_scope_mapping` | Path to the mounted `resources/roles_to_scope_mapping.yaml` — edit that file to change what a role grants | +| `[platform_api.auth.file.users]` | Local user credentials, active when `mode = "file"` (change the password hash before sharing). Each user names one or more `roles` from `resources/role-to-scope-mapping.yaml` — those roles are the whole grant, unioned | +| `[platform_api.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/role-to-scope-mapping.yaml` — edit that file to change what a role grants | | `[platform_api.server.https]` | Listener on `:9243`; `cert_file`/`key_file` point at `cert.pem`/`key.pem` | Each key's default value is written inline in `configs/config-template.toml` — a diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 4bf27b0d0d..4e1f9a83b3 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -26,7 +26,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-data:/app/data - ./resources/certificates:/app/data/certs:ro - ./resources/keys:/etc/platform-api/keys:ro diff --git a/portals/ai-workspace/production/README.md b/portals/ai-workspace/production/README.md index 2eda1b1e65..518cdf258b 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -115,7 +115,7 @@ Optional overrides (defaults shown): ```toml [platform_api.auth.authorization] enabled = true -mode = "scope" # or "role" for role-based auth (then set roles_to_scope_mapping) +mode = "scope" # or "role" for role-based auth (then set role_to_scope_mapping) [platform_api.auth.claim_mappings] user_id = "sub" diff --git a/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index bd3a560cc3..0186248fce 100644 --- a/portals/developer-portal/Makefile +++ b/portals/developer-portal/Makefile @@ -284,13 +284,13 @@ ifeq ($(PLATFORM_API_FROM_TAG),true) > $(DIST_DIR)/configs/.pa-config.toml @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/config/config-template.toml" \ > $(DIST_DIR)/configs/.pa-config-template.toml - @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/roles_to_scope_mapping.yaml" \ - > $(DIST_DIR)/resources/roles_to_scope_mapping.yaml + @git -C ../.. show "$(PLATFORM_API_TAG):platform-api/resources/role-to-scope-mapping.yaml" \ + > $(DIST_DIR)/resources/role-to-scope-mapping.yaml else @cp ../../platform-api/internal/database/schema.*.sql $(DIST_DIR)/resources/platform-api/db-scripts/ @cp ../../platform-api/config/config.toml $(DIST_DIR)/configs/.pa-config.toml @cp ../../platform-api/config/config-template.toml $(DIST_DIR)/configs/.pa-config-template.toml - @cp ../../platform-api/resources/roles_to_scope_mapping.yaml $(DIST_DIR)/resources/roles_to_scope_mapping.yaml + @cp ../../platform-api/resources/role-to-scope-mapping.yaml $(DIST_DIR)/resources/role-to-scope-mapping.yaml endif # Require a [platform_api] root table — pre-unified configs would merge into a broken file. @if ! grep -q '^\[platform_api' $(DIST_DIR)/configs/.pa-config.toml; then \ @@ -311,7 +311,7 @@ endif @rm -f $(DIST_DIR)/configs/.pa-config.toml $(DIST_DIR)/configs/.pa-config-template.toml $(DIST_DIR)/configs/.aiw-config-template.toml # Point the platform-api mount at the merged config so both containers share one file. @sed -e 's#\.\./\.\./platform-api/config/config\.toml:#./configs/config.toml:#' \ - -e 's#\.\./\.\./platform-api/resources/roles_to_scope_mapping\.yaml:#./resources/roles_to_scope_mapping.yaml:#' \ + -e 's#\.\./\.\./platform-api/resources/role-to-scope-mapping\.yaml:#./resources/role-to-scope-mapping.yaml:#' \ docker-compose.yaml > $(DIST_DIR)/docker-compose.yaml @cp distribution/README.md $(DIST_DIR)/README.md @mkdir -p $(DIST_DIR)/scripts diff --git a/portals/developer-portal/README.md b/portals/developer-portal/README.md index 54c43e1027..0f692efde6 100644 --- a/portals/developer-portal/README.md +++ b/portals/developer-portal/README.md @@ -249,16 +249,16 @@ The full annotated list of settings is in [`configs/config-template.toml`](confi ### Local auth -For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users and bcrypt-hashed passwords are defined there, under `[[platform_api.auth.file.users]]`; each names one or more roles from the [`roles_to_scope_mapping.yaml`](../../platform-api/resources/roles_to_scope_mapping.yaml) mounted alongside it, and those roles are where the `dp:*` scopes the portal enforces come from: +For quick exploration without an IdP, the portal delegates credential validation to a Platform API sidecar. `docker-compose.yaml` mounts the Platform API's own [`../../platform-api/config/config.toml`](../../platform-api/config/config.toml) directly — there is no per-portal copy. Users and bcrypt-hashed passwords are defined there, under `[[platform_api.auth.file.users]]`; each names one or more roles from the [`role-to-scope-mapping.yaml`](../../platform-api/resources/role-to-scope-mapping.yaml) mounted alongside it, and those roles are where the `dp:*` scopes the portal enforces come from: ```toml [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$..." # bcrypt hash — generate with: htpasswd -bnBC 12 "" | tr -d ':\n' -roles = ["ap_admin"] # grants dp:org_manage, dp:api_manage, … — see roles_to_scope_mapping.yaml +roles = ["ap_admin"] # grants dp:org_manage, dp:api_manage, … — see role-to-scope-mapping.yaml ``` -To change what a portal user may do, edit that role's entry in `roles_to_scope_mapping.yaml` — or name a second role alongside it — rather than listing scopes on the user block. +To change what a portal user may do, edit that role's entry in `role-to-scope-mapping.yaml` — or name a second role alongside it — rather than listing scopes on the user block. The portal config (or `APIP_DP_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: diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md index fc2252fa76..674a8f4d08 100644 --- a/portals/developer-portal/distribution/README.md +++ b/portals/developer-portal/distribution/README.md @@ -16,7 +16,7 @@ wso2apip-developer-portal-/ │ ├── config.toml # Unified active config — [developer_portal] + [platform_api] sections │ └── config-template.toml # Config reference — both active components, plus optional [ai_workspace] at the bottom └── resources/ - ├── roles_to_scope_mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) + ├── role-to-scope-mapping.yaml # Platform API role-to-scope mapping (edit to change what a role grants) ├── developer-portal/ │ └── db-scripts/ # Developer Portal PostgreSQL schema (reference copy) ├── platform-api/ @@ -135,8 +135,8 @@ Environment overrides go in `api-platform.env` (git-ignored; loaded into both co | `[platform_api.database].driver` | `sqlite3` or `postgres` | `sqlite3` | | `[platform_api.auth.jwt].public_key_file` / `.private_key_file` | RS256 keypair — platform-api signs login JWTs with the private key; the portal verifies with the public one | _(from `setup.sh`)_ | | `[platform_api.auth.idp]` | JWKS-based IDP auth — disabled in quickstart mode | disabled | -| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `roles` names one or more entries in `resources/roles_to_scope_mapping.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | -| `[platform_api.auth.authorization].roles_to_scope_mapping` | Path to the mounted `resources/roles_to_scope_mapping.yaml` — edit that file to change what a role grants | `/etc/platform-api/roles_to_scope_mapping.yaml` | +| `[[platform_api.auth.file.users]]` | Local user credentials — `username`/`password_hash` resolved from `setup.sh`'s env vars; `roles` names one or more entries in `resources/role-to-scope-mapping.yaml`, which is where that user's scopes come from | admin, generated by `setup.sh` | +| `[platform_api.auth.authorization].role_to_scope_mapping` | Path to the mounted `resources/role-to-scope-mapping.yaml` — edit that file to change what a role grants | `/etc/platform-api/role-to-scope-mapping.yaml` | See `configs/config-template.toml` for a fully-commented reference of every available setting across both active components (plus the optional `[ai_workspace]` section at the bottom). diff --git a/portals/developer-portal/docker-compose.platform-api.yaml b/portals/developer-portal/docker-compose.platform-api.yaml index 6cbcc0d76d..66c163af9f 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -42,7 +42,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-data:/app/data # Certs land under /app/data/certs to match the binary's compiled # default cert_file/key_file paths (./data/certs/{cert,key}.pem, diff --git a/portals/developer-portal/docker-compose.yaml b/portals/developer-portal/docker-compose.yaml index dcb0dca56d..c771cfb1e0 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -25,7 +25,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml:ro - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-data:/app/data - ./resources/certificates:/app/data/certs:ro - ./resources/keys:/etc/platform-api/keys:ro diff --git a/portals/developer-portal/it/configs/config-platform-api-it.toml b/portals/developer-portal/it/configs/config-platform-api-it.toml index 882d12782a..b9aef89184 100644 --- a/portals/developer-portal/it/configs/config-platform-api-it.toml +++ b/portals/developer-portal/it/configs/config-platform-api-it.toml @@ -42,7 +42,7 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' # Each user below names a role from this file — a role is a file-mode user's # entire grant, so the scope lists live there, not here. [platform_api.auth.authorization] -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api-it" }}' diff --git a/portals/developer-portal/it/configs/roles-platform-api-it.yaml b/portals/developer-portal/it/configs/roles-platform-api-it.yaml index 44e7244260..b1082ec162 100644 --- a/portals/developer-portal/it/configs/roles-platform-api-it.yaml +++ b/portals/developer-portal/it/configs/roles-platform-api-it.yaml @@ -10,7 +10,7 @@ # -------------------------------------------------------------------- # # Role-to-scope mapping for the developer-portal backend IT suite, named by -# auth.authorization.roles_to_scope_mapping in config-platform-api-it.toml. A file-mode +# auth.authorization.role_to_scope_mapping in config-platform-api-it.toml. A file-mode # user's roles are its entire grant, so the three IT accounts (admin / publisher / # developer) get one role each, defined here rather than as per-user scope lists. # diff --git a/portals/developer-portal/it/docker-compose.test.postgres.yaml b/portals/developer-portal/it/docker-compose.test.postgres.yaml index 1cab0a2b31..443819ede4 100644 --- a/portals/developer-portal/it/docker-compose.test.postgres.yaml +++ b/portals/developer-portal/it/docker-compose.test.postgres.yaml @@ -57,7 +57,7 @@ services: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ./configs/roles-platform-api-it.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-data:/app/data # TLS pair + RS256 JWT keypair generated once, host-side, by `make # ensure-certs` (mirrors ../../scripts/setup.sh's approach for the production diff --git a/portals/developer-portal/it/docker-compose.test.yaml b/portals/developer-portal/it/docker-compose.test.yaml index bad615f531..2b31466e88 100644 --- a/portals/developer-portal/it/docker-compose.test.yaml +++ b/portals/developer-portal/it/docker-compose.test.yaml @@ -36,7 +36,7 @@ services: APIP_CP_ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" volumes: - ./configs/config-platform-api-it.toml:/etc/platform-api/config-platform-api.toml:ro - - ./configs/roles-platform-api-it.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ./configs/roles-platform-api-it.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-data:/app/data # TLS pair + RS256 JWT keypair generated once, host-side, by `make # ensure-certs` (mirrors ../../scripts/setup.sh's approach for the production diff --git a/tests/ai-workspace-cli-e2e/docker-compose.yaml b/tests/ai-workspace-cli-e2e/docker-compose.yaml index fe050e47f6..b8affb3b2d 100644 --- a/tests/ai-workspace-cli-e2e/docker-compose.yaml +++ b/tests/ai-workspace-cli-e2e/docker-compose.yaml @@ -69,9 +69,9 @@ services: - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the + # Role-to-scope mapping named by auth.authorization.role_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/ai-workspace-cli-e2e/platform-api-config.toml b/tests/ai-workspace-cli-e2e/platform-api-config.toml index 0ecf4385ca..89da4c432b 100644 --- a/tests/ai-workspace-cli-e2e/platform-api-config.toml +++ b/tests/ai-workspace-cli-e2e/platform-api-config.toml @@ -27,7 +27,7 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' [platform_api.auth.authorization] enabled = true mode = "scope" -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' @@ -48,6 +48,6 @@ uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8 [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -# ap_admin from the shipped roles_to_scope_mapping.yaml — every Platform API scope this build +# ap_admin from the shipped role-to-scope-mapping.yaml — every Platform API scope this build # declares. The roles list is the user's whole grant; there is no per-user scope list. roles = ["ap_admin"] diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index c0bd8bc3e3..af6cd11b41 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -157,7 +157,7 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. against the RS256 `jwt_public.pem` shared via the `platform-api-jwt-keys` volume, org from the token's `org_handle` claim). The admin must carry `dp:*` scopes, which it gets from the `ap_admin` role in the - mounted `roles_to_scope_mapping.yaml` — that role spans both the `ap:*` and `dp:*` namespaces, + mounted `role-to-scope-mapping.yaml` — that role spans both the `ap:*` and `dp:*` namespaces, so the one admin JWT authorizes both products. Bearer auth (not API-key mode) is used because the write paths need a resolved user for `created_by`. - `BeforeSuite` generates the shared webhook secret (`prepareWebhookSecret`, exported diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index e6a6eab74c..7c5c7e4335 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -65,9 +65,9 @@ services: - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the + # Role-to-scope mapping named by auth.authorization.role_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 6fae6762b6..7504c23d4b 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -111,9 +111,9 @@ services: - APIP_CP_AUTH_FILE_ORGANIZATION_REGION=us volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the + # Role-to-scope mapping named by auth.authorization.role_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 6218655dd3..6cdd353334 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -117,9 +117,9 @@ services: - APIP_CP_WEBHOOK_SECRET=${E2E_WEBHOOK_SECRET:?set E2E_WEBHOOK_SECRET (the suite sets it automatically; export one to run compose by hand)} volumes: - ./platform-api-config.toml:/etc/platform-api/config.toml:ro - # Role-to-scope mapping named by auth.authorization.roles_to_scope_mapping — the + # Role-to-scope mapping named by auth.authorization.role_to_scope_mapping — the # shipped file, so the suite runs against the same grants operators get. - - ../../platform-api/resources/roles_to_scope_mapping.yaml:/etc/platform-api/roles_to_scope_mapping.yaml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/platform-api/role-to-scope-mapping.yaml:ro - platform-api-certs:/app/data/certs - platform-api-jwt-keys:/etc/platform-api/keys:ro ports: diff --git a/tests/integration-e2e/platform-api-config.toml b/tests/integration-e2e/platform-api-config.toml index 6b0ae14e41..797db61e5e 100644 --- a/tests/integration-e2e/platform-api-config.toml +++ b/tests/integration-e2e/platform-api-config.toml @@ -26,7 +26,7 @@ mode = '{{ env "APIP_CP_AUTH_MODE" "file" }}' # The admin below names a role from the shipped mapping, bind-mounted at this # path — a role is a file-mode user's entire grant. [platform_api.auth.authorization] -roles_to_scope_mapping = "/etc/platform-api/roles_to_scope_mapping.yaml" +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.jwt] issuer = '{{ env "APIP_CP_AUTH_JWT_ISSUER" "platform-api" }}' @@ -46,7 +46,7 @@ uuid = '{{ env "APIP_CP_AUTH_FILE_ORGANIZATION_UUID" "99089a17-72e0-4dd8 [[platform_api.auth.file.users]] username = "admin" password_hash = "$2y$10$U2yKMwGamGwDoMu0hRPT7u8nCuP8z/qxHFOKV6dhIxkJN9NJ0eVQ." -# ap_admin from the shipped roles_to_scope_mapping.yaml — every Platform API scope this build +# ap_admin from the shipped role-to-scope-mapping.yaml — every Platform API scope this build # declares, plus the Developer Portal scopes the devportal e2e stack asserts on. roles = ["ap_admin"]