mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d0679f0993
commit
80d6bf6da0
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -185,7 +185,6 @@ The following toggles require explicitly setting Grafana's [app mode]({{< relref
|
||||
| Feature toggle name | Description |
|
||||
| -------------------------------------- | -------------------------------------------------------------- |
|
||||
| `unifiedStorage` | SQL-based k8s storage |
|
||||
| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services |
|
||||
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
|
||||
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
|
||||
| `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
|
||||
|
25
go.mod
25
go.mod
@ -121,7 +121,7 @@ require (
|
||||
gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
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/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/hashicorp/errwrap v1.1.0 // 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/golang-lru v0.6.0 // 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/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/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/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code
|
||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.16.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
|
||||
)
|
||||
|
||||
@ -315,11 +313,7 @@ require (
|
||||
github.com/cockroachdb/redact v1.1.3 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // 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/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/go-connections v0.4.0 // 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/runner-go v1.12.0 // 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/evanphx/json-patch v5.6.0+incompatible // 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/googleapis/enterprise-certificate-proxy v0.3.2 // 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-retryablehttp v0.7.4 // indirect
|
||||
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/inconshreveable/mousetrap v1.1.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/kr/pretty v0.3.1 // 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-ieproxy v0.0.3 // indirect
|
||||
github.com/mattn/goveralls v0.0.6 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team
|
||||
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/image-spec v1.0.3-0.20220512140940-7b36cea86235 // 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/redis/rueidis v1.0.16 // 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/segmentio/asm 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/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad
|
||||
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/com v1.0.1 // indirect
|
||||
github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect
|
||||
|
@ -77,7 +77,6 @@ export interface FeatureToggles {
|
||||
alertStateHistoryLokiOnly?: boolean;
|
||||
unifiedRequestLog?: boolean;
|
||||
renderAuthJWT?: boolean;
|
||||
externalServiceAuth?: boolean;
|
||||
refactorVariablesTimeRange?: boolean;
|
||||
enableElasticsearchBackendQuerying?: boolean;
|
||||
faroDatasourceSelector?: boolean;
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -142,9 +142,6 @@ func TestParsePluginTestdata(t *testing.T) {
|
||||
"external-registration": {
|
||||
rootid: "grafana-test-datasource",
|
||||
},
|
||||
"oauth-external-registration": {
|
||||
rootid: "grafana-test-datasource",
|
||||
},
|
||||
}
|
||||
|
||||
staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata"))
|
||||
|
@ -422,20 +422,6 @@ schemas: [{
|
||||
#IAM: {
|
||||
// Permissions are the permissions that the external service needs its associated service account to have.
|
||||
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]
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
@ -132,24 +132,10 @@ type Header struct {
|
||||
// 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)
|
||||
type IAM struct {
|
||||
Impersonation *Impersonation `json:"impersonation,omitempty"`
|
||||
|
||||
// Permissions are the permissions that the external service needs its associated service account to have.
|
||||
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.
|
||||
type Include struct {
|
||||
// RBAC action the user must have to access the route
|
||||
|
@ -67,8 +67,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/encryption"
|
||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||
"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"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@ -372,8 +370,6 @@ var wireBasicSet = wire.NewSet(
|
||||
supportbundlesimpl.ProvideService,
|
||||
extsvcaccounts.ProvideExtSvcAccountsService,
|
||||
wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)),
|
||||
oasimpl.ProvideService,
|
||||
wire.Bind(new(oauthserver.OAuth2Server), new(*oasimpl.OAuth2ServiceImpl)),
|
||||
extsvcreg.ProvideExtSvcRegistry,
|
||||
wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*extsvcreg.Registry)),
|
||||
anonstore.ProvideAnonDBStore,
|
||||
|
@ -292,110 +292,6 @@ func Reduce(ps []Permission) map[string][]string {
|
||||
return reduced
|
||||
}
|
||||
|
||||
// intersectScopes computes the minimal list of scopes common to two slices.
|
||||
func intersectScopes(s1, s2 []string) []string {
|
||||
if len(s1) == 0 || len(s2) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// helpers
|
||||
splitScopes := func(s []string) (map[string]bool, map[string]bool) {
|
||||
scopes := make(map[string]bool)
|
||||
wildcards := make(map[string]bool)
|
||||
for _, s := range s {
|
||||
if isWildcard(s) {
|
||||
wildcards[s] = true
|
||||
} else {
|
||||
scopes[s] = true
|
||||
}
|
||||
}
|
||||
return scopes, wildcards
|
||||
}
|
||||
includes := func(wildcardsSet map[string]bool, scope string) bool {
|
||||
for wildcard := range wildcardsSet {
|
||||
if wildcard == "*" || strings.HasPrefix(scope, wildcard[:len(wildcard)-1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
res := make([]string, 0)
|
||||
|
||||
// split input into scopes and wildcards
|
||||
s1Scopes, s1Wildcards := splitScopes(s1)
|
||||
s2Scopes, s2Wildcards := splitScopes(s2)
|
||||
|
||||
// intersect wildcards
|
||||
wildcards := make(map[string]bool)
|
||||
for s := range s1Wildcards {
|
||||
// if s1 wildcard is included in s2 wildcards
|
||||
// then it is included in the intersection
|
||||
if includes(s2Wildcards, s) {
|
||||
wildcards[s] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
for s := range s2Wildcards {
|
||||
// if s2 wildcard is included in s1 wildcards
|
||||
// then it is included in the intersection
|
||||
if includes(s1Wildcards, s) {
|
||||
wildcards[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// intersect scopes
|
||||
scopes := make(map[string]bool)
|
||||
for s := range s1Scopes {
|
||||
// if s1 scope is included in s2 wilcards or s2 scopes
|
||||
// then it is included in the intersection
|
||||
if includes(s2Wildcards, s) || s2Scopes[s] {
|
||||
scopes[s] = true
|
||||
}
|
||||
}
|
||||
for s := range s2Scopes {
|
||||
// if s2 scope is included in s1 wilcards
|
||||
// then it is included in the intersection
|
||||
if includes(s1Wildcards, s) {
|
||||
scopes[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// merge wildcards and scopes
|
||||
for w := range wildcards {
|
||||
res = append(res, w)
|
||||
}
|
||||
for s := range scopes {
|
||||
res = append(res, s)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// Intersect returns the intersection of two slices of permissions, grouping scopes by action.
|
||||
func Intersect(p1, p2 []Permission) map[string][]string {
|
||||
if len(p1) == 0 || len(p2) == 0 {
|
||||
return map[string][]string{}
|
||||
}
|
||||
|
||||
res := make(map[string][]string)
|
||||
p1m := Reduce(p1)
|
||||
p2m := Reduce(p2)
|
||||
|
||||
// Loop over the smallest map
|
||||
if len(p1m) > len(p2m) {
|
||||
p1m, p2m = p2m, p1m
|
||||
}
|
||||
|
||||
for a1, s1 := range p1m {
|
||||
if s2, ok := p2m[a1]; ok {
|
||||
res[a1] = intersectScopes(s1, s2)
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func ValidateScope(scope string) bool {
|
||||
prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
|
||||
// verify that last char is either ':' or '/' if last character of scope is '*'
|
||||
|
@ -1,7 +1,6 @@
|
||||
package accesscontrol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -427,7 +427,7 @@ func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchO
|
||||
}
|
||||
|
||||
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.")
|
||||
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 {
|
||||
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.")
|
||||
return nil
|
||||
}
|
||||
|
@ -869,7 +869,7 @@ func TestService_SaveExternalServiceRole(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ac := setupTestEnv(t)
|
||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts)
|
||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
|
||||
for _, r := range tt.runs {
|
||||
err := ac.SaveExternalServiceRole(ctx, r.cmd)
|
||||
if r.wantErr {
|
||||
@ -915,7 +915,7 @@ func TestService_DeleteExternalServiceRole(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ac := setupTestEnv(t)
|
||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts)
|
||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
|
||||
|
||||
if tt.initCmd != nil {
|
||||
err := ac.SaveExternalServiceRole(ctx, *tt.initCmd)
|
||||
|
@ -341,9 +341,8 @@ const (
|
||||
ActionAPIKeyDelete = "apikeys:delete"
|
||||
|
||||
// Users actions
|
||||
ActionUsersRead = "users:read"
|
||||
ActionUsersWrite = "users:write"
|
||||
ActionUsersImpersonate = "users:impersonate"
|
||||
ActionUsersRead = "users:read"
|
||||
ActionUsersWrite = "users:write"
|
||||
|
||||
// We can ignore gosec G101 since this does not contain any credentials.
|
||||
// nolint:gosec
|
||||
|
@ -24,7 +24,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/authn/authnimpl/sync"
|
||||
"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/ldap/service"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
@ -72,7 +71,7 @@ func ProvideService(
|
||||
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
|
||||
socialService social.Service, cache *remotecache.RemoteCache,
|
||||
ldapService service.LDAP, registerer prometheus.Registerer,
|
||||
signingKeysService signingkeys.Service, oauthServer oauthserver.OAuth2Server,
|
||||
signingKeysService signingkeys.Service,
|
||||
settingsProviderService setting.Provider,
|
||||
) *Service {
|
||||
s := &Service{
|
||||
@ -136,9 +135,10 @@ func ProvideService(
|
||||
s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
|
||||
}
|
||||
|
||||
if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
||||
s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer))
|
||||
}
|
||||
// FIXME (gamab): Commenting that out for now as we want to re-use the client for external service auth
|
||||
// if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
||||
// s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer))
|
||||
// }
|
||||
|
||||
for name := range socialService.GetOAuthProviders() {
|
||||
clientName := authn.ClientWithPrefix(name)
|
||||
|
@ -2,7 +2,6 @@ package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -85,12 +85,6 @@ func TestBasic_Test(t *testing.T) {
|
||||
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 {
|
||||
|
@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"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/signingkeys"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@ -33,13 +32,12 @@ const (
|
||||
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{
|
||||
cfg: cfg,
|
||||
log: log.New(authn.ClientExtendedJWT),
|
||||
userService: userService,
|
||||
signingKeys: signingKeys,
|
||||
oauthServer: oauthServer,
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +46,6 @@ type ExtendedJWT struct {
|
||||
log log.Logger
|
||||
userService user.Service
|
||||
signingKeys signingkeys.Service
|
||||
oauthServer oauthserver.OAuth2Server
|
||||
}
|
||||
|
||||
type ExtendedJWTClaims struct {
|
||||
@ -222,10 +219,6 @@ func (s *ExtendedJWT) validateClientIdClaim(ctx context.Context, claims Extended
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -17,8 +17,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/models/roletype"
|
||||
"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/signingkeys"
|
||||
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest"
|
||||
@ -268,27 +266,6 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
|
||||
want: nil,
|
||||
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 {
|
||||
@ -521,21 +498,18 @@ func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv {
|
||||
}
|
||||
|
||||
userSvc := &usertest.FakeUserService{}
|
||||
oauthSvc := &oastest.FakeService{}
|
||||
|
||||
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc, oauthSvc)
|
||||
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc)
|
||||
|
||||
return &testEnv{
|
||||
oauthSvc: oauthSvc,
|
||||
userSvc: userSvc,
|
||||
s: extJwtClient,
|
||||
userSvc: userSvc,
|
||||
s: extJwtClient,
|
||||
}
|
||||
}
|
||||
|
||||
type testEnv struct {
|
||||
oauthSvc *oastest.FakeService
|
||||
userSvc *usertest.FakeUserService
|
||||
s *ExtendedJWT
|
||||
userSvc *usertest.FakeUserService
|
||||
s *ExtendedJWT
|
||||
}
|
||||
|
||||
func generateToken(payload ExtendedJWTClaims, signingKey any, alg jose.SignatureAlgorithm) string {
|
||||
|
@ -7,7 +7,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
OAuth2Server AuthProvider = "OAuth2Server"
|
||||
ServiceAccounts AuthProvider = "ServiceAccounts"
|
||||
|
||||
// TmpOrgID is the orgID we use while global service accounts are not supported.
|
||||
@ -40,23 +39,9 @@ type SelfCfg struct {
|
||||
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.
|
||||
type ExternalServiceRegistration struct {
|
||||
Name string
|
||||
// Impersonation access configuration
|
||||
// (this is not available on all auth providers)
|
||||
Impersonation ImpersonationCfg
|
||||
// Self access configuration
|
||||
Self SelfCfg
|
||||
// Auth Provider that the client will use to connect to Grafana
|
||||
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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, ",")
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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, ","))
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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) {}
|
@ -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
|
||||
}
|
@ -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
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package registry
|
||||
|
||||
// FIXME (gamab): we can eventually remove this package
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
@ -9,7 +11,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||
"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/serviceaccounts/extsvcaccounts"
|
||||
)
|
||||
@ -29,21 +30,20 @@ type serverLocker interface {
|
||||
type Registry struct {
|
||||
features featuremgmt.FeatureToggles
|
||||
logger log.Logger
|
||||
oauthReg extsvcauth.ExternalServiceRegistry
|
||||
saReg extsvcauth.ExternalServiceRegistry
|
||||
|
||||
// FIXME (gamab): we can remove this field and use the saReg.GetExternalServiceNames directly
|
||||
extSvcProviders map[string]extsvcauth.AuthProvider
|
||||
lock sync.Mutex
|
||||
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{
|
||||
extSvcProviders: map[string]extsvcauth.AuthProvider{},
|
||||
features: features,
|
||||
lock: sync.Mutex{},
|
||||
logger: log.New("extsvcauth.registry"),
|
||||
oauthReg: oauthServer,
|
||||
saReg: saSvc,
|
||||
serverLock: serverLock,
|
||||
}
|
||||
@ -70,11 +70,6 @@ func (r *Registry) CleanUpOrphanedExternalServices(ctx context.Context) error {
|
||||
errCleanUp = err
|
||||
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)
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
|
@ -14,9 +14,8 @@ import (
|
||||
)
|
||||
|
||||
type TestEnv struct {
|
||||
r *Registry
|
||||
oauthReg *tests.ExternalServiceRegistryMock
|
||||
saReg *tests.ExternalServiceRegistryMock
|
||||
r *Registry
|
||||
saReg *tests.ExternalServiceRegistryMock
|
||||
}
|
||||
|
||||
// Never lock in tests
|
||||
@ -29,12 +28,10 @@ func (f *fakeServerLock) LockExecuteAndReleaseWithRetries(ctx context.Context, a
|
||||
|
||||
func setupTestEnv(t *testing.T) *TestEnv {
|
||||
env := TestEnv{}
|
||||
env.oauthReg = tests.NewExternalServiceRegistryMock(t)
|
||||
env.saReg = tests.NewExternalServiceRegistryMock(t)
|
||||
env.r = &Registry{
|
||||
features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts),
|
||||
features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts),
|
||||
logger: log.New("extsvcauth.registry.test"),
|
||||
oauthReg: env.oauthReg,
|
||||
saReg: env.saReg,
|
||||
extSvcProviders: map[string]extsvcauth.AuthProvider{},
|
||||
serverLock: &fakeServerLock{},
|
||||
@ -51,39 +48,24 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) {
|
||||
name: "should not clean up when every service registered",
|
||||
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.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
|
||||
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",
|
||||
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.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
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@ -93,37 +75,6 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) {
|
||||
err := env.r.CleanUpOrphanedExternalServices(context.Background())
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
@ -443,13 +443,6 @@ var (
|
||||
Owner: grafanaAsCodeSquad,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
Name: "externalServiceAuth",
|
||||
Description: "Starts an OAuth2 authentication provider for external services",
|
||||
Stage: FeatureStageExperimental,
|
||||
RequiresDevMode: true,
|
||||
Owner: identityAccessTeam,
|
||||
},
|
||||
{
|
||||
Name: "refactorVariablesTimeRange",
|
||||
Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained",
|
||||
|
@ -58,7 +58,6 @@ alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,false,false,fa
|
||||
alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false
|
||||
unifiedRequestLog,experimental,@grafana/backend-platform,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
|
||||
enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false
|
||||
faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true
|
||||
|
|
@ -243,10 +243,6 @@ const (
|
||||
// Uses JWT-based auth for rendering instead of relying on remote cache
|
||||
FlagRenderAuthJWT = "renderAuthJWT"
|
||||
|
||||
// FlagExternalServiceAuth
|
||||
// Starts an OAuth2 authentication provider for external services
|
||||
FlagExternalServiceAuth = "externalServiceAuth"
|
||||
|
||||
// FlagRefactorVariablesTimeRange
|
||||
// Refactor time range variables flow to reduce number of API calls made when query variables are chained
|
||||
FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange"
|
||||
|
@ -1569,7 +1569,8 @@
|
||||
"metadata": {
|
||||
"name": "externalServiceAuth",
|
||||
"resourceVersion": "1708108588074",
|
||||
"creationTimestamp": "2024-02-16T18:36:28Z"
|
||||
"creationTimestamp": "2024-02-16T18:36:28Z",
|
||||
"deletionTimestamp": "2024-02-21T10:10:41Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Starts an OAuth2 authentication provider for external services",
|
||||
|
@ -508,116 +508,11 @@ func TestLoader_Load(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoader_Load_ExternalRegistration(t *testing.T) {
|
||||
boolPtr := func(b bool) *bool { return &b }
|
||||
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) {
|
||||
cfg := &config.Cfg{
|
||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth),
|
||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts),
|
||||
PluginsAllowUnsigned: []string{"grafana-test-datasource"},
|
||||
AWSAssumeRoleEnabled: true,
|
||||
}
|
||||
@ -676,7 +571,7 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
|
||||
"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_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth",
|
||||
"GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAccounts",
|
||||
}, env())
|
||||
return &fakes.FakeBackendPlugin{}, nil
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ type Service struct {
|
||||
|
||||
func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *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"),
|
||||
reg: reg,
|
||||
settingsSvc: settingsSvc,
|
||||
@ -58,18 +58,6 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string,
|
||||
|
||||
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.Enabled = enabled
|
||||
if len(svc.Permissions) > 0 {
|
||||
@ -77,16 +65,9 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string,
|
||||
}
|
||||
|
||||
registration := &extsvcauth.ExternalServiceRegistration{
|
||||
Name: pluginID,
|
||||
Impersonation: impersonation,
|
||||
Self: self,
|
||||
}
|
||||
|
||||
// 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}}
|
||||
Name: pluginID,
|
||||
Self: self,
|
||||
AuthProvider: extsvcauth.ServiceAccounts,
|
||||
}
|
||||
|
||||
extSvc, err := s.reg.SaveExternalService(ctx, registration)
|
||||
|
@ -48,7 +48,7 @@ func NewServiceAccountsAPI(
|
||||
RouterRegister: routerRegister,
|
||||
log: log.New("serviceaccounts.api"),
|
||||
permissionService: permissionService,
|
||||
isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth),
|
||||
isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -45,7 +45,7 @@ func ProvideExtSvcAccountsService(acSvc ac.Service, bus bus.Bus, db db.DB, featu
|
||||
tracer: tracer,
|
||||
}
|
||||
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) {
|
||||
// Register the metrics
|
||||
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.
|
||||
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.
|
||||
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")
|
||||
return nil, nil
|
||||
}
|
||||
@ -148,10 +148,6 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *
|
||||
|
||||
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{
|
||||
ExtSvcSlug: slug,
|
||||
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 {
|
||||
// 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")
|
||||
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
|
||||
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.
|
||||
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")
|
||||
return 0, nil
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ func ProvideServiceAccountsProxy(
|
||||
s := &ServiceAccountsProxy{
|
||||
log: log.New("serviceaccounts.proxy"),
|
||||
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)
|
||||
|
@ -5,7 +5,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"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/oauthserver"
|
||||
"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/ualert"
|
||||
@ -95,9 +94,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||
AddExternalAlertmanagerToDatasourceMigration(mg)
|
||||
|
||||
addFolderMigrations(mg)
|
||||
if oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
||||
oauthserver.AddMigration(mg)
|
||||
}
|
||||
|
||||
anonservice.AddMigration(mg)
|
||||
signingkeys.AddMigration(mg)
|
||||
|
@ -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]))
|
||||
}
|
@ -62,7 +62,7 @@ const availableFilters = [
|
||||
{ label: 'Disabled', value: ServiceAccountStateFilter.Disabled },
|
||||
];
|
||||
|
||||
if (config.featureToggles.externalServiceAccounts || config.featureToggles.externalServiceAuth) {
|
||||
if (config.featureToggles.externalServiceAccounts) {
|
||||
availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External });
|
||||
}
|
||||
|
||||
|
@ -79,7 +79,6 @@ golang.org/x/oauth2@v0.8.0
|
||||
github.com/drone/drone-cli@v1.6.1
|
||||
github.com/google/go-github/v45@v45.2.0
|
||||
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
|
||||
filippo.io/age@v1.1.1
|
||||
github.com/docker/docker@v23.0.4+incompatible
|
||||
|
Loading…
Reference in New Issue
Block a user