diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index de23ad63..f2a86d45 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -12,6 +12,8 @@ import ( "fmt" "io" "net/http" + "sort" + "strings" "time" "github.com/ClusterCockpit/cc-backend/internal/repository" @@ -34,6 +36,12 @@ type OpenIDConfig struct { // OAuth2 client secret for the OIDC provider. // Overridden by the OID_CLIENT_SECRET environment variable when set. ClientSecret string `json:"client-secret"` + + // Maps an OIDC role/group claim value to a CC role (admin/support/api/manager/user). + // This is the sole source of roles: a token role grants a CC role only if it is + // listed here. Unmapped token roles are ignored (no identity fallback), so literal + // CC role names must be mapped explicitly. Users without any mapped role get "user". + RoleMapping map[string]string `json:"role-mapping"` } type OIDC struct { @@ -41,6 +49,9 @@ type OIDC struct { provider *oidc.Provider authentication *Authentication clientID string + // roleMapping is the validated subset of OpenIDConfig.RoleMapping + // (IdP role/group name -> CC role). + roleMapping map[string]string } func randString(nByte int) (string, error) { @@ -89,11 +100,47 @@ func NewOIDC(a *Authentication) *OIDC { Scopes: []string{oidc.ScopeOpenID, "profile", "roles"}, } - oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a} + // Validate the optional role mapping once at startup. Invalid targets are + // dropped with a warning rather than failing startup. IdP names (keys) are + // kept verbatim so they match the raw token claim values. + roleMapping := make(map[string]string) + for name, ccRole := range Keys.OpenIDConfig.RoleMapping { + role := strings.ToLower(ccRole) + if !schema.IsValidRole(role) || role == schema.GetRoleString(schema.RoleAnonymous) { + cclog.Warnf("OIDC: ignoring role-mapping '%s' -> '%s': invalid or non-assignable target role", name, ccRole) + continue + } + roleMapping[name] = role + } + + oa := &OIDC{provider: provider, client: client, clientID: clientID, authentication: a, roleMapping: roleMapping} return oa } +// mapOIDCRoles translates raw OIDC role/group names into the CC role set using +// only the configured mapping (IdP name -> CC role). Unmapped names are ignored. +// Always returns at least [user]. The result is deduplicated and sorted. +func mapOIDCRoles(oidcRoles []string, mapping map[string]string) []string { + roleSet := make(map[string]bool) + for _, r := range oidcRoles { + if cc, ok := mapping[r]; ok { + roleSet[cc] = true + } + } + + if len(roleSet) == 0 { + return []string{schema.GetRoleString(schema.RoleUser)} + } + + roles := make([]string, 0, len(roleSet)) + for role := range roleSet { + roles = append(roles, role) + } + sort.Strings(roles) + return roles +} + func (oa *OIDC) RegisterEndpoints(r chi.Router) { r.HandleFunc("/oidc-login", oa.OAuth2Login) r.HandleFunc("/oidc-callback", oa.OAuth2Callback) @@ -240,28 +287,7 @@ func (oa *OIDC) OAuth2Callback(rw http.ResponseWriter, r *http.Request) { oidcRoles = append(oidcRoles, access.Roles...) } - roleSet := make(map[string]bool) - for _, r := range oidcRoles { - switch r { - case "user": - roleSet[schema.GetRoleString(schema.RoleUser)] = true - case "admin": - roleSet[schema.GetRoleString(schema.RoleAdmin)] = true - case "manager": - roleSet[schema.GetRoleString(schema.RoleManager)] = true - case "support": - roleSet[schema.GetRoleString(schema.RoleSupport)] = true - } - } - - var roles []string - for role := range roleSet { - roles = append(roles, role) - } - - if len(roles) == 0 { - roles = append(roles, schema.GetRoleString(schema.RoleUser)) - } + roles := mapOIDCRoles(oidcRoles, oa.roleMapping) user := &schema.User{ Username: username, diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go new file mode 100644 index 00000000..c82e1ee1 --- /dev/null +++ b/internal/auth/oidc_test.go @@ -0,0 +1,82 @@ +// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. This file is part of cc-backend. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "reflect" + "testing" + + "github.com/ClusterCockpit/cc-lib/v2/schema" +) + +func TestMapOIDCRoles(t *testing.T) { + var ( + user = schema.GetRoleString(schema.RoleUser) + admin = schema.GetRoleString(schema.RoleAdmin) + support = schema.GetRoleString(schema.RoleSupport) + api = schema.GetRoleString(schema.RoleAPI) + ) + + mapping := map[string]string{ + "cc-admins": admin, + "cc-support": support, + "cc-api": api, + "staff": support, // second name mapping to the same role + } + + tests := []struct { + name string + oidcRoles []string + mapping map[string]string + want []string + }{ + { + name: "explicit mapping to elevated roles", + oidcRoles: []string{"cc-admins", "cc-api"}, + mapping: mapping, + want: []string{admin, api}, + }, + { + name: "unmapped names are ignored (no identity fallback)", + oidcRoles: []string{"admin", "support", "unknown"}, + mapping: mapping, + want: []string{user}, + }, + { + name: "mix of mapped and unmapped keeps only mapped", + oidcRoles: []string{"cc-admins", "admin", "noise"}, + mapping: mapping, + want: []string{admin}, + }, + { + name: "empty token roles default to user", + oidcRoles: nil, + mapping: mapping, + want: []string{user}, + }, + { + name: "no mapping configured defaults to user", + oidcRoles: []string{"cc-admins", "admin"}, + mapping: map[string]string{}, + want: []string{user}, + }, + { + name: "duplicate target roles are deduplicated and sorted", + oidcRoles: []string{"cc-support", "staff", "cc-admins"}, + mapping: mapping, + want: []string{admin, support}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapOIDCRoles(tt.oidcRoles, tt.mapping) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("mapOIDCRoles() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/auth/schema.go b/internal/auth/schema.go index 310afa8a..0056e4cb 100644 --- a/internal/auth/schema.go +++ b/internal/auth/schema.go @@ -76,6 +76,13 @@ var configSchema = ` "client-secret": { "description": "OAuth2 client secret for the OIDC provider. Overridden by the OID_CLIENT_SECRET environment variable when set.", "type": "string" + }, + "role-mapping": { + "description": "Maps an OIDC role/group claim value (from realm_access/resource_access) to a CC role. Valid target roles: admin, support, api, manager, user. This is the sole source of roles: only mapped roles are honored, unmapped token roles are ignored (literal CC role names must be mapped explicitly). Users without any mapped role receive the base 'user' role.", + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "required": ["provider"]