AuthN: Remove embedded oauth server (#83146)

* AuthN: Remove embedded oauth server

* Restore main

* go mod tidy

* Fix problem

* Remove permission intersection

* Fix test and lint

* Fix TestData test

* Revert to origin/main

* Update go.mod

* Update go.mod

* Update go.sum
This commit is contained in:
Gabriel MABILLE 2024-02-26 11:29:09 +01:00 committed by GitHub
parent d0679f0993
commit 80d6bf6da0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 46 additions and 5631 deletions

View File

@ -505,32 +505,6 @@
} }
} }
} }
},
"impersonation": {
"type": "object",
"description": "Impersonation describes the permissions that the plugin will be restricted to when acting on behalf of the user.",
"properties": {
"groups": {
"type": "boolean",
"description": "Groups allows the service to list the impersonated user's teams."
},
"permissions": {
"type": "array",
"description": "Permissions are the permissions that the plugin needs when impersonating a user. The intersection of this set with the impersonated user's permission guarantees that the client will not gain more privileges than the impersonated user has.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"action": {
"type": "string"
},
"scope": {
"type": "string"
}
}
}
}
}
} }
} }
}, },

View File

@ -185,7 +185,6 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref
| Feature toggle name | Description | | Feature toggle name | Description |
| -------------------------------------- | -------------------------------------------------------------- | | -------------------------------------- | -------------------------------------------------------------- |
| `unifiedStorage` | SQL-based k8s storage | | `unifiedStorage` | SQL-based k8s storage |
| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services |
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server | | `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options | | `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
| `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service | | `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |

25
go.mod
View File

@ -121,7 +121,7 @@ require (
gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-squad-backend gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-squad-backend
xorm.io/builder v0.3.6 // indirect; @grafana/backend-platform xorm.io/builder v0.3.6 // @grafana/backend-platform
xorm.io/core v0.7.3 // @grafana/backend-platform xorm.io/core v0.7.3 // @grafana/backend-platform
xorm.io/xorm v0.8.2 // @grafana/alerting-squad-backend xorm.io/xorm v0.8.2 // @grafana/alerting-squad-backend
) )
@ -173,7 +173,7 @@ require (
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect; @grafana/alerting-squad github.com/hashicorp/go-multierror v1.1.1 // @grafana/alerting-squad
github.com/hashicorp/go-sockaddr v1.0.6 // indirect github.com/hashicorp/go-sockaddr v1.0.6 // indirect
github.com/hashicorp/golang-lru v0.6.0 // indirect github.com/hashicorp/golang-lru v0.6.0 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect
@ -263,12 +263,10 @@ require (
github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling
github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed // @grafana/grafana-as-code github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed // @grafana/grafana-as-code
github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 // @grafana/grafana-bi-squad github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 // @grafana/grafana-bi-squad
github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f // @grafana/grafana-authnz-team
github.com/redis/go-redis/v9 v9.0.2 // @grafana/alerting-squad-backend github.com/redis/go-redis/v9 v9.0.2 // @grafana/alerting-squad-backend
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code
go.opentelemetry.io/contrib/samplers/jaegerremote v0.16.0 // @grafana/backend-platform go.opentelemetry.io/contrib/samplers/jaegerremote v0.16.0 // @grafana/backend-platform
golang.org/x/mod v0.14.0 // @grafana/backend-platform golang.org/x/mod v0.14.0 // @grafana/backend-platform
gopkg.in/square/go-jose.v2 v2.6.0 // @grafana/grafana-authnz-team
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // @grafana/partner-datasources k8s.io/utils v0.0.0-20230726121419-3b25d923346b // @grafana/partner-datasources
) )
@ -315,11 +313,7 @@ require (
github.com/cockroachdb/redact v1.1.3 // indirect github.com/cockroachdb/redact v1.1.3 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
github.com/cristalhq/jwt/v4 v4.0.2 // indirect
github.com/dave/jennifer v1.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
github.com/docker/distribution v2.8.2+incompatible // indirect github.com/docker/distribution v2.8.2+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-connections v0.4.0 // indirect
github.com/drone-runners/drone-runner-docker v1.8.2 // indirect github.com/drone-runners/drone-runner-docker v1.8.2 // indirect
@ -327,7 +321,6 @@ require (
github.com/drone/envsubst v1.0.3 // indirect github.com/drone/envsubst v1.0.3 // indirect
github.com/drone/runner-go v1.12.0 // indirect github.com/drone/runner-go v1.12.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ecordell/optgen v0.0.6 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
@ -344,11 +337,8 @@ require (
github.com/google/s2a-go v0.1.7 // indirect github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-squad-backend github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-squad-backend
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/memberlist v0.5.0 // indirect github.com/hashicorp/memberlist v0.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/yaml v0.2.0 // indirect github.com/invopop/yaml v0.2.0 // indirect
@ -356,10 +346,8 @@ require (
github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/kr/pretty v0.3.1 // indirect github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-ieproxy v0.0.3 // indirect github.com/mattn/go-ieproxy v0.0.3 // indirect
github.com/mattn/goveralls v0.0.6 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
@ -368,12 +356,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235 // indirect github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235 // indirect
github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect
github.com/ory/go-acc v0.2.6 // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/viper v1.7.5 // indirect
github.com/ory/x v0.0.214 // indirect
github.com/pborman/uuid v1.2.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/redis/rueidis v1.0.16 // indirect github.com/redis/rueidis v1.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@ -382,12 +364,9 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/afero v1.9.2 // indirect
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad
github.com/stoewer/go-strcase v1.3.0 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect
github.com/unknwon/com v1.0.1 // indirect github.com/unknwon/com v1.0.1 // indirect
github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect

704
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -77,7 +77,6 @@ export interface FeatureToggles {
alertStateHistoryLokiOnly?: boolean; alertStateHistoryLokiOnly?: boolean;
unifiedRequestLog?: boolean; unifiedRequestLog?: boolean;
renderAuthJWT?: boolean; renderAuthJWT?: boolean;
externalServiceAuth?: boolean;
refactorVariablesTimeRange?: boolean; refactorVariablesTimeRange?: boolean;
enableElasticsearchBackendQuerying?: boolean; enableElasticsearchBackendQuerying?: boolean;
faroDatasourceSelector?: boolean; faroDatasourceSelector?: boolean;

View File

@ -1,37 +0,0 @@
{
"id": "grafana-test-datasource",
"type": "datasource",
"name": "Test",
"backend": true,
"executable": "gpx_test_datasource",
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"large": "img/ds.svg",
"small": "img/ds.svg"
},
"screenshots": [],
"updated": "2023-08-03",
"version": "1.0.0"
},
"iam": {
"impersonation": {
"groups" : true,
"permissions" : [
{
"action": "read",
"scope": "datasource"
}
]
},
"permissions" : [
{
"action": "read",
"scope": "datasource"
}
]
}
}

View File

@ -142,9 +142,6 @@ func TestParsePluginTestdata(t *testing.T) {
"external-registration": { "external-registration": {
rootid: "grafana-test-datasource", rootid: "grafana-test-datasource",
}, },
"oauth-external-registration": {
rootid: "grafana-test-datasource",
},
} }
staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata")) staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata"))

View File

@ -422,20 +422,6 @@ schemas: [{
#IAM: { #IAM: {
// Permissions are the permissions that the external service needs its associated service account to have. // Permissions are the permissions that the external service needs its associated service account to have.
permissions?: [...#Permission] permissions?: [...#Permission]
// Impersonation describes the permissions that the external service will have on behalf of the user
// This is only available with the OAuth2 Server
impersonation?: #Impersonation
}
#Impersonation: {
// Groups allows the service to list the impersonated user's teams.
// Defaults to true.
groups?: bool
// Permissions are the permissions that the external service needs when impersonating a user.
// The intersection of this set with the impersonated user's permission guarantees that the client will not
// gain more privileges than the impersonated user has.
permissions?: [...#Permission]
} }
} }
}] }]

View File

@ -132,24 +132,10 @@ type Header struct {
// IAM allows the plugin to get a service account with tailored permissions and a token // IAM allows the plugin to get a service account with tailored permissions and a token
// (or to use the client_credentials grant if the token provider is the OAuth2 Server) // (or to use the client_credentials grant if the token provider is the OAuth2 Server)
type IAM struct { type IAM struct {
Impersonation *Impersonation `json:"impersonation,omitempty"`
// Permissions are the permissions that the external service needs its associated service account to have. // Permissions are the permissions that the external service needs its associated service account to have.
Permissions []Permission `json:"permissions,omitempty"` Permissions []Permission `json:"permissions,omitempty"`
} }
// Impersonation defines model for Impersonation.
type Impersonation struct {
// Groups allows the service to list the impersonated user's teams.
// Defaults to true.
Groups *bool `json:"groups,omitempty"`
// Permissions are the permissions that the external service needs when impersonating a user.
// The intersection of this set with the impersonated user's permission guarantees that the client will not
// gain more privileges than the impersonated user has.
Permissions []Permission `json:"permissions,omitempty"`
}
// A resource to be included in a plugin. // A resource to be included in a plugin.
type Include struct { type Include struct {
// RBAC action the user must have to access the route // RBAC action the user must have to access the route

View File

@ -67,8 +67,6 @@ import (
"github.com/grafana/grafana/pkg/services/encryption" "github.com/grafana/grafana/pkg/services/encryption"
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service" encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
"github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oasimpl"
extsvcreg "github.com/grafana/grafana/pkg/services/extsvcauth/registry" extsvcreg "github.com/grafana/grafana/pkg/services/extsvcauth/registry"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder"
@ -372,8 +370,6 @@ var wireBasicSet = wire.NewSet(
supportbundlesimpl.ProvideService, supportbundlesimpl.ProvideService,
extsvcaccounts.ProvideExtSvcAccountsService, extsvcaccounts.ProvideExtSvcAccountsService,
wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)),
oasimpl.ProvideService,
wire.Bind(new(oauthserver.OAuth2Server), new(*oasimpl.OAuth2ServiceImpl)),
extsvcreg.ProvideExtSvcRegistry, extsvcreg.ProvideExtSvcRegistry,
wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*extsvcreg.Registry)), wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*extsvcreg.Registry)),
anonstore.ProvideAnonDBStore, anonstore.ProvideAnonDBStore,

View File

@ -292,110 +292,6 @@ func Reduce(ps []Permission) map[string][]string {
return reduced 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 { func ValidateScope(scope string) bool {
prefix, last := scope[:len(scope)-1], scope[len(scope)-1] prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
// verify that last char is either ':' or '/' if last character of scope is '*' // verify that last char is either ':' or '/' if last character of scope is '*'

View File

@ -1,7 +1,6 @@
package accesscontrol package accesscontrol
import ( import (
"fmt"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -126,210 +125,3 @@ 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)
})
}
}

View File

@ -427,7 +427,7 @@ func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchO
} }
func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error {
if !(s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) || s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts)) { if !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.") s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.")
return nil return nil
} }
@ -440,7 +440,7 @@ func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol
} }
func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error { func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error {
if !(s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) || s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts)) { if !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.") s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.")
return nil return nil
} }

View File

@ -869,7 +869,7 @@ func TestService_SaveExternalServiceRole(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ac := setupTestEnv(t) ac := setupTestEnv(t)
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts) ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
for _, r := range tt.runs { for _, r := range tt.runs {
err := ac.SaveExternalServiceRole(ctx, r.cmd) err := ac.SaveExternalServiceRole(ctx, r.cmd)
if r.wantErr { if r.wantErr {
@ -915,7 +915,7 @@ func TestService_DeleteExternalServiceRole(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ac := setupTestEnv(t) ac := setupTestEnv(t)
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts) ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
if tt.initCmd != nil { if tt.initCmd != nil {
err := ac.SaveExternalServiceRole(ctx, *tt.initCmd) err := ac.SaveExternalServiceRole(ctx, *tt.initCmd)

View File

@ -343,7 +343,6 @@ const (
// Users actions // Users actions
ActionUsersRead = "users:read" ActionUsersRead = "users:read"
ActionUsersWrite = "users:write" ActionUsersWrite = "users:write"
ActionUsersImpersonate = "users:impersonate"
// We can ignore gosec G101 since this does not contain any credentials. // We can ignore gosec G101 since this does not contain any credentials.
// nolint:gosec // nolint:gosec

View File

@ -24,7 +24,6 @@ import (
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authnimpl/sync" "github.com/grafana/grafana/pkg/services/authn/authnimpl/sync"
"github.com/grafana/grafana/pkg/services/authn/clients" "github.com/grafana/grafana/pkg/services/authn/clients"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ldap/service" "github.com/grafana/grafana/pkg/services/ldap/service"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
@ -72,7 +71,7 @@ func ProvideService(
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService, features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
socialService social.Service, cache *remotecache.RemoteCache, socialService social.Service, cache *remotecache.RemoteCache,
ldapService service.LDAP, registerer prometheus.Registerer, ldapService service.LDAP, registerer prometheus.Registerer,
signingKeysService signingkeys.Service, oauthServer oauthserver.OAuth2Server, signingKeysService signingkeys.Service,
settingsProviderService setting.Provider, settingsProviderService setting.Provider,
) *Service { ) *Service {
s := &Service{ s := &Service{
@ -136,9 +135,10 @@ func ProvideService(
s.RegisterClient(clients.ProvideJWT(jwtService, cfg)) s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
} }
if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { // FIXME (gamab): Commenting that out for now as we want to re-use the client for external service auth
s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer)) // if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
} // s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer))
// }
for name := range socialService.GetOAuthProviders() { for name := range socialService.GetOAuthProviders() {
clientName := authn.ClientWithPrefix(name) clientName := authn.ClientWithPrefix(name)

View File

@ -2,7 +2,6 @@ package clients
import ( import (
"context" "context"
"strings"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/util/errutil" "github.com/grafana/grafana/pkg/util/errutil"
@ -43,10 +42,6 @@ func (c *Basic) Test(ctx context.Context, r *authn.Request) bool {
if r.HTTPRequest == nil { if r.HTTPRequest == nil {
return false return false
} }
// The OAuth2 introspection endpoint uses basic auth but is handled by the oauthserver package.
if strings.EqualFold(r.HTTPRequest.RequestURI, "/oauth2/introspect") {
return false
}
return looksLikeBasicAuthRequest(r) return looksLikeBasicAuthRequest(r)
} }

View File

@ -85,12 +85,6 @@ func TestBasic_Test(t *testing.T) {
HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {"something"}}}, HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {"something"}}},
}, },
}, },
{
desc: "should fail when the URL ends with /oauth2/introspect",
req: &authn.Request{
HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}, RequestURI: "/oauth2/introspect"},
},
},
} }
for _, tt := range tests { for _, tt := range tests {

View File

@ -14,7 +14,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
@ -33,13 +32,12 @@ const (
rfc9068MediaType = "application/at+jwt" rfc9068MediaType = "application/at+jwt"
) )
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service, oauthServer oauthserver.OAuth2Server) *ExtendedJWT { func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service) *ExtendedJWT {
return &ExtendedJWT{ return &ExtendedJWT{
cfg: cfg, cfg: cfg,
log: log.New(authn.ClientExtendedJWT), log: log.New(authn.ClientExtendedJWT),
userService: userService, userService: userService,
signingKeys: signingKeys, signingKeys: signingKeys,
oauthServer: oauthServer,
} }
} }
@ -48,7 +46,6 @@ type ExtendedJWT struct {
log log.Logger log log.Logger
userService user.Service userService user.Service
signingKeys signingkeys.Service signingKeys signingkeys.Service
oauthServer oauthserver.OAuth2Server
} }
type ExtendedJWTClaims struct { type ExtendedJWTClaims struct {
@ -222,10 +219,6 @@ func (s *ExtendedJWT) validateClientIdClaim(ctx context.Context, claims Extended
return fmt.Errorf("missing 'client_id' claim") return fmt.Errorf("missing 'client_id' claim")
} }
if _, err := s.oauthServer.GetExternalService(ctx, claims.ClientID); err != nil {
return fmt.Errorf("invalid 'client_id' claim: %s", claims.ClientID)
}
return nil return nil
} }

View File

@ -17,8 +17,6 @@ import (
"github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/models/roletype"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oastest"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/signingkeys" "github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest" "github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest"
@ -268,27 +266,6 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
want: nil, want: nil,
wantErr: true, wantErr: true,
}, },
{
name: "should return error when the client was not found",
payload: ExtendedJWTClaims{
Claims: jwt.Claims{
Issuer: "http://localhost:3000",
Subject: "user:id:2",
Audience: jwt.Audience{"http://localhost:3000"},
ID: "1234567890",
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
},
ClientID: "unknown-client-id",
Scopes: []string{"profile", "groups"},
},
initTestEnv: func(env *testEnv) {
env.oauthSvc.ExpectedErr = oauthserver.ErrClientNotFoundFn("unknown-client-id")
},
orgID: 1,
want: nil,
wantErr: true,
},
} }
for _, tc := range testCases { for _, tc := range testCases {
@ -521,19 +498,16 @@ func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv {
} }
userSvc := &usertest.FakeUserService{} userSvc := &usertest.FakeUserService{}
oauthSvc := &oastest.FakeService{}
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc, oauthSvc) extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc)
return &testEnv{ return &testEnv{
oauthSvc: oauthSvc,
userSvc: userSvc, userSvc: userSvc,
s: extJwtClient, s: extJwtClient,
} }
} }
type testEnv struct { type testEnv struct {
oauthSvc *oastest.FakeService
userSvc *usertest.FakeUserService userSvc *usertest.FakeUserService
s *ExtendedJWT s *ExtendedJWT
} }

View File

