From f1b34627ac4ed3036cc63ec876a727669e6c1b24 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 3 Aug 2026 18:38:02 +0530 Subject: [PATCH 1/5] Implement role-based authorization in BFF with role-to-scope mapping support - Introduced AuthorizationConfig to manage authorization modes (scope/role) in AuthConfig. - Updated claim mapping to handle role-based scope expansion. - Added role-to-scope mapping functionality to align with platform API. - Enhanced tests to validate role mode behavior and scope expansion. - Updated configuration template to include role-to-scope mapping settings. --- platform-api/internal/middleware/auth.go | 30 +-- .../middleware/scope_enforcer_test.go | 2 +- .../bff/internal/config/config.go | 45 ++++- .../bff/internal/config/config_test.go | 79 ++++++++ .../bff/internal/config/default_config.go | 5 + .../bff/internal/config/oidc_scopes_test.go | 176 ++++++++++++++++ .../bff/internal/server/server.go | 23 ++- .../bff/internal/session/claims.go | 61 +++++- .../bff/internal/session/claims_test.go | 99 +++++++++ .../bff/internal/session/role_scope_map.go | 153 ++++++++++++++ .../internal/session/role_scope_map_test.go | 189 ++++++++++++++++++ .../ai-workspace/configs/config-template.toml | 24 +++ portals/ai-workspace/docker-compose.yaml | 4 +- 13 files changed, 864 insertions(+), 26 deletions(-) create mode 100644 portals/ai-workspace/bff/internal/config/oidc_scopes_test.go create mode 100644 portals/ai-workspace/bff/internal/session/role_scope_map.go create mode 100644 portals/ai-workspace/bff/internal/session/role_scope_map_test.go diff --git a/platform-api/internal/middleware/auth.go b/platform-api/internal/middleware/auth.go index fa244f90c2..2dbfd7cc6a 100644 --- a/platform-api/internal/middleware/auth.go +++ b/platform-api/internal/middleware/auth.go @@ -36,18 +36,18 @@ import ( type contextKey string const ( - keyUserID contextKey = "user_id" - keyUsername contextKey = "username" - keyEmail contextKey = "email" - keyFirstName contextKey = "first_name" - keyLastName contextKey = "last_name" - keyOrganization contextKey = "organization" - keyOrgName contextKey = "org_name" - keyOrgHandle contextKey = "org_handle" - keyScope contextKey = "scope" - keyAudience contextKey = "audience" - keyClaims contextKey = "claims" - keyPlatformRoles contextKey = "platform_roles" + keyUserID contextKey = "user_id" + keyUsername contextKey = "username" + keyEmail contextKey = "email" + keyFirstName contextKey = "first_name" + keyLastName contextKey = "last_name" + keyOrganization contextKey = "organization" + keyOrgName contextKey = "org_name" + keyOrgHandle contextKey = "org_handle" + keyScope contextKey = "scope" + keyAudience contextKey = "audience" + keyClaims contextKey = "claims" + keyRoles contextKey = "roles" ) // CustomClaims represents the JWT claims structure used in local JWT (non-IDP) mode. @@ -241,7 +241,7 @@ func validateLocalJWT(r *http.Request, tokenString string, config AuthConfig) (* ctx = context.WithValue(ctx, keyScope, claimsObj.Scope) ctx = context.WithValue(ctx, keyAudience, claimsObj.Audience) ctx = context.WithValue(ctx, keyClaims, claimsObj) - ctx = context.WithValue(ctx, keyPlatformRoles, platformRoles) + ctx = context.WithValue(ctx, keyRoles, platformRoles) return r.WithContext(ctx), nil } @@ -316,7 +316,7 @@ func PlatformClaimsMiddleware(claimNames ClaimMappings) func(http.Handler) http. ctx = context.WithValue(ctx, keyScope, scope) ctx = context.WithValue(ctx, keyAudience, aud) ctx = context.WithValue(ctx, keyClaims, claimsObj) - ctx = context.WithValue(ctx, keyPlatformRoles, platformRoles) + ctx = context.WithValue(ctx, keyRoles, platformRoles) next.ServeHTTP(w, r.WithContext(ctx)) }) @@ -575,7 +575,7 @@ func GetClaimsFromRequest(r *http.Request) (*CustomClaims, bool) { // GetPlatformRolesFromRequest extracts platform roles from the request context. func GetPlatformRolesFromRequest(r *http.Request) ([]string, bool) { - roles, ok := r.Context().Value(keyPlatformRoles).([]string) + roles, ok := r.Context().Value(keyRoles).([]string) return roles, ok } diff --git a/platform-api/internal/middleware/scope_enforcer_test.go b/platform-api/internal/middleware/scope_enforcer_test.go index 922497603f..96434464ee 100644 --- a/platform-api/internal/middleware/scope_enforcer_test.go +++ b/platform-api/internal/middleware/scope_enforcer_test.go @@ -385,7 +385,7 @@ func TestScopeEnforcer_RoleMode(t *testing.T) { // The scope claim carries a satisfying value that must be ignored in // role mode — only the expanded roles count. ctx := context.WithValue(req.Context(), keyScope, "ap:organization:manage") - ctx = context.WithValue(ctx, keyPlatformRoles, tc.roles) + ctx = context.WithValue(ctx, keyRoles, tc.roles) req = req.WithContext(ctx) rec := httptest.NewRecorder() diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index a40c671a43..710afe95a7 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -131,11 +131,37 @@ type SessionConfig struct { // AuthConfig is [ai_workspace.auth]: the login mode and the claim/OIDC settings. type AuthConfig struct { - Mode string `koanf:"mode"` // "basic" | "oidc" — informs the SPA which login UX to show - OIDC OIDCConfig `koanf:"oidc"` - ClaimMappings ClaimMappingConfig `koanf:"claim_mappings"` + Mode string `koanf:"mode"` // "basic" | "oidc" — informs the SPA which login UX to show + OIDC OIDCConfig `koanf:"oidc"` + ClaimMappings ClaimMappingConfig `koanf:"claim_mappings"` + Authorization AuthorizationConfig `koanf:"authorization"` } +// AuthorizationConfig is [ai_workspace.auth.authorization]: how the BFF derives the +// effective scopes it reports to the SPA on /api/session, which is what the UI gates +// every action on. It mirrors the Platform API's [platform_api.auth.authorization] key +// for key and MUST be set to the same mode — the Platform API enforces authorization, +// this only decides what the UI believes it may do. A mismatch either shows every +// operation as blocked when the API would have allowed it (mode left at "scope" against +// an IDP that mints no ap:* scopes), or offers actions that then fail with 403. +type AuthorizationConfig struct { + // Mode is "scope" (default — read the scope claim) or "role" (expand the roles + // claim through RoleToScopeMapping). Required as "role" for any IDP that cannot + // mint the platform's ap:* scopes, which includes Microsoft Entra ID. + Mode string `koanf:"mode"` + // RoleToScopeMapping is the path to role-to-scope-mapping.yaml — the same file, + // in the same shape, that the Platform API reads. Required in role mode; mount + // the same file both services use so the UI and the API cannot disagree about + // what a role grants. + RoleToScopeMapping string `koanf:"role_to_scope_mapping"` +} + +// AuthzModeScope and AuthzModeRole are the supported [auth.authorization] modes. +const ( + AuthzModeScope = "scope" + AuthzModeRole = "role" +) + // OIDCConfig is [ai_workspace.auth.oidc]: the confidential-client settings. The client // secret lives only here on the BFF and is never emitted to the browser. Enabled is // both a config key and derived — Load ORs it with (auth.mode == "oidc"). @@ -198,6 +224,7 @@ const CSRFHeaderName = "X-Requested-By" // Azure AD) issue no refresh token, so the BFF cannot silently renew the access // token and the user is logged out the moment it expires. Keep it in any override. const defaultOIDCScopes = "openid profile email offline_access" + + " ap:api_key:read" + " 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" + @@ -308,6 +335,18 @@ func (c *Config) validate() error { if c.Auth.Mode != "basic" && c.Auth.Mode != "oidc" { return fmt.Errorf("invalid [auth] mode %q: must be \"basic\" or \"oidc\"", c.Auth.Mode) } + // Fail closed on the authorization mode: an unrecognized value would fall through + // to reading the scope claim, which for a role-mode deployment means the SPA shows + // every operation as blocked with no error explaining why. + if c.Auth.Authorization.Mode != AuthzModeScope && c.Auth.Authorization.Mode != AuthzModeRole { + return fmt.Errorf("invalid [auth.authorization] mode %q: must be %q or %q", + c.Auth.Authorization.Mode, AuthzModeScope, AuthzModeRole) + } + // Role mode without a grant table can only ever expand to zero scopes, so refuse + // to start rather than serve a UI in which nothing is permitted. + if c.Auth.Authorization.Mode == AuthzModeRole && c.Auth.Authorization.RoleToScopeMapping == "" { + return fmt.Errorf("[auth.authorization] role_to_scope_mapping is required when mode = %q", AuthzModeRole) + } if !c.Server.HTTP.Enabled && !c.Server.HTTPS.Enabled { return fmt.Errorf("no listeners enabled: set [server.http] enabled = true and/or [server.https] enabled = true") } diff --git a/portals/ai-workspace/bff/internal/config/config_test.go b/portals/ai-workspace/bff/internal/config/config_test.go index c8c359e6dc..e3b370eddd 100644 --- a/portals/ai-workspace/bff/internal/config/config_test.go +++ b/portals/ai-workspace/bff/internal/config/config_test.go @@ -443,6 +443,85 @@ roles = "roles" } } +// Scope mode is the default, so an operator who never mentions [auth.authorization] +// keeps today's behaviour. +func TestLoad_AuthorizationModeDefaultsToScope(t *testing.T) { + cfgPath := writeConfig(t, ` +[ai_workspace.control_plane] +url = "https://platform-api:9243" +`) + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Auth.Authorization.Mode != AuthzModeScope { + t.Errorf("Authorization.Mode = %q, want %q", cfg.Auth.Authorization.Mode, AuthzModeScope) + } +} + +func TestLoad_AuthorizationRoleMode(t *testing.T) { + cfgPath := writeConfig(t, ` +[ai_workspace.control_plane] +url = "https://platform-api:9243" + +[ai_workspace.auth.authorization] +mode = "role" +role_to_scope_mapping = "/etc/ai-workspace/role-to-scope-mapping.yaml" +`) + cfg, err := Load(cfgPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Auth.Authorization.Mode != AuthzModeRole { + t.Errorf("Authorization.Mode = %q, want %q", cfg.Auth.Authorization.Mode, AuthzModeRole) + } + if cfg.Auth.Authorization.RoleToScopeMapping == "" { + t.Error("RoleToScopeMapping is empty, want the configured path") + } + // The grant table is a server-side concern; the SPA gates on the scopes + // /api/session reports, never on the table itself. + if _, ok := cfg.RuntimeConfig["APIP_AIW_AUTH_AUTHORIZATION_ROLE_TO_SCOPE_MAPPING"]; ok { + t.Error("role_to_scope_mapping must not reach the browser") + } +} + +// Role mode with no grant table can only expand to zero scopes, which would present a +// UI in which nothing is permitted. Refuse to start instead. +func TestLoad_RoleModeWithoutMapping_Errors(t *testing.T) { + cfgPath := writeConfig(t, ` +[ai_workspace.control_plane] +url = "https://platform-api:9243" + +[ai_workspace.auth.authorization] +mode = "role" +`) + _, err := Load(cfgPath) + if err == nil { + t.Fatal("Load() succeeded, want an error when role mode has no role_to_scope_mapping") + } + if !strings.Contains(err.Error(), "role_to_scope_mapping is required") { + t.Errorf("error = %v, want it to name role_to_scope_mapping", err) + } +} + +// A typo'd mode must not silently degrade to reading the scope claim. +func TestLoad_InvalidAuthorizationMode_Errors(t *testing.T) { + cfgPath := writeConfig(t, ` +[ai_workspace.control_plane] +url = "https://platform-api:9243" + +[ai_workspace.auth.authorization] +mode = "roles" +`) + _, err := Load(cfgPath) + if err == nil { + t.Fatal("Load() succeeded, want an error for an unknown authorization mode") + } + if !strings.Contains(err.Error(), "[auth.authorization] mode") { + t.Errorf("error = %v, want it to name [auth.authorization] mode", err) + } +} + // A malformed boolean must fail startup rather than fall back to the default. func TestLoad_InvalidBool_Errors(t *testing.T) { cfgPath := writeConfig(t, ` diff --git a/portals/ai-workspace/bff/internal/config/default_config.go b/portals/ai-workspace/bff/internal/config/default_config.go index 2aa91e1b6e..2869106bbd 100644 --- a/portals/ai-workspace/bff/internal/config/default_config.go +++ b/portals/ai-workspace/bff/internal/config/default_config.go @@ -69,6 +69,11 @@ func defaultConfig() *Config { OrgName: "org_name", OrgHandle: "org_handle", }, + // Mirrors the Platform API's [auth.authorization] default. Both sides must + // be switched to "role" together for an IDP that mints no ap:* scopes. + Authorization: AuthorizationConfig{ + Mode: AuthzModeScope, + }, }, } } diff --git a/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go b/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go new file mode 100644 index 0000000000..1b91727d32 --- /dev/null +++ b/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// specOperation is one scoped operation: the alternatives its security block accepts. +type specOperation struct { + method, path string + accepts []string +} + +// loadScopedOperations reads every operation carrying an OAuth2 security block from an +// OpenAPI spec. Returns nil when the spec is not present (a trimmed checkout). +func loadScopedOperations(t *testing.T, specPath string) []specOperation { + t.Helper() + data, err := os.ReadFile(specPath) + if err != nil { + return nil + } + // Navigated loosely rather than through a fixed struct: a path item legitimately + // carries non-operation keys (path-level "parameters", "$ref") that no operation + // shape can absorb. + var doc map[string]any + if err := yaml.Unmarshal(data, &doc); err != nil { + t.Fatalf("parse %s: %v", specPath, err) + } + paths, _ := doc["paths"].(map[string]any) + var ops []specOperation + for path, rawItem := range paths { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + for method, rawOp := range item { + op, ok := rawOp.(map[string]any) + if !ok { + continue + } + security, ok := op["security"].([]any) + if !ok { + continue + } + var accepts []string + for _, rawScheme := range security { + scheme, ok := rawScheme.(map[string]any) + if !ok { + continue + } + for _, rawScopes := range scheme { + scopes, ok := rawScopes.([]any) + if !ok { + continue + } + for _, s := range scopes { + if str, ok := s.(string); ok { + accepts = append(accepts, str) + } + } + } + } + if len(accepts) > 0 { + ops = append(ops, specOperation{strings.ToUpper(method), path, accepts}) + } + } + } + return ops +} + +func specPaths() []string { + root := filepath.Join("..", "..", "..", "..", "..") + return []string{ + filepath.Join(root, "platform-api", "resources", "openapi.yaml"), + filepath.Join(root, "platform-api", "plugins", "eventgateway", "openapi.yaml"), + } +} + +// The default scope set must satisfy every scoped operation the Platform API declares. +// This is what makes the trimmed list safe: the granular create/update/delete scopes +// are omitted only because each of their operations also accepts a resource :manage, +// and that is per-endpoint enumeration in the spec, not scope hierarchy — nothing +// expands `ap:x:manage` into `ap:x:y:read` at request time. +func TestDefaultOIDCScopesCoverEveryOperation(t *testing.T) { + granted := strings.Fields(defaultOIDCScopes) + checked := 0 + for _, specPath := range specPaths() { + ops := loadScopedOperations(t, specPath) + if ops == nil { + t.Logf("spec not present, skipping: %s", specPath) + continue + } + for _, op := range ops { + checked++ + satisfied := slices.ContainsFunc(op.accepts, func(s string) bool { + return slices.Contains(granted, s) + }) + if !satisfied { + t.Errorf("%s %s accepts %v — none of which the default scope set requests", + op.method, op.path, op.accepts) + } + } + } + if checked == 0 { + t.Skip("no specs available to check against") + } + t.Logf("verified %d scoped operations against %d requested scopes", checked, len(granted)) +} + +// ap:api_key:all:manage is the cross-user ownership override (GO-AUTH-019). Every +// endpoint accepting it also accepts a narrower scope, so it must never be requested +// for every session by default. +func TestDefaultOIDCScopesExcludeOwnershipOverride(t *testing.T) { + if slices.Contains(strings.Fields(defaultOIDCScopes), "ap:api_key:all:manage") { + t.Error("defaultOIDCScopes requests the cross-user override scope ap:api_key:all:manage") + } +} + +// offline_access is what makes silent token renewal possible; without it most IDPs +// return no refresh token and the session dies at the access token's expiry. +func TestDefaultOIDCScopesRequestOfflineAccess(t *testing.T) { + if !slices.Contains(strings.Fields(defaultOIDCScopes), "offline_access") { + t.Error("defaultOIDCScopes omits offline_access — token refresh would be impossible") + } +} + +// The default set requests every declared scope on purpose, so that whatever subset a +// least-privilege user actually holds survives the IDP's intersection of requested and +// entitled. A user granted only ap:rest_api:create would lose it if the request were +// trimmed to :manage/:read. +// +// The cost is size: encoded, this parameter is several kilobytes, which exceeds +// Microsoft Entra ID's authorize-URL limit outright (AADSTS90015). That is not fixed by +// trimming the shared default — Entra cannot mint ap:* scopes at all, so such a +// deployment must override [auth.oidc] scope with its own resource scope and pair that +// with [auth.authorization] mode = "role". This test pins the trade-off so the size is +// a conscious decision rather than a surprise. +func TestDefaultOIDCScopesRequestGranularScopes(t *testing.T) { + granted := strings.Fields(defaultOIDCScopes) + // Representative granular scopes that a :manage/:read-only request would drop. + for _, scope := range []string{ + "ap:rest_api:create", + "ap:project:delete", + "ap:llm_proxy:update", + "ap:rest_api:deployment:undeploy", + } { + if !slices.Contains(granted, scope) { + t.Errorf("defaultOIDCScopes omits %s — a user holding only that grant would lose it, "+ + "since an IDP grants the intersection of requested and entitled", scope) + } + } + t.Logf("requesting %d scopes, %d bytes encoded (Entra ID deployments must override this)", + len(granted), len(url.QueryEscape(defaultOIDCScopes))) +} diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index 3c97d88c53..0c4cb314e1 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -78,7 +78,10 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { // Shared by both auth modes: OIDC tokens from the configured IDP, and the HMAC // JWTs the Platform API's file-based login endpoint signs with the same mapped // claim names. Building it once keeps the two readers from drifting apart. - claims := buildClaimMapping(cfg.Auth.ClaimMappings) + claims, err := buildClaimMapping(cfg.Auth.ClaimMappings, cfg.Auth.Authorization) + if err != nil { + return nil, err + } s := &Server{ cfg: cfg, @@ -127,8 +130,22 @@ func (s *Server) Close() error { // config. Each field overrides the session-package default only when set, so an // operator can point a single claim (e.g. the display name) at the right key via // the CLAIM_MAPPINGS_* env vars without re-specifying the rest. -func buildClaimMapping(c config.ClaimMappingConfig) session.ClaimMapping { +// +// It also carries the authorization mode and, in role mode, the loaded role-to-scope +// grant table — the pair that decides whether a user's effective scopes come from the +// scope claim or from expanding the roles claim. A grant table that cannot be loaded +// fails startup rather than degrading to an empty one, which would present a UI in +// which nothing is permitted. +func buildClaimMapping(c config.ClaimMappingConfig, authz config.AuthorizationConfig) (session.ClaimMapping, error) { m := session.DefaultClaimMapping() + m.AuthzMode = authz.Mode + if authz.Mode == config.AuthzModeRole { + roleScopeMap, err := session.LoadRoleScopeMap(authz.RoleToScopeMapping) + if err != nil { + return session.ClaimMapping{}, err + } + m.RoleScopeMap = roleScopeMap + } if c.Username != "" { m.Username = c.Username } @@ -150,5 +167,5 @@ func buildClaimMapping(c config.ClaimMappingConfig) session.ClaimMapping { if c.OrgHandle != "" { m.OrgHandle = c.OrgHandle } - return m + return m, nil } diff --git a/portals/ai-workspace/bff/internal/session/claims.go b/portals/ai-workspace/bff/internal/session/claims.go index 05dd4d3832..e2794413de 100644 --- a/portals/ai-workspace/bff/internal/session/claims.go +++ b/portals/ai-workspace/bff/internal/session/claims.go @@ -38,8 +38,23 @@ type ClaimMapping struct { OrgID string OrgName string OrgHandle string + + // AuthzMode mirrors the Platform API's auth.authorization.mode: "scope" + // (default) reads the user's effective scopes from the scope claim, "role" + // derives them by expanding the roles claim through RoleScopeMap. It lives on + // the claim mapping rather than in its own struct because this value is already + // threaded to every place a User is built, and the mode decides which *claim* + // the scopes are read from — a mapping concern. + AuthzMode string + // RoleScopeMap is the loaded role-to-scope grant table, used only in role mode. + // Nil in scope mode. + RoleScopeMap map[string][]string } +// AuthzModeRole is the auth.authorization.mode value that derives effective scopes +// from the roles claim rather than from the scope claim. +const AuthzModeRole = "role" + // DefaultClaimMapping returns the built-in fallback mapping, used whenever a // config.ClaimMappingConfig field is left unset — for both file-based and OIDC // tokens. Callers may override individual keys to match a specific IDP. @@ -116,11 +131,16 @@ func UserFromClaims(claims, idClaims map[string]any, m ClaimMapping) User { // Resolve a human-friendly display name from the configured username claim, // then email, and only as a last resort the opaque subject id (so the UI // never shows a raw UUID when a readable claim is available). + // The roles claim is a string on some IDPs and an array on others (Entra ID emits + // ["ap_admin"]), so read both shapes — a plain string read would leave this empty + // for an array-valued claim. roleList also feeds the role-mode expansion below. + roleList := strSliceClaim(claims, m.Roles) + u := User{ Name: first(get(m.Username), get(m.Email), get("sub")), Email: get(m.Email), - Role: strClaim(claims, m.Roles), - Scopes: scopes(claims, m.Scope), + Role: strings.Join(roleList, " "), + Scopes: effectiveScopes(claims, roleList, m), } orgID := strClaim(claims, m.OrgID) @@ -146,6 +166,43 @@ func strClaim(claims map[string]any, key string) string { return "" } +// strSliceClaim reads a claim that may be a single string, a space-delimited string, +// or an array of strings. Roles arrive in all three shapes depending on the IDP: +// Asgardeo sends a string, Entra ID sends an array. +func strSliceClaim(claims map[string]any, key string) []string { + if key == "" || claims == nil { + return nil + } + switch v := claims[key].(type) { + case string: + return strings.Fields(v) + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} + +// effectiveScopes resolves the scopes the SPA gates on, mirroring the Platform API's +// resolveEffectiveScopes: in role mode the roles claim expanded through the grant +// table, otherwise the scope claim as-is. +// +// Role mode deliberately does not fall back to the scope claim when the expansion is +// empty. A role the operator never mapped granting nothing is a real deny-by-default +// outcome, and the Platform API reaches the same one for the same token — falling back +// here would show actions as available that then fail with 403. +func effectiveScopes(claims map[string]any, roleList []string, m ClaimMapping) []string { + if m.AuthzMode == AuthzModeRole { + return ExpandRoles(roleList, m.RoleScopeMap) + } + return scopes(claims, m.Scope) +} + // scopes reads the scope claim, which may be a space-delimited string ("scope") // or an array ("scp" on some IDPs). It checks the configured key and "scp". func scopes(claims map[string]any, key string) []string { diff --git a/portals/ai-workspace/bff/internal/session/claims_test.go b/portals/ai-workspace/bff/internal/session/claims_test.go index 3352217349..62accd4661 100644 --- a/portals/ai-workspace/bff/internal/session/claims_test.go +++ b/portals/ai-workspace/bff/internal/session/claims_test.go @@ -92,6 +92,105 @@ func TestUserFromClaims_ScopesArray(t *testing.T) { } } +// In role mode the effective scopes come from expanding the roles claim, not from the +// scope claim. This is the Microsoft Entra ID shape: roles as an array, and a scope +// claim ("scp") holding only the app's own API scope — never any ap:* scope. +func TestUserFromClaims_RoleMode(t *testing.T) { + claims := map[string]any{ + "preferred_username": "user1@example.onmicrosoft.com", + "roles": []any{"ap_admin"}, + "scp": "access", + } + m := DefaultClaimMapping() + m.Username = "preferred_username" + m.AuthzMode = AuthzModeRole + m.RoleScopeMap = map[string][]string{ + "ap_admin": {"ap:organization:manage", "ap:project:manage"}, + } + + u := UserFromClaims(claims, nil, m) + + want := []string{"ap:organization:manage", "ap:project:manage"} + if len(u.Scopes) != len(want) || u.Scopes[0] != want[0] || u.Scopes[1] != want[1] { + t.Errorf("Scopes = %v, want %v", u.Scopes, want) + } + // The token's own "access" scope must not leak through as an effective scope. + for _, s := range u.Scopes { + if s == "access" { + t.Errorf("Scopes = %v, want the scope claim ignored in role mode", u.Scopes) + } + } + if u.Role != "ap_admin" { + t.Errorf("Role = %q, want ap_admin", u.Role) + } +} + +// Several roles union; the display Role carries all of them. +func TestUserFromClaims_RoleModeSeveralRoles(t *testing.T) { + claims := map[string]any{"sub": "u1", "roles": []any{"ap_viewer", "ap_publisher"}} + m := DefaultClaimMapping() + m.AuthzMode = AuthzModeRole + m.RoleScopeMap = map[string][]string{ + "ap_viewer": {"ap:project:read"}, + "ap_publisher": {"ap:rest_api:manage"}, + } + u := UserFromClaims(claims, nil, m) + if len(u.Scopes) != 2 { + t.Errorf("Scopes = %v, want 2", u.Scopes) + } + if u.Role != "ap_viewer ap_publisher" { + t.Errorf("Role = %q, want both roles", u.Role) + } +} + +// A role the operator never mapped grants nothing, and role mode must NOT fall back to +// the scope claim — that would show actions as available which then fail with 403. +func TestUserFromClaims_RoleModeUnmappedRoleGrantsNothing(t *testing.T) { + claims := map[string]any{ + "sub": "u1", + "roles": []any{"SomeAzureGroup"}, + "scp": "access", + } + m := DefaultClaimMapping() + m.AuthzMode = AuthzModeRole + m.RoleScopeMap = map[string][]string{"ap_admin": {"ap:organization:manage"}} + + if u := UserFromClaims(claims, nil, m); len(u.Scopes) != 0 { + t.Errorf("Scopes = %v, want empty for an unmapped role", u.Scopes) + } +} + +// Scope mode is unchanged by the role-mode addition: the scope claim still wins and the +// roles claim is not expanded even when a map happens to be present. +func TestUserFromClaims_ScopeModeIgnoresRoles(t *testing.T) { + claims := map[string]any{ + "sub": "u1", + "roles": []any{"ap_admin"}, + "scope": "ap:project:read", + } + m := DefaultClaimMapping() + m.RoleScopeMap = map[string][]string{"ap_admin": {"ap:organization:manage"}} + + u := UserFromClaims(claims, nil, m) + if len(u.Scopes) != 1 || u.Scopes[0] != "ap:project:read" { + t.Errorf("Scopes = %v, want [ap:project:read]", u.Scopes) + } +} + +// A string-valued roles claim (Asgardeo) reads the same as an array one. +func TestUserFromClaims_RolesAsString(t *testing.T) { + claims := map[string]any{"sub": "u1", "roles": "ap_admin ap_viewer"} + m := DefaultClaimMapping() + m.AuthzMode = AuthzModeRole + m.RoleScopeMap = map[string][]string{ + "ap_admin": {"ap:organization:manage"}, + "ap_viewer": {"ap:project:read"}, + } + if u := UserFromClaims(claims, nil, m); len(u.Scopes) != 2 { + t.Errorf("Scopes = %v, want 2", u.Scopes) + } +} + func TestUserFromClaims_IDClaimsPreferred(t *testing.T) { at := map[string]any{"given_name": "", "email": ""} id := map[string]any{"given_name": "Alice", "email": "alice@example.com"} diff --git a/portals/ai-workspace/bff/internal/session/role_scope_map.go b/portals/ai-workspace/bff/internal/session/role_scope_map.go new file mode 100644 index 0000000000..84b5193591 --- /dev/null +++ b/portals/ai-workspace/bff/internal/session/role_scope_map.go @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "gopkg.in/yaml.v3" +) + +// Role-to-scope grant table (auth.authorization.role_to_scope_mapping). +// +// The BFF's counterpart of platform-api's internal/middleware/role_scope_map.go and +// api-portal's src/config/roleScopeMap.js — the same file, in the same shape, read by +// a third component. +// +// Why the BFF needs it: in role authorization mode a caller's effective scopes are not +// in their token. An external IDP emits roles — Microsoft Entra ID cannot mint the +// platform's ap:* scopes at all — and the Platform API derives the effective scopes by +// expanding those roles through this table on every request. The SPA gates every +// action on the scopes /api/session reports, so without the same expansion here it +// would see only whatever the IDP put in the scope claim and show every operation as +// blocked, even where the Platform API would have authorized the call. +// +// The Platform API remains the only enforcement point; this expansion is for display +// and UI gating. Both components must therefore read the SAME file, or the UI's view +// of what a role grants drifts from what is actually enforced. + +// maxMappingBytes caps the grant table read. A few hundred lines of YAML is normal; +// the ceiling guards against pointing the setting at something enormous by mistake. +const maxMappingBytes = 1 << 20 // 1 MiB + +// roleScopeEntry is a single entry: an IDP role name and the scopes it grants. +type roleScopeEntry struct { + Name string `yaml:"name"` + Scopes []string `yaml:"scopes"` +} + +// roleScopeConfig is the top-level structure of role-to-scope-mapping.yaml. +type roleScopeConfig struct { + Roles []roleScopeEntry `yaml:"roles"` +} + +// LoadRoleScopeMap reads a role-to-scope-mapping.yaml file and returns a map from IDP +// role name to the scopes that role grants. A token may carry several roles; callers +// union the lists at read time (see ExpandRoles). +// +// The path is operator-supplied configuration, not request input, so it is not confined +// to the {{ file }} allowlist — an operator may mount the grant table wherever they +// like. Traversal sequences are still rejected on the raw input, before normalization, +// since filepath.Clean would collapse them into a path that passes a later check. +func LoadRoleScopeMap(path string) (map[string][]string, error) { + if path == "" || strings.ContainsRune(path, '\x00') { + return nil, fmt.Errorf("role_to_scope_mapping is not a usable file path") + } + segments := strings.FieldsFunc(path, func(r rune) bool { return r == '/' || r == '\\' }) + if slices.Contains(segments, "..") { + return nil, fmt.Errorf("role_to_scope_mapping %q must not contain traversal sequences", path) + } + cleaned := filepath.Clean(path) + + info, err := os.Stat(cleaned) + if err != nil { + return nil, fmt.Errorf("role_to_scope_mapping file %q could not be read: %w", path, err) + } + if info.IsDir() { + return nil, fmt.Errorf("role_to_scope_mapping %q is not a file", path) + } + if info.Size() > maxMappingBytes { + return nil, fmt.Errorf("role_to_scope_mapping file %q exceeds the maximum allowed size", path) + } + + data, err := os.ReadFile(cleaned) + if err != nil { + return nil, fmt.Errorf("role_to_scope_mapping file %q could not be read: %w", path, err) + } + + var cfg roleScopeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("role_to_scope_mapping file %q is not valid YAML: %w", path, err) + } + if len(cfg.Roles) == 0 { + return nil, fmt.Errorf("role_to_scope_mapping file %q must contain a non-empty top-level \"roles\" list", path) + } + + m := make(map[string][]string, len(cfg.Roles)) + for i, entry := range cfg.Roles { + name := strings.TrimSpace(entry.Name) + if name == "" { + return nil, fmt.Errorf("role_to_scope_mapping %q: entry %d has no \"name\"", path, i) + } + // Rejected rather than last-wins: with two entries for one role, one is + // silently inert and which one depends on file order. + if _, dup := m[name]; dup { + return nil, fmt.Errorf("role_to_scope_mapping %q: role %q is declared more than once", path, name) + } + scopes := make([]string, 0, len(entry.Scopes)) + seen := make(map[string]struct{}, len(entry.Scopes)) + for _, scope := range entry.Scopes { + trimmed := strings.TrimSpace(scope) + if trimmed == "" { + continue + } + if _, dup := seen[trimmed]; dup { + continue + } + seen[trimmed] = struct{}{} + scopes = append(scopes, trimmed) + } + m[name] = scopes + } + return m, nil +} + +// ExpandRoles unions the scope lists granted by each of roles, preserving first-seen +// order and dropping duplicates. An unknown role contributes nothing, so a token +// carrying only roles the operator never mapped yields no scopes — the same +// deny-by-default outcome the Platform API reaches for the same token. +func ExpandRoles(roles []string, roleScopeMap map[string][]string) []string { + if len(roles) == 0 || roleScopeMap == nil { + return []string{} + } + out := make([]string, 0, len(roles)*8) + seen := make(map[string]struct{}) + for _, role := range roles { + for _, scope := range roleScopeMap[role] { + if _, dup := seen[scope]; dup { + continue + } + seen[scope] = struct{}{} + out = append(out, scope) + } + } + return out +} diff --git a/portals/ai-workspace/bff/internal/session/role_scope_map_test.go b/portals/ai-workspace/bff/internal/session/role_scope_map_test.go new file mode 100644 index 0000000000..41f3310392 --- /dev/null +++ b/portals/ai-workspace/bff/internal/session/role_scope_map_test.go @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// writeMapping writes a grant table to a temp file and returns its path. +func writeMapping(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "role-to-scope-mapping.yaml") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write mapping: %v", err) + } + return path +} + +const sampleMapping = ` +roles: + - name: ap_admin + scopes: + - ap:organization:manage + - ap:project:manage + - ap:project:manage + - name: ap_viewer + scopes: + - ap:project:read +` + +func TestLoadRoleScopeMap(t *testing.T) { + m, err := LoadRoleScopeMap(writeMapping(t, sampleMapping)) + if err != nil { + t.Fatalf("LoadRoleScopeMap: %v", err) + } + // The duplicate ap:project:manage is collapsed, not carried twice. + want := []string{"ap:organization:manage", "ap:project:manage"} + if !slices.Equal(m["ap_admin"], want) { + t.Errorf("ap_admin = %v, want %v", m["ap_admin"], want) + } + if !slices.Equal(m["ap_viewer"], []string{"ap:project:read"}) { + t.Errorf("ap_viewer = %v", m["ap_viewer"]) + } +} + +func TestLoadRoleScopeMap_Rejects(t *testing.T) { + tests := []struct { + name string + contents string + wantErr string + }{ + { + // Last-wins would leave one entry silently inert, decided by file order. + name: "duplicate role", + contents: "roles:\n - name: ap_admin\n scopes: [ap:project:read]\n - name: ap_admin\n scopes: [ap:project:manage]\n", + wantErr: "declared more than once", + }, + { + name: "entry without name", + contents: "roles:\n - scopes: [ap:project:read]\n", + wantErr: "has no \"name\"", + }, + { + name: "no roles list", + contents: "something_else: true\n", + wantErr: "must contain a non-empty top-level", + }, + { + name: "not yaml", + contents: "roles: [unclosed\n", + wantErr: "is not valid YAML", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadRoleScopeMap(writeMapping(t, tc.contents)) + if err == nil { + t.Fatalf("expected an error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +func TestLoadRoleScopeMap_RejectsBadPaths(t *testing.T) { + dir := t.TempDir() + tests := []struct { + name string + path string + wantErr string + }{ + {"empty", "", "not a usable file path"}, + {"null byte", dir + "/map\x00.yaml", "not a usable file path"}, + // Checked on the raw input: filepath.Clean would collapse this to a path + // containing no ".." at all, which a later check could not catch. + {"traversal", dir + "/../../etc/passwd", "traversal sequences"}, + {"missing", dir + "/absent.yaml", "could not be read"}, + {"directory", dir, "is not a file"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := LoadRoleScopeMap(tc.path) + if err == nil { + t.Fatalf("expected an error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tc.wantErr) + } + }) + } +} + +// The real grant table both services read must load and expand — a guard against the +// shipped file drifting into a shape this loader rejects. +func TestLoadRoleScopeMap_ShippedFile(t *testing.T) { + // Resolved to an absolute path: the loader rejects any ".." in the configured + // value, and a real deployment always mounts the table at an absolute path. + path, err := filepath.Abs(filepath.Join("..", "..", "..", "..", "..", "platform-api", "resources", "role-to-scope-mapping.yaml")) + if err != nil { + t.Fatalf("resolve shipped grant table path: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Skipf("shipped grant table not present at %s", path) + } + m, err := LoadRoleScopeMap(path) + if err != nil { + t.Fatalf("LoadRoleScopeMap(shipped): %v", err) + } + if len(ExpandRoles([]string{"ap_admin"}, m)) == 0 { + t.Error("ap_admin expanded to no scopes in the shipped grant table") + } +} + +func TestExpandRoles(t *testing.T) { + m := map[string][]string{ + "ap_admin": {"ap:organization:manage", "ap:project:manage"}, + "ap_viewer": {"ap:project:read", "ap:project:manage"}, + } + tests := []struct { + name string + roles []string + want []string + }{ + {"single role", []string{"ap_admin"}, []string{"ap:organization:manage", "ap:project:manage"}}, + { + // Union, first-seen order, no duplicate for the shared scope. + name: "several roles union", + roles: []string{"ap_admin", "ap_viewer"}, + want: []string{"ap:organization:manage", "ap:project:manage", "ap:project:read"}, + }, + // An unmapped role grants nothing — the same deny-by-default result the + // Platform API reaches for the same token. + {"unknown role", []string{"some_other_group"}, []string{}}, + {"no roles", nil, []string{}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := ExpandRoles(tc.roles, m); !slices.Equal(got, tc.want) { + t.Errorf("ExpandRoles(%v) = %v, want %v", tc.roles, got, tc.want) + } + }) + } +} + +func TestExpandRoles_NilMap(t *testing.T) { + if got := ExpandRoles([]string{"ap_admin"}, nil); len(got) != 0 { + t.Errorf("ExpandRoles with nil map = %v, want empty", got) + } +} diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 15745e543b..ca59206d54 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -207,6 +207,30 @@ absolute_ttl = "8h" mode = "basic" +# --------------------------------------------------------------------------- +# Authorization — how the BFF derives the effective scopes it reports to the SPA on +# /api/session, which is what the UI gates every action on. Mirrors +# [platform_api.auth.authorization] and MUST be set to the same mode: the Platform API +# enforces authorization, this only decides what the UI believes it may do. A mismatch +# either shows every operation as blocked when the API would have allowed it, or offers +# actions that then fail with 403. +# --------------------------------------------------------------------------- +[ai_workspace.auth.authorization] + +# "scope" (default) reads the token's scope claim. "role" expands the roles claim +# through the grant table below — required for any IDP that cannot mint the platform's +# ap:* scopes, which includes Microsoft Entra ID (it has no way to register them, and +# requesting the full set exceeds its authorize-URL length limit). +mode = "scope" + +# Absolute path to role-to-scope-mapping.yaml — the same file, in the same shape, that +# the Platform API reads. REQUIRED when mode = "role": startup fails without it rather +# than serving a UI in which nothing is permitted. Mount the same file both services +# use so the UI and the API can never disagree about what a role grants. A path +# containing ".." is rejected. +role_to_scope_mapping = "" + + # --------------------------------------------------------------------------- # JWT claim name mappings — which token claim carries each user/org field. Applies to # BOTH auth modes (a sibling of [ai_workspace.auth.oidc], not nested in it): in basic diff --git a/portals/ai-workspace/docker-compose.yaml b/portals/ai-workspace/docker-compose.yaml index 60206edf92..32b8dc44e8 100644 --- a/portals/ai-workspace/docker-compose.yaml +++ b/portals/ai-workspace/docker-compose.yaml @@ -51,12 +51,12 @@ services: format: raw volumes: - ./configs/config.toml:/etc/ai-workspace/config.toml:ro + - ../../platform-api/resources/role-to-scope-mapping.yaml:/etc/ai-workspace/role-to-scope-mapping.yaml:ro - ./resources/certificates:/etc/ai-workspace/tls:ro ports: - - "127.0.0.1:9680:9680" - "9643:9643" healthcheck: - test: ["CMD-SHELL", "curl -fs http://localhost:9680/healthz || curl -fk https://localhost:9643/healthz"] + test: ["CMD-SHELL", "curl -fk https://localhost:9643/healthz"] interval: 30s timeout: 5s start_period: 10s From 9840428781f00cc513d92fc52351c49b46137be2 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 3 Aug 2026 18:42:34 +0530 Subject: [PATCH 2/5] Update go.mod and go.sum to include gopkg.in/yaml.v3 v3.0.1 and gopkg.in/check.v1 dependencies --- portals/ai-workspace/bff/go.mod | 1 + portals/ai-workspace/bff/go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/portals/ai-workspace/bff/go.mod b/portals/ai-workspace/bff/go.mod index c405405cc3..15bf47d608 100644 --- a/portals/ai-workspace/bff/go.mod +++ b/portals/ai-workspace/bff/go.mod @@ -9,6 +9,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.2 github.com/wso2/api-platform/common v0.0.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/portals/ai-workspace/bff/go.sum b/portals/ai-workspace/bff/go.sum index 6aad93e97c..2b9da654a4 100644 --- a/portals/ai-workspace/bff/go.sum +++ b/portals/ai-workspace/bff/go.sum @@ -26,5 +26,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 3a6937aaaef0018b1073f9fb9d053959e909d4fb Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 3 Aug 2026 19:03:34 +0530 Subject: [PATCH 3/5] Refactor role scope mapping file handling to improve validation - Updated LoadRoleScopeMap function to open the file and validate its type using os.Open and f.Stat. - Changed error message for non-regular files to be more specific. - Enhanced test case to reflect the updated error message for directory paths. --- .../bff/internal/session/role_scope_map.go | 23 +++++++++++++++---- .../internal/session/role_scope_map_test.go | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/portals/ai-workspace/bff/internal/session/role_scope_map.go b/portals/ai-workspace/bff/internal/session/role_scope_map.go index 84b5193591..f13eb7057a 100644 --- a/portals/ai-workspace/bff/internal/session/role_scope_map.go +++ b/portals/ai-workspace/bff/internal/session/role_scope_map.go @@ -18,6 +18,7 @@ package session import ( "fmt" + "io" "os" "path/filepath" "slices" @@ -77,21 +78,35 @@ func LoadRoleScopeMap(path string) (map[string][]string, error) { } cleaned := filepath.Clean(path) - info, err := os.Stat(cleaned) + // Opened once and validated through the descriptor: a separate os.Stat followed by a + // path-based read checks one file and reads another if the path is replaced in + // between. IsRegular also rejects FIFOs and devices, where the reported size is 0 and + // the ceiling below would otherwise never trigger — the LimitReader is what bounds + // the read, the size check only fails fast. + f, err := os.Open(cleaned) if err != nil { return nil, fmt.Errorf("role_to_scope_mapping file %q could not be read: %w", path, err) } - if info.IsDir() { - return nil, fmt.Errorf("role_to_scope_mapping %q is not a file", path) + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("role_to_scope_mapping file %q could not be read: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("role_to_scope_mapping %q is not a regular file", path) } if info.Size() > maxMappingBytes { return nil, fmt.Errorf("role_to_scope_mapping file %q exceeds the maximum allowed size", path) } - data, err := os.ReadFile(cleaned) + data, err := io.ReadAll(io.LimitReader(f, maxMappingBytes+1)) if err != nil { return nil, fmt.Errorf("role_to_scope_mapping file %q could not be read: %w", path, err) } + if int64(len(data)) > maxMappingBytes { + return nil, fmt.Errorf("role_to_scope_mapping file %q exceeds the maximum allowed size", path) + } var cfg roleScopeConfig if err := yaml.Unmarshal(data, &cfg); err != nil { diff --git a/portals/ai-workspace/bff/internal/session/role_scope_map_test.go b/portals/ai-workspace/bff/internal/session/role_scope_map_test.go index 41f3310392..b5afb61684 100644 --- a/portals/ai-workspace/bff/internal/session/role_scope_map_test.go +++ b/portals/ai-workspace/bff/internal/session/role_scope_map_test.go @@ -115,7 +115,7 @@ func TestLoadRoleScopeMap_RejectsBadPaths(t *testing.T) { // containing no ".." at all, which a later check could not catch. {"traversal", dir + "/../../etc/passwd", "traversal sequences"}, {"missing", dir + "/absent.yaml", "could not be read"}, - {"directory", dir, "is not a file"}, + {"directory", dir, "is not a regular file"}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { From 69de2f04c21b89010d69c09c7b8091ad18aff568 Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 3 Aug 2026 19:20:37 +0530 Subject: [PATCH 4/5] Enhance config normalization to include case-folding for authorization mode - Updated the normalize function to apply case-folding to the Auth.Authorization.Mode field. - Improved comments for clarity on the normalization process and its implications. --- portals/ai-workspace/bff/internal/config/config.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 710afe95a7..21dca33fc0 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -307,12 +307,16 @@ func Load(paths ...string) (*Config, error) { } // normalize resolves the derived fields that are not a straight copy of a config key: -// case-folding (level/format/mode), trimming trailing slashes off URLs/prefixes, the +// case-folding (level/format/both auth modes), trimming trailing slashes off URLs/prefixes, the // oidc-mode-implies-enabled rule, and the fixed cookie attributes. func (c *Config) normalize() { c.Logging.Level = strings.ToLower(c.Logging.Level) c.Logging.Format = strings.ToLower(c.Logging.Format) c.Auth.Mode = strings.ToLower(c.Auth.Mode) + // Folded like the [auth] mode one line up: both are closed value sets compared + // against lowercase constants, so a capital letter must not be the difference + // between a config that starts and one that does not. + c.Auth.Authorization.Mode = strings.ToLower(c.Auth.Authorization.Mode) c.ControlPlane.URL = strings.TrimRight(c.ControlPlane.URL, "/") c.ControlPlane.PortalBasePath = strings.TrimRight(c.ControlPlane.PortalBasePath, "/") From 0f305672cad100d96463b89975cc25a3843f8cea Mon Sep 17 00:00:00 2001 From: Thushani Jayasekera Date: Mon, 3 Aug 2026 19:30:20 +0530 Subject: [PATCH 5/5] reduce the number of scopes requested --- .../bff/internal/config/config.go | 15 ------ .../bff/internal/config/oidc_scopes_test.go | 52 +++++++++++++++---- .../ai-workspace/configs/config-template.toml | 2 +- portals/ai-workspace/src/config.env.ts | 15 ------ 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 21dca33fc0..b59a0006f1 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -234,13 +234,6 @@ const defaultOIDCScopes = "openid profile email offline_access" + " 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" + @@ -250,14 +243,6 @@ const defaultOIDCScopes = "openid profile email offline_access" + " 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" // Load resolves configuration from one or more config.toml files. At least one path diff --git a/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go b/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go index 1b91727d32..ba96b0faef 100644 --- a/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go +++ b/portals/ai-workspace/bff/internal/config/oidc_scopes_test.go @@ -98,11 +98,36 @@ func specPaths() []string { } } -// The default scope set must satisfy every scoped operation the Platform API declares. -// This is what makes the trimmed list safe: the granular create/update/delete scopes -// are omitted only because each of their operations also accepts a resource :manage, -// and that is per-endpoint enumeration in the spec, not scope hierarchy — nothing -// expands `ap:x:manage` into `ap:x:y:read` at request time. +// excludedScopeResources are the resource families the AI Workspace does not surface, +// so the default scope set deliberately requests nothing for them. An operation whose +// every accepted alternative names one of these is out of scope for the coverage check +// below rather than a gap in the requested set. +var excludedScopeResources = []string{ + "ap:rest_api:", + "ap:subscription:", + "ap:subscription_plan:", + "ap:websub_api:", + "ap:webbroker_api:", +} + +func isExcludedScope(scope string) bool { + // The cross-user ownership override is never requested by default + // (TestDefaultOIDCScopesExcludeOwnershipOverride), so it can't stand in as the + // remaining alternative that keeps an excluded-family operation in the check. + if scope == "ap:api_key:all:manage" { + return true + } + return slices.ContainsFunc(excludedScopeResources, func(prefix string) bool { + return strings.HasPrefix(scope, prefix) + }) +} + +// The default scope set must satisfy every scoped operation the Platform API declares, +// excluding the resource families listed in excludedScopeResources. This is what makes +// the trimmed list safe: the granular create/update/delete scopes are omitted only +// because each of their operations also accepts a resource :manage, and that is +// per-endpoint enumeration in the spec, not scope hierarchy — nothing expands +// `ap:x:manage` into `ap:x:y:read` at request time. func TestDefaultOIDCScopesCoverEveryOperation(t *testing.T) { granted := strings.Fields(defaultOIDCScopes) checked := 0 @@ -113,6 +138,10 @@ func TestDefaultOIDCScopesCoverEveryOperation(t *testing.T) { continue } for _, op := range ops { + // Only reachable via an excluded family — not a coverage gap. + if !slices.ContainsFunc(op.accepts, func(s string) bool { return !isExcludedScope(s) }) { + continue + } checked++ satisfied := slices.ContainsFunc(op.accepts, func(s string) bool { return slices.Contains(granted, s) @@ -146,10 +175,11 @@ func TestDefaultOIDCScopesRequestOfflineAccess(t *testing.T) { } } -// The default set requests every declared scope on purpose, so that whatever subset a -// least-privilege user actually holds survives the IDP's intersection of requested and -// entitled. A user granted only ap:rest_api:create would lose it if the request were -// trimmed to :manage/:read. +// Within the resource families the workspace does surface, the default set requests +// every declared scope on purpose, so that whatever subset a least-privilege user +// actually holds survives the IDP's intersection of requested and entitled. A user +// granted only ap:llm_proxy:create would lose it if the request were trimmed to +// :manage/:read. // // The cost is size: encoded, this parameter is several kilobytes, which exceeds // Microsoft Entra ID's authorize-URL limit outright (AADSTS90015). That is not fixed by @@ -161,10 +191,10 @@ func TestDefaultOIDCScopesRequestGranularScopes(t *testing.T) { granted := strings.Fields(defaultOIDCScopes) // Representative granular scopes that a :manage/:read-only request would drop. for _, scope := range []string{ - "ap:rest_api:create", + "ap:llm_proxy:create", "ap:project:delete", "ap:llm_proxy:update", - "ap:rest_api:deployment:undeploy", + "ap:llm_proxy:deployment:undeploy", } { if !slices.Contains(granted, scope) { t.Errorf("defaultOIDCScopes omits %s — a user holding only that grant would lose it, "+ diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index ca59206d54..a3a62b181b 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -281,7 +281,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: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" +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: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:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" # ==================================================================== diff --git a/portals/ai-workspace/src/config.env.ts b/portals/ai-workspace/src/config.env.ts index 2eb3fc0296..2c356a6add 100644 --- a/portals/ai-workspace/src/config.env.ts +++ b/portals/ai-workspace/src/config.env.ts @@ -77,13 +77,6 @@ export const OIDC_SCOPE = getEnvOrDefault( ' 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: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' + @@ -93,14 +86,6 @@ export const OIDC_SCOPE = getEnvOrDefault( ' 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' );