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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions platform-api/internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
})
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion platform-api/internal/middleware/scope_enforcer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions portals/ai-workspace/bff/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions portals/ai-workspace/bff/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
66 changes: 47 additions & 19 deletions portals/ai-workspace/bff/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -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" +
Expand All @@ -207,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" +
Expand All @@ -223,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
Expand Down Expand Up @@ -280,12 +292,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, "/")
Expand All @@ -308,6 +324,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")
}
Expand Down
79 changes: 79 additions & 0 deletions portals/ai-workspace/bff/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, `
Expand Down
5 changes: 5 additions & 0 deletions portals/ai-workspace/bff/internal/config/default_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
}
}
Loading
Loading