@ -7,7 +7,6 @@ import (
) )
const ( const (
OAuth2Server AuthProvider = "OAuth2Server"
ServiceAccounts AuthProvider = "ServiceAccounts" ServiceAccounts AuthProvider = "ServiceAccounts"
// TmpOrgID is the orgID we use while global service accounts are not supported. // TmpOrgID is the orgID we use while global service accounts are not supported.
@ -40,23 +39,9 @@ type SelfCfg struct {
Permissions []accesscontrol.Permission Permissions []accesscontrol.Permission
} }
type ImpersonationCfg struct {
// Enabled allows the service to request access tokens to impersonate users
Enabled bool
// Groups allows the service to list the impersonated user's teams
Groups bool
// Permissions are the permissions that the external service needs when impersonating a user.
// The intersection of this set with the impersonated user's permission guarantees that the client will not
// gain more privileges than the impersonated user has and vice versa.
Permissions []accesscontrol.Permission
}
// ExternalServiceRegistration represents the registration form to save new client. // ExternalServiceRegistration represents the registration form to save new client.
type ExternalServiceRegistration struct { type ExternalServiceRegistration struct {
Name string Name string
// Impersonation access configuration
// (this is not available on all auth providers)
Impersonation ImpersonationCfg
// Self access configuration // Self access configuration
Self SelfCfg Self SelfCfg
// Auth Provider that the client will use to connect to Grafana // Auth Provider that the client will use to connect to Grafana

View File

@ -1,37 +0,0 @@
package api
import (
"github.com/grafana/grafana/pkg/api/routing"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
)
type api struct {
router routing.RouteRegister
oauthServer oauthserver.OAuth2Server
}
func NewAPI(
router routing.RouteRegister,
oauthServer oauthserver.OAuth2Server,
) *api {
return &api{
router: router,
oauthServer: oauthServer,
}
}
func (a *api) RegisterAPIEndpoints() {
a.router.Group("/oauth2", func(oauthRouter routing.RouteRegister) {
oauthRouter.Post("/introspect", a.handleIntrospectionRequest)
oauthRouter.Post("/token", a.handleTokenRequest)
})
}
func (a *api) handleTokenRequest(c *contextmodel.ReqContext) {
a.oauthServer.HandleTokenRequest(c.Resp, c.Req)
}
func (a *api) handleIntrospectionRequest(c *contextmodel.ReqContext) {
a.oauthServer.HandleIntrospectionRequest(c.Resp, c.Req)
}

View File

@ -1,25 +0,0 @@
package oauthserver
import (
"github.com/grafana/grafana/pkg/util/errutil"
)
var (
ErrClientNotFoundMessageID = "oauthserver.client-not-found"
)
var (
ErrClientRequiredID = errutil.BadRequest(
"oauthserver.required-client-id",
errutil.WithPublicMessage("client ID is required")).Errorf("Client ID is required")
ErrClientRequiredName = errutil.BadRequest(
"oauthserver.required-client-name",
errutil.WithPublicMessage("client name is required")).Errorf("Client name is required")
ErrClientNotFound = errutil.NotFound(
ErrClientNotFoundMessageID,
errutil.WithPublicMessage("Requested client has not been found"))
)
func ErrClientNotFoundFn(clientID string) error {
return ErrClientNotFound.Errorf("client '%s' not found", clientID)
}

View File

@ -1,153 +0,0 @@
package oauthserver
import (
"context"
"strconv"
"strings"
"github.com/ory/fosite"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/user"
)
type OAuthExternalService struct {
ID int64 `xorm:"id pk autoincr"`
Name string `xorm:"name"`
ClientID string `xorm:"client_id"`
Secret string `xorm:"secret"`
RedirectURI string `xorm:"redirect_uri"` // Not used yet (code flow)
GrantTypes string `xorm:"grant_types"` // CSV value
Audiences string `xorm:"audiences"` // CSV value
PublicPem []byte `xorm:"public_pem"`
ServiceAccountID int64 `xorm:"service_account_id"`
// SelfPermissions are the registered service account permissions (registered and managed permissions)
SelfPermissions []ac.Permission
// ImpersonatePermissions is the restriction set of permissions while impersonating
ImpersonatePermissions []ac.Permission
// SignedInUser refers to the current Service Account identity/user
SignedInUser *user.SignedInUser
Scopes []string
ImpersonateScopes []string
}
// ToExternalService converts the ExternalService (used internally by the oauthserver) to extsvcauth.ExternalService (used outside the package)
// If object must contain Key pairs, pass them as parameters, otherwise only the client PublicPem will be added.
func (c *OAuthExternalService) ToExternalService(keys *extsvcauth.KeyResult) *extsvcauth.ExternalService {
c2 := &extsvcauth.ExternalService{
ID: c.ClientID,
Name: c.Name,
Secret: c.Secret,
OAuthExtra: &extsvcauth.OAuthExtra{
GrantTypes: c.GrantTypes,
Audiences: c.Audiences,
RedirectURI: c.RedirectURI,
KeyResult: keys,
},
}
// Fallback to only display the public pem
if keys == nil && len(c.PublicPem) > 0 {
c2.OAuthExtra.KeyResult = &extsvcauth.KeyResult{PublicPem: string(c.PublicPem)}
}
return c2
}
func (c *OAuthExternalService) LogID() string {
return "{name: " + c.Name + ", clientID: " + c.ClientID + "}"
}
// GetID returns the client ID.
func (c *OAuthExternalService) GetID() string { return c.ClientID }
// GetHashedSecret returns the hashed secret as it is stored in the store.
func (c *OAuthExternalService) GetHashedSecret() []byte {
// Hashed version is stored in the secret field
return []byte(c.Secret)
}
// GetRedirectURIs returns the client's allowed redirect URIs.
func (c *OAuthExternalService) GetRedirectURIs() []string {
return []string{c.RedirectURI}
}
// GetGrantTypes returns the client's allowed grant types.
func (c *OAuthExternalService) GetGrantTypes() fosite.Arguments {
return strings.Split(c.GrantTypes, ",")
}
// GetResponseTypes returns the client's allowed response types.
// All allowed combinations of response types have to be listed, each combination having
// response types of the combination separated by a space.
func (c *OAuthExternalService) GetResponseTypes() fosite.Arguments {
return fosite.Arguments{"code"}
}
// GetScopes returns the scopes this client is allowed to request on its own behalf.
func (c *OAuthExternalService) GetScopes() fosite.Arguments {
if c.Scopes != nil {
return c.Scopes
}
ret := []string{"profile", "email", "groups", "entitlements"}
if c.SignedInUser != nil && c.SignedInUser.Permissions != nil {
perms := c.SignedInUser.Permissions[TmpOrgID]
for action := range perms {
// Add all actions that the plugin is allowed to request
ret = append(ret, action)
}
}
c.Scopes = ret
return ret
}
// GetScopes returns the scopes this client is allowed to request on a specific user.
func (c *OAuthExternalService) GetScopesOnUser(ctx context.Context, accessControl ac.AccessControl, userID int64) []string {
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10)))
hasAccess, errAccess := accessControl.Evaluate(ctx, c.SignedInUser, ev)
if errAccess != nil || !hasAccess {
return nil
}
if c.ImpersonateScopes != nil {
return c.ImpersonateScopes
}
ret := []string{}
if c.ImpersonatePermissions != nil {
perms := c.ImpersonatePermissions
for i := range perms {
if perms[i].Action == ac.ActionUsersRead && perms[i].Scope == ScopeGlobalUsersSelf {
ret = append(ret, "profile", "email", ac.ActionUsersRead)
continue
}
if perms[i].Action == ac.ActionUsersPermissionsRead && perms[i].Scope == ScopeUsersSelf {
ret = append(ret, "entitlements", ac.ActionUsersPermissionsRead)
continue
}
if perms[i].Action == ac.ActionTeamsRead && perms[i].Scope == ScopeTeamsSelf {
ret = append(ret, "groups", ac.ActionTeamsRead)
continue
}
// Add all actions that the plugin is allowed to request
ret = append(ret, perms[i].Action)
}
}
c.ImpersonateScopes = ret
return ret
}
// IsPublic returns true, if this client is marked as public.
func (c *OAuthExternalService) IsPublic() bool {
return false
}
// GetAudience returns the allowed audience(s) for this client.
func (c *OAuthExternalService) GetAudience() fosite.Arguments {
return strings.Split(c.Audiences, ",")
}

View File

@ -1,213 +0,0 @@
package oauthserver
import (
"context"
"testing"
"github.com/stretchr/testify/require"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
func setupTestEnv(t *testing.T) *OAuthExternalService {
t.Helper()
client := &OAuthExternalService{
Name: "my-ext-service",
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer",
ServiceAccountID: 2,
SelfPermissions: []ac.Permission{
{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll},
},
SignedInUser: &user.SignedInUser{
UserID: 2,
OrgID: 1,
},
}
return client
}
func TestExternalService_GetScopesOnUser(t *testing.T) {
testCases := []struct {
name string
impersonatePermissions []ac.Permission
initTestEnv func(*OAuthExternalService)
expectedScopes []string
}{
{
name: "should return nil when the service account has no impersonate permissions",
expectedScopes: nil,
},
{
name: "should return the 'profile', 'email' and associated RBAC action",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf},
}
},
expectedScopes: []string{"profile", "email", ac.ActionUsersRead},
},
{
name: "should return 'entitlements' and associated RBAC action scopes",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf},
}
},
expectedScopes: []string{"entitlements", ac.ActionUsersPermissionsRead},
},
{
name: "should return 'groups' and associated RBAC action scopes",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf},
}
},
expectedScopes: []string{"groups", ac.ActionTeamsRead},
},
{
name: "should return all scopes",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersRead, Scope: ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: ScopeUsersSelf},
{Action: ac.ActionTeamsRead, Scope: ScopeTeamsSelf},
{Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeDashboardsAll},
}
},
expectedScopes: []string{"profile", "email", ac.ActionUsersRead,
"entitlements", ac.ActionUsersPermissionsRead,
"groups", ac.ActionTeamsRead,
"dashboards:read"},
},
{
name: "should return stored scopes when the client's impersonate scopes has already been set",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
ac.ActionUsersImpersonate: {ac.ScopeUsersAll},
},
}
c.ImpersonateScopes = []string{"dashboard:create", "profile", "email", "entitlements", "groups"}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboard:create"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := setupTestEnv(t)
if tc.initTestEnv != nil {
tc.initTestEnv(c)
}
scopes := c.GetScopesOnUser(context.Background(), acimpl.ProvideAccessControl(setting.NewCfg()), 3)
require.ElementsMatch(t, tc.expectedScopes, scopes)
})
}
}
func TestExternalService_GetScopes(t *testing.T) {
testCases := []struct {
name string
impersonatePermissions []ac.Permission
initTestEnv func(*OAuthExternalService)
expectedScopes []string
}{
{
name: "should return default scopes when the signed in user is nil",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser = nil
},
expectedScopes: []string{"profile", "email", "entitlements", "groups"},
},
{
name: "should return default scopes when the signed in user has no permissions",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups"},
},
{
name: "should return additional scopes from signed in user's permissions",
initTestEnv: func(c *OAuthExternalService) {
c.SignedInUser.Permissions = map[int64]map[string][]string{
1: {
dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll},
},
}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups", "dashboards:read"},
},
{
name: "should return stored scopes when the client's scopes has already been set",
initTestEnv: func(c *OAuthExternalService) {
c.Scopes = []string{"profile", "email", "entitlements", "groups"}
},
expectedScopes: []string{"profile", "email", "entitlements", "groups"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := setupTestEnv(t)
if tc.initTestEnv != nil {
tc.initTestEnv(c)
}
scopes := c.GetScopes()
require.ElementsMatch(t, tc.expectedScopes, scopes)
})
}
}
func TestExternalService_ToDTO(t *testing.T) {
client := &OAuthExternalService{
ID: 1,
Name: "my-ext-service",
ClientID: "test",
Secret: "testsecret",
RedirectURI: "http://localhost:3000",
GrantTypes: "client_credentials,urn:ietf:params:oauth:grant-type:jwt-bearer",
Audiences: "https://example.org,https://second.example.org",
PublicPem: []byte("pem_encoded_public_key"),
}
dto := client.ToExternalService(nil)
require.Equal(t, client.ClientID, dto.ID)
require.Equal(t, client.Name, dto.Name)
require.Equal(t, client.Secret, dto.Secret)
require.NotNil(t, dto.OAuthExtra)
require.Equal(t, client.RedirectURI, dto.OAuthExtra.RedirectURI)
require.Equal(t, client.GrantTypes, dto.OAuthExtra.GrantTypes)
require.Equal(t, client.Audiences, dto.OAuthExtra.Audiences)
require.Equal(t, client.PublicPem, []byte(dto.OAuthExtra.KeyResult.PublicPem))
require.Empty(t, dto.OAuthExtra.KeyResult.PrivatePem)
require.Empty(t, dto.OAuthExtra.KeyResult.URL)
require.False(t, dto.OAuthExtra.KeyResult.Generated)
}

View File

@ -1,58 +0,0 @@
package oauthserver
import (
"context"
"net/http"
"github.com/grafana/grafana/pkg/services/extsvcauth"
"gopkg.in/square/go-jose.v2"
)
const (
// TmpOrgID is the orgID we use while global service accounts are not supported.
TmpOrgID int64 = 1
// NoServiceAccountID is the ID we use for client that have no service account associated.
NoServiceAccountID int64 = 0
// List of scopes used to identify the impersonated user.
ScopeUsersSelf = "users:self"
ScopeGlobalUsersSelf = "global.users:self"
ScopeTeamsSelf = "teams:self"
// Supported encryptions
RS256 = "RS256"
ES256 = "ES256"
)
// OAuth2Server represents a service in charge of managing OAuth2 clients
// and handling OAuth2 requests (token, introspection).
type OAuth2Server interface {
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
// it ensures that the associated service account has the correct permissions.
SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error)
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
// SignedInUser from the associated service account.
GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error)
// RemoveExternalService removes an external service and its associated resources from the store.
RemoveExternalService(ctx context.Context, name string) error
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
// grant (ex: client_credentials, jwtbearer).
HandleTokenRequest(rw http.ResponseWriter, req *http.Request)
// HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and
// to determine meta-information about this token.
HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request)
}
//go:generate mockery --name Store --structname MockStore --outpkg oastest --filename store_mock.go --output ./oastest/
type Store interface {
DeleteExternalService(ctx context.Context, id string) error
GetExternalService(ctx context.Context, id string) (*OAuthExternalService, error)
GetExternalServiceNames(ctx context.Context) ([]string, error)
GetExternalServiceByName(ctx context.Context, name string) (*OAuthExternalService, error)
GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error)
RegisterExternalService(ctx context.Context, client *OAuthExternalService) error
SaveExternalService(ctx context.Context, client *OAuthExternalService) error
UpdateExternalServiceGrantTypes(ctx context.Context, clientID, grantTypes string) error
}

View File

@ -1,162 +0,0 @@
package oasimpl
import (
"context"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/handler/rfc7523"
"gopkg.in/square/go-jose.v2"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils"
)
var _ fosite.ClientManager = &OAuth2ServiceImpl{}
var _ oauth2.AuthorizeCodeStorage = &OAuth2ServiceImpl{}
var _ oauth2.AccessTokenStorage = &OAuth2ServiceImpl{}
var _ oauth2.RefreshTokenStorage = &OAuth2ServiceImpl{}
var _ rfc7523.RFC7523KeyStorage = &OAuth2ServiceImpl{}
var _ oauth2.TokenRevocationStorage = &OAuth2ServiceImpl{}
// GetClient loads the client by its ID or returns an error
// if the client does not exist or another error occurred.
func (s *OAuth2ServiceImpl) GetClient(ctx context.Context, id string) (fosite.Client, error) {
return s.GetExternalService(ctx, id)
}
// ClientAssertionJWTValid returns an error if the JTI is
// known or the DB check failed and nil if the JTI is not known.
func (s *OAuth2ServiceImpl) ClientAssertionJWTValid(ctx context.Context, jti string) error {
return s.memstore.ClientAssertionJWTValid(ctx, jti)
}
// SetClientAssertionJWT marks a JTI as known for the given
// expiry time. Before inserting the new JTI, it will clean
// up any existing JTIs that have expired as those tokens can
// not be replayed due to the expiry.
func (s *OAuth2ServiceImpl) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
return s.memstore.SetClientAssertionJWT(ctx, jti, exp)
}
// GetAuthorizeCodeSession stores the authorization request for a given authorization code.
func (s *OAuth2ServiceImpl) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) (err error) {
return s.memstore.CreateAuthorizeCodeSession(ctx, code, request)
}
// GetAuthorizeCodeSession hydrates the session based on the given code and returns the authorization request.
// If the authorization code has been invalidated with `InvalidateAuthorizeCodeSession`, this
// method should return the ErrInvalidatedAuthorizeCode error.
//
// Make sure to also return the fosite.Requester value when returning the fosite.ErrInvalidatedAuthorizeCode error!
func (s *OAuth2ServiceImpl) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (request fosite.Requester, err error) {
return s.memstore.GetAuthorizeCodeSession(ctx, code, session)
}
// InvalidateAuthorizeCodeSession is called when an authorize code is being used. The state of the authorization
// code should be set to invalid and consecutive requests to GetAuthorizeCodeSession should return the
// ErrInvalidatedAuthorizeCode error.
func (s *OAuth2ServiceImpl) InvalidateAuthorizeCodeSession(ctx context.Context, code string) (err error) {
return s.memstore.InvalidateAuthorizeCodeSession(ctx, code)
}
func (s *OAuth2ServiceImpl) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
return s.memstore.CreateAccessTokenSession(ctx, signature, request)
}
func (s *OAuth2ServiceImpl) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return s.memstore.GetAccessTokenSession(ctx, signature, session)
}
func (s *OAuth2ServiceImpl) DeleteAccessTokenSession(ctx context.Context, signature string) (err error) {
return s.memstore.DeleteAccessTokenSession(ctx, signature)
}
func (s *OAuth2ServiceImpl) CreateRefreshTokenSession(ctx context.Context, signature string, request fosite.Requester) (err error) {
return s.memstore.CreateRefreshTokenSession(ctx, signature, request)
}
func (s *OAuth2ServiceImpl) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (request fosite.Requester, err error) {
return s.memstore.GetRefreshTokenSession(ctx, signature, session)
}
func (s *OAuth2ServiceImpl) DeleteRefreshTokenSession(ctx context.Context, signature string) (err error) {
return s.memstore.DeleteRefreshTokenSession(ctx, signature)
}
// RevokeRefreshToken revokes a refresh token as specified in:
// https://tools.ietf.org/html/rfc7009#section-2.1
// If the particular
// token is a refresh token and the authorization server supports the
// revocation of access tokens, then the authorization server SHOULD
// also invalidate all access tokens based on the same authorization
// grant (see Implementation Note).
func (s *OAuth2ServiceImpl) RevokeRefreshToken(ctx context.Context, requestID string) error {
return s.memstore.RevokeRefreshToken(ctx, requestID)
}
// RevokeRefreshTokenMaybeGracePeriod revokes a refresh token as specified in:
// https://tools.ietf.org/html/rfc7009#section-2.1
// If the particular
// token is a refresh token and the authorization server supports the
// revocation of access tokens, then the authorization server SHOULD
// also invalidate all access tokens based on the same authorization
// grant (see Implementation Note).
//
// If the Refresh Token grace period is greater than zero in configuration the token
// will have its expiration time set as UTCNow + GracePeriod.
func (s *OAuth2ServiceImpl) RevokeRefreshTokenMaybeGracePeriod(ctx context.Context, requestID string, signature string) error {
return s.memstore.RevokeRefreshTokenMaybeGracePeriod(ctx, requestID, signature)
}
// RevokeAccessToken revokes an access token as specified in:
// https://tools.ietf.org/html/rfc7009#section-2.1
// If the token passed to the request
// is an access token, the server MAY revoke the respective refresh
// token as well.
func (s *OAuth2ServiceImpl) RevokeAccessToken(ctx context.Context, requestID string) error {
return s.memstore.RevokeAccessToken(ctx, requestID)
}
// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check
// signature of jwt assertion in authorization grants.
func (s *OAuth2ServiceImpl) GetPublicKey(ctx context.Context, issuer string, subject string, kid string) (*jose.JSONWebKey, error) {
return s.sqlstore.GetExternalServicePublicKey(ctx, issuer)
}
// GetPublicKeys returns public key, set issued by 'issuer', and assigned for subject.
func (s *OAuth2ServiceImpl) GetPublicKeys(ctx context.Context, issuer string, subject string) (*jose.JSONWebKeySet, error) {
jwk, err := s.sqlstore.GetExternalServicePublicKey(ctx, issuer)
if err != nil {
return nil, err
}
return &jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{*jwk},
}, nil
}
// GetPublicKeyScopes returns assigned scope for assertion, identified by public key, issued by 'issuer'.
func (s *OAuth2ServiceImpl) GetPublicKeyScopes(ctx context.Context, issuer string, subject string, kid string) ([]string, error) {
client, err := s.GetExternalService(ctx, issuer)
if err != nil {
return nil, err
}
userID, err := utils.ParseUserIDFromSubject(subject)
if err != nil {
return nil, err
}
return client.GetScopesOnUser(ctx, s.accessControl, userID), nil
}
// IsJWTUsed returns true, if JWT is not known yet or it can not be considered valid, because it must be already
// expired.
func (s *OAuth2ServiceImpl) IsJWTUsed(ctx context.Context, jti string) (bool, error) {
return s.memstore.IsJWTUsed(ctx, jti)
}
// MarkJWTUsedForTime marks JWT as used for a time passed in exp parameter. This helps ensure that JWTs are not
// replayed by maintaining the set of used "jti" values for the length of time for which the JWT would be
// considered valid based on the applicable "exp" instant. (https://tools.ietf.org/html/rfc7523#section-3)
func (s *OAuth2ServiceImpl) MarkJWTUsedForTime(ctx context.Context, jti string, exp time.Time) error {
return s.memstore.MarkJWTUsedForTime(ctx, jti, exp)
}

