Skip to content

Commit 1cda078

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 e611c97 commit 1cda078

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,
@@ -100,7 +101,8 @@ type OIDCAPI struct {
100101
Provider rp.RelyingParty
101102
UserChangeNotifier *UserChangeNotifier
102103
UsernameClaim string
103-
RoleExpression jmespath.JMESPath
104+
GroupsClaim string
105+
RBACResolver RBACResolver
104106
PasswordStrength int
105107
SecureCookie bool
106108
AutoRegister bool
@@ -542,19 +544,14 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) {
542544
}
543545

544546
func (a *OIDCAPI) parseUserRole(fallback model.Role, claims map[string]any) (model.Role, error) {
545-
if a.RoleExpression == nil {
547+
if a.RBACResolver == nil || a.GroupsClaim == "" {
546548
return fallback, nil
547549
}
548550

549-
roleData, err := a.RoleExpression.Search(claims)
550-
if err != nil {
551-
return fallback, err
552-
}
553-
554-
roleStr, ok := roleData.(string)
551+
groupsRaw, ok := claims[a.GroupsClaim]
555552
if !ok {
556-
return fallback, fmt.Errorf("role claim expression expected string, got %T", roleData)
553+
return fallback, fmt.Errorf("groups claim %q is missing", a.GroupsClaim)
557554
}
558555

559-
return model.ParseRole(roleStr)
556+
return a.RBACResolver.ResolveRole(groupsRaw)
560557
}

api/oidc_test.go

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"github.com/gotify/server/v3/mode"
1313
"github.com/gotify/server/v3/model"
1414
"github.com/gotify/server/v3/test/testdb"
15-
jmespath "github.com/jmespath-community/go-jmespath/pkg/api"
1615
"github.com/stretchr/testify/assert"
1716
"github.com/stretchr/testify/suite"
1817
"github.com/zitadel/oidc/v3/pkg/oidc"
@@ -231,7 +230,9 @@ func (s *OIDCSuite) Test_ResolveUser_CustomClaim() {
231230
assert.NotNil(s.T(), user.OIDCID)
232231
}
233232

234-
func (s *OIDCSuite) Test_ResolveUser_NoRoleExpression() {
233+
func (s *OIDCSuite) Test_ResolveUser_NoRBACResolver() {
234+
s.a.GroupsClaim = "roles"
235+
235236
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
236237
user, status, err := s.a.resolveUser(testIssuer, info)
237238

@@ -242,10 +243,23 @@ func (s *OIDCSuite) Test_ResolveUser_NoRoleExpression() {
242243
assert.NotNil(s.T(), user.OIDCID)
243244
}
244245

245-
func (s *OIDCSuite) Test_ResolveUser_AdminRoleExpression() {
246-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
246+
func (s *OIDCSuite) Test_ResolveUser_NoRBACClaim() {
247+
// canary to proof that this resolver is never actually used
248+
s.a.RBACResolver = NewStaticRBACResolver(model.RoleAdmin)
249+
250+
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
251+
user, status, err := s.a.resolveUser(testIssuer, info)
252+
247253
assert.NoError(s.T(), err)
248-
s.a.RoleExpression = role
254+
assert.Equal(s.T(), 0, status)
255+
assert.Equal(s.T(), "test-user", user.Name)
256+
assert.Equal(s.T(), false, user.Admin)
257+
assert.NotNil(s.T(), user.OIDCID)
258+
}
259+
260+
func (s *OIDCSuite) Test_ResolveUser_AdminRBACResolver() {
261+
s.a.GroupsClaim = "roles"
262+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
249263

250264
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "admin-user", "roles": []string{"admin"}}}
251265
user, status, err := s.a.resolveUser(testIssuer, info)
@@ -257,10 +271,9 @@ func (s *OIDCSuite) Test_ResolveUser_AdminRoleExpression() {
257271
assert.NotNil(s.T(), user.OIDCID)
258272
}
259273

260-
func (s *OIDCSuite) Test_ResolveUser_UserRoleExpression() {
261-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
262-
assert.NoError(s.T(), err)
263-
s.a.RoleExpression = role
274+
func (s *OIDCSuite) Test_ResolveUser_UserRBACResolver() {
275+
s.a.GroupsClaim = "roles"
276+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
264277

265278
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
266279
user, status, err := s.a.resolveUser(testIssuer, info)
@@ -272,10 +285,9 @@ func (s *OIDCSuite) Test_ResolveUser_UserRoleExpression() {
272285
assert.NotNil(s.T(), user.OIDCID)
273286
}
274287

275-
func (s *OIDCSuite) Test_ResolveUser_ForbiddenRoleExpression() {
276-
role, err := jmespath.Compile("contains(roles[*], 'gotify') && 'User' || 'None'")
277-
assert.NoError(s.T(), err)
278-
s.a.RoleExpression = role
288+
func (s *OIDCSuite) Test_ResolveUser_ForbiddenRBACResolver() {
289+
s.a.GroupsClaim = "roles"
290+
s.a.RBACResolver = NewSimpleUserRBACResolver("users")
279291

280292
info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "test-user", "roles": []string{"tester"}}}
281293
_, status, err := s.a.resolveUser(testIssuer, info)
@@ -284,34 +296,31 @@ func (s *OIDCSuite) Test_ResolveUser_ForbiddenRoleExpression() {
284296
assert.Equal(s.T(), 403, status)
285297
}
286298

287-
func (s *OIDCSuite) Test_ResolveUser_InvalidRoleExpression() {
288-
role, err := jmespath.Compile("contains(roles[*], 'gotify') && 'User' || 'FooBar'")
289-
assert.NoError(s.T(), err)
290-
s.a.RoleExpression = role
299+
func (s *OIDCSuite) Test_ResolveUser_GroupsClaimMissing() {
300+
s.a.GroupsClaim = "roles"
301+
s.a.RBACResolver = NewSimpleUserRBACResolver("users")
291302

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

295-
assert.EqualError(s.T(), err, `role claim mapping error: invalid role "FooBar"`)
306+
assert.EqualError(s.T(), err, `role claim mapping error: groups claim "roles" is missing`)
296307
assert.Equal(s.T(), 500, status)
297308
}
298309

299-
func (s *OIDCSuite) Test_ResolveUser_MalformedRoleExpression() {
300-
role, err := jmespath.Compile("contains(roles[*], 'gotify')")
301-
assert.NoError(s.T(), err)
302-
s.a.RoleExpression = role
310+
func (s *OIDCSuite) Test_ResolveUser_MalformedRBACResolver() {
311+
s.a.GroupsClaim = "admin"
312+
s.a.RBACResolver = NewSimpleUserRBACResolver("users")
303313

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

307-
assert.EqualError(s.T(), err, `role claim mapping error: role claim expression expected string, got bool`)
317+
assert.EqualError(s.T(), err, `role claim mapping error: RBAC resolver expected strings, got bool`)
308318
assert.Equal(s.T(), 500, status)
309319
}
310320

311-
func (s *OIDCSuite) Test_ResolveUser_PromoteRoleExpression() {
312-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
313-
assert.NoError(s.T(), err)
314-
s.a.RoleExpression = role
321+
func (s *OIDCSuite) Test_ResolveUser_PromoteRBACResolver() {
322+
s.a.GroupsClaim = "roles"
323+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
315324

316325
oidcID := testIssuer + "#sub-1"
317326
s.db.CreateUser(&model.User{ID: 1, Name: "alice", OIDCID: &oidcID})
@@ -328,10 +337,9 @@ func (s *OIDCSuite) Test_ResolveUser_PromoteRoleExpression() {
328337
assert.False(s.T(), s.notified)
329338
}
330339

331-
func (s *OIDCSuite) Test_ResolveUser_DemoteRoleExpression() {
332-
role, err := jmespath.Compile("contains(roles[*], 'admin') && 'Admin' || 'User'")
333-
assert.NoError(s.T(), err)
334-
s.a.RoleExpression = role
340+
func (s *OIDCSuite) Test_ResolveUser_DemoteRBACResolver() {
341+
s.a.GroupsClaim = "roles"
342+
s.a.RBACResolver = NewSimpleAdminRBACResolver("admin")
335343

336344
oidcID := testIssuer + "#sub-1"
337345
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)