AuthN: Embed an OAuth2 server for external service authentication (#68086)

* Moving POC files from #64283 to a new branch

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Adding missing permission definition

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Force the service instantiation while client isn't merged

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Merge conf with main

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Leave go-sqlite3 version unchanged

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* tidy

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* User SearchUserPermissions instead of SearchUsersPermissions

* Replace DummyKeyService with signingkeys.Service

* Use user🆔<id> as subject

* Fix introspection endpoint issue

* Add X-Grafana-Org-Id to get_resources.bash script

* Regenerate toggles_gen.go
* Fix basic.go

* Add GetExternalService tests

* Add GetPublicKeyScopes tests

* Add GetScopesOnUser tests

* Add GetScopes tests

* Add ParsePublicKeyPem tests

* Add database test for GetByName

* re-add comments

* client tests added

* Add GetExternalServicePublicKey tests

* Add other test case to GetExternalServicePublicKey

* client_credentials grant test

* Add test to jwtbearer grant

* Test Comments

* Add handleKeyOptions tests

* Add RSA key generation test

* Add ECDSA by default to EmbeddedSigningKeysService

* Clean up org id scope and audiences

* Add audiences to the DB

* Fix check on Audience

* Fix double import

* Add AC Store mock and align oauthserver tests

* Fix test after rebase

* Adding missing store function to mock

* Fix double import

* Add CODEOWNER

* Fix some linting errors

* errors don't need type assertion

* Typo codeowners

* use mockery for oauthserver store

* Add feature toggle check

* Fix db tests to handle the feature flag

* Adding call to DeleteExternalServiceRole

* Fix flaky test

* Re-organize routes comments and plan futur work

* Add client_id check to Extended JWT client

* Clean up

* Fix

* Remove background service registry instantiation of the OAuth server

* Comment cleanup

* Remove unused client function

* Update go.mod to use the latest ory/fosite commit

* Remove oauth2_server related configs from defaults.ini

* Add audiences to DTO

* Fix flaky test

* Remove registration endpoint and demo scripts. Document code

* Rename packages

* Remove the OAuthService vs OAuthServer confusion

* fix incorrect import ext_jwt_test

* Comments and order

* Comment basic auth

* Remove unecessary todo

* Clean api

* Moving ParsePublicKeyPem to utils

* re ordering functions in service.go

* Fix comment

* comment on the redirect uri

* Add RBAC actions, not only scopes

* Fix tests

* re-import featuremgmt in migrations

* Fix wire

* Fix scopes in test

* Fix flaky test

* Remove todo, the intersection should always return the minimal set

* Remove unecessary check from intersection code

* Allow env overrides on settings

* remove the term app name

* Remove app keyword for client instead and use Name instead of ExternalServiceName

* LogID remove ExternalService ref

* Use Name instead of ExternalServiceName

* Imports order

* Inline

* Using ExternalService and ExternalServiceDTO

* Remove xorm tags

* comment

* Rename client files

* client -> external service

* comments

* Move test to correct package

* slimmer test

* cachedUser -> cachedExternalService

* Fix aggregate store test

* PluginAuthSession -> AuthSession

* Revert the nil cehcks

* Remove unecessary extra

* Removing custom session

* fix typo in test

* Use constants for tests

* Simplify HandleToken tests

* Refactor the HandleTokenRequest test

* test message

* Review test

* Prevent flacky test on client as well

* go imports

* Revert changes from 526e48ad45

* AuthN: Change the External Service registration form (#68649)

* AuthN: change the External Service registration form

* Gen default permissions

* Change demo script registration form

* Remove unecessary comment

* Nit.

* Reduce cyclomatic complexity

* Remove demo_scripts

* Handle case with no service account

* Comments

* Group key gen

* Nit.

* Check the SaveExternalService test

* Rename cachedUser to cachedClient in test

* One more test case to database test

* Comments

* Remove last org scope

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>

* Update pkg/services/oauthserver/utils/utils_test.go

* Update pkg/services/sqlstore/migrations/oauthserver/migrations.go

Remove comment

* Update pkg/setting/setting.go

Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>

---------

Co-authored-by: Mihály Gyöngyösi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
Gabriel MABILLE
2023-05-25 15:38:30 +02:00
committed by GitHub
co-authored by Mihály Gyöngyösi
parent 73681a251e
commit edf1775d49
38 changed files with 5190 additions and 113 deletions
+105
View File
@@ -196,6 +196,7 @@ func GroupScopesByAction(permissions []Permission) map[string][]string {
return m
}
// Reduce will reduce a list of permissions to its minimal form, grouping scopes by action
func Reduce(ps []Permission) map[string][]string {
reduced := make(map[string][]string)
scopesByAction := make(map[string]map[string]bool)
@@ -260,6 +261,110 @@ func Reduce(ps []Permission) map[string][]string {
return reduced
}
// intersectScopes computes the minimal list of scopes common to two slices.
func intersectScopes(s1, s2 []string) []string {
if len(s1) == 0 || len(s2) == 0 {
return []string{}
}
// helpers
splitScopes := func(s []string) (map[string]bool, map[string]bool) {
scopes := make(map[string]bool)
wildcards := make(map[string]bool)
for _, s := range s {
if isWildcard(s) {
wildcards[s] = true
} else {
scopes[s] = true
}
}
return scopes, wildcards
}
includes := func(wildcardsSet map[string]bool, scope string) bool {
for wildcard := range wildcardsSet {
if wildcard == "*" || strings.HasPrefix(scope, wildcard[:len(wildcard)-1]) {
return true
}
}
return false
}
res := make([]string, 0)
// split input into scopes and wildcards
s1Scopes, s1Wildcards := splitScopes(s1)
s2Scopes, s2Wildcards := splitScopes(s2)
// intersect wildcards
wildcards := make(map[string]bool)
for s := range s1Wildcards {
// if s1 wildcard is included in s2 wildcards
// then it is included in the intersection
if includes(s2Wildcards, s) {
wildcards[s] = true
continue
}
}
for s := range s2Wildcards {
// if s2 wildcard is included in s1 wildcards
// then it is included in the intersection
if includes(s1Wildcards, s) {
wildcards[s] = true
}
}
// intersect scopes
scopes := make(map[string]bool)
for s := range s1Scopes {
// if s1 scope is included in s2 wilcards or s2 scopes
// then it is included in the intersection
if includes(s2Wildcards, s) || s2Scopes[s] {
scopes[s] = true
}
}
for s := range s2Scopes {
// if s2 scope is included in s1 wilcards
// then it is included in the intersection
if includes(s1Wildcards, s) {
scopes[s] = true
}
}
// merge wildcards and scopes
for w := range wildcards {
res = append(res, w)
}
for s := range scopes {
res = append(res, s)
}
return res
}
// Intersect returns the intersection of two slices of permissions, grouping scopes by action.
func Intersect(p1, p2 []Permission) map[string][]string {
if len(p1) == 0 || len(p2) == 0 {
return map[string][]string{}
}
res := make(map[string][]string)
p1m := Reduce(p1)
p2m := Reduce(p2)
// Loop over the smallest map
if len(p1m) > len(p2m) {
p1m, p2m = p2m, p1m
}
for a1, s1 := range p1m {
if s2, ok := p2m[a1]; ok {
res[a1] = intersectScopes(s1, s2)
}
}
return res
}
func ValidateScope(scope string) bool {
prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
// verify that last char is either ':' or '/' if last character of scope is '*'
@@ -1,6 +1,7 @@
package accesscontrol
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
@@ -125,3 +126,210 @@ func TestReduce(t *testing.T) {
})
}
}
func TestIntersect(t *testing.T) {
tests := []struct {
name string
p1 []Permission
p2 []Permission
want map[string][]string
}{
{
name: "no permission",
p1: []Permission{},
p2: []Permission{},
want: map[string][]string{},
},
{
name: "no intersection",
p1: []Permission{{Action: "orgs:read"}},
p2: []Permission{{Action: "orgs:write"}},
want: map[string][]string{},
},
{
name: "intersection no scopes",
p1: []Permission{{Action: "orgs:read"}},
p2: []Permission{{Action: "orgs:read"}},
want: map[string][]string{"orgs:read": {}},
},
{
name: "unbalanced intersection",
p1: []Permission{{Action: "teams:read", Scope: "teams:id:1"}},
p2: []Permission{{Action: "teams:read"}},
want: map[string][]string{"teams:read": {}},
},
{
name: "intersection",
p1: []Permission{
{Action: "teams:read", Scope: "teams:id:1"},
{Action: "teams:read", Scope: "teams:id:2"},
{Action: "teams:write", Scope: "teams:id:1"},
},
p2: []Permission{
{Action: "teams:read", Scope: "teams:id:1"},
{Action: "teams:read", Scope: "teams:id:3"},
{Action: "teams:write", Scope: "teams:id:1"},
},
want: map[string][]string{
"teams:read": {"teams:id:1"},
"teams:write": {"teams:id:1"},
},
},
{
name: "intersection with wildcards",
p1: []Permission{
{Action: "teams:read", Scope: "teams:id:1"},
{Action: "teams:read", Scope: "teams:id:2"},
{Action: "teams:write", Scope: "teams:id:1"},
},
p2: []Permission{
{Action: "teams:read", Scope: "*"},
{Action: "teams:write", Scope: "*"},
},
want: map[string][]string{
"teams:read": {"teams:id:1", "teams:id:2"},
"teams:write": {"teams:id:1"},
},
},
{
name: "intersection with wildcards on both sides",
p1: []Permission{
{Action: "dashboards:read", Scope: "dashboards:uid:1"},
{Action: "dashboards:read", Scope: "folders:uid:1"},
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
{Action: "folders:read", Scope: "folders:uid:1"},
},
p2: []Permission{
{Action: "dashboards:read", Scope: "folders:uid:*"},
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
{Action: "folders:read", Scope: "folders:uid:*"},
},
want: map[string][]string{
"dashboards:read": {"dashboards:uid:*", "folders:uid:1"},
"folders:read": {"folders:uid:1"},
},
},
{
name: "intersection with wildcards of different sizes",
p1: []Permission{
{Action: "dashboards:read", Scope: "folders:uid:1"},
{Action: "dashboards:read", Scope: "dashboards:*"},
{Action: "folders:read", Scope: "folders:*"},
{Action: "teams:read", Scope: "teams:id:1"},
},
p2: []Permission{
{Action: "dashboards:read", Scope: "folders:uid:*"},
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
{Action: "folders:read", Scope: "folders:uid:*"},
{Action: "teams:read", Scope: "*"},
},
want: map[string][]string{
"dashboards:read": {"dashboards:uid:*", "folders:uid:1"},
"folders:read": {"folders:uid:*"},
"teams:read": {"teams:id:1"},
},
},
}
check := func(t *testing.T, want map[string][]string, p1, p2 []Permission) {
intersect := Intersect(p1, p2)
for action, scopes := range intersect {
want, ok := want[action]
require.True(t, ok)
require.ElementsMatch(t, scopes, want, fmt.Sprintf("scopes for %v differs from expected", action))
}
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Intersect is commutative
check(t, tt.want, tt.p1, tt.p2)
check(t, tt.want, tt.p2, tt.p1)
})
}
}
func Test_intersectScopes(t *testing.T) {
tests := []struct {
name string
s1 []string
s2 []string
want []string
}{
{
name: "no values",
s1: []string{},
s2: []string{},
want: []string{},
},
{
name: "no values on one side",
s1: []string{},
s2: []string{"teams:id:1"},
want: []string{},
},
{
name: "empty values on one side",
s1: []string{""},
s2: []string{"team:id:1"},
want: []string{},
},
{
name: "no intersection",
s1: []string{"teams:id:1"},
s2: []string{"teams:id:2"},
want: []string{},
},
{
name: "intersection",
s1: []string{"teams:id:1"},
s2: []string{"teams:id:1"},
want: []string{"teams:id:1"},
},
{
name: "intersection with wildcard",
s1: []string{"teams:id:1", "teams:id:2"},
s2: []string{"teams:id:*"},
want: []string{"teams:id:1", "teams:id:2"},
},
{
name: "intersection of wildcards",
s1: []string{"teams:id:*"},
s2: []string{"teams:id:*"},
want: []string{"teams:id:*"},
},
{
name: "intersection with a bigger wildcards",
s1: []string{"teams:id:*"},
s2: []string{"teams:*"},
want: []string{"teams:id:*"},
},
{
name: "intersection of different wildcards with a bigger one",
s1: []string{"dashboards:uid:*", "folders:uid:*"},
s2: []string{"*"},
want: []string{"dashboards:uid:*", "folders:uid:*"},
},
{
name: "intersection with wildcards and scopes on both sides",
s1: []string{"dashboards:uid:*", "folders:uid:1"},
s2: []string{"folders:uid:*", "dashboards:uid:1"},
want: []string{"dashboards:uid:1", "folders:uid:1"},
},
{
name: "intersection of non reduced list of scopes",
s1: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1"},
s2: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:2"},
want: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1", "dashboards:uid:2"},
},
}
check := func(t *testing.T, want []string, s1, s2 []string) {
intersect := intersectScopes(s1, s2)
require.ElementsMatch(t, want, intersect)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Intersect is commutative
check(t, tt.want, tt.s1, tt.s2)
check(t, tt.want, tt.s2, tt.s1)
})
}
}
@@ -58,6 +58,7 @@ func ProvideOSSService(cfg *setting.Cfg, store store, cache *localcache.CacheSer
return s
}
//go:generate mockery --name store --structname MockStore --outpkg actest --filename store_mock.go --output ../actest/
type store interface {
GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error)
SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error)
@@ -0,0 +1,151 @@
// Code generated by mockery v2.20.0. DO NOT EDIT.
package actest
import (
accesscontrol "github.com/grafana/grafana/pkg/services/accesscontrol"
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockStore is an autogenerated mock type for the store type
type MockStore struct {
mock.Mock
}
// DeleteExternalServiceRole provides a mock function with given fields: ctx, externalServiceID
func (_m *MockStore) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error {
ret := _m.Called(ctx, externalServiceID)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, externalServiceID)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteUserPermissions provides a mock function with given fields: ctx, orgID, userID
func (_m *MockStore) DeleteUserPermissions(ctx context.Context, orgID int64, userID int64) error {
ret := _m.Called(ctx, orgID, userID)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64, int64) error); ok {
r0 = rf(ctx, orgID, userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetUserPermissions provides a mock function with given fields: ctx, query
func (_m *MockStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) {
ret := _m.Called(ctx, query)
var r0 []accesscontrol.Permission
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error)); ok {
return rf(ctx, query)
}
if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.GetUserPermissionsQuery) []accesscontrol.Permission); ok {
r0 = rf(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]accesscontrol.Permission)
}
}
if rf, ok := ret.Get(1).(func(context.Context, accesscontrol.GetUserPermissionsQuery) error); ok {
r1 = rf(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetUsersBasicRoles provides a mock function with given fields: ctx, userFilter, orgID
func (_m *MockStore) GetUsersBasicRoles(ctx context.Context, userFilter []int64, orgID int64) (map[int64][]string, error) {
ret := _m.Called(ctx, userFilter, orgID)
var r0 map[int64][]string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, []int64, int64) (map[int64][]string, error)); ok {
return rf(ctx, userFilter, orgID)
}
if rf, ok := ret.Get(0).(func(context.Context, []int64, int64) map[int64][]string); ok {
r0 = rf(ctx, userFilter, orgID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int64][]string)
}
}
if rf, ok := ret.Get(1).(func(context.Context, []int64, int64) error); ok {
r1 = rf(ctx, userFilter, orgID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SaveExternalServiceRole provides a mock function with given fields: ctx, cmd
func (_m *MockStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error {
ret := _m.Called(ctx, cmd)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, accesscontrol.SaveExternalServiceRoleCommand) error); ok {
r0 = rf(ctx, cmd)
} else {
r0 = ret.Error(0)
}
return r0
}
// SearchUsersPermissions provides a mock function with given fields: ctx, orgID, options
func (_m *MockStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) {
ret := _m.Called(ctx, orgID, options)
var r0 map[int64][]accesscontrol.Permission
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error)); ok {
return rf(ctx, orgID, options)
}
if rf, ok := ret.Get(0).(func(context.Context, int64, accesscontrol.SearchOptions) map[int64][]accesscontrol.Permission); ok {
r0 = rf(ctx, orgID, options)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[int64][]accesscontrol.Permission)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64, accesscontrol.SearchOptions) error); ok {
r1 = rf(ctx, orgID, options)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type mockConstructorTestingTNewMockStore interface {
mock.TestingT
Cleanup(func())
}
// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockStore(t mockConstructorTestingTNewMockStore) *MockStore {
mock := &MockStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+6 -3
View File
@@ -316,8 +316,10 @@ const (
ActionAPIKeyDelete = "apikeys:delete"
// Users actions
ActionUsersRead = "users:read"
ActionUsersWrite = "users:write"
ActionUsersRead = "users:read"
ActionUsersWrite = "users:write"
ActionUsersImpersonate = "users:impersonate"
// We can ignore gosec G101 since this does not contain any credentials.
// nolint:gosec
ActionUsersAuthTokenList = "users.authtoken:read"
@@ -375,7 +377,8 @@ const (
ScopeAPIKeysAll = "apikeys:*"
// Users scope
ScopeUsersAll = "users:*"
ScopeUsersAll = "users:*"
ScopeUsersPrefix = "users:id:"
// Settings scope
ScopeSettingsAll = "settings:*"