View File

@ -1,119 +0,0 @@
package oasimpl
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/user"
)
var cachedExternalService = func() *oauthserver.OAuthExternalService {
return &oauthserver.OAuthExternalService{
Name: "my-ext-service",
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: 1,
SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
SignedInUser: &user.SignedInUser{
UserID: 2,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {
"users:impersonate": {"users:*"},
},
},
},
}
}
func TestOAuth2ServiceImpl_GetPublicKeyScopes(t *testing.T) {
testCases := []struct {
name string
initTestEnv func(*TestEnv)
impersonatePermissions []ac.Permission
userID string
expectedScopes []string
wantErr bool
}{
{
name: "should error out when GetExternalService returns error",
initTestEnv: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn("my-ext-service"))
},
wantErr: true,
},
{
name: "should error out when the user id cannot be parsed",
initTestEnv: func(env *TestEnv) {
env.S.cache.Set("my-ext-service", *cachedExternalService(), time.Minute)
},
userID: "user:3",
wantErr: true,
},
{
name: "should return no scope when the external service is not allowed to impersonate the user",
initTestEnv: func(env *TestEnv) {
client := cachedExternalService()
client.SignedInUser.Permissions = map[int64]map[string][]string{}
env.S.cache.Set("my-ext-service", *client, time.Minute)
},
userID: "user:id:3",
expectedScopes: nil,
wantErr: false,
},
{
name: "should return no scope when the external service has an no impersonate permission",
initTestEnv: func(env *TestEnv) {
client := cachedExternalService()
client.ImpersonatePermissions = []ac.Permission{}
env.S.cache.Set("my-ext-service", *client, time.Minute)
},
userID: "user:id:3",
expectedScopes: []string{},
wantErr: false,
},
{
name: "should return the scopes when the external service has impersonate permissions",
initTestEnv: func(env *TestEnv) {
env.S.cache.Set("my-ext-service", *cachedExternalService(), time.Minute)
client := cachedExternalService()
client.ImpersonatePermissions = []ac.Permission{
{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll},
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf}}
env.S.cache.Set("my-ext-service", *client, time.Minute)
},
userID: "user:id:3",
expectedScopes: []string{"users:impersonate",
"profile", "email", ac.ActionUsersRead,
"entitlements", ac.ActionUsersPermissionsRead,
"groups", ac.ActionTeamsRead},
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
env := setupTestEnv(t)
if tc.initTestEnv != nil {
tc.initTestEnv(env)
}
scopes, err := env.S.GetPublicKeyScopes(context.Background(), "my-ext-service", tc.userID, "")
if tc.wantErr {
require.Error(t, err)
return
}
require.ElementsMatch(t, tc.expectedScopes, scopes)
})
}
}

View File

@ -1,21 +0,0 @@
package oasimpl
import (
"log"
"net/http"
)
// HandleIntrospectionRequest handles the OAuth2 query to determine the active state of an OAuth 2.0 token and
// to determine meta-information about this token
func (s *OAuth2ServiceImpl) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context()
currentOAuthSessionData := NewAuthSession()
ir, err := s.oauthProvider.NewIntrospectionRequest(ctx, req, currentOAuthSessionData)
if err != nil {
log.Printf("Error occurred in NewIntrospectionRequest: %+v", err)
s.oauthProvider.WriteIntrospectionError(ctx, rw, err)
return
}
s.oauthProvider.WriteIntrospectionResponse(ctx, rw, ir)
}

View File

@ -1,500 +0,0 @@
package oasimpl
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"time"
"github.com/go-jose/go-jose/v3"
"github.com/ory/fosite"
"github.com/ory/fosite/compose"
"github.com/ory/fosite/storage"
"github.com/ory/fosite/token/jwt"
"golang.org/x/crypto/bcrypt"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/slugify"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/api"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/store"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
)
const (
cacheExpirationTime = 5 * time.Minute
cacheCleanupInterval = 5 * time.Minute
)
type OAuth2ServiceImpl struct {
cache *localcache.CacheService
memstore *storage.MemoryStore
cfg *setting.Cfg
sqlstore oauthserver.Store
oauthProvider fosite.OAuth2Provider
logger log.Logger
accessControl ac.AccessControl
acService ac.Service
saService serviceaccounts.ExtSvcAccountsService
userService user.Service
teamService team.Service
publicKey any
}
func ProvideService(router routing.RouteRegister, bus bus.Bus, db db.DB, cfg *setting.Cfg,
extSvcAccSvc serviceaccounts.ExtSvcAccountsService, accessControl ac.AccessControl, acSvc ac.Service, userSvc user.Service,
teamSvc team.Service, keySvc signingkeys.Service, fmgmt *featuremgmt.FeatureManager) (*OAuth2ServiceImpl, error) {
if !fmgmt.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
return nil, nil
}
config := &fosite.Config{
AccessTokenLifespan: cfg.OAuth2ServerAccessTokenLifespan,
TokenURL: fmt.Sprintf("%voauth2/token", cfg.AppURL),
AccessTokenIssuer: cfg.AppURL,
IDTokenIssuer: cfg.AppURL,
ScopeStrategy: fosite.WildcardScopeStrategy,
}
s := &OAuth2ServiceImpl{
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
cfg: cfg,
accessControl: accessControl,
acService: acSvc,
memstore: storage.NewMemoryStore(),
sqlstore: store.NewStore(db),
logger: log.New("oauthserver"),
userService: userSvc,
saService: extSvcAccSvc,
teamService: teamSvc,
}
api := api.NewAPI(router, s)
api.RegisterAPIEndpoints()
bus.AddEventListener(s.handlePluginStateChanged)
s.oauthProvider = newProvider(config, s, keySvc)
return s, nil
}
func newProvider(config *fosite.Config, storage any, signingKeyService signingkeys.Service) fosite.OAuth2Provider {
keyGetter := func(ctx context.Context) (any, error) {
_, key, err := signingKeyService.GetOrCreatePrivateKey(ctx, signingkeys.ServerPrivateKeyID, jose.ES256)
return key, err
}
return compose.Compose(
config,
storage,
&compose.CommonStrategy{
CoreStrategy: compose.NewOAuth2JWTStrategy(keyGetter, compose.NewOAuth2HMACStrategy(config), config),
Signer: &jwt.DefaultSigner{GetPrivateKey: keyGetter},
},
compose.OAuth2ClientCredentialsGrantFactory,
compose.RFC7523AssertionGrantFactory,
compose.OAuth2TokenIntrospectionFactory,
compose.OAuth2TokenRevocationFactory,
)
}
// HasExternalService returns whether an external service has been saved with that name.
func (s *OAuth2ServiceImpl) HasExternalService(ctx context.Context, name string) (bool, error) {
client, errRetrieve := s.sqlstore.GetExternalServiceByName(ctx, name)
if errRetrieve != nil && !errors.Is(errRetrieve, oauthserver.ErrClientNotFound) {
return false, errRetrieve
}
return client != nil, nil
}
// GetExternalService retrieves an external service from store by client_id. It populates the SelfPermissions and
// SignedInUser from the associated service account.
// For performance reason, the service uses caching.
func (s *OAuth2ServiceImpl) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) {
entry, ok := s.cache.Get(id)
if ok {
client, ok := entry.(oauthserver.OAuthExternalService)
if ok {
s.logger.Debug("GetExternalService: cache hit", "id", id)
return &client, nil
}
}
client, err := s.sqlstore.GetExternalService(ctx, id)
if err != nil {
return nil, err
}
if err := s.setClientUser(ctx, client); err != nil {
return nil, err
}
s.cache.Set(id, *client, cacheExpirationTime)
return client, nil
}
// setClientUser sets the SignedInUser and SelfPermissions fields of the client
func (s *OAuth2ServiceImpl) setClientUser(ctx context.Context, client *oauthserver.OAuthExternalService) error {
if client.ServiceAccountID == oauthserver.NoServiceAccountID {
s.logger.Debug("GetExternalService: service has no service account, hence no permission", "client_id", client.ClientID, "name", client.Name)
// Create a signed in user with no role and no permission
client.SignedInUser = &user.SignedInUser{
UserID: oauthserver.NoServiceAccountID,
OrgID: oauthserver.TmpOrgID,
Name: client.Name,
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
}
return nil
}
s.logger.Debug("GetExternalService: fetch permissions", "client_id", client.ClientID)
sa, err := s.saService.RetrieveExtSvcAccount(ctx, oauthserver.TmpOrgID, client.ServiceAccountID)
if err != nil {
s.logger.Error("GetExternalService: error fetching service account", "id", client.ClientID, "error", err)
return err
}
client.SignedInUser = &user.SignedInUser{
UserID: sa.ID,
OrgID: oauthserver.TmpOrgID,
OrgRole: sa.Role,
Login: sa.Login,
Name: sa.Name,
Permissions: map[int64]map[string][]string{},
}
client.SelfPermissions, err = s.acService.GetUserPermissions(ctx, client.SignedInUser, ac.Options{})
if err != nil {
s.logger.Error("GetExternalService: error fetching permissions", "client_id", client.ClientID, "error", err)
return err
}
client.SignedInUser.Permissions[oauthserver.TmpOrgID] = ac.GroupScopesByAction(client.SelfPermissions)
return nil
}
// GetExternalServiceNames get the names of External Service in store
func (s *OAuth2ServiceImpl) GetExternalServiceNames(ctx context.Context) ([]string, error) {
s.logger.Debug("Get external service names from store")
res, err := s.sqlstore.GetExternalServiceNames(ctx)
if err != nil {
s.logger.Error("Could not fetch clients from store", "error", err.Error())
return nil, err
}
return res, nil
}
func (s *OAuth2ServiceImpl) RemoveExternalService(ctx context.Context, name string) error {
s.logger.Info("Remove external service", "service", name)
client, err := s.sqlstore.GetExternalServiceByName(ctx, name)
if err != nil {
if errors.Is(err, oauthserver.ErrClientNotFound) {
s.logger.Debug("No external service linked to this name", "name", name)
return nil
}
s.logger.Error("Error fetching external service", "name", name, "error", err.Error())
return err
}
// Since we will delete the service, clear cache entry
s.cache.Delete(client.ClientID)
// Delete the OAuth client info in store
if err := s.sqlstore.DeleteExternalService(ctx, client.ClientID); err != nil {
s.logger.Error("Error deleting external service", "name", name, "error", err.Error())
return err
}
s.logger.Debug("Deleted external service", "name", name, "client_id", client.ClientID)
// Remove the associated service account
return s.saService.RemoveExtSvcAccount(ctx, oauthserver.TmpOrgID, slugify.Slugify(name))
}
// SaveExternalService creates or updates an external service in the database, it generates client_id and secrets and
// it ensures that the associated service account has the correct permissions.
// Database consistency is not guaranteed, consider changing this in the future.
func (s *OAuth2ServiceImpl) SaveExternalService(ctx context.Context, registration *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
if registration == nil {
s.logger.Warn("RegisterExternalService called without registration")
return nil, nil
}
slug := registration.Name
s.logger.Info("Registering external service", "external service", slug)
// Check if the client already exists in store
client, errFetchExtSvc := s.sqlstore.GetExternalServiceByName(ctx, slug)
if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) {
s.logger.Error("Error fetching service", "external service", slug, "error", errFetchExtSvc)
return nil, errFetchExtSvc
}
// Otherwise, create a new client
if client == nil {
s.logger.Debug("External service does not yet exist", "external service", slug)
client = &oauthserver.OAuthExternalService{
Name: slug,
ServiceAccountID: oauthserver.NoServiceAccountID,
Audiences: s.cfg.AppURL,
}
}
// Parse registration form to compute required permissions for the client
client.SelfPermissions, client.ImpersonatePermissions = s.handleRegistrationPermissions(registration)
if registration.OAuthProviderCfg == nil {
return nil, errors.New("missing oauth provider configuration")
}
if registration.OAuthProviderCfg.RedirectURI != nil {
client.RedirectURI = *registration.OAuthProviderCfg.RedirectURI
}
var errGenCred error
client.ClientID, client.Secret, errGenCred = s.genCredentials()
if errGenCred != nil {
s.logger.Error("Error generating credentials", "client", client.LogID(), "error", errGenCred)
return nil, errGenCred
}
grantTypes := s.computeGrantTypes(registration.Self.Enabled, registration.Impersonation.Enabled)
client.GrantTypes = strings.Join(grantTypes, ",")
// Handle key options
s.logger.Debug("Handle key options")
keys, err := s.handleKeyOptions(ctx, registration.OAuthProviderCfg.Key)
if err != nil {
s.logger.Error("Error handling key options", "client", client.LogID(), "error", err)
return nil, err
}
if keys != nil {
client.PublicPem = []byte(keys.PublicPem)
}
dto := client.ToExternalService(keys)
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(client.Secret), bcrypt.DefaultCost)
if err != nil {
s.logger.Error("Error hashing secret", "client", client.LogID(), "error", err)
return nil, err
}
client.Secret = string(hashedSecret)
s.logger.Debug("Save service account")
saID, errSaveServiceAccount := s.saService.ManageExtSvcAccount(ctx, &serviceaccounts.ManageExtSvcAccountCmd{
ExtSvcSlug: slugify.Slugify(client.Name),
Enabled: registration.Self.Enabled,
OrgID: oauthserver.TmpOrgID,
Permissions: client.SelfPermissions,
})
if errSaveServiceAccount != nil {
return nil, errSaveServiceAccount
}
client.ServiceAccountID = saID
err = s.sqlstore.SaveExternalService(ctx, client)
if err != nil {
s.logger.Error("Error saving external service", "client", client.LogID(), "error", err)
return nil, err
}
s.logger.Debug("Registered", "client", client.LogID())
return dto, nil
}
// randString generates a a cryptographically secure random string of n bytes
func (s *OAuth2ServiceImpl) randString(n int) (string, error) {
res := make([]byte, n)
if _, err := rand.Read(res); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(res), nil
}
func (s *OAuth2ServiceImpl) genCredentials() (string, string, error) {
id, err := s.randString(20)
if err != nil {
return "", "", err
}
// client_secret must be at least 32 bytes long
secret, err := s.randString(32)
if err != nil {
return "", "", err
}
return id, secret, err
}
func (s *OAuth2ServiceImpl) computeGrantTypes(selfAccessEnabled, impersonationEnabled bool) []string {
grantTypes := []string{}
if selfAccessEnabled {
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
}
if impersonationEnabled {
grantTypes = append(grantTypes, string(fosite.GrantTypeJWTBearer))
}
return grantTypes
}
func (s *OAuth2ServiceImpl) handleKeyOptions(ctx context.Context, keyOption *extsvcauth.KeyOption) (*extsvcauth.KeyResult, error) {
if keyOption == nil {
return nil, fmt.Errorf("keyOption is nil")
}
var publicPem, privatePem string
if keyOption.Generate {
switch s.cfg.OAuth2ServerGeneratedKeyTypeForClient {
case "RSA":
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
publicPem = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PUBLIC KEY",
Bytes: x509.MarshalPKCS1PublicKey(&privateKey.PublicKey),
}))
privatePem = string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}))
s.logger.Debug("RSA key has been generated")
default: // default to ECDSA
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
publicDer, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, err
}
privateDer, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return nil, err
}
publicPem = string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicDer,
}))
privatePem = string(pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateDer,
}))
s.logger.Debug("ECDSA key has been generated")
}
return &extsvcauth.KeyResult{
PrivatePem: privatePem,
PublicPem: publicPem,
Generated: true,
}, nil
}
// TODO MVP allow specifying a URL to get the public key
// if registration.Key.URL != "" {
// return &oauthserver.KeyResult{
// URL: registration.Key.URL,
// }, nil
// }
if keyOption.PublicPEM != "" {
pemEncoded, err := base64.StdEncoding.DecodeString(keyOption.PublicPEM)
if err != nil {
s.logger.Error("Cannot decode base64 encoded PEM string", "error", err)
}
_, err = utils.ParsePublicKeyPem(pemEncoded)
if err != nil {
s.logger.Error("Cannot parse PEM encoded string", "error", err)
return nil, err
}
return &extsvcauth.KeyResult{
PublicPem: string(pemEncoded),
}, nil
}
return nil, fmt.Errorf("at least one key option must be specified")
}
// handleRegistrationPermissions parses the registration form to retrieve requested permissions and adds default
// permissions when impersonation is requested
func (*OAuth2ServiceImpl) handleRegistrationPermissions(registration *extsvcauth.ExternalServiceRegistration) ([]ac.Permission, []ac.Permission) {
selfPermissions := registration.Self.Permissions
impersonatePermissions := []ac.Permission{}
if len(registration.Impersonation.Permissions) > 0 {
requiredForToken := []ac.Permission{
{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf},
}
if registration.Impersonation.Groups {
requiredForToken = append(requiredForToken, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
}
impersonatePermissions = append(requiredForToken, registration.Impersonation.Permissions...)
selfPermissions = append(selfPermissions, ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll})
}
return selfPermissions, impersonatePermissions
}
// handlePluginStateChanged reset the client authorized grant_types according to the plugin state
func (s *OAuth2ServiceImpl) handlePluginStateChanged(ctx context.Context, event *pluginsettings.PluginStateChangedEvent) error {
s.logger.Debug("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
if event.OrgId != extsvcauth.TmpOrgID {
s.logger.Debug("External Service not tied to this organization", "OrgId", event.OrgId)
return nil
}
// Retrieve client associated to the plugin
client, err := s.sqlstore.GetExternalServiceByName(ctx, event.PluginId)
if err != nil {
if errors.Is(err, oauthserver.ErrClientNotFound) {
s.logger.Debug("No external service linked to this plugin", "pluginId", event.PluginId)
return nil
}
s.logger.Error("Error fetching service", "pluginId", event.PluginId, "error", err.Error())
return err
}
// Since we will change the grants, clear cache entry
s.cache.Delete(client.ClientID)
if !event.Enabled {
// Plugin is disabled => remove all grant_types
return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, "")
}
if err := s.setClientUser(ctx, client); err != nil {
return err
}
// The plugin has self permissions (not only impersonate)
canOnlyImpersonate := len(client.SelfPermissions) == 1 && (client.SelfPermissions[0].Action == ac.ActionUsersImpersonate)
selfEnabled := len(client.SelfPermissions) > 0 && !canOnlyImpersonate
// The plugin declared impersonate permissions
impersonateEnabled := len(client.ImpersonatePermissions) > 0
grantTypes := s.computeGrantTypes(selfEnabled, impersonateEnabled)
return s.sqlstore.UpdateExternalServiceGrantTypes(ctx, client.ClientID, strings.Join(grantTypes, ","))
}

View File

