From 3474690adc4c38ee3b2de0dd62f3a4ce3414d453 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 19 Jun 2026 11:28:20 +0200 Subject: [PATCH] feat(auth): map OIDC token roles to CC roles via configurable mapping OIDC roles were translated by a hardcoded switch that only recognised the literal names user/admin/manager/support (dropping api) and required the IdP to emit exactly those names. Add an optional auth.oidc.role-mapping (OIDC role/group claim value -> CC role) so operators can map their own realm/client role names, including api. The mapping is the sole source of roles: only mapped names are honored, unmapped token roles are ignored, and users without any mapped role receive the base "user" role. Mapping targets are validated once at startup. Role assignment stays authoritative on every login as before. Note: deployments relying on the IdP emitting literal CC role names must now map them explicitly (e.g. "admin": "admin"). Co-Authored-By: Claude Opus 4.8 (1M context) Entire-Checkpoint: 66451678484b --- internal/auth/oidc.go | 72 ++++++++++++++++++++++----------- internal/auth/oidc_test.go | 82 ++++++++++++++++++++++++++++++++++++++ internal/auth/schema.go | 7 ++++ 3 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 internal/auth/oidc_test.go 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"]