mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-08-31 00:47:15 +02:00
Merge branch 'feat/74-assign-user-role-based-on-ldap-group' into metric-store-tickets
This commit is contained in:
@@ -189,7 +189,14 @@ func (s *Server) init() error {
|
|||||||
|
|
||||||
if auth.Keys.OpenIDConfig != nil {
|
if auth.Keys.OpenIDConfig != nil {
|
||||||
openIDConnect := auth.NewOIDC(authHandle)
|
openIDConnect := auth.NewOIDC(authHandle)
|
||||||
openIDConnect.RegisterEndpoints(s.router)
|
// The OIDC callback mutates the session (SaveSession renews the token and
|
||||||
|
// writes the session data), so these routes have to run inside
|
||||||
|
// scs.LoadAndSave just like /login and /logout. Without it the session
|
||||||
|
// is missing from the request context and scs panics.
|
||||||
|
s.router.Group(func(r chi.Router) {
|
||||||
|
r.Use(sessionManager.LoadAndSave)
|
||||||
|
openIDConnect.RegisterEndpoints(r)
|
||||||
|
})
|
||||||
info["hasOpenIDConnect"] = true
|
info["hasOpenIDConnect"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"addr": "0.0.0.0:8088",
|
||||||
|
"short-running-jobs-duration": 300,
|
||||||
|
"emission-constant": 317,
|
||||||
|
"resampling": {
|
||||||
|
"minimum-points": 600,
|
||||||
|
"trigger": 30,
|
||||||
|
"resolutions": [600, 300, 120, 60]
|
||||||
|
},
|
||||||
|
"api-subjects": {
|
||||||
|
"subject-job-event": "cc.job.event",
|
||||||
|
"subject-node-state": "cc.node.state"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nats": {
|
||||||
|
"address": "nats://0.0.0.0:4222",
|
||||||
|
"username": "root",
|
||||||
|
"password": "root"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"jwts": {
|
||||||
|
"max-age": "2000h",
|
||||||
|
"public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=",
|
||||||
|
"private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q=="
|
||||||
|
},
|
||||||
|
"oidc": {
|
||||||
|
"provider": "http://localhost:8080/realms/clustercockpit",
|
||||||
|
"client-id": "cc-backend",
|
||||||
|
"client-secret": "cc-backend-dev-secret",
|
||||||
|
"sync-user-on-login": true,
|
||||||
|
"update-user-on-login": true,
|
||||||
|
"role-mapping": {
|
||||||
|
"cc-admin": "admin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ldap": {
|
||||||
|
"url": "ldap://127.0.0.1:389",
|
||||||
|
"user-base": "ou=users,dc=example,dc=com",
|
||||||
|
"search-dn": "cn=admin,dc=example,dc=com",
|
||||||
|
"user-bind": "uid={username},ou=users,dc=example,dc=com",
|
||||||
|
"user-filter": "(objectclass=posixAccount)",
|
||||||
|
"username-attr": "cn",
|
||||||
|
"uid-attr": "uid",
|
||||||
|
"sync-password": "mashup",
|
||||||
|
"sync-interval": "2h",
|
||||||
|
"sync-user-on-login": true,
|
||||||
|
"update-user-on-login": true,
|
||||||
|
"role-filters": {
|
||||||
|
"admin": "(memberOf=cn=cc-admin,ou=groups,dc=example,dc=com)",
|
||||||
|
"support": "(memberOf=cn=cc-support,ou=groups,dc=example,dc=com)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"commit-job-worker": "1m",
|
||||||
|
"duration-worker": "5m",
|
||||||
|
"footprint-worker": "10m"
|
||||||
|
},
|
||||||
|
"archive": {
|
||||||
|
"kind": "file",
|
||||||
|
"path": "./var/job-archive"
|
||||||
|
},
|
||||||
|
"metric-store-external": [
|
||||||
|
{
|
||||||
|
"scope": "fritz",
|
||||||
|
"url": "http://0.0.0.0:8084",
|
||||||
|
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlcyI6WyJST0xFX0FETUlOIiwiUk9MRV9BTkFMWVNUIiwiUk9MRV9VU0VSIl19.d-3_3FZTsadPjDEdsWrrQ7nS0edMAR4zjl-eK7rJU3HziNBfI9PDHDIpJVHTNN5E5SlLGLFXctWyKAkwhXL-Dw"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scope": "alex",
|
||||||
|
"url": "http://0.0.0.0:8084",
|
||||||
|
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlcyI6WyJST0xFX0FETUlOIiwiUk9MRV9BTkFMWVNUIiwiUk9MRV9VU0VSIl19.d-3_3FZTsadPjDEdsWrrQ7nS0edMAR4zjl-eK7rJU3HziNBfI9PDHDIpJVHTNN5E5SlLGLFXctWyKAkwhXL-Dw"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metric-store": {
|
||||||
|
"retention-in-memory": "24h",
|
||||||
|
"memory-cap": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
+218
-3
@@ -9,6 +9,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,12 +38,25 @@ type LdapConfig struct {
|
|||||||
// Password for the LDAP admin account used for syncing (optional).
|
// Password for the LDAP admin account used for syncing (optional).
|
||||||
// Overridden by the LDAP_ADMIN_PASSWORD environment variable when set.
|
// Overridden by the LDAP_ADMIN_PASSWORD environment variable when set.
|
||||||
SyncPassword string `json:"sync-password"`
|
SyncPassword string `json:"sync-password"`
|
||||||
|
|
||||||
|
// Maps an elevated role (admin/support/api/manager) to an LDAP filter.
|
||||||
|
// An account matching the filter is granted that role; LDAP is authoritative
|
||||||
|
// for every role listed here (it is both added and removed to match group
|
||||||
|
// membership). Roles not listed here are never touched. Empty/absent means
|
||||||
|
// roles are never modified by LDAP.
|
||||||
|
RoleFilters map[string]string `json:"role-filters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LdapAuthenticator struct {
|
type LdapAuthenticator struct {
|
||||||
syncPassword string
|
syncPassword string
|
||||||
UserAttr string
|
UserAttr string
|
||||||
UIDAttr string
|
UIDAttr string
|
||||||
|
|
||||||
|
// roleFilters holds the validated subset of LdapConfig.RoleFilters.
|
||||||
|
roleFilters map[string]string
|
||||||
|
// managedRoles is the sorted list of roles LDAP is authoritative for
|
||||||
|
// (the keys of roleFilters). Empty when no role filters are configured.
|
||||||
|
managedRoles []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Authenticator = (*LdapAuthenticator)(nil)
|
var _ Authenticator = (*LdapAuthenticator)(nil)
|
||||||
@@ -64,6 +79,26 @@ func (la *LdapAuthenticator) Init() error {
|
|||||||
la.UIDAttr = "uid"
|
la.UIDAttr = "uid"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the optional role filters. Invalid keys are dropped with a
|
||||||
|
// warning rather than failing startup. The baseline "user" role cannot be
|
||||||
|
// LDAP-managed (it is always granted), and an empty filter is meaningless.
|
||||||
|
la.roleFilters = make(map[string]string)
|
||||||
|
for role, filter := range Keys.LdapConfig.RoleFilters {
|
||||||
|
role = strings.ToLower(role)
|
||||||
|
if !schema.IsValidRole(role) || role == schema.GetRoleString(schema.RoleUser) ||
|
||||||
|
role == schema.GetRoleString(schema.RoleAnonymous) {
|
||||||
|
cclog.Warnf("LDAP: ignoring role-filter for invalid or non-assignable role '%s'", role)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filter) == "" {
|
||||||
|
cclog.Warnf("LDAP: ignoring empty role-filter for role '%s'", role)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
la.roleFilters[role] = filter
|
||||||
|
la.managedRoles = append(la.managedRoles, role)
|
||||||
|
}
|
||||||
|
sort.Strings(la.managedRoles)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +112,25 @@ func (la *LdapAuthenticator) CanLogin(
|
|||||||
|
|
||||||
if user != nil {
|
if user != nil {
|
||||||
if user.AuthSource == schema.AuthViaLDAP {
|
if user.AuthSource == schema.AuthViaLDAP {
|
||||||
|
// Refresh elevated roles from LDAP when role filters are configured
|
||||||
|
// and role updates on login are enabled. Without role filters this
|
||||||
|
// stays a fast path with no extra LDAP query.
|
||||||
|
if len(la.managedRoles) > 0 && lc.UpdateUserOnLogin {
|
||||||
|
if l, err := la.getLdapConnection(true); err != nil {
|
||||||
|
cclog.Warnf("LDAP: skipping role refresh for user '%s': connection error", user.Username)
|
||||||
|
} else {
|
||||||
|
defer l.Close()
|
||||||
|
if matched, err := la.matchRoles(l, user.Username); err == nil {
|
||||||
|
roles := mergeLdapRoles(user.Roles, la.managedRoles, matched, user.Projects)
|
||||||
|
current := append([]string{}, user.Roles...)
|
||||||
|
sort.Strings(current)
|
||||||
|
if !slices.Equal(roles, current) {
|
||||||
|
user.Roles = roles
|
||||||
|
handleLdapUser(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return user, true
|
return user, true
|
||||||
}
|
}
|
||||||
} else if lc.SyncUserOnLogin {
|
} else if lc.SyncUserOnLogin {
|
||||||
@@ -106,10 +160,18 @@ func (la *LdapAuthenticator) CanLogin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry := sr.Entries[0]
|
entry := sr.Entries[0]
|
||||||
|
|
||||||
|
roles := []string{schema.GetRoleString(schema.RoleUser)}
|
||||||
|
if len(la.managedRoles) > 0 {
|
||||||
|
if matched, err := la.matchRoles(l, username); err == nil {
|
||||||
|
roles = mergeLdapRoles(nil, la.managedRoles, matched, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user = &schema.User{
|
user = &schema.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
Name: entry.GetAttributeValue(la.UserAttr),
|
Name: entry.GetAttributeValue(la.UserAttr),
|
||||||
Roles: []string{schema.GetRoleString(schema.RoleUser)},
|
Roles: roles,
|
||||||
Projects: make([]string, 0),
|
Projects: make([]string, 0),
|
||||||
AuthType: schema.AuthSession,
|
AuthType: schema.AuthSession,
|
||||||
AuthSource: schema.AuthViaLDAP,
|
AuthSource: schema.AuthViaLDAP,
|
||||||
@@ -194,6 +256,28 @@ func (la *LdapAuthenticator) Sync() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate configured role filters once over the whole base. Empty when no
|
||||||
|
// role filters are configured, in which case role handling is a no-op and
|
||||||
|
// behaviour is identical to before.
|
||||||
|
matched, err := la.matchRolesBulk(l)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current roles/projects of users that already hold a non-default role, so
|
||||||
|
// existing users can be reconciled without a per-user lookup.
|
||||||
|
currentRoles := map[string]*schema.User{}
|
||||||
|
if len(la.managedRoles) > 0 {
|
||||||
|
specials, err := ur.ListUsers(true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, u := range specials {
|
||||||
|
currentRoles[u.Username] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userRole := schema.GetRoleString(schema.RoleUser)
|
||||||
for username, where := range users {
|
for username, where := range users {
|
||||||
if where == InDB && lc.SyncDelOldUsers {
|
if where == InDB && lc.SyncDelOldUsers {
|
||||||
if err := ur.DelUser(username); err != nil {
|
if err := ur.DelUser(username); err != nil {
|
||||||
@@ -204,25 +288,156 @@ func (la *LdapAuthenticator) Sync() error {
|
|||||||
} else if where == InLdap {
|
} else if where == InLdap {
|
||||||
name := newnames[username]
|
name := newnames[username]
|
||||||
|
|
||||||
|
roles := []string{userRole}
|
||||||
|
if len(la.managedRoles) > 0 {
|
||||||
|
roles = mergeLdapRoles(nil, la.managedRoles, matched[username], nil)
|
||||||
|
}
|
||||||
|
|
||||||
user := &schema.User{
|
user := &schema.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
Name: name,
|
Name: name,
|
||||||
Roles: []string{schema.GetRoleString(schema.RoleUser)},
|
Roles: roles,
|
||||||
Projects: make([]string, 0),
|
Projects: make([]string, 0),
|
||||||
AuthSource: schema.AuthViaLDAP,
|
AuthSource: schema.AuthViaLDAP,
|
||||||
}
|
}
|
||||||
|
|
||||||
cclog.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name)
|
cclog.Debugf("sync: add %v (name: %v, roles: %v, ldap: true)", username, name, roles)
|
||||||
if err := ur.AddUserIfNotExists(user); err != nil {
|
if err := ur.AddUserIfNotExists(user); err != nil {
|
||||||
cclog.Errorf("User '%s' LDAP: Insert into DB failed", username)
|
cclog.Errorf("User '%s' LDAP: Insert into DB failed", username)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if where == InBoth && len(la.managedRoles) > 0 {
|
||||||
|
// Reconcile elevated roles for existing users: LDAP is authoritative
|
||||||
|
// for the managed roles, all other roles are preserved.
|
||||||
|
cur := []string{userRole}
|
||||||
|
var projects []string
|
||||||
|
if u, ok := currentRoles[username]; ok {
|
||||||
|
cur = u.Roles
|
||||||
|
projects = u.Projects
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := mergeLdapRoles(cur, la.managedRoles, matched[username], projects)
|
||||||
|
sortedCur := append([]string{}, cur...)
|
||||||
|
sort.Strings(sortedCur)
|
||||||
|
if !slices.Equal(roles, sortedCur) {
|
||||||
|
cclog.Debugf("sync: update %v roles %v -> %v", username, cur, roles)
|
||||||
|
if err := ur.UpdateRoles(username, roles); err != nil {
|
||||||
|
cclog.Errorf("User '%s' LDAP: role update failed: %v", username, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeLdapRoles computes the role set for an LDAP user. Every current role that
|
||||||
|
// LDAP does not manage is preserved, the baseline "user" role is always present,
|
||||||
|
// and the matched managed roles are added. managed is the set of roles LDAP is
|
||||||
|
// authoritative for; matched is the subset of those the account currently
|
||||||
|
// qualifies for. projects is used to guard manager removal: a manager that still
|
||||||
|
// has assigned projects keeps the role even if it is no longer matched (mirrors
|
||||||
|
// UserRepository.RemoveRole). The result is deduplicated and sorted.
|
||||||
|
func mergeLdapRoles(current, managed, matched, projects []string) []string {
|
||||||
|
managedSet := make(map[string]bool, len(managed))
|
||||||
|
for _, r := range managed {
|
||||||
|
managedSet[r] = true
|
||||||
|
}
|
||||||
|
matchedSet := make(map[string]bool, len(matched))
|
||||||
|
for _, r := range matched {
|
||||||
|
matchedSet[r] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]bool{schema.GetRoleString(schema.RoleUser): true}
|
||||||
|
|
||||||
|
// Preserve roles LDAP does not manage (e.g. a manually granted manager).
|
||||||
|
for _, r := range current {
|
||||||
|
if !managedSet[r] {
|
||||||
|
result[r] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add managed roles the account currently qualifies for.
|
||||||
|
for _, r := range managed {
|
||||||
|
if matchedSet[r] {
|
||||||
|
result[r] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: do not strip a manager that still has assigned projects.
|
||||||
|
managerRole := schema.GetRoleString(schema.RoleManager)
|
||||||
|
if managedSet[managerRole] && !matchedSet[managerRole] && len(projects) > 0 &&
|
||||||
|
slices.Contains(current, managerRole) {
|
||||||
|
cclog.Warnf("LDAP: keeping role 'manager' despite no filter match: user still has assigned project(s): %v", projects)
|
||||||
|
result[managerRole] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := make([]string, 0, len(result))
|
||||||
|
for r := range result {
|
||||||
|
roles = append(roles, r)
|
||||||
|
}
|
||||||
|
sort.Strings(roles)
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRoles evaluates all configured role filters for a single user and returns
|
||||||
|
// the managed roles the account qualifies for. Used on the login path where only
|
||||||
|
// one user is inspected.
|
||||||
|
func (la *LdapAuthenticator) matchRoles(l *ldap.Conn, username string) ([]string, error) {
|
||||||
|
if len(la.managedRoles) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matched := make([]string, 0, len(la.managedRoles))
|
||||||
|
for _, role := range la.managedRoles {
|
||||||
|
filter := fmt.Sprintf("(&(%s=%s)%s)", la.UIDAttr, ldap.EscapeFilter(username), la.roleFilters[role])
|
||||||
|
sr, err := l.Search(ldap.NewSearchRequest(
|
||||||
|
Keys.LdapConfig.UserBase,
|
||||||
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
filter,
|
||||||
|
[]string{la.UIDAttr}, nil))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warnf("LDAP: role filter search for role '%s' failed: %v", role, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(sr.Entries) > 0 {
|
||||||
|
matched = append(matched, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchRolesBulk evaluates every configured role filter once over the whole user
|
||||||
|
// base and returns a username -> matched managed roles mapping. Used by Sync,
|
||||||
|
// it costs one LDAP search per configured role rather than one per user.
|
||||||
|
func (la *LdapAuthenticator) matchRolesBulk(l *ldap.Conn) (map[string][]string, error) {
|
||||||
|
matched := map[string][]string{}
|
||||||
|
if len(la.managedRoles) == 0 {
|
||||||
|
return matched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lc := Keys.LdapConfig
|
||||||
|
for _, role := range la.managedRoles {
|
||||||
|
filter := fmt.Sprintf("(&%s%s)", lc.UserFilter, la.roleFilters[role])
|
||||||
|
sr, err := l.Search(ldap.NewSearchRequest(
|
||||||
|
lc.UserBase,
|
||||||
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
filter,
|
||||||
|
[]string{la.UIDAttr}, nil))
|
||||||
|
if err != nil {
|
||||||
|
cclog.Warnf("LDAP: role filter search for role '%s' failed: %v", role, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range sr.Entries {
|
||||||
|
if username := entry.GetAttributeValue(la.UIDAttr); username != "" {
|
||||||
|
matched[username] = append(matched[username], role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) {
|
func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) {
|
||||||
lc := Keys.LdapConfig
|
lc := Keys.LdapConfig
|
||||||
conn, err := ldap.DialURL(lc.URL,
|
conn, err := ldap.DialURL(lc.URL,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 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 TestMergeLdapRoles(t *testing.T) {
|
||||||
|
var (
|
||||||
|
user = schema.GetRoleString(schema.RoleUser)
|
||||||
|
admin = schema.GetRoleString(schema.RoleAdmin)
|
||||||
|
support = schema.GetRoleString(schema.RoleSupport)
|
||||||
|
api = schema.GetRoleString(schema.RoleAPI)
|
||||||
|
manager = schema.GetRoleString(schema.RoleManager)
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current []string
|
||||||
|
managed []string
|
||||||
|
matched []string
|
||||||
|
projects []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no managed roles is a no-op keeping user baseline",
|
||||||
|
current: []string{user},
|
||||||
|
managed: nil,
|
||||||
|
matched: nil,
|
||||||
|
want: []string{user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add matched elevated role to plain user",
|
||||||
|
current: []string{user},
|
||||||
|
managed: []string{admin, support},
|
||||||
|
matched: []string{admin},
|
||||||
|
want: []string{admin, user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove managed role when no longer matched",
|
||||||
|
current: []string{admin, user},
|
||||||
|
managed: []string{admin},
|
||||||
|
matched: nil,
|
||||||
|
want: []string{user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserve non-managed roles (manager, api)",
|
||||||
|
current: []string{api, manager, user},
|
||||||
|
managed: []string{admin, support},
|
||||||
|
matched: []string{support},
|
||||||
|
want: []string{api, manager, support, user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed manager with projects is not removed",
|
||||||
|
current: []string{manager, user},
|
||||||
|
managed: []string{manager},
|
||||||
|
matched: nil,
|
||||||
|
projects: []string{"projA"},
|
||||||
|
want: []string{manager, user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "managed manager without projects is removed",
|
||||||
|
current: []string{manager, user},
|
||||||
|
managed: []string{manager},
|
||||||
|
matched: nil,
|
||||||
|
want: []string{user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user baseline always present even if absent in current",
|
||||||
|
current: []string{admin},
|
||||||
|
managed: []string{admin},
|
||||||
|
matched: []string{admin},
|
||||||
|
want: []string{admin, user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new user (nil current) gets matched roles plus baseline",
|
||||||
|
current: nil,
|
||||||
|
managed: []string{admin, support, api},
|
||||||
|
matched: []string{admin, api},
|
||||||
|
want: []string{admin, api, user},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "result is deduplicated",
|
||||||
|
current: []string{admin, admin, user},
|
||||||
|
managed: []string{admin},
|
||||||
|
matched: []string{admin},
|
||||||
|
want: []string{admin, user},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := mergeLdapRoles(tt.current, tt.managed, tt.matched, tt.projects)
|
||||||
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Errorf("mergeLdapRoles() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-23
@@ -12,6 +12,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
"github.com/ClusterCockpit/cc-backend/internal/repository"
|
||||||
@@ -34,6 +36,12 @@ type OpenIDConfig struct {
|
|||||||
// OAuth2 client secret for the OIDC provider.
|
// OAuth2 client secret for the OIDC provider.
|
||||||
// Overridden by the OID_CLIENT_SECRET environment variable when set.
|
// Overridden by the OID_CLIENT_SECRET environment variable when set.
|
||||||
ClientSecret string `json:"client-secret"`
|
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 {
|
type OIDC struct {
|
||||||
@@ -41,6 +49,9 @@ type OIDC struct {
|
|||||||
provider *oidc.Provider
|
provider *oidc.Provider
|
||||||
authentication *Authentication
|
authentication *Authentication
|
||||||
clientID string
|
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) {
|
func randString(nByte int) (string, error) {
|
||||||
@@ -89,11 +100,47 @@ func NewOIDC(a *Authentication) *OIDC {
|
|||||||
Scopes: []string{oidc.ScopeOpenID, "profile", "roles"},
|
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
|
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) {
|
func (oa *OIDC) RegisterEndpoints(r chi.Router) {
|
||||||
r.HandleFunc("/oidc-login", oa.OAuth2Login)
|
r.HandleFunc("/oidc-login", oa.OAuth2Login)
|
||||||
r.HandleFunc("/oidc-callback", oa.OAuth2Callback)
|
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...)
|
oidcRoles = append(oidcRoles, access.Roles...)
|
||||||
}
|
}
|
||||||
|
|
||||||
roleSet := make(map[string]bool)
|
roles := mapOIDCRoles(oidcRoles, oa.roleMapping)
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
user := &schema.User{
|
user := &schema.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,13 @@ var configSchema = `
|
|||||||
"client-secret": {
|
"client-secret": {
|
||||||
"description": "OAuth2 client secret for the OIDC provider. Overridden by the OID_CLIENT_SECRET environment variable when set.",
|
"description": "OAuth2 client secret for the OIDC provider. Overridden by the OID_CLIENT_SECRET environment variable when set.",
|
||||||
"type": "string"
|
"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"]
|
"required": ["provider"]
|
||||||
@@ -131,6 +138,13 @@ var configSchema = `
|
|||||||
"sync-password": {
|
"sync-password": {
|
||||||
"description": "Password for the LDAP admin account used for syncing. Overridden by the LDAP_ADMIN_PASSWORD environment variable when set.",
|
"description": "Password for the LDAP admin account used for syncing. Overridden by the LDAP_ADMIN_PASSWORD environment variable when set.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role-filters": {
|
||||||
|
"description": "Maps an elevated role to an LDAP filter; accounts matching the filter are granted that role. LDAP is authoritative for every role listed here (roles are added and removed to match group membership), while roles not listed are preserved. Applied during sync and at login. Valid keys: admin, support, api, manager.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["url", "user-base", "search-dn", "user-bind", "user-filter"]
|
"required": ["url", "user-base", "search-dn", "user-bind", "user-filter"]
|
||||||
|
|||||||
@@ -298,6 +298,18 @@ func (r *UserRepository) UpdateUser(dbUser *schema.User, user *schema.User) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateRoles overwrites a user's role list with the provided roles.
|
||||||
|
// Used by the LDAP sync to reconcile elevated roles; callers are responsible for
|
||||||
|
// computing the full role set (the value replaces the existing one verbatim).
|
||||||
|
func (r *UserRepository) UpdateRoles(username string, roles []string) error {
|
||||||
|
rolesJSON, _ := json.Marshal(roles)
|
||||||
|
if _, err := sq.Update("hpc_user").Set("roles", rolesJSON).Where("hpc_user.username = ?", username).RunWith(r.DB).Exec(); err != nil {
|
||||||
|
cclog.Errorf("error while updating roles of user '%s'", username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *UserRepository) DelUser(username string) error {
|
func (r *UserRepository) DelUser(username string) error {
|
||||||
_, err := r.DB.Exec(`DELETE FROM hpc_user WHERE hpc_user.username = ?`, username)
|
_, err := r.DB.Exec(`DELETE FROM hpc_user WHERE hpc_user.username = ?`, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user