@ -1,625 +0,0 @@
package oasimpl
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"fmt"
"slices"
"testing"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/storage"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oastest"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
sa "github.com/grafana/grafana/pkg/services/serviceaccounts"
saTests "github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest"
"github.com/grafana/grafana/pkg/services/team/teamtest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
)
const (
AppURL = "https://oauth.test/"
TokenURL = AppURL + "oauth2/token"
)
var (
pk, _ = rsa.GenerateKey(rand.Reader, 4096)
Client1Key, _ = rsa.GenerateKey(rand.Reader, 4096)
)
type TestEnv struct {
S *OAuth2ServiceImpl
Cfg *setting.Cfg
AcStore *actest.MockStore
OAuthStore *oastest.MockStore
UserService *usertest.FakeUserService
TeamService *teamtest.FakeService
SAService *saTests.MockExtSvcAccountsService
}
func setupTestEnv(t *testing.T) *TestEnv {
t.Helper()
cfg := setting.NewCfg()
cfg.AppURL = AppURL
config := &fosite.Config{
AccessTokenLifespan: time.Hour,
TokenURL: TokenURL,
AccessTokenIssuer: AppURL,
IDTokenIssuer: AppURL,
ScopeStrategy: fosite.WildcardScopeStrategy,
}
fmgt := featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth)
env := &TestEnv{
Cfg: cfg,
AcStore: &actest.MockStore{},
OAuthStore: &oastest.MockStore{},
UserService: usertest.NewUserServiceFake(),
TeamService: teamtest.NewFakeService(),
SAService: saTests.NewMockExtSvcAccountsService(t),
}
env.S = &OAuth2ServiceImpl{
cache: localcache.New(cacheExpirationTime, cacheCleanupInterval),
cfg: cfg,
accessControl: acimpl.ProvideAccessControl(cfg),
acService: acimpl.ProvideOSSService(cfg, env.AcStore, localcache.New(0, 0), fmgt),
memstore: storage.NewMemoryStore(),
sqlstore: env.OAuthStore,
logger: log.New("oauthserver.test"),
userService: env.UserService,
saService: env.SAService,
teamService: env.TeamService,
publicKey: &pk.PublicKey,
}
env.S.oauthProvider = newProvider(config, env.S, &signingkeystest.FakeSigningKeysService{
ExpectedSinger: pk,
ExpectedKeyID: "default",
ExpectedError: nil,
})
return env
}
func TestOAuth2ServiceImpl_SaveExternalService(t *testing.T) {
const serviceName = "my-ext-service"
tests := []struct {
name string
init func(*TestEnv)
cmd *extsvcauth.ExternalServiceRegistration
mockChecks func(*testing.T, *TestEnv)
wantErr bool
}{
{
name: "should create a new client without permissions",
init: func(env *TestEnv) {
// No client at the beginning
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName))
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
// Return a service account ID
env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(0), nil)
},
cmd: &extsvcauth.ExternalServiceRegistration{
Name: serviceName,
OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}},
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalServiceByName", mock.Anything, mock.MatchedBy(func(name string) bool {
return name == serviceName
}))
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.OAuthExternalService) bool {
return client.Name == serviceName && client.ClientID != "" && client.Secret != "" &&
len(client.GrantTypes) == 0 && len(client.PublicPem) > 0 && client.ServiceAccountID == 0 &&
len(client.ImpersonatePermissions) == 0
}))
},
},
{
name: "should allow client credentials grant with correct permissions",
init: func(env *TestEnv) {
// No client at the beginning
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName))
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
// Return a service account ID
env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(10), nil)
},
cmd: &extsvcauth.ExternalServiceRegistration{
Name: serviceName,
Self: extsvcauth.SelfCfg{
Enabled: true,
Permissions: []ac.Permission{{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}},
},
OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}},
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalServiceByName", mock.Anything, mock.MatchedBy(func(name string) bool {
return name == serviceName
}))
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.OAuthExternalService) bool {
return client.Name == serviceName && len(client.ClientID) > 0 && len(client.Secret) > 0 &&
client.GrantTypes == string(fosite.GrantTypeClientCredentials) &&
len(client.PublicPem) > 0 && client.ServiceAccountID == 10 &&
len(client.ImpersonatePermissions) == 0 &&
len(client.SelfPermissions) > 0
}))
// Check that despite no credential_grants the service account still has a permission to impersonate users
env.SAService.AssertCalled(t, "ManageExtSvcAccount", mock.Anything,
mock.MatchedBy(func(cmd *sa.ManageExtSvcAccountCmd) bool {
return len(cmd.Permissions) == 1 && cmd.Permissions[0] == ac.Permission{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}
}))
},
},
{
name: "should allow jwt bearer grant and set default permissions",
init: func(env *TestEnv) {
// No client at the beginning
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName))
env.OAuthStore.On("SaveExternalService", mock.Anything, mock.Anything).Return(nil)
// The service account needs to be created with a permission to impersonate users
env.SAService.On("ManageExtSvcAccount", mock.Anything, mock.Anything).Return(int64(10), nil)
},
cmd: &extsvcauth.ExternalServiceRegistration{
Name: serviceName,
OAuthProviderCfg: &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}},
Impersonation: extsvcauth.ImpersonationCfg{
Enabled: true,
Groups: true,
Permissions: []ac.Permission{{Action: "dashboards:read", Scope: "dashboards:*"}},
},
},
mockChecks: func(t *testing.T, env *TestEnv) {
// Check that the external service impersonate permissions contains the default permissions required to populate the access token
env.OAuthStore.AssertCalled(t, "SaveExternalService", mock.Anything, mock.MatchedBy(func(client *oauthserver.OAuthExternalService) bool {
impPerm := client.ImpersonatePermissions
return slices.Contains(impPerm, ac.Permission{Action: "dashboards:read", Scope: "dashboards:*"}) &&
slices.Contains(impPerm, ac.Permission{Action: ac.ActionUsersRead, Scope: oauthserver.ScopeGlobalUsersSelf}) &&
slices.Contains(impPerm, ac.Permission{Action: ac.ActionUsersPermissionsRead, Scope: oauthserver.ScopeUsersSelf}) &&
slices.Contains(impPerm, ac.Permission{Action: ac.ActionTeamsRead, Scope: oauthserver.ScopeTeamsSelf})
}))
// Check that despite no credential_grants the service account still has a permission to impersonate users
env.SAService.AssertCalled(t, "ManageExtSvcAccount", mock.Anything,
mock.MatchedBy(func(cmd *sa.ManageExtSvcAccountCmd) bool {
return len(cmd.Permissions) == 1 && cmd.Permissions[0] == ac.Permission{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}
}))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
dto, err := env.S.SaveExternalService(context.Background(), tt.cmd)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
// Check that we generated client ID and secret
require.NotEmpty(t, dto.ID)
require.NotEmpty(t, dto.Secret)
// Check that we have generated keys and that we correctly return them
if tt.cmd.OAuthProviderCfg.Key != nil && tt.cmd.OAuthProviderCfg.Key.Generate {
require.NotNil(t, dto.OAuthExtra.KeyResult)
require.True(t, dto.OAuthExtra.KeyResult.Generated)
require.NotEmpty(t, dto.OAuthExtra.KeyResult.PublicPem)
require.NotEmpty(t, dto.OAuthExtra.KeyResult.PrivatePem)
}
// Check that we computed grant types and created or updated the service account
if tt.cmd.Self.Enabled {
require.NotNil(t, dto.OAuthExtra.GrantTypes)
require.Contains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should contain client_credentials")
} else {
require.NotContains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeClientCredentials, "grant types should not contain client_credentials")
}
// Check that we updated grant types
if tt.cmd.Impersonation.Enabled {
require.NotNil(t, dto.OAuthExtra.GrantTypes)
require.Contains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should contain JWT Bearer grant")
} else {
require.NotContains(t, dto.OAuthExtra.GrantTypes, fosite.GrantTypeJWTBearer, "grant types should not contain JWT Bearer grant")
}
// Check that mocks were called as expected
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
env.AcStore.AssertExpectations(t)
// Additional checks performed
if tt.mockChecks != nil {
tt.mockChecks(t, env)
}
})
}
}
func TestOAuth2ServiceImpl_GetExternalService(t *testing.T) {
const serviceName = "my-ext-service"
dummyClient := func() *oauthserver.OAuthExternalService {
return &oauthserver.OAuthExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: 1,
}
}
cachedClient := &oauthserver.OAuthExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: 1,
SelfPermissions: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
SignedInUser: &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {
"users:impersonate": {"users:*"},
},
},
},
}
testCases := []struct {
name string
init func(*TestEnv)
mockChecks func(*testing.T, *TestEnv)
wantPerm []ac.Permission
wantErr bool
}{
{
name: "should hit the cache",
init: func(env *TestEnv) {
env.S.cache.Set(serviceName, *cachedClient, time.Minute)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertNotCalled(t, "GetExternalService", mock.Anything, mock.Anything)
},
wantPerm: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
},
{
name: "should return error when the client was not found",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName))
},
wantErr: true,
},
{
name: "should return error when the service account was not found",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil)
env.SAService.On("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{}, sa.ErrServiceAccountNotFound)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", mock.Anything, 1, 1)
},
wantErr: true,
},
{
name: "should return error when the service account has no permissions",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil)
env.SAService.On("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{}, nil)
env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return(nil, fmt.Errorf("some error"))
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", mock.Anything, 1, 1)
},
wantErr: true,
},
{
name: "should return correctly",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(dummyClient(), nil)
env.SAService.On("RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1)).Return(&sa.ExtSvcAccount{ID: 1}, nil)
env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return([]ac.Permission{{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}}, nil)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
env.SAService.AssertCalled(t, "RetrieveExtSvcAccount", mock.Anything, int64(1), int64(1))
},
wantPerm: []ac.Permission{{Action: "users:impersonate", Scope: "users:*"}},
},
{
name: "should return correctly when the client has no service account",
init: func(env *TestEnv) {
client := &oauthserver.OAuthExternalService{
Name: serviceName,
ClientID: "RANDOMID",
Secret: "RANDOMSECRET",
GrantTypes: "client_credentials",
PublicPem: []byte("-----BEGIN PUBLIC KEY-----"),
ServiceAccountID: oauthserver.NoServiceAccountID,
}
env.OAuthStore.On("GetExternalService", mock.Anything, mock.Anything).Return(client, nil)
},
mockChecks: func(t *testing.T, env *TestEnv) {
env.OAuthStore.AssertCalled(t, "GetExternalService", mock.Anything, mock.Anything)
},
wantPerm: []ac.Permission{},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
client, err := env.S.GetExternalService(context.Background(), serviceName)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.mockChecks != nil {
tt.mockChecks(t, env)
}
require.Equal(t, serviceName, client.Name)
require.ElementsMatch(t, client.SelfPermissions, tt.wantPerm)
assertArrayInMap(t, client.SignedInUser.Permissions[1], ac.GroupScopesByAction(tt.wantPerm))
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
})
}
}
func assertArrayInMap[K comparable, V string](t *testing.T, m1 map[K][]V, m2 map[K][]V) {
for k, v := range m1 {
require.Contains(t, m2, k)
require.ElementsMatch(t, v, m2[k])
}
}
func TestOAuth2ServiceImpl_RemoveExternalService(t *testing.T) {
const serviceName = "my-ext-service"
const clientID = "RANDOMID"
dummyClient := &oauthserver.OAuthExternalService{
Name: serviceName,
ClientID: clientID,
ServiceAccountID: 1,
}
testCases := []struct {
name string
init func(*TestEnv)
}{
{
name: "should do nothing on not found",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(nil, oauthserver.ErrClientNotFoundFn(serviceName))
},
},
{
name: "should remove the external service and its associated service account",
init: func(env *TestEnv) {
env.OAuthStore.On("GetExternalServiceByName", mock.Anything, serviceName).Return(dummyClient, nil)
env.OAuthStore.On("DeleteExternalService", mock.Anything, clientID).Return(nil)
env.SAService.On("RemoveExtSvcAccount", mock.Anything, oauthserver.TmpOrgID, serviceName).Return(nil)
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
err := env.S.RemoveExternalService(context.Background(), serviceName)
require.NoError(t, err)
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
})
}
}
func TestTestOAuth2ServiceImpl_handleKeyOptions(t *testing.T) {
testCases := []struct {
name string
keyOption *extsvcauth.KeyOption
expectedResult *extsvcauth.KeyResult
wantErr bool
}{
{
name: "should return error when the key option is nil",
wantErr: true,
},
{
name: "should return error when the key option is empty",
keyOption: &extsvcauth.KeyOption{},
wantErr: true,
},
{
name: "should return successfully when PublicPEM is specified",
keyOption: &extsvcauth.KeyOption{
PublicPEM: base64.StdEncoding.EncodeToString([]byte(`-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`)),
},
wantErr: false,
expectedResult: &extsvcauth.KeyResult{
PublicPem: `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`,
Generated: false,
PrivatePem: "",
URL: "",
},
},
}
env := setupTestEnv(t)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := env.S.handleKeyOptions(context.Background(), tc.keyOption)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.expectedResult, result)
})
}
t.Run("should generate an ECDSA key pair (default) when generate key option is specified", func(t *testing.T) {
result, err := env.S.handleKeyOptions(context.Background(), &extsvcauth.KeyOption{Generate: true})
require.NoError(t, err)
require.NotNil(t, result.PrivatePem)
require.NotNil(t, result.PublicPem)
require.True(t, result.Generated)
})
t.Run("should generate an RSA key pair when generate key option is specified", func(t *testing.T) {
env.S.cfg.OAuth2ServerGeneratedKeyTypeForClient = "RSA"
result, err := env.S.handleKeyOptions(context.Background(), &extsvcauth.KeyOption{Generate: true})
require.NoError(t, err)
require.NotNil(t, result.PrivatePem)
require.NotNil(t, result.PublicPem)
require.True(t, result.Generated)
})
}
func TestOAuth2ServiceImpl_handlePluginStateChanged(t *testing.T) {
pluginID := "my-app"
clientID := "RANDOMID"
impersonatePermission := []ac.Permission{{Action: ac.ActionUsersImpersonate, Scope: ac.ScopeUsersAll}}
selfPermission := append(impersonatePermission, ac.Permission{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll})
saID := int64(101)
client := &oauthserver.OAuthExternalService{
ID: 11,
Name: pluginID,
ClientID: clientID,
Secret: "SECRET",
ServiceAccountID: saID,
}
clientWithImpersonate := &oauthserver.OAuthExternalService{
ID: 11,
Name: pluginID,
ClientID: clientID,
Secret: "SECRET",
ImpersonatePermissions: []ac.Permission{
{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll},
},
ServiceAccountID: saID,
}
extSvcAcc := &sa.ExtSvcAccount{
ID: saID,
Login: "sa-my-app",
Name: pluginID,
OrgID: extsvcauth.TmpOrgID,
IsDisabled: false,
Role: org.RoleNone,
}
tests := []struct {
name string
init func(*TestEnv)
cmd *pluginsettings.PluginStateChangedEvent
}{
{
name: "should do nothing with not found",
init: func(te *TestEnv) {
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, "unknown").Return(nil, oauthserver.ErrClientNotFoundFn("unknown"))
},
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: "unknown", OrgId: 1, Enabled: false},
},
{
name: "should remove grants",
init: func(te *TestEnv) {
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, pluginID).Return(clientWithImpersonate, nil)
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, "").Return(nil)
},
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: false},
},
{
name: "should set both grants",
init: func(te *TestEnv) {
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(clientWithImpersonate, nil)
te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil)
te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(selfPermission, nil)
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID,
string(fosite.GrantTypeClientCredentials)+","+string(fosite.GrantTypeJWTBearer)).Return(nil)
},
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true},
},
{
name: "should set impersonate grant",
init: func(te *TestEnv) {
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(clientWithImpersonate, nil)
te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil)
te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(impersonatePermission, nil)
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, string(fosite.GrantTypeJWTBearer)).Return(nil)
},
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true},
},
{
name: "should set client_credentials grant",
init: func(te *TestEnv) {
te.OAuthStore.On("GetExternalServiceByName", mock.Anything, mock.Anything).Return(client, nil)
te.SAService.On("RetrieveExtSvcAccount", mock.Anything, extsvcauth.TmpOrgID, saID).Return(extSvcAcc, nil)
te.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything, mock.Anything).Return(selfPermission, nil)
te.OAuthStore.On("UpdateExternalServiceGrantTypes", mock.Anything, clientID, string(fosite.GrantTypeClientCredentials)).Return(nil)
},
cmd: &pluginsettings.PluginStateChangedEvent{PluginId: pluginID, OrgId: 1, Enabled: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
if tt.init != nil {
tt.init(env)
}
err := env.S.handlePluginStateChanged(context.Background(), tt.cmd)
require.NoError(t, err)
// Check that mocks were called as expected
env.OAuthStore.AssertExpectations(t)
env.SAService.AssertExpectations(t)
env.AcStore.AssertExpectations(t)
})
}
}

View File

@ -1,16 +0,0 @@
package oasimpl
import (
"github.com/ory/fosite/handler/oauth2"
"github.com/ory/fosite/token/jwt"
)
func NewAuthSession() *oauth2.JWTSession {
sess := &oauth2.JWTSession{
JWTClaims: new(jwt.JWTClaims),
JWTHeader: new(jwt.Headers),
}
// Our tokens will follow the RFC9068
sess.JWTHeader.Add("typ", "at+jwt")
return sess
}

View File

