Skip to content

Commit 7c460e4

Browse files
committed
implement simpler RBAC solution
instead of running the OIDC claims against a generic expression evaluator (like Grafana does), we simple calculate the user roles based on the group membership. this is similar to how ArgoCD, ElasticSearch, and other perform their permission mapping.
1 parent a646b3d commit 7c460e4

9 files changed

Lines changed: 440 additions & 71 deletions

File tree

api/oidc.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"github.com/gotify/server/v3/database"
2020
"github.com/gotify/server/v3/decaymap"
2121
"github.com/gotify/server/v3/model"
22-
jmespath "github.com/jmespath-community/go-jmespath/pkg/api"
2322
"github.com/rs/zerolog"
2423
"github.com/rs/zerolog/log"
2524
"github.com/zitadel/oidc/v3/pkg/client/rp"
@@ -58,20 +57,22 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
5857
log.Fatal().Err(err).Msg("failed to initialize OIDC provider")
5958
}
6059

61-
var roleExpression jmespath.JMESPath
62-
if conf.OIDC.RoleExpression != "" {
63-
roleExpression, err = jmespath.Compile(conf.OIDC.RoleExpression)
64-
if err != nil {
65-
log.Fatal().Err(err).Msg("unable to compile role claim JMESPath expression")
66-
}
60+
var rbacResolver RBACResolver
61+
// no need to perform any mapping if the input is empty
62+
if conf.OIDC.GroupsClaim != "" {
63+
rbacResolver = NewSimpleRBACResolver(
64+
conf.OIDC.GroupsUser,
65+
conf.OIDC.GroupsAdmin,
66+
)
6767
}
6868

6969
return &OIDCAPI{
7070
DB: db,
7171
Provider: provider,
7272
UserChangeNotifier: userChangeNotifier,
7373
UsernameClaim: conf.OIDC.UsernameClaim,
74-
RoleExpression: roleExpression,
74+
GroupsClaim: conf.OIDC.GroupsClaim,
75+
RBACResolver: rbacResolver,
7576
PasswordStrength: conf.PassStrength,
7677
SecureCookie: conf.Server.SecureCookie,
7778
AutoRegister: conf.OIDC.AutoRegister,
@@ -101,7 +102,8 @@ type OIDCAPI struct {
101102
Provider rp.RelyingParty
102103
UserChangeNotifier *UserChangeNotifier
103104
UsernameClaim string
104-
RoleExpression jmespath.JMESPath
105+
GroupsClaim string
106+
RBACResolver RBACResolver
105107
PasswordStrength int
106108
SecureCookie bool
107109
AutoRegister bool
@@ -554,19 +556,14 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) {
554556
}
555557

556558
func (a *OIDCAPI) parseUserRole(fallback model.Role, claims map[string]any) (model.Role, error) {
557-
if a.RoleExpression == nil {
559+
if a.RBACResolver == nil || a.GroupsClaim == "" {
558560
return fallback, nil
559561
}
560562

561-
roleData, err := a.RoleExpression.Search(claims)
562-
if err != nil {
563-
return fallback, err
564-
}
565-
566-
roleStr, ok := roleData.(string)
563+
groupsRaw, ok := claims[a.GroupsClaim]
567564
if !ok {
568-
return fallback, fmt.Errorf("role claim expression expected string, got %T", roleData)
565+
return fallback, fmt.Errorf("groups claim %q is missing", a.GroupsClaim)
569566
}
570567

571-
return model.ParseRole(roleStr)
568+
return a.RBACResolver.ResolveRole(groupsRaw)
572569
}

api/oidc_test.go

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import (
1616
"github.com/gotify/server/v3/mode"
1717
"github.com/gotify/server/v3/model"
1818
"github.com/gotify/server/v3/test/testdb"
19-
jmespath "github.com/jmespath-community/go-jmespath/pkg/api"
2019
"github.com/stretchr/testify/assert"
2120
"github.com/stretchr/testify/suite"
2221
"github.com/zitadel/oidc/v3/pkg/client/rp"
@@ -322,7 +321,9 @@ func (s *OIDCSuite) Test_ResolveUser_CustomClaim() {
322321
assert.NotNil(s.T(), user.OIDCID)
323322
}
324323

325-
func (s *OIDCSuite) Test_ResolveUser_NoRoleExpression() {
324+
func (s *OIDCSuite) Test_ResolveUser_NoRBACResolver() {
325+
s.a.GroupsClaim = "roles"
326+
326327
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
327328
user, status, err := s.a.resolveUser(testIssuer, info)
328329

@@ -333,10 +334,23 @@ func (s *OIDCSuite) Test_ResolveUser_NoRoleExpression() {
333334
assert.NotNil(s.T(), user.OIDCID)
334335
}
335336

336-
func (s *OIDCSuite) Test_ResolveUser_AdminRoleExpression() {
337-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
337+
func (s *OIDCSuite) Test_ResolveUser_NoRBACClaim() {
338+
// canary to proof that this resolver is never actually used
339+
s.a.RBACResolver = NewStaticRBACResolver(model.RoleAdmin)
340+
341+
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
342+
user, status, err := s.a.resolveUser(testIssuer, info)
343+
338344
assert.NoError(s.T(), err)
339-
s.a.RoleExpression = role
345+
assert.Equal(s.T(), 0, status)
346+
assert.Equal(s.T(), "test-user", user.Name)
347+
assert.Equal(s.T(), false, user.Admin)
348+
assert.NotNil(s.T(), user.OIDCID)
349+
}
350+
351+
func (s *OIDCSuite) Test_ResolveUser_AdminRBACResolver() {
352+
s.a.GroupsClaim = "roles"
353+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
340354

341355
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "admin-user", "roles": []string{"admin"}}}
342356
user, status, err := s.a.resolveUser(testIssuer, info)
@@ -348,10 +362,9 @@ func (s *OIDCSuite) Test_ResolveUser_AdminRoleExpression() {
348362
assert.NotNil(s.T(), user.OIDCID)
349363
}
350364

351-
func (s *OIDCSuite) Test_ResolveUser_UserRoleExpression() {
352-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
353-
assert.NoError(s.T(), err)
354-
s.a.RoleExpression = role
365+
func (s *OIDCSuite) Test_ResolveUser_UserRBACResolver() {
366+
s.a.GroupsClaim = "roles"
367+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
355368

356369
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
357370
user, status, err := s.a.resolveUser(testIssuer, info)
@@ -363,10 +376,9 @@ func (s *OIDCSuite) Test_ResolveUser_UserRoleExpression() {
363376
assert.NotNil(s.T(), user.OIDCID)
364377
}
365378

366-
func (s *OIDCSuite) Test_ResolveUser_ForbiddenRoleExpression() {
367-
role, err := jmespath.Compile("contains(roles[*], 'gotify') && 'User' || 'None'")
368-
assert.NoError(s.T(), err)
369-
s.a.RoleExpression = role
379+
func (s *OIDCSuite) Test_ResolveUser_ForbiddenRBACResolver() {
380+
s.a.GroupsClaim = "roles"
381+
s.a.RBACResolver = NewSimpleUserRBACResolver("users")
370382

371383
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
372384
_, status, err := s.a.resolveUser(testIssuer, info)
@@ -375,34 +387,31 @@ func (s *OIDCSuite) Test_ResolveUser_ForbiddenRoleExpression() {
375387
assert.Equal(s.T(), 403, status)
376388
}
377389

378-
func (s *OIDCSuite) Test_ResolveUser_InvalidRoleExpression() {
379-
role, err := jmespath.Compile("contains(roles[*], 'gotify') && 'User' || 'FooBar'")
380-
assert.NoError(s.T(), err)
381-
s.a.RoleExpression = role
390+
func (s *OIDCSuite) Test_ResolveUser_GroupsClaimMissing() {
391+
s.a.GroupsClaim = "roles"
392+
s.a.RBACResolver = NewSimpleUserRBACResolver("users")
382393

383-
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
394+
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user"}}
384395
_, status, err := s.a.resolveUser(testIssuer, info)
385396

386-
assert.EqualError(s.T(), err, `role claim mapping error: invalid role "FooBar"`)
397+
assert.EqualError(s.T(), err, `role claim mapping error: groups claim "roles" is missing`)
387398
assert.Equal(s.T(), 500, status)
388399
}
389400

390-
func (s *OIDCSuite) Test_ResolveUser_MalformedRoleExpression() {
391-
role, err := jmespath.Compile("contains(roles[*], 'gotify')")
392-
assert.NoError(s.T(), err)
393-
s.a.RoleExpression = role
401+
func (s *OIDCSuite) Test_ResolveUser_MalformedRBACResolver() {
402+
s.a.GroupsClaim = "admin"
403+
s.a.RBACResolver = NewSimpleUserRBACResolver("users")
394404

395-
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
405+
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "admin": true}}
396406
_, status, err := s.a.resolveUser(testIssuer, info)
397407

398-
assert.EqualError(s.T(), err, `role claim mapping error: role claim expression expected string, got bool`)
408+
assert.EqualError(s.T(), err, `role claim mapping error: RBAC resolver expected strings, got bool`)
399409
assert.Equal(s.T(), 500, status)
400410
}
401411

402-
func (s *OIDCSuite) Test_ResolveUser_PromoteRoleExpression() {
403-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
404-
assert.NoError(s.T(), err)
405-
s.a.RoleExpression = role
412+
func (s *OIDCSuite) Test_ResolveUser_PromoteRBACResolver() {
413+
s.a.GroupsClaim = "roles"
414+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
406415

407416
oidcID := testIssuer + "#sub-1"
408417
s.db.CreateUser(&model.User{ID: 1, Name: "alice", OIDCID: &oidcID})
@@ -419,10 +428,9 @@ func (s *OIDCSuite) Test_ResolveUser_PromoteRoleExpression() {
419428
assert.False(s.T(), s.notified)
420429
}
421430

422-
func (s *OIDCSuite) Test_ResolveUser_DemoteRoleExpression() {
423-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
424-
assert.NoError(s.T(), err)
425-
s.a.RoleExpression = role
431+
func (s *OIDCSuite) Test_ResolveUser_DemoteRBACResolver() {
432+
s.a.GroupsClaim = "roles"
433+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
426434

427435
oidcID := testIssuer + "#sub-1"
428436
s.db.CreateUser(&model.User{ID: 1, Name: "alice", Admin: true, OIDCID: &oidcID})

api/rbac.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
7+
"github.com/gotify/server/v3/model"
8+
)
9+
10+
// RBACResolver allows for arbitrary input to be mapped to roles for
11+
// use in a permission system.
12+
type RBACResolver interface {
13+
// ResolveRole determines the role associated with the given subject.
14+
// Input type support differs between resolver implementations.
15+
ResolveRole(subject any) (model.Role, error)
16+
}
17+
18+
// StaticRBACResolver is a [RBACResolver] implementation which
19+
// always returns the same value for resolver requests.
20+
type StaticRBACResolver model.Role
21+
22+
// NewStaticRBACResolver returns a RBAC resolver which
23+
// unconditionally resolves all requests to the provided
24+
// role.
25+
func NewStaticRBACResolver(r model.Role) StaticRBACResolver {
26+
return StaticRBACResolver(r)
27+
}
28+
29+
// ResolveRole returns the role defined during construction.
30+
func (r StaticRBACResolver) ResolveRole(_ any) (model.Role, error) {
31+
return model.Role(r), nil
32+
}
33+
34+
// SimpleRBACResolver is a [RBACResolver] implementation which
35+
// determines roles based on user and admin rules.
36+
type SimpleRBACResolver struct {
37+
userRoles []string
38+
adminRoles []string
39+
}
40+
41+
// NewSimpleRBACResolver creates a new [SimpleRBACResolver] instance using
42+
// the provided role comparisons.
43+
func NewSimpleRBACResolver(userRoles, adminRoles []string) *SimpleRBACResolver {
44+
r := &SimpleRBACResolver{
45+
userRoles: userRoles,
46+
adminRoles: adminRoles,
47+
}
48+
49+
return r
50+
}
51+
52+
// NewSimpleAdminRBACResolver calls NewSimpleRBACResolver(nil, adminRoles)
53+
func NewSimpleAdminRBACResolver(adminRoles ...string) *SimpleRBACResolver {
54+
return NewSimpleRBACResolver(nil, adminRoles)
55+
}
56+
57+
// NewSimpleUserRBACResolver calls NewSimpleRBACResolver(userRoles, nil)
58+
func NewSimpleUserRBACResolver(userRoles ...string) *SimpleRBACResolver {
59+
return NewSimpleRBACResolver(userRoles, nil)
60+
}
61+
62+
// ResolveRole expects either a string or []string as input, otherwise
63+
// an error is returned.
64+
// Requests are first compared against the admin roles to determine
65+
// eligibility to [RoleAdmin]. An empty user role list or a match
66+
// with the list will yield [RoleUser], otherwise [RoleNone] is returned.
67+
func (r *SimpleRBACResolver) ResolveRole(subject any) (model.Role, error) {
68+
if subject == nil {
69+
return r.resolveSlice(nil), nil
70+
}
71+
72+
switch roles := subject.(type) {
73+
case string:
74+
return r.resolveSlice([]string{roles}), nil
75+
case []string:
76+
return r.resolveSlice(roles), nil
77+
default:
78+
return model.RoleNone, fmt.Errorf("RBAC resolver expected strings, got %T", subject)
79+
}
80+
}
81+
82+
func (r *SimpleRBACResolver) resolveSlice(roles []string) model.Role {
83+
if slicesIntersect(r.adminRoles, roles) {
84+
return model.RoleAdmin
85+
} else if len(r.userRoles) == 0 || slicesIntersect(r.userRoles, roles) {
86+
return model.RoleUser
87+
}
88+
89+
return model.RoleNone
90+
}
91+
92+
func slicesIntersect(haystack, needle []string) bool {
93+
if len(needle) == 0 || len(haystack) == 0 {
94+
return false
95+
}
96+
97+
for _, n := range needle {
98+
if slices.Contains(haystack, n) {
99+
return true
100+
}
101+
}
102+
103+
return false
104+
}

0 commit comments

Comments
 (0)