diff --git a/distribution/all-in-one/docker-compose.yaml b/distribution/all-in-one/docker-compose.yaml index 30f2d52678..635f8e49f6 100644 --- a/distribution/all-in-one/docker-compose.yaml +++ b/distribution/all-in-one/docker-compose.yaml @@ -137,10 +137,9 @@ services: - "9243:9243" volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml: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 - # 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 d11f076511..c4c19390dc 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_to_scope_mapping = {{ $auth.authorization.roleToScopeMapping | 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,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" }}'` }} - scopes = {{ $auth.file.admin.scopes | 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] @@ -166,4 +168,12 @@ data: {{ . | nindent 4 | trim }} {{- end }} + {{- with $auth.authorization.roles }} + # 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. + role-to-scope-mapping.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..8df1b253a1 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.roleToScopeMapping }} + subPath: role-to-scope-mapping.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: 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 # 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 f2baa66411..34a184323c 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -106,13 +106,48 @@ 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 the role→scope mapping YAML. Required when mode=role, and in file + # 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. + 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/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. + 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 # Claim-name mappings shared by all modes. claimMappings: organization: organization @@ -122,7 +157,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.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 # PEM files from the Secret (secrets.keys.jwtPublicKey / jwtPrivateKey). @@ -145,9 +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 scopes are configured here (add more users via configToml). + # the granted roles are configured here (add more users via configToml). admin: - 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 — 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 roleToScopeMapping 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: @@ -155,8 +198,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..8aabbb4c99 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/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 — +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` @@ -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.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]` | `mode` — one of `internal_token`, `file`, or `idp` | +| `[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 | | `[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,107 @@ 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_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 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/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: | 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_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 `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/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 +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. + +##### 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 `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.role_to_scope_mapping +``` + +`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 roles from the same +`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 `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 `role-to-scope-mapping.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..b3042b6a83 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_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/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 -# 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,12 @@ 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 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. +roles = "roles" # IDP (JWKS-based) — used when mode = "idp" (Asgardeo, Keycloak, Auth0, etc.). # jwks_url and issuer are required in that mode. @@ -174,11 +194,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,18 +212,33 @@ 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" +# 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 +# whether auth.authorization.mode is "scope" (default) or "role". A user with no +# 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 roles it names. To grant +# something no shipped role covers, name several roles, or add a role to that file +# (see resources/role-to-scope-mapping.yaml for the shipped ones and the scope +# namespaces it may use). +# +# Left empty here because role_to_scope_mapping above is empty in this template — set that +# first, then name a 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$" -# scopes = "ap:organization:read ap:gateway:read ap:rest_api:read ap:llm_provider:read" +# roles = ["ap_viewer"] -# 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 +255,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..6bb4fb3c73 100644 --- a/platform-api/config/config.go +++ b/platform-api/config/config.go @@ -45,7 +45,16 @@ import ( type FileBasedUser struct { Username string `json:"username" koanf:"username"` PasswordHash string `json:"password_hash" koanf:"password_hash"` - Scopes string `json:"scopes" koanf:"scopes"` + // Roles names one or more of the roles in the + // 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 + // 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) @@ -105,12 +114,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 +130,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 +164,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_to_scope_mapping 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"` + // 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. + RoleToScopeMapping string `koanf:"role_to_scope_mapping"` +} + // ClaimMappings holds JWT claim name mappings, shared across all auth modes. type ClaimMappings struct { Organization string `koanf:"organization"` @@ -166,12 +205,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 +306,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 +316,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 +534,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 +691,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 +702,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 +722,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 +759,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 +770,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 +896,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.RoleToScopeMapping == "" { + return fmt.Errorf("auth.authorization.mode=%s requires auth.authorization.role_to_scope_mapping 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 +949,26 @@ 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) } + // 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.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.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.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) + } } return nil } diff --git a/platform-api/config/config.toml b/platform-api/config/config.toml index 6660f393cb..0569ef4ecc 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/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. +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" [platform_api.auth.jwt] issuer = "platform-api" @@ -36,4 +43,7 @@ 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" +# The roles are the whole grant — name more than one to union what they grant, or +# edit an entry in the mounted role-to-scope-mapping.yaml to change what this +# user may do. +roles = ["ap_admin"] 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..5bc5d3de98 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,26 +362,74 @@ func TestValidateAuthConfig(t *testing.T) { }, { name: "file mode fully configured", + auth: Auth{ + Mode: AuthModeFile, + JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + 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"}}}, + }, + }, + }, + { + // 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, 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"}}, + }, + }, + wantErr: "roles is required", + }, + { + // The role is expanded from the mapping file at login, so without the + // file it would 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", Roles: []string{"ap_admin"}}}, + }, + }, + wantErr: "auth.authorization.role_to_scope_mapping", + }, + { + // 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, JWT: JWT{PublicKeyFile: validJWTPublicKeyFile, PrivateKeyFile: validJWTPrivateKeyFile, TokenTTL: time.Hour}, + 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"}}, + Users: FileBasedUsers{{Username: "admin", PasswordHash: "$2a$12$hash", Roles: []string{"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 +445,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 +463,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, 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, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.yaml"}, + wantErr: "auth.claim_mappings.roles", + }, + { + name: "role mode without role_to_scope_mapping file", + authz: Authorization{Enabled: true, Mode: AuthzModeRole}, + claims: ClaimMappings{Roles: "roles"}, + wantErr: "auth.authorization.role_to_scope_mapping", + }, + { + 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, RoleToScopeMapping: "/etc/platform-api/role-to-scope-mapping.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. @@ -545,6 +674,9 @@ encryption_key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd [platform_api.auth] mode = "file" +[platform_api.auth.authorization] +role_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.yaml" + [platform_api.auth.jwt] public_key_file = "` + validJWTPublicKeyFile + `" private_key_file = "` + validJWTPrivateKeyFile + `" @@ -557,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" +roles = ["ap_admin"] ` require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) @@ -589,3 +721,33 @@ scopes = "ap:api_key:all:manage" 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 +// 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_USER_ROLE", "ap_operator") + cfg, err := loadWithKeys(t, `private_key_file = "`+validJWTPrivateKeyFile+`" + +[platform_api.auth] +mode = "file" + +[platform_api.auth.authorization] +role_to_scope_mapping = "/etc/platform-api/role-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_USER_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 af03df0f99..f910ea767c 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, + // 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. + }, // 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..5cd294958e 100644 --- a/platform-api/internal/handler/auth_login.go +++ b/platform-api/internal/handler/auth_login.go @@ -20,6 +20,8 @@ package handler import ( "log/slog" "net/http" + "slices" + "strings" "time" "github.com/wso2/api-platform/platform-api/config" @@ -43,12 +45,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_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. + 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) { @@ -89,23 +96,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 and is used - // as a literal flat key if configured that way. + // 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"): matched.Scopes, - 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 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 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) @@ -128,6 +142,32 @@ func (h *AuthLoginHandler) Login(w http.ResponseWriter, r *http.Request) error { return nil } +// 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 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 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 { + 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) + } + } + 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 { @@ -136,3 +176,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 new file mode 100644 index 0000000000..941137bf40 --- /dev/null +++ b/platform-api/internal/handler/auth_login_test.go @@ -0,0 +1,143 @@ +/* + * 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/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + + "github.com/wso2/api-platform/platform-api/config" +) + +// 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 +// list unnecessary. +func TestEffectiveScopes(t *testing.T) { + h := NewAuthLoginHandler(&config.Server{}, map[string][]string{ + "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 { + name string + user config.FileBasedUser + want string + }{ + { + name: "role expands to its scopes, in order", + 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{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{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{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: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, h.effectiveScopes(&tt.user)) + }) + } +} + +// 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) + }) +} 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/openapi_scope_registry.go b/platform-api/internal/middleware/openapi_scope_registry.go index 0d145fd82b..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.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 bd716116e7..18eee79404 100644 --- a/platform-api/internal/middleware/role_scope_map.go +++ b/platform-api/internal/middleware/role_scope_map.go @@ -20,53 +20,85 @@ package middleware import ( "fmt" "os" + "regexp" + "strings" "gopkg.in/yaml.v3" ) -// roleScopeEntry is a single entry in roles.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.yaml. +// roleScopeConfig is the top-level structure of role-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 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.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.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.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 } 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 ":" — 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 +// 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 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 { for _, s := range scopes { + if !wellFormedScope.MatchString(s) { + 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.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/middleware/role_scope_map_test.go b/platform-api/internal/middleware/role_scope_map_test.go new file mode 100644 index 0000000000..da8ef0334f --- /dev/null +++ b/platform-api/internal/middleware/role_scope_map_test.go @@ -0,0 +1,99 @@ +/* + * 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", + }, + { + // 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"}, + }, + { + // "*" 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 { + 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..018be2d157 100644 --- a/platform-api/internal/server/role_scope_map_test.go +++ b/platform-api/internal/server/role_scope_map_test.go @@ -25,9 +25,10 @@ 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 +// 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() @@ -36,19 +37,20 @@ 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(), "role-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 role-to-scope-mapping.yaml: %v", err) } 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.RoleToScopeMapping = path return cfg } @@ -74,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 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) { @@ -90,19 +92,109 @@ 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 role-to-scope-mapping.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(path) - cfg.Auth.IDP.ValidationMode = "scope" + cfg := roleModeConfig(writeRolesFile(t, "widget-admin", "ap:widget_read")) + cfg.Auth.Authorization.Mode = config.AuthzModeScope - m, err := loadRoleScopeMap(cfg, emptyRegistry(t), testLogger()) + 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) + } +} + +// 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_to_scope_mapping 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.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) + } + + 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") + } + if !strings.Contains(err.Error(), "ap_admn") { + t.Fatalf("unexpected error: %v", err) + } + + // 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", Roles: []string{"ap_admn"}}} + if err := validateFileUserRoles(cfg, roleScopeMap); err != nil { + t.Fatalf("unexpected error outside file mode: %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/role-to-scope-mapping.yaml") + if err != nil { + t.Fatalf("loading the shipped role-to-scope-mapping.yaml: %v", err) + } + if err := middleware.ValidateRoleScopeMap(m, reg); err != nil { + 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 role-to-scope-mapping.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/server.go b/platform-api/internal/server/server.go index fc0fb15064..c6840e4cbb 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) @@ -449,13 +448,22 @@ 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 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.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 } + 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,55 @@ 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.RoleToScopeMapping == "" { return nil, nil } - m, err := middleware.LoadRoleScopeMap(cfg.Auth.IDP.RoleMappings) + 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.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.IDP.RoleMappings, "roles", len(m)) + slogger.Info("Loaded role-to-scope mapping", "path", cfg.Auth.Authorization.RoleToScopeMapping, "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 { + 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.RoleToScopeMapping) + } + } + } + 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/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml new file mode 100644 index 0000000000..3d70a06a7b --- /dev/null +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -0,0 +1,281 @@ +# 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: +# +# * 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[].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 — 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 +# 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 — 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: + # Platform administrator — full access to every resource and operation, + # across both the Platform API and the Developer Portal. + - name: ap_admin + scopes: + # 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: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 + + # Platform operator / CI-CD service account — runs gateways, deployments, + # subscription plans, key managers and webhooks; reads everything else. + - name: ap_operator + scopes: + # 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:rest_api: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:subscription: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 + + # 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: + # 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:manifest:read + - ap:gateway_custom_policy:read + - 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: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 + + # 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: + # Platform API + - ap:organization:read + - ap:project:read + - ap:gateway: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 + - ap:application:association:api_key:read + - ap:subscription:read + - ap:subscription_plan:read + - ap:llm_template: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: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/platform-api/resources/roles.yaml b/platform-api/resources/roles.yaml deleted file mode 100644 index 5a01dfee9b..0000000000 --- a/platform-api/resources/roles.yaml +++ /dev/null @@ -1,117 +0,0 @@ -# Role-to-scope mapping for IDP role mode (auth.idp.validation_mode: role). -# -# 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. -# -# Supported IDPs: -# Asgardeo — set roles: roles -# Keycloak — set roles: realm_access.roles (or resource_access..roles) -# Microsoft Entra ID — set roles: roles -# -# 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 -# -# This file requires a server restart to take effect. - -roles: - - name: platform-admin - scopes: - - ap:api_key:read - - ap:api_key:all:manage - - ap:organization:manage - - ap:project:manage - - ap:gateway:manage - - ap:gateway_custom_policy:manage - - ap:rest_api:manage - - ap:application:manage - - ap:subscription:manage - - ap:subscription_plan:manage - - ap:llm_template:manage - - ap:llm_provider:manage - - ap:llm_proxy:manage - - ap:mcp_proxy:manage - - ap:websub_api:manage - - ap:webbroker_api:manage - - - name: platform-operator - scopes: - - ap:api_key:read - - ap:organization:read - - ap:project:read - - ap:gateway:manage - - ap:gateway_custom_policy:manage - - ap:subscription_plan:manage - - ap:llm_template:manage - - ap:rest_api:read - - ap:rest_api:deployment:read - - 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 - - - name: platform-developer - scopes: - - ap:api_key:read - - ap:organization:read - - ap:project:manage - - ap:gateway:read - - ap:gateway:artifact:read - - ap:gateway:manifest:read - - ap:gateway_custom_policy:read - - ap:rest_api:manage - - ap:application: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 - - - name: platform-viewer - scopes: - - ap:api_key:read - - ap:organization:read - - ap:project:read - - ap:gateway:read - - ap:gateway:artifact:read - - ap:gateway:manifest:read - - ap:gateway_custom_policy:read - - ap:rest_api:read - - ap:rest_api:deployment:read - - ap:application:read - - ap:application:api_key:read - - ap:application:association:read - - ap:application:association:api_key:read - - ap:subscription:read - - ap:subscription_plan:read - - ap:llm_template: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 diff --git a/portals/ai-workspace/Makefile b/portals/ai-workspace/Makefile index 5c2773108f..ac314001b3 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/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.yaml $(DIST_DIR)/resources/roles.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 \ @@ -394,7 +394,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/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/README.md b/portals/ai-workspace/README.md index 79fec2da4d..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 "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 `[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/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(); diff --git a/portals/ai-workspace/distribution/README.md b/portals/ai-workspace/distribution/README.md index d4d1fa6a56..373f7bad7e 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 + ├── 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) ``` @@ -125,10 +125,11 @@ 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) | +| `[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 c3f110725c..66b9df1cb2 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/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/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 60104c286b..518cdf258b 100644 --- a/portals/ai-workspace/production/README.md +++ b/portals/ai-workspace/production/README.md @@ -113,10 +113,11 @@ org_handle = "org_handle" Optional overrides (defaults shown): ```toml -[auth.idp] -validation_mode = "scope" # or "role" for role-based auth +[platform_api.auth.authorization] +enabled = true +mode = "scope" # or "role" for role-based auth (then set role_to_scope_mapping) -[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/portals/developer-portal/Makefile b/portals/developer-portal/Makefile index 773b7b480f..ac8b841ffd 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/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/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 \ @@ -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/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 5d59025c8f..0f692efde6 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 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' -scopes = "dp:org_manage dp:api_manage ..." +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 `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: ```toml diff --git a/portals/developer-portal/distribution/README.md b/portals/developer-portal/distribution/README.md index 6a70f6a7fe..3572f8470d 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/ + ├── 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,7 +136,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; `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 2ea88183dc..b5aa49b7f6 100644 --- a/portals/developer-portal/docker-compose.platform-api.yaml +++ b/portals/developer-portal/docker-compose.platform-api.yaml @@ -41,6 +41,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml: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 bf82fef453..14f24d11c0 100644 --- a/portals/developer-portal/docker-compose.yaml +++ b/portals/developer-portal/docker-compose.yaml @@ -24,6 +24,7 @@ services: format: raw volumes: - ../../platform-api/config/config.toml:/etc/platform-api/config.toml: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 86764acd6e..b9aef89184 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_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" }}' # 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" +roles = ["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" +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" -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" +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 new file mode 100644 index 0000000000..b1082ec162 --- /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_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 +# 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..443819ede4 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/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 96e3e9807f..2b31466e88 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/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 4ec7dbc7dd..b8affb3b2d 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_to_scope_mapping — the + # shipped file, so the suite runs against the same grants operators get. + - ../../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: - "${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..89da4c432b 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_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.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 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 f9fce381a0..af6cd11b41 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 `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 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 9b65c37f5d..7c5c7e4335 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -56,13 +56,18 @@ 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_to_scope_mapping — the + # shipped file, so the suite runs against the same grants operators get. + - ../../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 6bc853e7d7..7504c23d4b 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -102,13 +102,18 @@ 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_to_scope_mapping — the + # shipped file, so the suite runs against the same grants operators get. + - ../../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 6faeaedb98..6cdd353334 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,9 +115,11 @@ 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_to_scope_mapping — the + # shipped file, so the suite runs against the same grants operators get. + - ../../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 62b3b01998..797db61e5e 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_to_scope_mapping = "/etc/platform-api/role-to-scope-mapping.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 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"] [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..6aa2b783c1 100644 --- a/tests/integration-e2e/suite_test.go +++ b/tests/integration-e2e/suite_test.go @@ -47,13 +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 - // 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"}]` ) // Host-side endpoints. Ports are overridable so the suite can run alongside @@ -175,13 +168,6 @@ func bringUpStack() error { return err } - if suite.db == "postgres" { - // Give the admin JWT the 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" {