@ -1,353 +0,0 @@
package oasimpl
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/oauth2"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
// HandleTokenRequest handles the client's OAuth2 query to obtain an access_token by presenting its authorization
// grant (ex: client_credentials, jwtbearer)
func (s *OAuth2ServiceImpl) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {
// This context will be passed to all methods.
ctx := req.Context()
// Create an empty session object which will be passed to the request handlers
oauthSession := NewAuthSession()
// This will create an access request object and iterate through the registered TokenEndpointHandlers to validate the request.
accessRequest, err := s.oauthProvider.NewAccessRequest(ctx, req, oauthSession)
if err != nil {
s.writeAccessError(ctx, rw, accessRequest, err)
return
}
client, err := s.GetExternalService(ctx, accessRequest.GetClient().GetID())
if err != nil || client == nil {
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
})
return
}
oauthSession.JWTClaims.Add("client_id", client.ClientID)
errClientCred := s.handleClientCredentials(ctx, accessRequest, oauthSession, client)
if errClientCred != nil {
s.writeAccessError(ctx, rw, accessRequest, errClientCred)
return
}
errJWTBearer := s.handleJWTBearer(ctx, accessRequest, oauthSession, client)
if errJWTBearer != nil {
s.writeAccessError(ctx, rw, accessRequest, errJWTBearer)
return
}
// All tokens we generate in this service should target Grafana's API.
accessRequest.GrantAudience(s.cfg.AppURL)
// Prepare response, fosite handlers will populate the token.
response, err := s.oauthProvider.NewAccessResponse(ctx, accessRequest)
if err != nil {
s.writeAccessError(ctx, rw, accessRequest, err)
return
}
s.oauthProvider.WriteAccessResponse(ctx, rw, accessRequest, response)
}
// writeAccessError logs the error then uses fosite to write the error back to the user.
func (s *OAuth2ServiceImpl) writeAccessError(ctx context.Context, rw http.ResponseWriter, accessRequest fosite.AccessRequester, err error) {
var fositeErr *fosite.RFC6749Error
if errors.As(err, &fositeErr) {
s.logger.Error("Description", fositeErr.DescriptionField, "hint", fositeErr.HintField, "error", fositeErr.ErrorField)
} else {
s.logger.Error("Error", err)
}
s.oauthProvider.WriteAccessError(ctx, rw, accessRequest, err)
}
// splitOAuthScopes sort scopes that are generic (profile, email, groups, entitlements) from scopes
// that are RBAC actions (used to further restrict the entitlements embedded in the access_token)
func splitOAuthScopes(requestedScopes fosite.Arguments) (map[string]bool, map[string]bool) {
actionsFilter := map[string]bool{}
claimsFilter := map[string]bool{}
for _, scope := range requestedScopes {
switch scope {
case "profile", "email", "groups", "entitlements":
claimsFilter[scope] = true
default:
actionsFilter[scope] = true
}
}
return actionsFilter, claimsFilter
}
// handleJWTBearer populates the "impersonation" access_token generated by fosite to match the rfc9068 specifications (entitlements, groups).
// It ensures that the user can be impersonated, that the generated token audiences only contain Grafana's AppURL (and token endpoint)
// and that entitlements solely contain the user's permissions that the client is allowed to have.
func (s *OAuth2ServiceImpl) handleJWTBearer(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.OAuthExternalService) error {
if !accessRequest.GetGrantTypes().ExactOne(string(fosite.GrantTypeJWTBearer)) {
return nil
}
userID, err := utils.ParseUserIDFromSubject(oauthSession.Subject)
if err != nil {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
}
}
// Check audiences list only contains the AppURL and the token endpoint
for _, aud := range accessRequest.GetGrantedAudience() {
if aud != fmt.Sprintf("%voauth2/token", s.cfg.AppURL) && aud != s.cfg.AppURL {
return &fosite.RFC6749Error{
DescriptionField: "Client is not allowed to target this Audience.",
HintField: "The audience must be the AppURL or the token endpoint.",
ErrorField: "invalid_request",
CodeField: http.StatusForbidden,
}
}
}
// If the client was not allowed to impersonate the user we would not have reached this point given allowed scopes would have been empty
// But just in case we check again
ev := ac.EvalPermission(ac.ActionUsersImpersonate, ac.Scope("users", "id", strconv.FormatInt(userID, 10)))
hasAccess, errAccess := s.accessControl.Evaluate(ctx, client.SignedInUser, ev)
if errAccess != nil || !hasAccess {
return &fosite.RFC6749Error{
DescriptionField: "Client is not allowed to impersonate subject.",
ErrorField: "restricted_access",
CodeField: http.StatusForbidden,
}
}
// Populate claims' suject from the session subject
oauthSession.JWTClaims.Subject = oauthSession.Subject
// Get the user
query := user.GetUserByIDQuery{ID: userID}
dbUser, err := s.userService.GetByID(ctx, &query)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the requested subject.",
ErrorField: "not_found",
CodeField: http.StatusBadRequest,
}
}
return &fosite.RFC6749Error{
DescriptionField: "The request subject could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
oauthSession.Username = dbUser.Login
// Split scopes into actions and claims
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
teams := []*team.TeamDTO{}
// Fetch teams if the groups scope is requested or if we need to populate it in the entitlements
if claimsFilter["groups"] ||
(claimsFilter["entitlements"] && (len(actionsFilter) == 0 || actionsFilter["teams:read"])) {
var errGetTeams error
teams, errGetTeams = s.teamService.GetTeamsByUser(ctx, &team.GetTeamsByUserQuery{
OrgID: oauthserver.TmpOrgID,
UserID: dbUser.ID,
// Fetch teams without restriction on permissions
SignedInUser: &user.SignedInUser{
OrgID: oauthserver.TmpOrgID,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
ac.ActionTeamsRead: {ac.ScopeTeamsAll},
},
},
},
})
if errGetTeams != nil {
return &fosite.RFC6749Error{
DescriptionField: "The teams scope could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
}
if claimsFilter["profile"] {
oauthSession.JWTClaims.Add("name", dbUser.Name)
oauthSession.JWTClaims.Add("login", dbUser.Login)
oauthSession.JWTClaims.Add("updated_at", dbUser.Updated.Unix())
}
if claimsFilter["email"] {
oauthSession.JWTClaims.Add("email", dbUser.Email)
}
if claimsFilter["groups"] {
teamNames := make([]string, 0, len(teams))
for _, team := range teams {
teamNames = append(teamNames, team.Name)
}
oauthSession.JWTClaims.Add("groups", teamNames)
}
if claimsFilter["entitlements"] {
// Get the user permissions (apply the actions filter)
permissions, errGetPermission := s.filteredUserPermissions(ctx, userID, actionsFilter)
if errGetPermission != nil {
return errGetPermission
}
// Compute the impersonated permissions (apply the actions filter, replace the scope self with the user id)
impPerms := s.filteredImpersonatePermissions(client.ImpersonatePermissions, userID, teams, actionsFilter)
// Intersect the permissions with the client permissions
intesect := ac.Intersect(permissions, impPerms)
oauthSession.JWTClaims.Add("entitlements", intesect)
}
return nil
}
// filteredUserPermissions gets the user permissions and applies the actions filter
func (s *OAuth2ServiceImpl) filteredUserPermissions(ctx context.Context, userID int64, actionsFilter map[string]bool) ([]ac.Permission, error) {
permissions, err := s.acService.SearchUserPermissions(ctx, oauthserver.TmpOrgID,
ac.SearchOptions{NamespacedID: fmt.Sprintf("%s:%d", identity.NamespaceUser, userID)})
if err != nil {
return nil, &fosite.RFC6749Error{
DescriptionField: "The permissions scope could not be processed.",
ErrorField: "server_error",
CodeField: http.StatusInternalServerError,
}
}
// Apply the actions filter
if len(actionsFilter) > 0 {
filtered := []ac.Permission{}
for i := range permissions {
if actionsFilter[permissions[i].Action] {
filtered = append(filtered, permissions[i])
}
}
permissions = filtered
}
return permissions, nil
}
// filteredImpersonatePermissions computes the impersonated permissions.
// It applies the actions filter and replaces the "self RBAC scopes" (~ scope templates) by the correct user id/team id.
func (*OAuth2ServiceImpl) filteredImpersonatePermissions(impersonatePermissions []ac.Permission, userID int64, teams []*team.TeamDTO, actionsFilter map[string]bool) []ac.Permission {
// Compute the impersonated permissions
impPerms := impersonatePermissions
// Apply the actions filter
if len(actionsFilter) > 0 {
filtered := []ac.Permission{}
for i := range impPerms {
if actionsFilter[impPerms[i].Action] {
filtered = append(filtered, impPerms[i])
}
}
impPerms = filtered
}
// Replace the scope self with the user id
correctScopes := []ac.Permission{}
for i := range impPerms {
switch impPerms[i].Scope {
case oauthserver.ScopeGlobalUsersSelf:
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("global.users", "id", strconv.FormatInt(userID, 10)),
})
case oauthserver.ScopeUsersSelf:
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("users", "id", strconv.FormatInt(userID, 10)),
})
case oauthserver.ScopeTeamsSelf:
for t := range teams {
correctScopes = append(correctScopes, ac.Permission{
Action: impPerms[i].Action,
Scope: ac.Scope("teams", "id", strconv.FormatInt(teams[t].ID, 10)),
})
}
default:
correctScopes = append(correctScopes, impPerms[i])
}
continue
}
return correctScopes
}
// handleClientCredentials populates the client's access_token generated by fosite to match the rfc9068 specifications (entitlements, groups)
func (s *OAuth2ServiceImpl) handleClientCredentials(ctx context.Context, accessRequest fosite.AccessRequester, oauthSession *oauth2.JWTSession, client *oauthserver.OAuthExternalService) error {
if !accessRequest.GetGrantTypes().ExactOne("client_credentials") {
return nil
}
// Set the subject to the service account associated to the client
oauthSession.JWTClaims.Subject = fmt.Sprintf("user:id:%d", client.ServiceAccountID)
sa := client.SignedInUser
if sa == nil {
return &fosite.RFC6749Error{
DescriptionField: "Could not find the service account of the client",
ErrorField: "not_found",
CodeField: http.StatusNotFound,
}
}
oauthSession.Username = sa.Login
// For client credentials, scopes are not marked as granted by fosite but the request would have been rejected
// already if the client was not allowed to request them
for _, scope := range accessRequest.GetRequestedScopes() {
accessRequest.GrantScope(scope)
}
// Split scopes into actions and claims
actionsFilter, claimsFilter := splitOAuthScopes(accessRequest.GetGrantedScopes())
if claimsFilter["profile"] {
oauthSession.JWTClaims.Add("name", sa.Name)
oauthSession.JWTClaims.Add("login", sa.Login)
}
if claimsFilter["email"] {
s.logger.Debug("Service accounts have no emails")
}
if claimsFilter["groups"] {
s.logger.Debug("Service accounts have no groups")
}
if claimsFilter["entitlements"] {
s.logger.Debug("Processing client entitlements")
if sa.Permissions != nil && sa.Permissions[oauthserver.TmpOrgID] != nil {
perms := sa.Permissions[oauthserver.TmpOrgID]
if len(actionsFilter) > 0 {
filtered := map[string][]string{}
for action := range actionsFilter {
if _, ok := perms[action]; ok {
filtered[action] = perms[action]
}
}
perms = filtered
}
oauthSession.JWTClaims.Add("entitlements", perms)
} else {
s.logger.Debug("Client has no permissions")
}
}
return nil
}

View File

@ -1,745 +0,0 @@
package oasimpl
import (
"context"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/ory/fosite"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/maps"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/grafana/grafana/pkg/models/roletype"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
sa "github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/user"
)
func TestOAuth2ServiceImpl_handleClientCredentials(t *testing.T) {
client1 := &oauthserver.OAuthExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeClientCredentials),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{
UserID: 2,
Name: "Test App",
Login: "testapp",
OrgRole: roletype.RoleViewer,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
"dashboards:read": {"dashboards:*", "folders:*"},
"dashboards:write": {"dashboards:uid:1"},
},
},
},
}
tests := []struct {
name string
scopes []string
client *oauthserver.OAuthExternalService
expectedClaims map[string]any
wantErr bool
}{
{
name: "no claim without client_credentials grant type",
client: &oauthserver.OAuthExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{},
},
},
{
name: "no claims without scopes",
client: client1,
},
{
name: "profile claims",
client: client1,
scopes: []string{"profile"},
expectedClaims: map[string]any{"name": "Test App", "login": "testapp"},
},
{
name: "email claims should be empty",
client: client1,
scopes: []string{"email"},
},
{
name: "groups claims should be empty",
client: client1,
scopes: []string{"groups"},
},
{
name: "entitlements claims",
client: client1,
scopes: []string{"entitlements"},
expectedClaims: map[string]any{"entitlements": map[string][]string{
"dashboards:read": {"dashboards:*", "folders:*"},
"dashboards:write": {"dashboards:uid:1"},
}},
},
{
name: "scoped entitlements claims",
client: client1,
scopes: []string{"entitlements", "dashboards:write"},
expectedClaims: map[string]any{"entitlements": map[string][]string{
"dashboards:write": {"dashboards:uid:1"},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
env := setupTestEnv(t)
session := &fosite.DefaultSession{}
requester := fosite.NewAccessRequest(session)
requester.GrantTypes = fosite.Arguments(strings.Split(tt.client.GrantTypes, ","))
requester.RequestedScope = fosite.Arguments(tt.scopes)
sessionData := NewAuthSession()
err := env.S.handleClientCredentials(ctx, requester, sessionData, tt.client)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.expectedClaims == nil {
require.Empty(t, sessionData.JWTClaims.Extra)
return
}
require.Len(t, sessionData.JWTClaims.Extra, len(tt.expectedClaims))
for claimsKey, claimsValue := range tt.expectedClaims {
switch expected := claimsValue.(type) {
case []string:
require.ElementsMatch(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
case map[string][]string:
actual, ok := sessionData.JWTClaims.Extra[claimsKey].(map[string][]string)
require.True(t, ok, "expected map[string][]string")
require.ElementsMatch(t, maps.Keys(expected), maps.Keys(actual))
for expKey, expValue := range expected {
require.ElementsMatch(t, expValue, actual[expKey])
}
default:
require.Equal(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
}
}
})
}
}
func TestOAuth2ServiceImpl_handleJWTBearer(t *testing.T) {
now := time.Now()
client1 := &oauthserver.OAuthExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{
UserID: 2,
OrgID: oauthserver.TmpOrgID,
Name: "Test App",
Login: "testapp",
OrgRole: roletype.RoleViewer,
Permissions: map[int64]map[string][]string{
oauthserver.TmpOrgID: {
"users:impersonate": {"users:*"},
},
},
},
}
user56 := &user.User{
ID: 56,
Email: "user56@example.org",
Login: "user56",
Name: "User 56",
Updated: now,
}
teams := []*team.TeamDTO{
{ID: 1, Name: "Team 1", OrgID: 1},
{ID: 2, Name: "Team 2", OrgID: 1},
}
client1WithPerm := func(perms []ac.Permission) *oauthserver.OAuthExternalService {
client := *client1
client.ImpersonatePermissions = perms
return &client
}
tests := []struct {
name string
initEnv func(*TestEnv)
scopes []string
client *oauthserver.OAuthExternalService
subject string
expectedClaims map[string]any
wantErr bool
}{
{
name: "no claim without jwtbearer grant type",
client: &oauthserver.OAuthExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeClientCredentials),
ServiceAccountID: 2,
},
},
{
name: "err invalid subject",
client: client1,
subject: "invalid_subject",
wantErr: true,
},
{
name: "err client is not allowed to impersonate",
client: &oauthserver.OAuthExternalService{
Name: "testapp",
ClientID: "RANDOMID",
GrantTypes: string(fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
SignedInUser: &user.SignedInUser{
UserID: 2,
Name: "Test App",
Login: "testapp",
OrgRole: roletype.RoleViewer,
Permissions: map[int64]map[string][]string{oauthserver.TmpOrgID: {}},
},
},
subject: "user:id:56",
wantErr: true,
},
{
name: "err subject not found",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedError = user.ErrUserNotFound
},
client: client1,
subject: "user:id:56",
wantErr: true,
},
{
name: "no claim without scope",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
},
client: client1,
subject: "user:id:56",
},
{
name: "profile claims",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
},
client: client1,
subject: "user:id:56",
scopes: []string{"profile"},
expectedClaims: map[string]any{
"name": "User 56",
"login": "user56",
"updated_at": now.Unix(),
},
},
{
name: "email claim",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
},
client: client1,
subject: "user:id:56",
scopes: []string{"email"},
expectedClaims: map[string]any{
"email": "user56@example.org",
},
},
{
name: "groups claim",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.TeamService.ExpectedTeamsByUser = teams
},
client: client1,
subject: "user:id:56",
scopes: []string{"groups"},
expectedClaims: map[string]any{
"groups": []string{"Team 1", "Team 2"},
},
},
{
name: "no entitlement without permission intersection",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {{Action: "dashboards:read", Scope: "dashboards:uid:1"}},
}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "datasources:read", Scope: "datasources:*"},
}),
subject: "user:id:56",
expectedClaims: map[string]any{
"entitlements": map[string][]string{},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements contains only the intersection of permissions",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {
{Action: "dashboards:read", Scope: "dashboards:uid:1"},
{Action: "datasources:read", Scope: "datasources:uid:1"},
},
}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "datasources:read", Scope: "datasources:*"},
}),
subject: "user:id:56",
expectedClaims: map[string]any{
"entitlements": map[string][]string{
"datasources:read": {"datasources:uid:1"},
},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements have correctly translated users:self permissions",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {
{Action: "users:read", Scope: "global.users:id:*"},
{Action: "users.permissions:read", Scope: "users:id:*"},
}}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "users:read", Scope: "global.users:self"},
{Action: "users.permissions:read", Scope: "users:self"},
}),
subject: "user:id:56",
expectedClaims: map[string]any{
"entitlements": map[string][]string{
"users:read": {"global.users:id:56"},
"users.permissions:read": {"users:id:56"},
},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements have correctly translated teams:self permissions",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.TeamService.ExpectedTeamsByUser = teams
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {{Action: "teams:read", Scope: "teams:*"}}}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "teams:read", Scope: "teams:self"},
}),
subject: "user:id:56",
expectedClaims: map[string]any{
"entitlements": map[string][]string{"teams:read": {"teams:id:1", "teams:id:2"}},
},
scopes: []string{"entitlements"},
},
{
name: "entitlements are correctly filtered based on scopes",
initEnv: func(env *TestEnv) {
env.UserService.ExpectedUser = user56
env.TeamService.ExpectedTeamsByUser = teams
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
56: {"Viewer"}}, nil)
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
56: {
{Action: "users:read", Scope: "global.users:id:*"},
{Action: "datasources:read", Scope: "datasources:uid:1"},
}}, nil)
},
client: client1WithPerm([]ac.Permission{
{Action: "users:read", Scope: "global.users:*"},
{Action: "datasources:read", Scope: "datasources:*"},
}),
subject: "user:id:56",
expectedClaims: map[string]any{
"entitlements": map[string][]string{"users:read": {"global.users:id:*"}},
},
scopes: []string{"entitlements", "users:read"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
env := setupTestEnv(t)
session := &fosite.DefaultSession{}
requester := fosite.NewAccessRequest(session)
requester.GrantTypes = fosite.Arguments(strings.Split(tt.client.GrantTypes, ","))
requester.RequestedScope = fosite.Arguments(tt.scopes)
requester.GrantedScope = fosite.Arguments(tt.scopes)
sessionData := NewAuthSession()
sessionData.Subject = tt.subject
if tt.initEnv != nil {
tt.initEnv(env)
}
err := env.S.handleJWTBearer(ctx, requester, sessionData, tt.client)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
if tt.expectedClaims == nil {
require.Empty(t, sessionData.JWTClaims.Extra)
return
}
require.Len(t, sessionData.JWTClaims.Extra, len(tt.expectedClaims))
for claimsKey, claimsValue := range tt.expectedClaims {
switch expected := claimsValue.(type) {
case []string:
require.ElementsMatch(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
case map[string][]string:
actual, ok := sessionData.JWTClaims.Extra[claimsKey].(map[string][]string)
require.True(t, ok, "expected map[string][]string")
require.ElementsMatch(t, maps.Keys(expected), maps.Keys(actual))
for expKey, expValue := range expected {
require.ElementsMatch(t, expValue, actual[expKey])
}
default:
require.Equal(t, claimsValue, sessionData.JWTClaims.Extra[claimsKey])
}
}
env.AcStore.AssertExpectations(t)
})
}
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
type claims struct {
jwt.Claims
ClientID string `json:"client_id"`
Groups []string `json:"groups"`
Email string `json:"email"`
Name string `json:"name"`
Login string `json:"login"`
Scopes []string `json:"scope"`
Entitlements map[string][]string `json:"entitlements"`
}
func TestOAuth2ServiceImpl_HandleTokenRequest(t *testing.T) {
tests := []struct {
name string
tweakTestClient func(*oauthserver.OAuthExternalService)
reqParams url.Values
wantCode int
wantScope []string
wantClaims *claims
}{
{
name: "should allow client credentials grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeClientCredentials)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"scope": {"profile email groups entitlements"},
"audience": {AppURL},
},
wantCode: http.StatusOK,
wantScope: []string{"profile", "email", "groups", "entitlements"},
wantClaims: &claims{
Claims: jwt.Claims{
Subject: "user:id:2", // From client1.ServiceAccountID
Issuer: AppURL, // From env.S.Config.Issuer
Audience: jwt.Audience{AppURL},
},
ClientID: "CLIENT1ID",
Name: "client-1",
Login: "client-1",
Entitlements: map[string][]string{
"users:impersonate": {"users:*"},
},
},
},
{
name: "should allow jwt-bearer grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL),
},
"scope": {"profile email groups entitlements"},
},
wantCode: http.StatusOK,
wantScope: []string{"profile", "email", "groups", "entitlements"},
wantClaims: &claims{
Claims: jwt.Claims{
Subject: "user:id:56", // To match the assertion
Issuer: AppURL, // From env.S.Config.Issuer
Audience: jwt.Audience{TokenURL, AppURL},
},
ClientID: "CLIENT1ID",
Email: "user56@example.org",
Name: "User 56",
Login: "user56",
Groups: []string{"Team 1", "Team 2"},
Entitlements: map[string][]string{
"dashboards:read": {"folders:uid:UID1"},
"folders:read": {"folders:uid:UID1"},
"users:read": {"global.users:id:56"},
},
},
},
{
name: "should deny jwt-bearer grant with wrong audience",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, "invalid audience"),
},
"scope": {"profile email groups entitlements"},
},
wantCode: http.StatusForbidden,
},
{
name: "should deny jwt-bearer grant for clients without the grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL),
},
"scope": {"profile email groups entitlements"},
},
tweakTestClient: func(es *oauthserver.OAuthExternalService) {
es.GrantTypes = string(fosite.GrantTypeClientCredentials)
},
wantCode: http.StatusBadRequest,
},
{
name: "should deny client_credentials grant for clients without the grant",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeClientCredentials)},
"client_id": {"CLIENT1ID"},
"client_secret": {"CLIENT1SECRET"},
"scope": {"profile email groups entitlements"},
"audience": {AppURL},
},
tweakTestClient: func(es *oauthserver.OAuthExternalService) {
es.GrantTypes = string(fosite.GrantTypeJWTBearer)
},
wantCode: http.StatusBadRequest,
},
{
name: "should deny client_credentials grant with wrong secret",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeClientCredentials)},
"client_id": {"CLIENT1ID"},
"client_secret": {"WRONG_SECRET"},
"scope": {"profile email groups entitlements"},
"audience": {AppURL},
},
tweakTestClient: func(es *oauthserver.OAuthExternalService) {
es.GrantTypes = string(fosite.GrantTypeClientCredentials)
},
wantCode: http.StatusUnauthorized,
},
{
name: "should deny jwt-bearer grant with wrong secret",
reqParams: url.Values{
"grant_type": {string(fosite.GrantTypeJWTBearer)},
"client_id": {"CLIENT1ID"},
"client_secret": {"WRONG_SECRET"},
"assertion": {
genAssertion(t, Client1Key, "CLIENT1ID", "user:id:56", TokenURL, AppURL),
},
"scope": {"profile email groups entitlements"},
},
tweakTestClient: func(es *oauthserver.OAuthExternalService) {
es.GrantTypes = string(fosite.GrantTypeJWTBearer)
},
wantCode: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
setupHandleTokenRequestEnv(t, env, tt.tweakTestClient)
req := httptest.NewRequest("POST", "/oauth2/token", strings.NewReader(tt.reqParams.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
env.S.HandleTokenRequest(resp, req)
require.Equal(t, tt.wantCode, resp.Code, resp.Body.String())
if tt.wantCode != http.StatusOK {
return
}
body := resp.Body.Bytes()
require.NotEmpty(t, body)
var tokenResp tokenResponse
require.NoError(t, json.Unmarshal(body, &tokenResp))
// Check token response
require.NotEmpty(t, tokenResp.Scope)
require.ElementsMatch(t, tt.wantScope, strings.Split(tokenResp.Scope, " "))
require.Positive(t, tokenResp.ExpiresIn)
require.Equal(t, "bearer", tokenResp.TokenType)
require.NotEmpty(t, tokenResp.AccessToken)
// Check access token
parsedToken, err := jwt.ParseSigned(tokenResp.AccessToken)
require.NoError(t, err)
require.Len(t, parsedToken.Headers, 1)
typeHeader := parsedToken.Headers[0].ExtraHeaders["typ"]
require.Equal(t, "at+jwt", strings.ToLower(typeHeader.(string)))
require.Equal(t, "RS256", parsedToken.Headers[0].Algorithm)
// Check access token claims
var claims claims
require.NoError(t, parsedToken.Claims(pk.Public(), &claims))
// Check times and remove them
require.Positive(t, claims.IssuedAt.Time())
require.Positive(t, claims.Expiry.Time())
claims.IssuedAt = jwt.NewNumericDate(time.Time{})
claims.Expiry = jwt.NewNumericDate(time.Time{})
// Check the ID and remove it
require.NotEmpty(t, claims.ID)
claims.ID = ""
// Compare the rest
require.Equal(t, tt.wantClaims, &claims)
})
}
}
func genAssertion(t *testing.T, signKey *rsa.PrivateKey, clientID, sub string, audience ...string) string {
key := jose.SigningKey{Algorithm: jose.RS256, Key: signKey}
assertion := jwt.Claims{
ID: uuid.New().String(),
Issuer: clientID,
Subject: sub,
Audience: audience,
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
}
var signerOpts = jose.SignerOptions{}
signerOpts.WithType("JWT")
rsaSigner, errSigner := jose.NewSigner(key, &signerOpts)
require.NoError(t, errSigner)
builder := jwt.Signed(rsaSigner)
rawJWT, errSign := builder.Claims(assertion).CompactSerialize()
require.NoError(t, errSign)
return rawJWT
}
// setupHandleTokenRequestEnv creates a client and a user and sets all Mocks call for the handleTokenRequest test cases
func setupHandleTokenRequestEnv(t *testing.T, env *TestEnv, opt func(*oauthserver.OAuthExternalService)) {
now := time.Now()
hashedSecret, err := bcrypt.GenerateFromPassword([]byte("CLIENT1SECRET"), bcrypt.DefaultCost)
require.NoError(t, err)
client1 := &oauthserver.OAuthExternalService{
Name: "client-1",
ClientID: "CLIENT1ID",
Secret: string(hashedSecret),
GrantTypes: string(fosite.GrantTypeClientCredentials + "," + fosite.GrantTypeJWTBearer),
ServiceAccountID: 2,
ImpersonatePermissions: []ac.Permission{
{Action: "users:read", Scope: oauthserver.ScopeGlobalUsersSelf},
{Action: "users.permissions:read", Scope: oauthserver.ScopeUsersSelf},
{Action: "teams:read", Scope: oauthserver.ScopeTeamsSelf},
{Action: "folders:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
SelfPermissions: []ac.Permission{
{Action: "users:impersonate", Scope: "users:*"},
},
Audiences: AppURL,
}
// Apply any option the test case might need
if opt != nil {
opt(client1)
}
sa1 := &sa.ExtSvcAccount{
ID: client1.ServiceAccountID,
Name: client1.Name,
Login: client1.Name,
OrgID: oauthserver.TmpOrgID,
IsDisabled: false,
Role: roletype.RoleNone,
}
user56 := &user.User{
ID: 56,
Email: "user56@example.org",
Login: "user56",
Name: "User 56",
Updated: now,
}
user56Permissions := []ac.Permission{
{Action: "users:read", Scope: "global.users:id:56"},
{Action: "folders:read", Scope: "folders:uid:UID1"},
{Action: "dashboards:read", Scope: "folders:uid:UID1"},
{Action: "datasources:read", Scope: "datasources:uid:DS_UID2"}, // This one should be ignored when impersonating
}
user56Teams := []*team.TeamDTO{
{ID: 1, Name: "Team 1", OrgID: 1},
{ID: 2, Name: "Team 2", OrgID: 1},
}
// To retrieve the Client, its publicKey and its permissions
env.OAuthStore.On("GetExternalService", mock.Anything, client1.ClientID).Return(client1, nil)
env.OAuthStore.On("GetExternalServicePublicKey", mock.Anything, client1.ClientID).Return(&jose.JSONWebKey{Key: Client1Key.Public(), Algorithm: "RS256"}, nil)
env.SAService.On("RetrieveExtSvcAccount", mock.Anything, oauthserver.TmpOrgID, client1.ServiceAccountID).Return(sa1, nil)
env.AcStore.On("GetUserPermissions", mock.Anything, mock.Anything).Return(client1.SelfPermissions, nil)
// To retrieve the user to impersonate, its permissions and its teams
env.AcStore.On("SearchUsersPermissions", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]ac.Permission{
user56.ID: user56Permissions}, nil)
env.AcStore.On("GetUsersBasicRoles", mock.Anything, mock.Anything, mock.Anything).Return(map[int64][]string{
user56.ID: {"Viewer"}}, nil)
env.TeamService.ExpectedTeamsByUser = user56Teams
env.UserService.ExpectedUser = user56
}

View File

@ -1,38 +0,0 @@
package oastest
import (
"context"
"net/http"
"github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"gopkg.in/square/go-jose.v2"
)
type FakeService struct {
ExpectedClient *oauthserver.OAuthExternalService
ExpectedKey *jose.JSONWebKey
ExpectedErr error
}
var _ oauthserver.OAuth2Server = &FakeService{}
func (s *FakeService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
return s.ExpectedClient.ToExternalService(nil), s.ExpectedErr
}
func (s *FakeService) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) {
return s.ExpectedClient, s.ExpectedErr
}
func (s *FakeService) GetExternalServiceNames(ctx context.Context) ([]string, error) {
return nil, nil
}
func (s *FakeService) RemoveExternalService(ctx context.Context, name string) error {
return s.ExpectedErr
}
func (s *FakeService) HandleTokenRequest(rw http.ResponseWriter, req *http.Request) {}
func (s *FakeService) HandleIntrospectionRequest(rw http.ResponseWriter, req *http.Request) {}

View File

@ -1,191 +0,0 @@
// Code generated by mockery v2.35.2. DO NOT EDIT.
package oastest
import (
context "context"
mock "github.com/stretchr/testify/mock"
jose "gopkg.in/square/go-jose.v2"
oauthserver "github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
)
// MockStore is an autogenerated mock type for the Store type
type MockStore struct {
mock.Mock
}
// DeleteExternalService provides a mock function with given fields: ctx, id
func (_m *MockStore) DeleteExternalService(ctx context.Context, id string) error {
ret := _m.Called(ctx, id)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetExternalService provides a mock function with given fields: ctx, id
func (_m *MockStore) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) {
ret := _m.Called(ctx, id)
var r0 *oauthserver.OAuthExternalService
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.OAuthExternalService, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.OAuthExternalService); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauthserver.OAuthExternalService)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExternalServiceByName provides a mock function with given fields: ctx, name
func (_m *MockStore) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.OAuthExternalService, error) {
ret := _m.Called(ctx, name)
var r0 *oauthserver.OAuthExternalService
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*oauthserver.OAuthExternalService, error)); ok {
return rf(ctx, name)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *oauthserver.OAuthExternalService); ok {
r0 = rf(ctx, name)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauthserver.OAuthExternalService)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, name)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExternalServiceNames provides a mock function with given fields: ctx
func (_m *MockStore) GetExternalServiceNames(ctx context.Context) ([]string, error) {
ret := _m.Called(ctx)
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []string); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetExternalServicePublicKey provides a mock function with given fields: ctx, clientID
func (_m *MockStore) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) {
ret := _m.Called(ctx, clientID)
var r0 *jose.JSONWebKey
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (*jose.JSONWebKey, error)); ok {
return rf(ctx, clientID)
}
if rf, ok := ret.Get(0).(func(context.Context, string) *jose.JSONWebKey); ok {
r0 = rf(ctx, clientID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*jose.JSONWebKey)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, clientID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RegisterExternalService provides a mock function with given fields: ctx, client
func (_m *MockStore) RegisterExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error {
ret := _m.Called(ctx, client)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.OAuthExternalService) error); ok {
r0 = rf(ctx, client)
} else {
r0 = ret.Error(0)
}
return r0
}
// SaveExternalService provides a mock function with given fields: ctx, client
func (_m *MockStore) SaveExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error {
ret := _m.Called(ctx, client)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *oauthserver.OAuthExternalService) error); ok {
r0 = rf(ctx, client)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateExternalServiceGrantTypes provides a mock function with given fields: ctx, clientID, grantTypes
func (_m *MockStore) UpdateExternalServiceGrantTypes(ctx context.Context, clientID string, grantTypes string) error {
ret := _m.Called(ctx, clientID, grantTypes)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
r0 = rf(ctx, clientID, grantTypes)
} else {
r0 = ret.Error(0)
}
return r0
}
// 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.
// The first argument is typically a *testing.T value.
func NewMockStore(t interface {
mock.TestingT
Cleanup(func())
}) *MockStore {
mock := &MockStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -1,252 +0,0 @@
package store
import (
"context"
"crypto/ecdsa"
"crypto/rsa"
"errors"
"gopkg.in/square/go-jose.v2"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/utils"
)
type store struct {
db db.DB
}
func NewStore(db db.DB) oauthserver.Store {
return &store{db: db}
}
func createImpersonatePermissions(sess *db.Session, client *oauthserver.OAuthExternalService) error {
if len(client.ImpersonatePermissions) == 0 {
return nil
}
insertPermQuery := make([]any, 1, len(client.ImpersonatePermissions)*3+1)
insertPermStmt := `INSERT INTO oauth_impersonate_permission (client_id, action, scope) VALUES `
for _, perm := range client.ImpersonatePermissions {
insertPermStmt += "(?, ?, ?),"
insertPermQuery = append(insertPermQuery, client.ClientID, perm.Action, perm.Scope)
}
insertPermQuery[0] = insertPermStmt[:len(insertPermStmt)-1]
_, err := sess.Exec(insertPermQuery...)
return err
}
func registerExternalService(sess *db.Session, client *oauthserver.OAuthExternalService) error {
insertQuery := []any{
`INSERT INTO oauth_client (name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
client.Name,
client.ClientID,
client.Secret,
client.GrantTypes,
client.Audiences,
client.ServiceAccountID,
client.PublicPem,
client.RedirectURI,
}
if _, err := sess.Exec(insertQuery...); err != nil {
return err
}
return createImpersonatePermissions(sess, client)
}
func (s *store) RegisterExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error {
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
return registerExternalService(sess, client)
})
}
func recreateImpersonatePermissions(sess *db.Session, client *oauthserver.OAuthExternalService, prevClientID string) error {
deletePermQuery := `DELETE FROM oauth_impersonate_permission WHERE client_id = ?`
if _, errDelPerm := sess.Exec(deletePermQuery, prevClientID); errDelPerm != nil {
return errDelPerm
}
if len(client.ImpersonatePermissions) == 0 {
return nil
}
return createImpersonatePermissions(sess, client)
}
func updateExternalService(sess *db.Session, client *oauthserver.OAuthExternalService, prevClientID string) error {
updateQuery := []any{
`UPDATE oauth_client SET client_id = ?, secret = ?, grant_types = ?, audiences = ?, service_account_id = ?, public_pem = ?, redirect_uri = ? WHERE name = ?`,
client.ClientID,
client.Secret,
client.GrantTypes,
client.Audiences,
client.ServiceAccountID,
client.PublicPem,
client.RedirectURI,
client.Name,
}
if _, err := sess.Exec(updateQuery...); err != nil {
return err
}
return recreateImpersonatePermissions(sess, client, prevClientID)
}
func (s *store) SaveExternalService(ctx context.Context, client *oauthserver.OAuthExternalService) error {
if client.Name == "" {
return oauthserver.ErrClientRequiredName
}
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
previous, errFetchExtSvc := getExternalServiceByName(sess, client.Name)
if errFetchExtSvc != nil && !errors.Is(errFetchExtSvc, oauthserver.ErrClientNotFound) {
return errFetchExtSvc
}
if previous == nil {
return registerExternalService(sess, client)
}
return updateExternalService(sess, client, previous.ClientID)
})
}
func (s *store) GetExternalService(ctx context.Context, id string) (*oauthserver.OAuthExternalService, error) {
res := &oauthserver.OAuthExternalService{}
if id == "" {
return nil, oauthserver.ErrClientRequiredID
}
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
getClientQuery := `SELECT
id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri
FROM oauth_client
WHERE client_id = ?`
found, err := sess.SQL(getClientQuery, id).Get(res)
if err != nil {
return err
}
if !found {
res = nil
return oauthserver.ErrClientNotFoundFn(id)
}
impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?`
return sess.SQL(impersonatePermQuery, id).Find(&res.ImpersonatePermissions)
})
return res, err
}
// GetPublicKey returns public key, issued by 'issuer', and assigned for subject. Public key is used to check
// signature of jwt assertion in authorization grants.
func (s *store) GetExternalServicePublicKey(ctx context.Context, clientID string) (*jose.JSONWebKey, error) {
res := &oauthserver.OAuthExternalService{}
if clientID == "" {
return nil, oauthserver.ErrClientRequiredID
}
if err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
getKeyQuery := `SELECT public_pem FROM oauth_client WHERE client_id = ?`
found, err := sess.SQL(getKeyQuery, clientID).Get(res)
if err != nil {
return err
}
if !found {
return oauthserver.ErrClientNotFoundFn(clientID)
}
return nil
}); err != nil {
return nil, err
}
key, errParseKey := utils.ParsePublicKeyPem(res.PublicPem)
if errParseKey != nil {
return nil, errParseKey
}
var alg string
switch key.(type) {
case *rsa.PublicKey:
alg = oauthserver.RS256
case *ecdsa.PublicKey:
alg = oauthserver.ES256
}
return &jose.JSONWebKey{
Algorithm: alg,
Key: key,
}, nil
}
func (s *store) GetExternalServiceByName(ctx context.Context, name string) (*oauthserver.OAuthExternalService, error) {
res := &oauthserver.OAuthExternalService{}
if name == "" {
return nil, oauthserver.ErrClientRequiredName
}
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
var errGetByName error
res, errGetByName = getExternalServiceByName(sess, name)
return errGetByName
})
return res, err
}
func getExternalServiceByName(sess *db.Session, name string) (*oauthserver.OAuthExternalService, error) {
res := &oauthserver.OAuthExternalService{}
getClientQuery := `SELECT
id, name, client_id, secret, grant_types, audiences, service_account_id, public_pem, redirect_uri
FROM oauth_client
WHERE name = ?`
found, err := sess.SQL(getClientQuery, name).Get(res)
if err != nil {
return nil, err
}
if !found {
return nil, oauthserver.ErrClientNotFoundFn(name)
}
impersonatePermQuery := `SELECT action, scope FROM oauth_impersonate_permission WHERE client_id = ?`
errPerm := sess.SQL(impersonatePermQuery, res.ClientID).Find(&res.ImpersonatePermissions)
return res, errPerm
}
// FIXME: If we ever do a search method remove that method
func (s *store) GetExternalServiceNames(ctx context.Context) ([]string, error) {
res := []string{}
err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
return sess.SQL(`SELECT name FROM oauth_client`).Find(&res)
})
return res, err
}
func (s *store) UpdateExternalServiceGrantTypes(ctx context.Context, clientID, grantTypes string) error {
if clientID == "" {
return oauthserver.ErrClientRequiredID
}
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
query := `UPDATE oauth_client SET grant_types = ? WHERE client_id = ?`
_, err := sess.Exec(query, grantTypes, clientID)
return err
})
}
func (s *store) DeleteExternalService(ctx context.Context, id string) error {
if id == "" {
return oauthserver.ErrClientRequiredID
}
return s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if _, err := sess.Exec(`DELETE FROM oauth_client WHERE client_id = ?`, id); err != nil {
return err
}
_, err := sess.Exec(`DELETE FROM oauth_impersonate_permission WHERE client_id = ?`, id)
return err
})
}

View File

@ -1,490 +0,0 @@
package store
import (
"context"
"errors"
"testing"
"github.com/go-jose/go-jose/v3"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/testsuite"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
func TestStore_RegisterAndGetClient(t *testing.T) {
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
tests := []struct {
name string
client oauthserver.OAuthExternalService
wantErr bool
}{
{
name: "register and get",
client: oauthserver.OAuthExternalService{
Name: "The Worst App Ever",
ClientID: "ANonRandomClientID",
Secret: "ICouldKeepSecrets",
GrantTypes: "clients_credentials",
PublicPem: []byte(`------BEGIN FAKE PUBLIC KEY-----
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg
QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl
eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ
cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g
UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3Qg
QW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtl
eS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJ
cyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4g
UlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4g
VGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBO
b3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNB
IEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4gVGhp
cyBJcyBOb3QgQW4gUlNBIEtleS4gVGhpcyBJcyBOb3QgQW4gUlNBIEtleS4uLi4gSXQgSXMgSnVz
dCBBIFJlZ3VsYXIgQmFzZTY0IEVuY29kZWQgU3RyaW5nLi4uCg==
------END FAKE PUBLIC KEY-----`),
ServiceAccountID: 2,
SelfPermissions: nil,
ImpersonatePermissions: nil,
RedirectURI: "/whereto",
},
wantErr: false,
},
{
name: "register with impersonate permissions and get",
client: oauthserver.OAuthExternalService{
Name: "The Best App Ever",
ClientID: "AnAlmostRandomClientID",
Secret: "ICannotKeepSecrets",
GrantTypes: "clients_credentials",
PublicPem: []byte(`test`),
ServiceAccountID: 2,
SelfPermissions: nil,
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:create", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
{Action: "dashboards:write", Scope: "folders:*"},
{Action: "dashboards:write", Scope: "dashboards:*"},
},
RedirectURI: "/whereto",
},
wantErr: false,
},
{
name: "register with audiences and get",
client: oauthserver.OAuthExternalService{
Name: "The Most Normal App Ever",
ClientID: "AnAlmostRandomClientIDAgain",
Secret: "ICanKeepSecretsEventually",
GrantTypes: "clients_credentials",
PublicPem: []byte(`test`),
ServiceAccountID: 2,
SelfPermissions: nil,
Audiences: "https://oauth.test/,https://sub.oauth.test/",
RedirectURI: "/whereto",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
err := s.RegisterExternalService(ctx, &tt.client)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
// Compare results
compareClientToStored(t, s, &tt.client)
})
}
}
func TestStore_SaveExternalService(t *testing.T) {
client1 := oauthserver.OAuthExternalService{
Name: "my-external-service",
ClientID: "ClientID",
Secret: "Secret",
GrantTypes: "client_credentials",
PublicPem: []byte("test"),
ServiceAccountID: 2,
ImpersonatePermissions: []accesscontrol.Permission{},
RedirectURI: "/whereto",
}
client1WithPerm := client1
client1WithPerm.ImpersonatePermissions = []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
}
client1WithNewSecrets := client1
client1WithNewSecrets.ClientID = "NewClientID"
client1WithNewSecrets.Secret = "NewSecret"
client1WithNewSecrets.PublicPem = []byte("newtest")
client1WithAud := client1
client1WithAud.Audiences = "https://oauth.test/,https://sub.oauth.test/"
tests := []struct {
name string
runs []oauthserver.OAuthExternalService
wantErr bool
}{
{
name: "error no name",
runs: []oauthserver.OAuthExternalService{{}},
wantErr: true,
},
{
name: "simple register",
runs: []oauthserver.OAuthExternalService{client1},
wantErr: false,
},
{
name: "no update",
runs: []oauthserver.OAuthExternalService{client1, client1},
wantErr: false,
},
{
name: "add permissions",
runs: []oauthserver.OAuthExternalService{client1, client1WithPerm},
wantErr: false,
},
{
name: "remove permissions",
runs: []oauthserver.OAuthExternalService{client1WithPerm, client1},
wantErr: false,
},
{
name: "update id and secrets",
runs: []oauthserver.OAuthExternalService{client1, client1WithNewSecrets},
wantErr: false,
},
{
name: "update audience",
runs: []oauthserver.OAuthExternalService{client1, client1WithAud},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
for i := range tt.runs {
err := s.SaveExternalService(context.Background(), &tt.runs[i])
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
compareClientToStored(t, s, &tt.runs[i])
}
})
}
}
func TestStore_GetExternalServiceByName(t *testing.T) {
client1 := oauthserver.OAuthExternalService{
Name: "my-external-service",
ClientID: "ClientID",
Secret: "Secret",
GrantTypes: "client_credentials",
PublicPem: []byte("test"),
ServiceAccountID: 2,
ImpersonatePermissions: []accesscontrol.Permission{},
RedirectURI: "/whereto",
}
client2 := oauthserver.OAuthExternalService{
Name: "my-external-service-2",
ClientID: "ClientID2",
Secret: "Secret2",
GrantTypes: "client_credentials,urn:ietf:params:grant-type:jwt-bearer",
PublicPem: []byte("test2"),
ServiceAccountID: 3,
Audiences: "https://oauth.test/,https://sub.oauth.test/",
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
RedirectURI: "/whereto",
}
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), &client1))
require.NoError(t, s.SaveExternalService(context.Background(), &client2))
tests := []struct {
name string
search string
want *oauthserver.OAuthExternalService
wantErr bool
}{
{
name: "no name provided",
search: "",
want: nil,
wantErr: true,
},
{
name: "not found",
search: "unknown-external-service",
want: nil,
wantErr: true,
},
{
name: "search client 1 by name",
search: "my-external-service",
want: &client1,
wantErr: false,
},
{
name: "search client 2 by name",
search: "my-external-service-2",
want: &client2,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stored, err := s.GetExternalServiceByName(context.Background(), tt.search)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
compareClients(t, stored, tt.want)
})
}
}
func TestStore_GetExternalServicePublicKey(t *testing.T) {
clientID := "ClientID"
createClient := func(clientID string, publicPem string) *oauthserver.OAuthExternalService {
return &oauthserver.OAuthExternalService{
Name: "my-external-service",
ClientID: clientID,
Secret: "Secret",
GrantTypes: "client_credentials",
PublicPem: []byte(publicPem),
ServiceAccountID: 2,
ImpersonatePermissions: []accesscontrol.Permission{},
RedirectURI: "/whereto",
}
}
testCases := []struct {
name string
client *oauthserver.OAuthExternalService
clientID string
want *jose.JSONWebKey
wantKeyType string
wantErr bool
}{
{
name: "should return an error when clientID is empty",
clientID: "",
client: createClient(clientID, ""),
want: nil,
wantErr: true,
},
{
name: "should return an error when the client was not found",
clientID: "random",
client: createClient(clientID, ""),
want: nil,
wantErr: true,
},
{
name: "should return an error when PublicPem is not valid",
clientID: clientID,
client: createClient(clientID, ""),
want: nil,
wantErr: true,
},
{
name: "should return the JSON Web Key ES256",
clientID: clientID,
client: createClient(clientID, `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`),
wantKeyType: oauthserver.ES256,
wantErr: false,
},
{
name: "should return the JSON Web Key RS256",
clientID: clientID,
client: createClient(clientID, `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAxkly/cHvsxd6EcShGUlFAB5lIMlIbGRocCVWbIM26f6pnGr+gCNv
s365DQdQ/jUjF8bSEQM+EtjGlv2Y7Jm7dQROpPzX/1M+53Us/Gl138UtAEgL5ZKe
SKN5J/f9Nx4wkgb99v2Bt0nz6xv+kSJwgR0o8zi8shDR5n7a5mTdlQe2NOixzWlT
vnpp6Tm+IE+XyXXcrCr01I9Rf+dKuYOPSJ1K3PDgFmmGvsLcjRCCK9EftfY0keU+
IP+sh8ewNxc6KcaLBXm3Tadb1c/HyuMi6FyYw7s9m8tyAvI1CMBAcXqLIEaRgNrc
vuO8AU0bVoUmYMKhozkcCYHudkeS08hEjQIDAQAB
-----END RSA PUBLIC KEY-----`),
wantKeyType: oauthserver.RS256,
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), tc.client))
webKey, err := s.GetExternalServicePublicKey(context.Background(), tc.clientID)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.wantKeyType, webKey.Algorithm)
})
}
}
func TestStore_RemoveExternalService(t *testing.T) {
ctx := context.Background()
client1 := oauthserver.OAuthExternalService{
Name: "my-external-service",
ClientID: "ClientID",
ImpersonatePermissions: []accesscontrol.Permission{},
}
client2 := oauthserver.OAuthExternalService{
Name: "my-external-service-2",
ClientID: "ClientID2",
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
}
// Init store
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), &client1))
require.NoError(t, s.SaveExternalService(context.Background(), &client2))
// Check presence of clients in store
getState := func(t *testing.T) map[string]bool {
client, err := s.GetExternalService(ctx, "ClientID")
if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) {
require.Fail(t, "error fetching client")
}
client2, err := s.GetExternalService(ctx, "ClientID2")
if err != nil && !errors.Is(err, oauthserver.ErrClientNotFound) {
require.Fail(t, "error fetching client")
}
return map[string]bool{
"ClientID": client != nil,
"ClientID2": client2 != nil,
}
}
tests := []struct {
name string
id string
state map[string]bool
wantErr bool
}{
{
name: "no id provided",
state: map[string]bool{"ClientID": true, "ClientID2": true},
wantErr: true,
},
{
name: "not found",
id: "ClientID3",
state: map[string]bool{"ClientID": true, "ClientID2": true},
wantErr: false,
},
{
name: "remove client 2",
id: "ClientID2",
state: map[string]bool{"ClientID": true, "ClientID2": false},
wantErr: false,
},
{
name: "remove client 1",
id: "ClientID",
state: map[string]bool{"ClientID": false, "ClientID2": false},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := s.DeleteExternalService(ctx, tt.id)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.EqualValues(t, tt.state, getState(t))
})
}
}
func Test_store_GetExternalServiceNames(t *testing.T) {
ctx := context.Background()
client1 := oauthserver.OAuthExternalService{
Name: "my-external-service",
ClientID: "ClientID",
ImpersonatePermissions: []accesscontrol.Permission{},
}
client2 := oauthserver.OAuthExternalService{
Name: "my-external-service-2",
ClientID: "ClientID2",
ImpersonatePermissions: []accesscontrol.Permission{
{Action: "dashboards:read", Scope: "folders:*"},
{Action: "dashboards:read", Scope: "dashboards:*"},
},
}
// Init store
s := &store{db: db.InitTestDB(t, db.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagExternalServiceAuth}})}
require.NoError(t, s.SaveExternalService(context.Background(), &client1))
require.NoError(t, s.SaveExternalService(context.Background(), &client2))
got, err := s.GetExternalServiceNames(ctx)
require.NoError(t, err)
require.ElementsMatch(t, []string{"my-external-service", "my-external-service-2"}, got)
}
func compareClientToStored(t *testing.T, s *store, wanted *oauthserver.OAuthExternalService) {
ctx := context.Background()
stored, err := s.GetExternalService(ctx, wanted.ClientID)
require.NoError(t, err)
require.NotNil(t, stored)
compareClients(t, stored, wanted)
}
func compareClients(t *testing.T, stored *oauthserver.OAuthExternalService, wanted *oauthserver.OAuthExternalService) {
// Reset ID so we can compare
require.NotZero(t, stored.ID)
stored.ID = 0
// Compare permissions separately
wantedPerms := wanted.ImpersonatePermissions
storedPerms := stored.ImpersonatePermissions
wanted.ImpersonatePermissions = nil
stored.ImpersonatePermissions = nil
require.EqualValues(t, *wanted, *stored)
require.ElementsMatch(t, wantedPerms, storedPerms)
}

View File

@ -1,35 +0,0 @@
package utils
import (
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/authn"
)
// ParseUserIDFromSubject parses the user ID from format "user:id:<id>".
func ParseUserIDFromSubject(subject string) (int64, error) {
trimmed := strings.TrimPrefix(subject, fmt.Sprintf("%s:id:", authn.NamespaceUser))
return strconv.ParseInt(trimmed, 10, 64)
}
// ParsePublicKeyPem parses the public key from the PEM encoded public key.
func ParsePublicKeyPem(publicPem []byte) (any, error) {
block, _ := pem.Decode(publicPem)
if block == nil {
return nil, errors.New("could not decode PEM block")
}
switch block.Type {
case "PUBLIC KEY":
return x509.ParsePKIXPublicKey(block.Bytes)
case "RSA PUBLIC KEY":
return x509.ParsePKCS1PublicKey(block.Bytes)
default:
return nil, fmt.Errorf("unknown key type %q", block.Type)
}
}

View File

@ -1,82 +0,0 @@
package utils
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParsePublicKeyPem(t *testing.T) {
testCases := []struct {
name string
publicKeyPem string
wantErr bool
}{
{
name: "should return error when the public key pem is empty",
publicKeyPem: "",
wantErr: true,
},
{
name: "should return error when the public key pem is invalid",
publicKeyPem: `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAxP72NEnQF3o3eFFMtFqyloW9oLhTydxXS2dA2NolMvXewO77
UvJw54wkOdrJrJO2BIw+XBrrb+13+koRUnwa2DNsh+SWG0PEe/31mt0zJrCmNM37
iJYIu3KZR2aRlierVY5gyrIniBIZ9blQspI6SRY9xmo6Wdh0VCRnsCV5sMlaqerI
snLpYOjGtMmL0rFuW2jKrAzpbq7L99IDgPbiH7tluaQkGIxoc29S4wjwg0NgQONT
GkfJEeXQIkxOHNM5WGb8mvjX4U0jMdXvC4WUWcS+KpcIV7ee8uEs2xDz++N4HYAS
ty37sY8wwW22QUW9I7XlSC4rsC88Ft5ar8yLsQIDAQABAoIBAAQ1yTv+mFmKGYGj
JiskFZVBNDdpPRQvNvfj8+c2iU08ozc3HEyuZQKT1InefsknCoCwIRyNkDrPBc2F
8/cR8y5r8e25EUqxoPM/7xXxVIinBZRTEyU9BKCB71vu6Z1eiWs9jNzEIDNopKCj
ZmG8nY2Gkckp58eYCEtskEE72c0RBPg8ZTBdc1cLqbNVUjkLvR5e98ruDz6b+wyH
FnztZ0k48zM047Ior69OwFRBg+S7d6cgMMmcq4X2pg3xgQMs0Se/4+pmvBf9JPSB
kl3qpVAkzM1IFdrmpFtBzeaqYNj3uU6Bm7NxEiqjAoeDxO231ziSdzIPtXIy5eRl
9WMZCqkCgYEA1ZOaT77aa54zgjAwjNB2Poo3yoUtYJz+yNCR0CPM4MzCas3PR4XI
WUXo+RNofWvRJF88aAVX7+J0UTnRr25rN12NDbo3aZhX2YNDGBe3hgB/FOAI5UAh
9SaU070PFeGzqlu/xWdx5GFk/kiNUNLX/X4xgUGPTiwY4LQeI9lffzkCgYEA7CA7
VHaNPGVsaNKMJVjrZeYOxNBsrH99IEgaP76DC+EVR2JYVzrNxmN6ZlRxD4CRTcyd
oquTFoFFw26gJIJAYF8MtusOD3PArnpdCRSoELezYdtVhS0yx8TSHGVC9MWSSt7O
IdjzEFpA99HPkYFjXUiWXjfCTK7ofI0RXC6a+DkCgYEAoQb8nYuEGwfYRhwXPtQd
kuGbVvI6WFGGN9opVgjn+8Xl/6jU01QmzkhLcyAS9B1KPmYfoT4GIzNWB7fURLS3
2bKLGwJ/rPnTooe5Gn0nPb06E38mtdI4yCEirNIqgZD+aT9rw2ZPFKXqA16oTXvq
pZFzucS4S3Qr/Z9P6i+GNOECgYBkvPuS9WEcO0kdD3arGFyVhKkYXrN+hIWlmB1a
xLS0BLtHUTXPQU85LIez0KLLslZLkthN5lVCbLSOxEueR9OfSe3qvC2ref7icWHv
1dg+CaGGRkUeJEJd6CKb6re+Jexb9OKMnjpU56yADgs4ULNLwQQl/jPu81BMkwKt
CVUkQQKBgFvbuUmYtP3aqV/Kt036Q6aB6Xwg29u2XFTe4BgW7c55teebtVmGA/zc
GMwRsF4rWCcScmHGcSKlW9L6S6OxmkYjDDRhimKyHgoiQ9tawWag2XCeOlyJ+hkc
/qwwKxScuFIi2xwT+aAmR70Xk11qXTft+DaEcHdxOOZD8gA0Gxr3
-----END RSA PRIVATE KEY-----`,
wantErr: true,
},
{
name: "should parse the public key if it is in PKCS1 format",
publicKeyPem: `-----BEGIN RSA PUBLIC KEY-----
MIIBCgKCAQEAy06MeS06Ea7zGKfOM8kosxuUBMNhrWKWMvW4Jq1IXG+lyTfann2+
kI1rKeWAQ9YbxNzLynahoKN47EQ6mqM1Yj5v9iKWtSvCMKHWBuqrG5ksaEQaAVsA
PDg8aOQrI1MSW9Hoc1CummcWX+HKNPVwIzG3sCboENFzEG8GrJgoNHZgmyOYEMMD
2WCdfY0I9Dm0/uuNMAcyMuVhRhOtT3j91zCXvDju2+M2EejApMkV9r7FqGmNH5Hw
8u43nWXnWc4UYXEItE8nPxuqsZia2mdB5MSIdKu8a7ytFcQ+tiK6vempnxHZytEL
6NDX8DLydHbEsLUn6hc76ODVkr/wRiuYdQIDAQAB
-----END RSA PUBLIC KEY-----`,
wantErr: false,
},
{
name: "should parse the public key if it is in PKIX/X.509 format",
publicKeyPem: `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbsGtoGJTopAIbhqy49/vyCJuDot+
mgGaC8vUIigFQVsVB+v/HZ4yG1Rcvysig+tyNk1dZQpozpFc2dGmzHlGhw==
-----END PUBLIC KEY-----`,
wantErr: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
_, err := ParsePublicKeyPem([]byte(tc.publicKeyPem))
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@ -1,5 +1,7 @@
package registry package registry
// FIXME (gamab): we can eventually remove this package
import ( import (
"context" "context"
"sync" "sync"
@ -9,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/services/extsvcauth" "github.com/grafana/grafana/pkg/services/extsvcauth"
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oasimpl"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts"
) )
@ -29,21 +30,20 @@ type serverLocker interface {
type Registry struct { type Registry struct {
features featuremgmt.FeatureToggles features featuremgmt.FeatureToggles
logger log.Logger logger log.Logger
oauthReg extsvcauth.ExternalServiceRegistry
saReg extsvcauth.ExternalServiceRegistry saReg extsvcauth.ExternalServiceRegistry
// FIXME (gamab): we can remove this field and use the saReg.GetExternalServiceNames directly
extSvcProviders map[string]extsvcauth.AuthProvider extSvcProviders map[string]extsvcauth.AuthProvider
lock sync.Mutex lock sync.Mutex
serverLock serverLocker serverLock serverLocker
} }
func ProvideExtSvcRegistry(oauthServer *oasimpl.OAuth2ServiceImpl, saSvc *extsvcaccounts.ExtSvcAccountsService, serverLock *serverlock.ServerLockService, features featuremgmt.FeatureToggles) *Registry { func ProvideExtSvcRegistry(saSvc *extsvcaccounts.ExtSvcAccountsService, serverLock *serverlock.ServerLockService, features featuremgmt.FeatureToggles) *Registry {
return &Registry{ return &Registry{
extSvcProviders: map[string]extsvcauth.AuthProvider{}, extSvcProviders: map[string]extsvcauth.AuthProvider{},
features: features, features: features,
lock: sync.Mutex{}, lock: sync.Mutex{},
logger: log.New("extsvcauth.registry"), logger: log.New("extsvcauth.registry"),
oauthReg: oauthServer,
saReg: saSvc, saReg: saSvc,
serverLock: serverLock, serverLock: serverLock,
} }
@ -70,11 +70,6 @@ func (r *Registry) CleanUpOrphanedExternalServices(ctx context.Context) error {
errCleanUp = err errCleanUp = err
return return
} }
case extsvcauth.OAuth2Server:
if err := r.oauthReg.RemoveExternalService(ctx, name); err != nil {
errCleanUp = err
return
}
} }
} }
} }
@ -121,13 +116,6 @@ func (r *Registry) RemoveExternalService(ctx context.Context, name string) error
} }
r.logger.Debug("Routing External Service removal to the External Service Account service", "service", name) r.logger.Debug("Routing External Service removal to the External Service Account service", "service", name)
return r.saReg.RemoveExternalService(ctx, name) return r.saReg.RemoveExternalService(ctx, name)
case extsvcauth.OAuth2Server:
if !r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
r.logger.Debug("Skipping External Service removal, flag disabled", "service", name, "flag", featuremgmt.FlagExternalServiceAccounts)
return nil
}
r.logger.Debug("Routing External Service removal to the OAuth2Server", "service", name)
return r.oauthReg.RemoveExternalService(ctx, name)
default: default:
return extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", provider) return extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", provider)
} }
@ -157,13 +145,6 @@ func (r *Registry) SaveExternalService(ctx context.Context, cmd *extsvcauth.Exte
} }
r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name) r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name)
extSvc, errSave = r.saReg.SaveExternalService(ctx, cmd) extSvc, errSave = r.saReg.SaveExternalService(ctx, cmd)
case extsvcauth.OAuth2Server:
if !r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
r.logger.Warn("Skipping External Service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAuth)
return
}
r.logger.Debug("Routing the External Service registration to the OAuth2Server", "service", cmd.Name)
extSvc, errSave = r.oauthReg.SaveExternalService(ctx, cmd)
default: default:
errSave = extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", cmd.AuthProvider) errSave = extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", cmd.AuthProvider)
} }
@ -187,16 +168,7 @@ func (r *Registry) retrieveExtSvcProviders(ctx context.Context) (map[string]exts
extsvcs[names[i]] = extsvcauth.ServiceAccounts extsvcs[names[i]] = extsvcauth.ServiceAccounts
} }
} }
// Important to run this second as the OAuth server uses External Service Accounts as well.
if r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
names, err := r.oauthReg.GetExternalServiceNames(ctx)
if err != nil {
return nil, err
}
for i := range names {
extsvcs[names[i]] = extsvcauth.OAuth2Server
}
}
return extsvcs, nil return extsvcs, nil
} }

View File

@ -15,7 +15,6 @@ import (
type TestEnv struct { type TestEnv struct {
r *Registry r *Registry
oauthReg *tests.ExternalServiceRegistryMock
saReg *tests.ExternalServiceRegistryMock saReg *tests.ExternalServiceRegistryMock
} }
@ -29,12 +28,10 @@ func (f *fakeServerLock) LockExecuteAndReleaseWithRetries(ctx context.Context, a
func setupTestEnv(t *testing.T) *TestEnv { func setupTestEnv(t *testing.T) *TestEnv {
env := TestEnv{} env := TestEnv{}
env.oauthReg = tests.NewExternalServiceRegistryMock(t)
env.saReg = tests.NewExternalServiceRegistryMock(t) env.saReg = tests.NewExternalServiceRegistryMock(t)
env.r = &Registry{ env.r = &Registry{
features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts), features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts),
logger: log.New("extsvcauth.registry.test"), logger: log.New("extsvcauth.registry.test"),
oauthReg: env.oauthReg,
saReg: env.saReg, saReg: env.saReg,
extSvcProviders: map[string]extsvcauth.AuthProvider{}, extSvcProviders: map[string]extsvcauth.AuthProvider{},
serverLock: &fakeServerLock{}, serverLock: &fakeServerLock{},
@ -51,39 +48,24 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) {
name: "should not clean up when every service registered", name: "should not clean up when every service registered",
init: func(te *TestEnv) { init: func(te *TestEnv) {
// Have registered two services one requested a service account, the other requested to be an oauth client // Have registered two services one requested a service account, the other requested to be an oauth client
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server} te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts}
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil)
// Also return the external service account attached to the OAuth Server // Also return the external service account attached to the OAuth Server
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "oauth-svc"}, nil) te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc"}, nil)
}, },
}, },
{ {
name: "should clean up an orphaned service account", name: "should clean up an orphaned service account",
init: func(te *TestEnv) { init: func(te *TestEnv) {
// Have registered two services one requested a service account, the other requested to be an oauth client // Have registered two services one requested a service account, the other requested to be an oauth client
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server} te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts}
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil)
// Also return the external service account attached to the OAuth Server // Also return the external service account attached to the OAuth Server
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-sa-svc", "oauth-svc"}, nil) te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-sa-svc"}, nil)
te.saReg.On("RemoveExternalService", mock.Anything, "orphaned-sa-svc").Return(nil) te.saReg.On("RemoveExternalService", mock.Anything, "orphaned-sa-svc").Return(nil)
}, },
}, },
{
name: "should clean up an orphaned OAuth Client",
init: func(te *TestEnv) {
// Have registered two services one requested a service account, the other requested to be an oauth client
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server}
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc", "orphaned-oauth-svc"}, nil)
// Also return the external service account attached to the OAuth Server
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-oauth-svc", "oauth-svc"}, nil)
te.oauthReg.On("RemoveExternalService", mock.Anything, "orphaned-oauth-svc").Return(nil)
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -93,37 +75,6 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) {
err := env.r.CleanUpOrphanedExternalServices(context.Background()) err := env.r.CleanUpOrphanedExternalServices(context.Background())
require.NoError(t, err) require.NoError(t, err)
env.oauthReg.AssertExpectations(t)
env.saReg.AssertExpectations(t)
})
}
}
func TestRegistry_GetExternalServiceNames(t *testing.T) {
tests := []struct {
name string
init func(*TestEnv)
want []string
}{
{
name: "should deduplicate names",
init: func(te *TestEnv) {
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "oauth-svc"}, nil)
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil)
},
want: []string{"sa-svc", "oauth-svc"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := setupTestEnv(t)
tt.init(env)
names, err := env.r.GetExternalServiceNames(context.Background())
require.NoError(t, err)
require.ElementsMatch(t, tt.want, names)
env.oauthReg.AssertExpectations(t)
env.saReg.AssertExpectations(t) env.saReg.AssertExpectations(t)
}) })
} }

View File

@ -443,13 +443,6 @@ var (
Owner: grafanaAsCodeSquad, Owner: grafanaAsCodeSquad,
HideFromAdminPage: true, HideFromAdminPage: true,
}, },
{
Name: "externalServiceAuth",
Description: "Starts an OAuth2 authentication provider for external services",
Stage: FeatureStageExperimental,
RequiresDevMode: true,
Owner: identityAccessTeam,
},
{ {
Name: "refactorVariablesTimeRange", Name: "refactorVariablesTimeRange",
Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained", Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained",

View File

@ -58,7 +58,6 @@ alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,false,false,fa
alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false
unifiedRequestLog,experimental,@grafana/backend-platform,false,false,false unifiedRequestLog,experimental,@grafana/backend-platform,false,false,false
renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false
externalServiceAuth,experimental,@grafana/identity-access-team,true,false,false
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false
enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false
faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
58 alertStateHistoryLokiOnly experimental @grafana/alerting-squad false false false
59 unifiedRequestLog experimental @grafana/backend-platform false false false
60 renderAuthJWT preview @grafana/grafana-as-code false false false
externalServiceAuth experimental @grafana/identity-access-team true false false
61 refactorVariablesTimeRange preview @grafana/dashboards-squad false false false
62 enableElasticsearchBackendQuerying GA @grafana/observability-logs false false false
63 faroDatasourceSelector preview @grafana/app-o11y false false true

View File

@ -243,10 +243,6 @@ const (
// Uses JWT-based auth for rendering instead of relying on remote cache // Uses JWT-based auth for rendering instead of relying on remote cache
FlagRenderAuthJWT = "renderAuthJWT" FlagRenderAuthJWT = "renderAuthJWT"
// FlagExternalServiceAuth
// Starts an OAuth2 authentication provider for external services
FlagExternalServiceAuth = "externalServiceAuth"
// FlagRefactorVariablesTimeRange // FlagRefactorVariablesTimeRange
// Refactor time range variables flow to reduce number of API calls made when query variables are chained // Refactor time range variables flow to reduce number of API calls made when query variables are chained
FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange" FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange"

View File

@ -1569,7 +1569,8 @@
"metadata": { "metadata": {
"name": "externalServiceAuth", "name": "externalServiceAuth",
"resourceVersion": "1708108588074", "resourceVersion": "1708108588074",
"creationTimestamp": "2024-02-16T18:36:28Z" "creationTimestamp": "2024-02-16T18:36:28Z",
"deletionTimestamp": "2024-02-21T10:10:41Z"
}, },
"spec": { "spec": {
"description": "Starts an OAuth2 authentication provider for external services", "description": "Starts an OAuth2 authentication provider for external services",

View File

@ -508,116 +508,11 @@ func TestLoader_Load(t *testing.T) {
} }
func TestLoader_Load_ExternalRegistration(t *testing.T) { func TestLoader_Load_ExternalRegistration(t *testing.T) {
boolPtr := func(b bool) *bool { return &b }
stringPtr := func(s string) *string { return &s } stringPtr := func(s string) *string { return &s }
t.Run("Load a plugin with oauth client registration", func(t *testing.T) {
cfg := &config.Cfg{
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth),
PluginsAllowUnsigned: []string{"grafana-test-datasource"},
AWSAssumeRoleEnabled: true,
}
pluginPaths := []string{filepath.Join(testDataDir(t), "oauth-external-registration")}
expected := []*plugins.Plugin{
{JSONData: plugins.JSONData{
ID: "grafana-test-datasource",
Type: plugins.TypeDataSource,
Name: "Test",
Backend: true,
Executable: "gpx_test_datasource",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "https://grafana.com",
},
Version: "1.0.0",
Logos: plugins.Logos{
Small: "public/plugins/grafana-test-datasource/img/ds.svg",
Large: "public/plugins/grafana-test-datasource/img/ds.svg",
},
Updated: "2023-08-03",
Screenshots: []plugins.Screenshots{},
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
IAM: &plugindef.IAM{
Impersonation: &plugindef.Impersonation{
Groups: boolPtr(true),
Permissions: []plugindef.Permission{
{
Action: "read",
Scope: stringPtr("datasource"),
},
},
},
Permissions: []plugindef.Permission{
{
Action: "read",
Scope: stringPtr("datasource"),
},
},
},
},
FS: mustNewStaticFSForTests(t, pluginPaths[0]),
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
Module: "public/plugins/grafana-test-datasource/module.js",
BaseURL: "public/plugins/grafana-test-datasource",
ExternalService: &auth.ExternalService{
ClientID: "client-id",
ClientSecret: "secretz",
PrivateKey: "priv@t3",
},
},
}
backendFactoryProvider := fakes.NewFakeBackendProcessProvider()
backendFactoryProvider.BackendFactoryFunc = func(ctx context.Context, plugin *plugins.Plugin) backendplugin.PluginFactoryFunc {
return func(pluginID string, logger log.Logger, env func() []string) (backendplugin.Plugin, error) {
require.Equal(t, "grafana-test-datasource", pluginID)
require.Equal(t, []string{
"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=",
"GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=",
"GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz",
"GF_PLUGIN_APP_PRIVATE_KEY=priv@t3", "GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth",
}, env())
return &fakes.FakeBackendPlugin{}, nil
}
}
l := newLoaderWithOpts(t, cfg, loaderDepOpts{
authServiceRegistry: &fakes.FakeAuthService{
Result: &auth.ExternalService{
ClientID: "client-id",
ClientSecret: "secretz",
PrivateKey: "priv@t3",
},
},
backendFactoryProvider: backendFactoryProvider,
})
got, err := l.Load(context.Background(), &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
PluginURIsFunc: func(ctx context.Context) []string {
return pluginPaths
},
DefaultSignatureFunc: func(ctx context.Context) (plugins.Signature, bool) {
return plugins.Signature{}, false
},
})
require.NoError(t, err)
if !cmp.Equal(got, expected, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
}
})
t.Run("Load a plugin with service account registration", func(t *testing.T) { t.Run("Load a plugin with service account registration", func(t *testing.T) {
cfg := &config.Cfg{ cfg := &config.Cfg{
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth), Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts),
PluginsAllowUnsigned: []string{"grafana-test-datasource"}, PluginsAllowUnsigned: []string{"grafana-test-datasource"},
AWSAssumeRoleEnabled: true, AWSAssumeRoleEnabled: true,
} }
@ -676,7 +571,7 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=", "GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=",
"GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=", "GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=",
"GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz", "GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz",
"GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth", "GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAccounts",
}, env()) }, env())
return &fakes.FakeBackendPlugin{}, nil return &fakes.FakeBackendPlugin{}, nil
} }

View File

@ -23,7 +23,7 @@ type Service struct {
func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service { func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service {
s := &Service{ s := &Service{
featureEnabled: cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) || cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts), featureEnabled: cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
log: log.New("plugins.external.registration"), log: log.New("plugins.external.registration"),
reg: reg, reg: reg,
settingsSvc: settingsSvc, settingsSvc: settingsSvc,
@ -58,18 +58,6 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string,
enabled = (settings != nil) && settings.Enabled enabled = (settings != nil) && settings.Enabled
} }
impersonation := extsvcauth.ImpersonationCfg{}
if svc.Impersonation != nil {
impersonation.Permissions = toAccessControlPermissions(svc.Impersonation.Permissions)
impersonation.Enabled = enabled
if svc.Impersonation.Groups != nil {
impersonation.Groups = *svc.Impersonation.Groups
} else {
impersonation.Groups = true
}
}
self := extsvcauth.SelfCfg{} self := extsvcauth.SelfCfg{}
self.Enabled = enabled self.Enabled = enabled
if len(svc.Permissions) > 0 { if len(svc.Permissions) > 0 {
@ -78,15 +66,8 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string,
registration := &extsvcauth.ExternalServiceRegistration{ registration := &extsvcauth.ExternalServiceRegistration{
Name: pluginID, Name: pluginID,
Impersonation: impersonation,
Self: self, Self: self,
} AuthProvider: extsvcauth.ServiceAccounts,
// Default authProvider now is ServiceAccounts
registration.AuthProvider = extsvcauth.ServiceAccounts
if svc.Impersonation != nil {
registration.AuthProvider = extsvcauth.OAuth2Server
registration.OAuthProviderCfg = &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}}
} }
extSvc, err := s.reg.SaveExternalService(ctx, registration) extSvc, err := s.reg.SaveExternalService(ctx, registration)

View File

@ -48,7 +48,7 @@ func NewServiceAccountsAPI(
RouterRegister: routerRegister, RouterRegister: routerRegister,
log: log.New("serviceaccounts.api"), log: log.New("serviceaccounts.api"),
permissionService: permissionService, permissionService: permissionService,
isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth), isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
} }
} }

View File

@ -45,7 +45,7 @@ func ProvideExtSvcAccountsService(acSvc ac.Service, bus bus.Bus, db db.DB, featu
tracer: tracer, tracer: tracer,
} }
if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) { if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) {
// Register the metrics // Register the metrics
esa.metrics = newMetrics(reg, saSvc, logger) esa.metrics = newMetrics(reg, saSvc, logger)
@ -133,7 +133,7 @@ func (esa *ExtSvcAccountsService) GetExternalServiceNames(ctx context.Context) (
// SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions. // SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions.
func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) { func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
// This is double proofing, we should never reach here anyway the flags have already been checked. // This is double proofing, we should never reach here anyway the flags have already been checked.
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
return nil, nil return nil, nil
} }
@ -148,10 +148,6 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *
slug := slugify.Slugify(cmd.Name) slug := slugify.Slugify(cmd.Name)
if cmd.Impersonation.Enabled {
esa.logger.Warn("Impersonation setup skipped. It is not possible to impersonate with a service account token.", "service", slug)
}
saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{ saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{
ExtSvcSlug: slug, ExtSvcSlug: slug,
Enabled: cmd.Self.Enabled, Enabled: cmd.Self.Enabled,
@ -181,7 +177,7 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *
func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error { func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error {
// This is double proofing, we should never reach here anyway the flags have already been checked. // This is double proofing, we should never reach here anyway the flags have already been checked.
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
return nil return nil
} }
@ -220,7 +216,7 @@ func (esa *ExtSvcAccountsService) RemoveExtSvcAccount(ctx context.Context, orgID
// ManageExtSvcAccount creates, updates or deletes the service account associated with an external service // ManageExtSvcAccount creates, updates or deletes the service account associated with an external service
func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) { func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) {
// This is double proofing, we should never reach here anyway the flags have already been checked. // This is double proofing, we should never reach here anyway the flags have already been checked.
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) { if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services") esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
return 0, nil return 0, nil
} }

View File

@ -38,7 +38,7 @@ func ProvideServiceAccountsProxy(
s := &ServiceAccountsProxy{ s := &ServiceAccountsProxy{
log: log.New("serviceaccounts.proxy"), log: log.New("serviceaccounts.proxy"),
proxiedService: proxiedService, proxiedService: proxiedService,
isProxyEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth), isProxyEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
} }
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService, features) serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService, features)

View File

@ -5,7 +5,6 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/oauthserver"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/signingkeys" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/signingkeys"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
@ -95,9 +94,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
AddExternalAlertmanagerToDatasourceMigration(mg) AddExternalAlertmanagerToDatasourceMigration(mg)
addFolderMigrations(mg) addFolderMigrations(mg)
if oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
oauthserver.AddMigration(mg)
}
anonservice.AddMigration(mg) anonservice.AddMigration(mg)
signingkeys.AddMigration(mg) signingkeys.AddMigration(mg)

View File

@ -1,52 +0,0 @@
package oauthserver
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func AddMigration(mg *migrator.Migrator) {
impersonatePermissionsTable := migrator.Table{
Name: "oauth_impersonate_permission",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "action", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "scope", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
},
Indices: []*migrator.Index{
{Cols: []string{"client_id", "action", "scope"}, Type: migrator.UniqueIndex},
},
}
clientTable := migrator.Table{
Name: "oauth_client",
Columns: []*migrator.Column{
{Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "name", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
{Name: "client_id", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "secret", Type: migrator.DB_Varchar, Length: 190, Nullable: false},
{Name: "grant_types", Type: migrator.DB_Text, Nullable: true},
{Name: "audiences", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
{Name: "service_account_id", Type: migrator.DB_BigInt, Nullable: true},
{Name: "public_pem", Type: migrator.DB_Text, Nullable: true},
{Name: "redirect_uri", Type: migrator.DB_Varchar, Length: 190, Nullable: true},
},
Indices: []*migrator.Index{
{Cols: []string{"client_id"}, Type: migrator.UniqueIndex},
{Cols: []string{"client_id", "service_account_id"}, Type: migrator.UniqueIndex},
{Cols: []string{"name"}, Type: migrator.UniqueIndex},
},
}
// Impersonate Permission
mg.AddMigration("create impersonate permissions table", migrator.NewAddTableMigration(impersonatePermissionsTable))
//------- indexes ------------------
mg.AddMigration("add unique index client_id action scope", migrator.NewAddIndexMigration(impersonatePermissionsTable, impersonatePermissionsTable.Indices[0]))
// Client
mg.AddMigration("create client table", migrator.NewAddTableMigration(clientTable))
//------- indexes ------------------
mg.AddMigration("add unique index client_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[0]))
mg.AddMigration("add unique index client_id service_account_id", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[1]))
mg.AddMigration("add unique index name", migrator.NewAddIndexMigration(clientTable, clientTable.Indices[2]))
}

View File

@ -62,7 +62,7 @@ const availableFilters = [
{ label: 'Disabled', value: ServiceAccountStateFilter.Disabled }, { label: 'Disabled', value: ServiceAccountStateFilter.Disabled },
]; ];
if (config.featureToggles.externalServiceAccounts || config.featureToggles.externalServiceAuth) { if (config.featureToggles.externalServiceAccounts) {
availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External }); availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External });
} }

View File

@ -79,7 +79,6 @@ golang.org/x/oauth2@v0.8.0
github.com/drone/drone-cli@v1.6.1 github.com/drone/drone-cli@v1.6.1
github.com/google/go-github/v45@v45.2.0 github.com/google/go-github/v45@v45.2.0
github.com/Masterminds/semver/v3@v3.1.1 github.com/Masterminds/semver/v3@v3.1.1
github.com/ory/fosite@v0.44.1-0.20230317114349-45a6785cc54f
gopkg.in/square/go-jose.v2@v2.6.0 gopkg.in/square/go-jose.v2@v2.6.0
filippo.io/age@v1.1.1 filippo.io/age@v1.1.1
github.com/docker/docker@v23.0.4+incompatible github.com/docker/docker@v23.0.4+incompatible