mirror of https://github.com/grafana/grafana.git
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 |
|
| Feature toggle name | Description |
|
||||||
| -------------------------------------- | -------------------------------------------------------------- |
|
| -------------------------------------- | -------------------------------------------------------------- |
|
||||||
| `unifiedStorage` | SQL-based k8s storage |
|
| `unifiedStorage` | SQL-based k8s storage |
|
||||||
| `externalServiceAuth` | Starts an OAuth2 authentication provider for external services |
|
|
||||||
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
|
| `grafanaAPIServerWithExperimentalAPIs` | Register experimental APIs with the k8s API server |
|
||||||
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
|
| `grafanaAPIServerEnsureKubectlAccess` | Start an additional https handler and write kubectl options |
|
||||||
| `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
|
| `kubernetesQueryServiceRewrite` | Rewrite requests targeting /ds/query to the query service |
|
||||||
|
|
25
go.mod
25
go.mod
|
@ -121,7 +121,7 @@ require (
|
||||||
gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform
|
gopkg.in/mail.v2 v2.3.1 // @grafana/backend-platform
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-squad-backend
|
gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-squad-backend
|
||||||
xorm.io/builder v0.3.6 // indirect; @grafana/backend-platform
|
xorm.io/builder v0.3.6 // @grafana/backend-platform
|
||||||
xorm.io/core v0.7.3 // @grafana/backend-platform
|
xorm.io/core v0.7.3 // @grafana/backend-platform
|
||||||
xorm.io/xorm v0.8.2 // @grafana/alerting-squad-backend
|
xorm.io/xorm v0.8.2 // @grafana/alerting-squad-backend
|
||||||
)
|
)
|
||||||
|
@ -173,7 +173,7 @@ require (
|
||||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
|
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect; @grafana/alerting-squad
|
github.com/hashicorp/go-multierror v1.1.1 // @grafana/alerting-squad
|
||||||
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
github.com/hashicorp/go-sockaddr v1.0.6 // indirect
|
||||||
github.com/hashicorp/golang-lru v0.6.0 // indirect
|
github.com/hashicorp/golang-lru v0.6.0 // indirect
|
||||||
github.com/hashicorp/yamux v0.1.1 // indirect
|
github.com/hashicorp/yamux v0.1.1 // indirect
|
||||||
|
@ -263,12 +263,10 @@ require (
|
||||||
github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling
|
github.com/grafana/tempo v1.5.1-0.20230524121406-1dc1bfe7085b // @grafana/observability-traces-and-profiling
|
||||||
github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed // @grafana/grafana-as-code
|
github.com/grafana/thema v0.0.0-20230712153715-375c1b45f3ed // @grafana/grafana-as-code
|
||||||
github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 // @grafana/grafana-bi-squad
|
github.com/microsoft/go-mssqldb v1.6.1-0.20240214161942-b65008136246 // @grafana/grafana-bi-squad
|
||||||
github.com/ory/fosite v0.44.1-0.20230317114349-45a6785cc54f // @grafana/grafana-authnz-team
|
|
||||||
github.com/redis/go-redis/v9 v9.0.2 // @grafana/alerting-squad-backend
|
github.com/redis/go-redis/v9 v9.0.2 // @grafana/alerting-squad-backend
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code
|
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // @grafana/grafana-as-code
|
||||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.16.0 // @grafana/backend-platform
|
go.opentelemetry.io/contrib/samplers/jaegerremote v0.16.0 // @grafana/backend-platform
|
||||||
golang.org/x/mod v0.14.0 // @grafana/backend-platform
|
golang.org/x/mod v0.14.0 // @grafana/backend-platform
|
||||||
gopkg.in/square/go-jose.v2 v2.6.0 // @grafana/grafana-authnz-team
|
|
||||||
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // @grafana/partner-datasources
|
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // @grafana/partner-datasources
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -315,11 +313,7 @@ require (
|
||||||
github.com/cockroachdb/redact v1.1.3 // indirect
|
github.com/cockroachdb/redact v1.1.3 // indirect
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||||
github.com/cristalhq/jwt/v4 v4.0.2 // indirect
|
|
||||||
github.com/dave/jennifer v1.5.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgraph-io/ristretto v0.1.1 // indirect
|
|
||||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect
|
|
||||||
github.com/docker/distribution v2.8.2+incompatible // indirect
|
github.com/docker/distribution v2.8.2+incompatible // indirect
|
||||||
github.com/docker/go-connections v0.4.0 // indirect
|
github.com/docker/go-connections v0.4.0 // indirect
|
||||||
github.com/drone-runners/drone-runner-docker v1.8.2 // indirect
|
github.com/drone-runners/drone-runner-docker v1.8.2 // indirect
|
||||||
|
@ -327,7 +321,6 @@ require (
|
||||||
github.com/drone/envsubst v1.0.3 // indirect
|
github.com/drone/envsubst v1.0.3 // indirect
|
||||||
github.com/drone/runner-go v1.12.0 // indirect
|
github.com/drone/runner-go v1.12.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ecordell/optgen v0.0.6 // indirect
|
|
||||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
|
github.com/evanphx/json-patch v5.6.0+incompatible // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
@ -344,11 +337,8 @@ require (
|
||||||
github.com/google/s2a-go v0.1.7 // indirect
|
github.com/google/s2a-go v0.1.7 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||||
github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect
|
github.com/grafana/regexp v0.0.0-20221123153739-15dc172cd2db // indirect
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
github.com/hashicorp/go-retryablehttp v0.7.4 // indirect
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-squad-backend
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-squad-backend
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
|
||||||
github.com/hashicorp/memberlist v0.5.0 // indirect
|
github.com/hashicorp/memberlist v0.5.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/invopop/yaml v0.2.0 // indirect
|
github.com/invopop/yaml v0.2.0 // indirect
|
||||||
|
@ -356,10 +346,8 @@ require (
|
||||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.6 // indirect
|
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-ieproxy v0.0.3 // indirect
|
github.com/mattn/go-ieproxy v0.0.3 // indirect
|
||||||
github.com/mattn/goveralls v0.0.6 // indirect
|
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team
|
github.com/mitchellh/mapstructure v1.5.0 //@grafana/grafana-authnz-team
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
|
@ -368,12 +356,6 @@ require (
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235 // indirect
|
github.com/opencontainers/image-spec v1.0.3-0.20220512140940-7b36cea86235 // indirect
|
||||||
github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect
|
github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect
|
||||||
github.com/ory/go-acc v0.2.6 // indirect
|
|
||||||
github.com/ory/go-convenience v0.1.0 // indirect
|
|
||||||
github.com/ory/viper v1.7.5 // indirect
|
|
||||||
github.com/ory/x v0.0.214 // indirect
|
|
||||||
github.com/pborman/uuid v1.2.0 // indirect
|
|
||||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/redis/rueidis v1.0.16 // indirect
|
github.com/redis/rueidis v1.0.16 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
@ -382,12 +364,9 @@ require (
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
github.com/shopspring/decimal v1.2.0 // indirect
|
github.com/shopspring/decimal v1.2.0 // indirect
|
||||||
github.com/spf13/afero v1.9.2 // indirect
|
|
||||||
github.com/spf13/cast v1.5.0 // indirect
|
github.com/spf13/cast v1.5.0 // indirect
|
||||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad
|
github.com/spf13/pflag v1.0.5 // @grafana-app-platform-squad
|
||||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||||
github.com/subosito/gotenv v1.4.1 // indirect
|
|
||||||
github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect
|
github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect
|
||||||
github.com/unknwon/com v1.0.1 // indirect
|
github.com/unknwon/com v1.0.1 // indirect
|
||||||
github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect
|
github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect
|
||||||
|
|
|
@ -77,7 +77,6 @@ export interface FeatureToggles {
|
||||||
alertStateHistoryLokiOnly?: boolean;
|
alertStateHistoryLokiOnly?: boolean;
|
||||||
unifiedRequestLog?: boolean;
|
unifiedRequestLog?: boolean;
|
||||||
renderAuthJWT?: boolean;
|
renderAuthJWT?: boolean;
|
||||||
externalServiceAuth?: boolean;
|
|
||||||
refactorVariablesTimeRange?: boolean;
|
refactorVariablesTimeRange?: boolean;
|
||||||
enableElasticsearchBackendQuerying?: boolean;
|
enableElasticsearchBackendQuerying?: boolean;
|
||||||
faroDatasourceSelector?: boolean;
|
faroDatasourceSelector?: boolean;
|
||||||
|
|
|
@ -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": {
|
"external-registration": {
|
||||||
rootid: "grafana-test-datasource",
|
rootid: "grafana-test-datasource",
|
||||||
},
|
},
|
||||||
"oauth-external-registration": {
|
|
||||||
rootid: "grafana-test-datasource",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata"))
|
staticRootPath, err := filepath.Abs(filepath.Join("..", "manager", "testdata"))
|
||||||
|
|
|
@ -422,20 +422,6 @@ schemas: [{
|
||||||
#IAM: {
|
#IAM: {
|
||||||
// Permissions are the permissions that the external service needs its associated service account to have.
|
// Permissions are the permissions that the external service needs its associated service account to have.
|
||||||
permissions?: [...#Permission]
|
permissions?: [...#Permission]
|
||||||
|
|
||||||
// Impersonation describes the permissions that the external service will have on behalf of the user
|
|
||||||
// This is only available with the OAuth2 Server
|
|
||||||
impersonation?: #Impersonation
|
|
||||||
}
|
|
||||||
|
|
||||||
#Impersonation: {
|
|
||||||
// Groups allows the service to list the impersonated user's teams.
|
|
||||||
// Defaults to true.
|
|
||||||
groups?: bool
|
|
||||||
// Permissions are the permissions that the external service needs when impersonating a user.
|
|
||||||
// The intersection of this set with the impersonated user's permission guarantees that the client will not
|
|
||||||
// gain more privileges than the impersonated user has.
|
|
||||||
permissions?: [...#Permission]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
|
|
|
@ -132,24 +132,10 @@ type Header struct {
|
||||||
// IAM allows the plugin to get a service account with tailored permissions and a token
|
// IAM allows the plugin to get a service account with tailored permissions and a token
|
||||||
// (or to use the client_credentials grant if the token provider is the OAuth2 Server)
|
// (or to use the client_credentials grant if the token provider is the OAuth2 Server)
|
||||||
type IAM struct {
|
type IAM struct {
|
||||||
Impersonation *Impersonation `json:"impersonation,omitempty"`
|
|
||||||
|
|
||||||
// Permissions are the permissions that the external service needs its associated service account to have.
|
// Permissions are the permissions that the external service needs its associated service account to have.
|
||||||
Permissions []Permission `json:"permissions,omitempty"`
|
Permissions []Permission `json:"permissions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Impersonation defines model for Impersonation.
|
|
||||||
type Impersonation struct {
|
|
||||||
// Groups allows the service to list the impersonated user's teams.
|
|
||||||
// Defaults to true.
|
|
||||||
Groups *bool `json:"groups,omitempty"`
|
|
||||||
|
|
||||||
// Permissions are the permissions that the external service needs when impersonating a user.
|
|
||||||
// The intersection of this set with the impersonated user's permission guarantees that the client will not
|
|
||||||
// gain more privileges than the impersonated user has.
|
|
||||||
Permissions []Permission `json:"permissions,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A resource to be included in a plugin.
|
// A resource to be included in a plugin.
|
||||||
type Include struct {
|
type Include struct {
|
||||||
// RBAC action the user must have to access the route
|
// RBAC action the user must have to access the route
|
||||||
|
|
|
@ -67,8 +67,6 @@ import (
|
||||||
"github.com/grafana/grafana/pkg/services/encryption"
|
"github.com/grafana/grafana/pkg/services/encryption"
|
||||||
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
encryptionservice "github.com/grafana/grafana/pkg/services/encryption/service"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
|
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oasimpl"
|
|
||||||
extsvcreg "github.com/grafana/grafana/pkg/services/extsvcauth/registry"
|
extsvcreg "github.com/grafana/grafana/pkg/services/extsvcauth/registry"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
|
@ -372,8 +370,6 @@ var wireBasicSet = wire.NewSet(
|
||||||
supportbundlesimpl.ProvideService,
|
supportbundlesimpl.ProvideService,
|
||||||
extsvcaccounts.ProvideExtSvcAccountsService,
|
extsvcaccounts.ProvideExtSvcAccountsService,
|
||||||
wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)),
|
wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)),
|
||||||
oasimpl.ProvideService,
|
|
||||||
wire.Bind(new(oauthserver.OAuth2Server), new(*oasimpl.OAuth2ServiceImpl)),
|
|
||||||
extsvcreg.ProvideExtSvcRegistry,
|
extsvcreg.ProvideExtSvcRegistry,
|
||||||
wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*extsvcreg.Registry)),
|
wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*extsvcreg.Registry)),
|
||||||
anonstore.ProvideAnonDBStore,
|
anonstore.ProvideAnonDBStore,
|
||||||
|
|
|
@ -292,110 +292,6 @@ func Reduce(ps []Permission) map[string][]string {
|
||||||
return reduced
|
return reduced
|
||||||
}
|
}
|
||||||
|
|
||||||
// intersectScopes computes the minimal list of scopes common to two slices.
|
|
||||||
func intersectScopes(s1, s2 []string) []string {
|
|
||||||
if len(s1) == 0 || len(s2) == 0 {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpers
|
|
||||||
splitScopes := func(s []string) (map[string]bool, map[string]bool) {
|
|
||||||
scopes := make(map[string]bool)
|
|
||||||
wildcards := make(map[string]bool)
|
|
||||||
for _, s := range s {
|
|
||||||
if isWildcard(s) {
|
|
||||||
wildcards[s] = true
|
|
||||||
} else {
|
|
||||||
scopes[s] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return scopes, wildcards
|
|
||||||
}
|
|
||||||
includes := func(wildcardsSet map[string]bool, scope string) bool {
|
|
||||||
for wildcard := range wildcardsSet {
|
|
||||||
if wildcard == "*" || strings.HasPrefix(scope, wildcard[:len(wildcard)-1]) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
res := make([]string, 0)
|
|
||||||
|
|
||||||
// split input into scopes and wildcards
|
|
||||||
s1Scopes, s1Wildcards := splitScopes(s1)
|
|
||||||
s2Scopes, s2Wildcards := splitScopes(s2)
|
|
||||||
|
|
||||||
// intersect wildcards
|
|
||||||
wildcards := make(map[string]bool)
|
|
||||||
for s := range s1Wildcards {
|
|
||||||
// if s1 wildcard is included in s2 wildcards
|
|
||||||
// then it is included in the intersection
|
|
||||||
if includes(s2Wildcards, s) {
|
|
||||||
wildcards[s] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for s := range s2Wildcards {
|
|
||||||
// if s2 wildcard is included in s1 wildcards
|
|
||||||
// then it is included in the intersection
|
|
||||||
if includes(s1Wildcards, s) {
|
|
||||||
wildcards[s] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// intersect scopes
|
|
||||||
scopes := make(map[string]bool)
|
|
||||||
for s := range s1Scopes {
|
|
||||||
// if s1 scope is included in s2 wilcards or s2 scopes
|
|
||||||
// then it is included in the intersection
|
|
||||||
if includes(s2Wildcards, s) || s2Scopes[s] {
|
|
||||||
scopes[s] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for s := range s2Scopes {
|
|
||||||
// if s2 scope is included in s1 wilcards
|
|
||||||
// then it is included in the intersection
|
|
||||||
if includes(s1Wildcards, s) {
|
|
||||||
scopes[s] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge wildcards and scopes
|
|
||||||
for w := range wildcards {
|
|
||||||
res = append(res, w)
|
|
||||||
}
|
|
||||||
for s := range scopes {
|
|
||||||
res = append(res, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intersect returns the intersection of two slices of permissions, grouping scopes by action.
|
|
||||||
func Intersect(p1, p2 []Permission) map[string][]string {
|
|
||||||
if len(p1) == 0 || len(p2) == 0 {
|
|
||||||
return map[string][]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
res := make(map[string][]string)
|
|
||||||
p1m := Reduce(p1)
|
|
||||||
p2m := Reduce(p2)
|
|
||||||
|
|
||||||
// Loop over the smallest map
|
|
||||||
if len(p1m) > len(p2m) {
|
|
||||||
p1m, p2m = p2m, p1m
|
|
||||||
}
|
|
||||||
|
|
||||||
for a1, s1 := range p1m {
|
|
||||||
if s2, ok := p2m[a1]; ok {
|
|
||||||
res[a1] = intersectScopes(s1, s2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
func ValidateScope(scope string) bool {
|
func ValidateScope(scope string) bool {
|
||||||
prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
|
prefix, last := scope[:len(scope)-1], scope[len(scope)-1]
|
||||||
// verify that last char is either ':' or '/' if last character of scope is '*'
|
// verify that last char is either ':' or '/' if last character of scope is '*'
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package accesscontrol
|
package accesscontrol
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
@ -126,210 +125,3 @@ func TestReduce(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntersect(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
p1 []Permission
|
|
||||||
p2 []Permission
|
|
||||||
want map[string][]string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no permission",
|
|
||||||
p1: []Permission{},
|
|
||||||
p2: []Permission{},
|
|
||||||
want: map[string][]string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no intersection",
|
|
||||||
p1: []Permission{{Action: "orgs:read"}},
|
|
||||||
p2: []Permission{{Action: "orgs:write"}},
|
|
||||||
want: map[string][]string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection no scopes",
|
|
||||||
p1: []Permission{{Action: "orgs:read"}},
|
|
||||||
p2: []Permission{{Action: "orgs:read"}},
|
|
||||||
want: map[string][]string{"orgs:read": {}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unbalanced intersection",
|
|
||||||
p1: []Permission{{Action: "teams:read", Scope: "teams:id:1"}},
|
|
||||||
p2: []Permission{{Action: "teams:read"}},
|
|
||||||
want: map[string][]string{"teams:read": {}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection",
|
|
||||||
p1: []Permission{
|
|
||||||
{Action: "teams:read", Scope: "teams:id:1"},
|
|
||||||
{Action: "teams:read", Scope: "teams:id:2"},
|
|
||||||
{Action: "teams:write", Scope: "teams:id:1"},
|
|
||||||
},
|
|
||||||
p2: []Permission{
|
|
||||||
{Action: "teams:read", Scope: "teams:id:1"},
|
|
||||||
{Action: "teams:read", Scope: "teams:id:3"},
|
|
||||||
{Action: "teams:write", Scope: "teams:id:1"},
|
|
||||||
},
|
|
||||||
want: map[string][]string{
|
|
||||||
"teams:read": {"teams:id:1"},
|
|
||||||
"teams:write": {"teams:id:1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection with wildcards",
|
|
||||||
p1: []Permission{
|
|
||||||
{Action: "teams:read", Scope: "teams:id:1"},
|
|
||||||
{Action: "teams:read", Scope: "teams:id:2"},
|
|
||||||
{Action: "teams:write", Scope: "teams:id:1"},
|
|
||||||
},
|
|
||||||
p2: []Permission{
|
|
||||||
{Action: "teams:read", Scope: "*"},
|
|
||||||
{Action: "teams:write", Scope: "*"},
|
|
||||||
},
|
|
||||||
want: map[string][]string{
|
|
||||||
"teams:read": {"teams:id:1", "teams:id:2"},
|
|
||||||
"teams:write": {"teams:id:1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection with wildcards on both sides",
|
|
||||||
p1: []Permission{
|
|
||||||
{Action: "dashboards:read", Scope: "dashboards:uid:1"},
|
|
||||||
{Action: "dashboards:read", Scope: "folders:uid:1"},
|
|
||||||
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
|
|
||||||
{Action: "folders:read", Scope: "folders:uid:1"},
|
|
||||||
},
|
|
||||||
p2: []Permission{
|
|
||||||
{Action: "dashboards:read", Scope: "folders:uid:*"},
|
|
||||||
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
|
|
||||||
{Action: "folders:read", Scope: "folders:uid:*"},
|
|
||||||
},
|
|
||||||
want: map[string][]string{
|
|
||||||
"dashboards:read": {"dashboards:uid:*", "folders:uid:1"},
|
|
||||||
"folders:read": {"folders:uid:1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection with wildcards of different sizes",
|
|
||||||
p1: []Permission{
|
|
||||||
{Action: "dashboards:read", Scope: "folders:uid:1"},
|
|
||||||
{Action: "dashboards:read", Scope: "dashboards:*"},
|
|
||||||
{Action: "folders:read", Scope: "folders:*"},
|
|
||||||
{Action: "teams:read", Scope: "teams:id:1"},
|
|
||||||
},
|
|
||||||
p2: []Permission{
|
|
||||||
{Action: "dashboards:read", Scope: "folders:uid:*"},
|
|
||||||
{Action: "dashboards:read", Scope: "dashboards:uid:*"},
|
|
||||||
{Action: "folders:read", Scope: "folders:uid:*"},
|
|
||||||
{Action: "teams:read", Scope: "*"},
|
|
||||||
},
|
|
||||||
want: map[string][]string{
|
|
||||||
"dashboards:read": {"dashboards:uid:*", "folders:uid:1"},
|
|
||||||
"folders:read": {"folders:uid:*"},
|
|
||||||
"teams:read": {"teams:id:1"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
check := func(t *testing.T, want map[string][]string, p1, p2 []Permission) {
|
|
||||||
intersect := Intersect(p1, p2)
|
|
||||||
for action, scopes := range intersect {
|
|
||||||
want, ok := want[action]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.ElementsMatch(t, scopes, want, fmt.Sprintf("scopes for %v differs from expected", action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Intersect is commutative
|
|
||||||
check(t, tt.want, tt.p1, tt.p2)
|
|
||||||
check(t, tt.want, tt.p2, tt.p1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_intersectScopes(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
s1 []string
|
|
||||||
s2 []string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no values",
|
|
||||||
s1: []string{},
|
|
||||||
s2: []string{},
|
|
||||||
want: []string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no values on one side",
|
|
||||||
s1: []string{},
|
|
||||||
s2: []string{"teams:id:1"},
|
|
||||||
want: []string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty values on one side",
|
|
||||||
s1: []string{""},
|
|
||||||
s2: []string{"team:id:1"},
|
|
||||||
want: []string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no intersection",
|
|
||||||
s1: []string{"teams:id:1"},
|
|
||||||
s2: []string{"teams:id:2"},
|
|
||||||
want: []string{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection",
|
|
||||||
s1: []string{"teams:id:1"},
|
|
||||||
s2: []string{"teams:id:1"},
|
|
||||||
want: []string{"teams:id:1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection with wildcard",
|
|
||||||
s1: []string{"teams:id:1", "teams:id:2"},
|
|
||||||
s2: []string{"teams:id:*"},
|
|
||||||
want: []string{"teams:id:1", "teams:id:2"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection of wildcards",
|
|
||||||
s1: []string{"teams:id:*"},
|
|
||||||
s2: []string{"teams:id:*"},
|
|
||||||
want: []string{"teams:id:*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection with a bigger wildcards",
|
|
||||||
s1: []string{"teams:id:*"},
|
|
||||||
s2: []string{"teams:*"},
|
|
||||||
want: []string{"teams:id:*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection of different wildcards with a bigger one",
|
|
||||||
s1: []string{"dashboards:uid:*", "folders:uid:*"},
|
|
||||||
s2: []string{"*"},
|
|
||||||
want: []string{"dashboards:uid:*", "folders:uid:*"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection with wildcards and scopes on both sides",
|
|
||||||
s1: []string{"dashboards:uid:*", "folders:uid:1"},
|
|
||||||
s2: []string{"folders:uid:*", "dashboards:uid:1"},
|
|
||||||
want: []string{"dashboards:uid:1", "folders:uid:1"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "intersection of non reduced list of scopes",
|
|
||||||
s1: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1"},
|
|
||||||
s2: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:2"},
|
|
||||||
want: []string{"dashboards:uid:*", "dashboards:*", "dashboards:uid:1", "dashboards:uid:2"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
check := func(t *testing.T, want []string, s1, s2 []string) {
|
|
||||||
intersect := intersectScopes(s1, s2)
|
|
||||||
require.ElementsMatch(t, want, intersect)
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Intersect is commutative
|
|
||||||
check(t, tt.want, tt.s1, tt.s2)
|
|
||||||
check(t, tt.want, tt.s2, tt.s1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -427,7 +427,7 @@ func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchO
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error {
|
func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error {
|
||||||
if !(s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) || s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts)) {
|
if !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||||
s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.")
|
s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -440,7 +440,7 @@ func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error {
|
func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalServiceID string) error {
|
||||||
if !(s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) || s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts)) {
|
if !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||||
s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.")
|
s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -869,7 +869,7 @@ func TestService_SaveExternalServiceRole(t *testing.T) {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ac := setupTestEnv(t)
|
ac := setupTestEnv(t)
|
||||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts)
|
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
|
||||||
for _, r := range tt.runs {
|
for _, r := range tt.runs {
|
||||||
err := ac.SaveExternalServiceRole(ctx, r.cmd)
|
err := ac.SaveExternalServiceRole(ctx, r.cmd)
|
||||||
if r.wantErr {
|
if r.wantErr {
|
||||||
|
@ -915,7 +915,7 @@ func TestService_DeleteExternalServiceRole(t *testing.T) {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ac := setupTestEnv(t)
|
ac := setupTestEnv(t)
|
||||||
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts)
|
ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts)
|
||||||
|
|
||||||
if tt.initCmd != nil {
|
if tt.initCmd != nil {
|
||||||
err := ac.SaveExternalServiceRole(ctx, *tt.initCmd)
|
err := ac.SaveExternalServiceRole(ctx, *tt.initCmd)
|
||||||
|
|
|
@ -341,9 +341,8 @@ const (
|
||||||
ActionAPIKeyDelete = "apikeys:delete"
|
ActionAPIKeyDelete = "apikeys:delete"
|
||||||
|
|
||||||
// Users actions
|
// Users actions
|
||||||
ActionUsersRead = "users:read"
|
ActionUsersRead = "users:read"
|
||||||
ActionUsersWrite = "users:write"
|
ActionUsersWrite = "users:write"
|
||||||
ActionUsersImpersonate = "users:impersonate"
|
|
||||||
|
|
||||||
// We can ignore gosec G101 since this does not contain any credentials.
|
// We can ignore gosec G101 since this does not contain any credentials.
|
||||||
// nolint:gosec
|
// nolint:gosec
|
||||||
|
|
|
@ -24,7 +24,6 @@ import (
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/services/authn/authnimpl/sync"
|
"github.com/grafana/grafana/pkg/services/authn/authnimpl/sync"
|
||||||
"github.com/grafana/grafana/pkg/services/authn/clients"
|
"github.com/grafana/grafana/pkg/services/authn/clients"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
|
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/ldap/service"
|
"github.com/grafana/grafana/pkg/services/ldap/service"
|
||||||
"github.com/grafana/grafana/pkg/services/login"
|
"github.com/grafana/grafana/pkg/services/login"
|
||||||
|
@ -72,7 +71,7 @@ func ProvideService(
|
||||||
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
|
features *featuremgmt.FeatureManager, oauthTokenService oauthtoken.OAuthTokenService,
|
||||||
socialService social.Service, cache *remotecache.RemoteCache,
|
socialService social.Service, cache *remotecache.RemoteCache,
|
||||||
ldapService service.LDAP, registerer prometheus.Registerer,
|
ldapService service.LDAP, registerer prometheus.Registerer,
|
||||||
signingKeysService signingkeys.Service, oauthServer oauthserver.OAuth2Server,
|
signingKeysService signingkeys.Service,
|
||||||
settingsProviderService setting.Provider,
|
settingsProviderService setting.Provider,
|
||||||
) *Service {
|
) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
|
@ -136,9 +135,10 @@ func ProvideService(
|
||||||
s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
|
s.RegisterClient(clients.ProvideJWT(jwtService, cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
// FIXME (gamab): Commenting that out for now as we want to re-use the client for external service auth
|
||||||
s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer))
|
// if s.cfg.ExtendedJWTAuthEnabled && features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
||||||
}
|
// s.RegisterClient(clients.ProvideExtendedJWT(userService, cfg, signingKeysService, oauthServer))
|
||||||
|
// }
|
||||||
|
|
||||||
for name := range socialService.GetOAuthProviders() {
|
for name := range socialService.GetOAuthProviders() {
|
||||||
clientName := authn.ClientWithPrefix(name)
|
clientName := authn.ClientWithPrefix(name)
|
||||||
|
|
|
@ -2,7 +2,6 @@ package clients
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/util/errutil"
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
|
@ -43,10 +42,6 @@ func (c *Basic) Test(ctx context.Context, r *authn.Request) bool {
|
||||||
if r.HTTPRequest == nil {
|
if r.HTTPRequest == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// The OAuth2 introspection endpoint uses basic auth but is handled by the oauthserver package.
|
|
||||||
if strings.EqualFold(r.HTTPRequest.RequestURI, "/oauth2/introspect") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return looksLikeBasicAuthRequest(r)
|
return looksLikeBasicAuthRequest(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -85,12 +85,6 @@ func TestBasic_Test(t *testing.T) {
|
||||||
HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {"something"}}},
|
HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {"something"}}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
desc: "should fail when the URL ends with /oauth2/introspect",
|
|
||||||
req: &authn.Request{
|
|
||||||
HTTPRequest: &http.Request{Header: map[string][]string{authorizationHeaderName: {encodeBasicAuth("user", "password")}}, RequestURI: "/oauth2/introspect"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
@ -14,7 +14,6 @@ import (
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
|
|
||||||
"github.com/grafana/grafana/pkg/services/login"
|
"github.com/grafana/grafana/pkg/services/login"
|
||||||
"github.com/grafana/grafana/pkg/services/signingkeys"
|
"github.com/grafana/grafana/pkg/services/signingkeys"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
@ -33,13 +32,12 @@ const (
|
||||||
rfc9068MediaType = "application/at+jwt"
|
rfc9068MediaType = "application/at+jwt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service, oauthServer oauthserver.OAuth2Server) *ExtendedJWT {
|
func ProvideExtendedJWT(userService user.Service, cfg *setting.Cfg, signingKeys signingkeys.Service) *ExtendedJWT {
|
||||||
return &ExtendedJWT{
|
return &ExtendedJWT{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log.New(authn.ClientExtendedJWT),
|
log: log.New(authn.ClientExtendedJWT),
|
||||||
userService: userService,
|
userService: userService,
|
||||||
signingKeys: signingKeys,
|
signingKeys: signingKeys,
|
||||||
oauthServer: oauthServer,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +46,6 @@ type ExtendedJWT struct {
|
||||||
log log.Logger
|
log log.Logger
|
||||||
userService user.Service
|
userService user.Service
|
||||||
signingKeys signingkeys.Service
|
signingKeys signingkeys.Service
|
||||||
oauthServer oauthserver.OAuth2Server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtendedJWTClaims struct {
|
type ExtendedJWTClaims struct {
|
||||||
|
@ -222,10 +219,6 @@ func (s *ExtendedJWT) validateClientIdClaim(ctx context.Context, claims Extended
|
||||||
return fmt.Errorf("missing 'client_id' claim")
|
return fmt.Errorf("missing 'client_id' claim")
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.oauthServer.GetExternalService(ctx, claims.ClientID); err != nil {
|
|
||||||
return fmt.Errorf("invalid 'client_id' claim: %s", claims.ClientID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,8 +17,6 @@ import (
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/models/roletype"
|
"github.com/grafana/grafana/pkg/models/roletype"
|
||||||
"github.com/grafana/grafana/pkg/services/authn"
|
"github.com/grafana/grafana/pkg/services/authn"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver"
|
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oastest"
|
|
||||||
"github.com/grafana/grafana/pkg/services/login"
|
"github.com/grafana/grafana/pkg/services/login"
|
||||||
"github.com/grafana/grafana/pkg/services/signingkeys"
|
"github.com/grafana/grafana/pkg/services/signingkeys"
|
||||||
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest"
|
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystest"
|
||||||
|
@ -268,27 +266,6 @@ func TestExtendedJWT_Authenticate(t *testing.T) {
|
||||||
want: nil,
|
want: nil,
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "should return error when the client was not found",
|
|
||||||
payload: ExtendedJWTClaims{
|
|
||||||
Claims: jwt.Claims{
|
|
||||||
Issuer: "http://localhost:3000",
|
|
||||||
Subject: "user:id:2",
|
|
||||||
Audience: jwt.Audience{"http://localhost:3000"},
|
|
||||||
ID: "1234567890",
|
|
||||||
Expiry: jwt.NewNumericDate(time.Date(2023, 5, 3, 0, 0, 0, 0, time.UTC)),
|
|
||||||
IssuedAt: jwt.NewNumericDate(time.Date(2023, 5, 2, 0, 0, 0, 0, time.UTC)),
|
|
||||||
},
|
|
||||||
ClientID: "unknown-client-id",
|
|
||||||
Scopes: []string{"profile", "groups"},
|
|
||||||
},
|
|
||||||
initTestEnv: func(env *testEnv) {
|
|
||||||
env.oauthSvc.ExpectedErr = oauthserver.ErrClientNotFoundFn("unknown-client-id")
|
|
||||||
},
|
|
||||||
orgID: 1,
|
|
||||||
want: nil,
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
|
@ -521,21 +498,18 @@ func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv {
|
||||||
}
|
}
|
||||||
|
|
||||||
userSvc := &usertest.FakeUserService{}
|
userSvc := &usertest.FakeUserService{}
|
||||||
oauthSvc := &oastest.FakeService{}
|
|
||||||
|
|
||||||
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc, oauthSvc)
|
extJwtClient := ProvideExtendedJWT(userSvc, cfg, signingKeysSvc)
|
||||||
|
|
||||||
return &testEnv{
|
return &testEnv{
|
||||||
oauthSvc: oauthSvc,
|
userSvc: userSvc,
|
||||||
userSvc: userSvc,
|
s: extJwtClient,
|
||||||
s: extJwtClient,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type testEnv struct {
|
type testEnv struct {
|
||||||
oauthSvc *oastest.FakeService
|
userSvc *usertest.FakeUserService
|
||||||
userSvc *usertest.FakeUserService
|
s *ExtendedJWT
|
||||||
s *ExtendedJWT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateToken(payload ExtendedJWTClaims, signingKey any, alg jose.SignatureAlgorithm) string {
|
func generateToken(payload ExtendedJWTClaims, signingKey any, alg jose.SignatureAlgorithm) string {
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
OAuth2Server AuthProvider = "OAuth2Server"
|
|
||||||
ServiceAccounts AuthProvider = "ServiceAccounts"
|
ServiceAccounts AuthProvider = "ServiceAccounts"
|
||||||
|
|
||||||
// TmpOrgID is the orgID we use while global service accounts are not supported.
|
// TmpOrgID is the orgID we use while global service accounts are not supported.
|
||||||
|
@ -40,23 +39,9 @@ type SelfCfg struct {
|
||||||
Permissions []accesscontrol.Permission
|
Permissions []accesscontrol.Permission
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImpersonationCfg struct {
|
|
||||||
// Enabled allows the service to request access tokens to impersonate users
|
|
||||||
Enabled bool
|
|
||||||
// Groups allows the service to list the impersonated user's teams
|
|
||||||
Groups bool
|
|
||||||
// Permissions are the permissions that the external service needs when impersonating a user.
|
|
||||||
// The intersection of this set with the impersonated user's permission guarantees that the client will not
|
|
||||||
// gain more privileges than the impersonated user has and vice versa.
|
|
||||||
Permissions []accesscontrol.Permission
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExternalServiceRegistration represents the registration form to save new client.
|
// ExternalServiceRegistration represents the registration form to save new client.
|
||||||
type ExternalServiceRegistration struct {
|
type ExternalServiceRegistration struct {
|
||||||
Name string
|
Name string
|
||||||
// Impersonation access configuration
|
|
||||||
// (this is not available on all auth providers)
|
|
||||||
Impersonation ImpersonationCfg
|
|
||||||
// Self access configuration
|
// Self access configuration
|
||||||
Self SelfCfg
|
Self SelfCfg
|
||||||
// Auth Provider that the client will use to connect to Grafana
|
// Auth Provider that the client will use to connect to Grafana
|
||||||
|
|
|
@ -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
|
package registry
|
||||||
|
|
||||||
|
// FIXME (gamab): we can eventually remove this package
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
@ -9,7 +11,6 @@ import (
|
||||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
"github.com/grafana/grafana/pkg/infra/slugify"
|
"github.com/grafana/grafana/pkg/infra/slugify"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
"github.com/grafana/grafana/pkg/services/extsvcauth"
|
||||||
"github.com/grafana/grafana/pkg/services/extsvcauth/oauthserver/oasimpl"
|
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts"
|
"github.com/grafana/grafana/pkg/services/serviceaccounts/extsvcaccounts"
|
||||||
)
|
)
|
||||||
|
@ -29,21 +30,20 @@ type serverLocker interface {
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
features featuremgmt.FeatureToggles
|
features featuremgmt.FeatureToggles
|
||||||
logger log.Logger
|
logger log.Logger
|
||||||
oauthReg extsvcauth.ExternalServiceRegistry
|
|
||||||
saReg extsvcauth.ExternalServiceRegistry
|
saReg extsvcauth.ExternalServiceRegistry
|
||||||
|
|
||||||
|
// FIXME (gamab): we can remove this field and use the saReg.GetExternalServiceNames directly
|
||||||
extSvcProviders map[string]extsvcauth.AuthProvider
|
extSvcProviders map[string]extsvcauth.AuthProvider
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
serverLock serverLocker
|
serverLock serverLocker
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideExtSvcRegistry(oauthServer *oasimpl.OAuth2ServiceImpl, saSvc *extsvcaccounts.ExtSvcAccountsService, serverLock *serverlock.ServerLockService, features featuremgmt.FeatureToggles) *Registry {
|
func ProvideExtSvcRegistry(saSvc *extsvcaccounts.ExtSvcAccountsService, serverLock *serverlock.ServerLockService, features featuremgmt.FeatureToggles) *Registry {
|
||||||
return &Registry{
|
return &Registry{
|
||||||
extSvcProviders: map[string]extsvcauth.AuthProvider{},
|
extSvcProviders: map[string]extsvcauth.AuthProvider{},
|
||||||
features: features,
|
features: features,
|
||||||
lock: sync.Mutex{},
|
lock: sync.Mutex{},
|
||||||
logger: log.New("extsvcauth.registry"),
|
logger: log.New("extsvcauth.registry"),
|
||||||
oauthReg: oauthServer,
|
|
||||||
saReg: saSvc,
|
saReg: saSvc,
|
||||||
serverLock: serverLock,
|
serverLock: serverLock,
|
||||||
}
|
}
|
||||||
|
@ -70,11 +70,6 @@ func (r *Registry) CleanUpOrphanedExternalServices(ctx context.Context) error {
|
||||||
errCleanUp = err
|
errCleanUp = err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case extsvcauth.OAuth2Server:
|
|
||||||
if err := r.oauthReg.RemoveExternalService(ctx, name); err != nil {
|
|
||||||
errCleanUp = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -121,13 +116,6 @@ func (r *Registry) RemoveExternalService(ctx context.Context, name string) error
|
||||||
}
|
}
|
||||||
r.logger.Debug("Routing External Service removal to the External Service Account service", "service", name)
|
r.logger.Debug("Routing External Service removal to the External Service Account service", "service", name)
|
||||||
return r.saReg.RemoveExternalService(ctx, name)
|
return r.saReg.RemoveExternalService(ctx, name)
|
||||||
case extsvcauth.OAuth2Server:
|
|
||||||
if !r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
|
|
||||||
r.logger.Debug("Skipping External Service removal, flag disabled", "service", name, "flag", featuremgmt.FlagExternalServiceAccounts)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
r.logger.Debug("Routing External Service removal to the OAuth2Server", "service", name)
|
|
||||||
return r.oauthReg.RemoveExternalService(ctx, name)
|
|
||||||
default:
|
default:
|
||||||
return extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", provider)
|
return extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", provider)
|
||||||
}
|
}
|
||||||
|
@ -157,13 +145,6 @@ func (r *Registry) SaveExternalService(ctx context.Context, cmd *extsvcauth.Exte
|
||||||
}
|
}
|
||||||
r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name)
|
r.logger.Debug("Routing the External Service registration to the External Service Account service", "service", cmd.Name)
|
||||||
extSvc, errSave = r.saReg.SaveExternalService(ctx, cmd)
|
extSvc, errSave = r.saReg.SaveExternalService(ctx, cmd)
|
||||||
case extsvcauth.OAuth2Server:
|
|
||||||
if !r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
|
|
||||||
r.logger.Warn("Skipping External Service authentication, flag disabled", "service", cmd.Name, "flag", featuremgmt.FlagExternalServiceAuth)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.logger.Debug("Routing the External Service registration to the OAuth2Server", "service", cmd.Name)
|
|
||||||
extSvc, errSave = r.oauthReg.SaveExternalService(ctx, cmd)
|
|
||||||
default:
|
default:
|
||||||
errSave = extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", cmd.AuthProvider)
|
errSave = extsvcauth.ErrUnknownProvider.Errorf("unknown provider '%v'", cmd.AuthProvider)
|
||||||
}
|
}
|
||||||
|
@ -187,16 +168,7 @@ func (r *Registry) retrieveExtSvcProviders(ctx context.Context) (map[string]exts
|
||||||
extsvcs[names[i]] = extsvcauth.ServiceAccounts
|
extsvcs[names[i]] = extsvcauth.ServiceAccounts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Important to run this second as the OAuth server uses External Service Accounts as well.
|
|
||||||
if r.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
|
|
||||||
names, err := r.oauthReg.GetExternalServiceNames(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i := range names {
|
|
||||||
extsvcs[names[i]] = extsvcauth.OAuth2Server
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return extsvcs, nil
|
return extsvcs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,9 +14,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type TestEnv struct {
|
type TestEnv struct {
|
||||||
r *Registry
|
r *Registry
|
||||||
oauthReg *tests.ExternalServiceRegistryMock
|
saReg *tests.ExternalServiceRegistryMock
|
||||||
saReg *tests.ExternalServiceRegistryMock
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never lock in tests
|
// Never lock in tests
|
||||||
|
@ -29,12 +28,10 @@ func (f *fakeServerLock) LockExecuteAndReleaseWithRetries(ctx context.Context, a
|
||||||
|
|
||||||
func setupTestEnv(t *testing.T) *TestEnv {
|
func setupTestEnv(t *testing.T) *TestEnv {
|
||||||
env := TestEnv{}
|
env := TestEnv{}
|
||||||
env.oauthReg = tests.NewExternalServiceRegistryMock(t)
|
|
||||||
env.saReg = tests.NewExternalServiceRegistryMock(t)
|
env.saReg = tests.NewExternalServiceRegistryMock(t)
|
||||||
env.r = &Registry{
|
env.r = &Registry{
|
||||||
features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth, featuremgmt.FlagExternalServiceAccounts),
|
features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts),
|
||||||
logger: log.New("extsvcauth.registry.test"),
|
logger: log.New("extsvcauth.registry.test"),
|
||||||
oauthReg: env.oauthReg,
|
|
||||||
saReg: env.saReg,
|
saReg: env.saReg,
|
||||||
extSvcProviders: map[string]extsvcauth.AuthProvider{},
|
extSvcProviders: map[string]extsvcauth.AuthProvider{},
|
||||||
serverLock: &fakeServerLock{},
|
serverLock: &fakeServerLock{},
|
||||||
|
@ -51,39 +48,24 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) {
|
||||||
name: "should not clean up when every service registered",
|
name: "should not clean up when every service registered",
|
||||||
init: func(te *TestEnv) {
|
init: func(te *TestEnv) {
|
||||||
// Have registered two services one requested a service account, the other requested to be an oauth client
|
// Have registered two services one requested a service account, the other requested to be an oauth client
|
||||||
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server}
|
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts}
|
||||||
|
|
||||||
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil)
|
|
||||||
// Also return the external service account attached to the OAuth Server
|
// Also return the external service account attached to the OAuth Server
|
||||||
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "oauth-svc"}, nil)
|
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc"}, nil)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "should clean up an orphaned service account",
|
name: "should clean up an orphaned service account",
|
||||||
init: func(te *TestEnv) {
|
init: func(te *TestEnv) {
|
||||||
// Have registered two services one requested a service account, the other requested to be an oauth client
|
// Have registered two services one requested a service account, the other requested to be an oauth client
|
||||||
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server}
|
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts}
|
||||||
|
|
||||||
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil)
|
|
||||||
// Also return the external service account attached to the OAuth Server
|
// Also return the external service account attached to the OAuth Server
|
||||||
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-sa-svc", "oauth-svc"}, nil)
|
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-sa-svc"}, nil)
|
||||||
|
|
||||||
te.saReg.On("RemoveExternalService", mock.Anything, "orphaned-sa-svc").Return(nil)
|
te.saReg.On("RemoveExternalService", mock.Anything, "orphaned-sa-svc").Return(nil)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "should clean up an orphaned OAuth Client",
|
|
||||||
init: func(te *TestEnv) {
|
|
||||||
// Have registered two services one requested a service account, the other requested to be an oauth client
|
|
||||||
te.r.extSvcProviders = map[string]extsvcauth.AuthProvider{"sa-svc": extsvcauth.ServiceAccounts, "oauth-svc": extsvcauth.OAuth2Server}
|
|
||||||
|
|
||||||
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc", "orphaned-oauth-svc"}, nil)
|
|
||||||
// Also return the external service account attached to the OAuth Server
|
|
||||||
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "orphaned-oauth-svc", "oauth-svc"}, nil)
|
|
||||||
|
|
||||||
te.oauthReg.On("RemoveExternalService", mock.Anything, "orphaned-oauth-svc").Return(nil)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
@ -93,37 +75,6 @@ func TestRegistry_CleanUpOrphanedExternalServices(t *testing.T) {
|
||||||
err := env.r.CleanUpOrphanedExternalServices(context.Background())
|
err := env.r.CleanUpOrphanedExternalServices(context.Background())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
env.oauthReg.AssertExpectations(t)
|
|
||||||
env.saReg.AssertExpectations(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegistry_GetExternalServiceNames(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
init func(*TestEnv)
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "should deduplicate names",
|
|
||||||
init: func(te *TestEnv) {
|
|
||||||
te.saReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"sa-svc", "oauth-svc"}, nil)
|
|
||||||
te.oauthReg.On("GetExternalServiceNames", mock.Anything).Return([]string{"oauth-svc"}, nil)
|
|
||||||
},
|
|
||||||
want: []string{"sa-svc", "oauth-svc"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
env := setupTestEnv(t)
|
|
||||||
tt.init(env)
|
|
||||||
|
|
||||||
names, err := env.r.GetExternalServiceNames(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.ElementsMatch(t, tt.want, names)
|
|
||||||
|
|
||||||
env.oauthReg.AssertExpectations(t)
|
|
||||||
env.saReg.AssertExpectations(t)
|
env.saReg.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -443,13 +443,6 @@ var (
|
||||||
Owner: grafanaAsCodeSquad,
|
Owner: grafanaAsCodeSquad,
|
||||||
HideFromAdminPage: true,
|
HideFromAdminPage: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: "externalServiceAuth",
|
|
||||||
Description: "Starts an OAuth2 authentication provider for external services",
|
|
||||||
Stage: FeatureStageExperimental,
|
|
||||||
RequiresDevMode: true,
|
|
||||||
Owner: identityAccessTeam,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: "refactorVariablesTimeRange",
|
Name: "refactorVariablesTimeRange",
|
||||||
Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained",
|
Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained",
|
||||||
|
|
|
@ -58,7 +58,6 @@ alertStateHistoryLokiPrimary,experimental,@grafana/alerting-squad,false,false,fa
|
||||||
alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false
|
alertStateHistoryLokiOnly,experimental,@grafana/alerting-squad,false,false,false
|
||||||
unifiedRequestLog,experimental,@grafana/backend-platform,false,false,false
|
unifiedRequestLog,experimental,@grafana/backend-platform,false,false,false
|
||||||
renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false
|
renderAuthJWT,preview,@grafana/grafana-as-code,false,false,false
|
||||||
externalServiceAuth,experimental,@grafana/identity-access-team,true,false,false
|
|
||||||
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false
|
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false
|
||||||
enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false
|
enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,false,false,false
|
||||||
faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true
|
faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true
|
||||||
|
|
|
|
@ -243,10 +243,6 @@ const (
|
||||||
// Uses JWT-based auth for rendering instead of relying on remote cache
|
// Uses JWT-based auth for rendering instead of relying on remote cache
|
||||||
FlagRenderAuthJWT = "renderAuthJWT"
|
FlagRenderAuthJWT = "renderAuthJWT"
|
||||||
|
|
||||||
// FlagExternalServiceAuth
|
|
||||||
// Starts an OAuth2 authentication provider for external services
|
|
||||||
FlagExternalServiceAuth = "externalServiceAuth"
|
|
||||||
|
|
||||||
// FlagRefactorVariablesTimeRange
|
// FlagRefactorVariablesTimeRange
|
||||||
// Refactor time range variables flow to reduce number of API calls made when query variables are chained
|
// Refactor time range variables flow to reduce number of API calls made when query variables are chained
|
||||||
FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange"
|
FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange"
|
||||||
|
|
|
@ -1569,7 +1569,8 @@
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "externalServiceAuth",
|
"name": "externalServiceAuth",
|
||||||
"resourceVersion": "1708108588074",
|
"resourceVersion": "1708108588074",
|
||||||
"creationTimestamp": "2024-02-16T18:36:28Z"
|
"creationTimestamp": "2024-02-16T18:36:28Z",
|
||||||
|
"deletionTimestamp": "2024-02-21T10:10:41Z"
|
||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"description": "Starts an OAuth2 authentication provider for external services",
|
"description": "Starts an OAuth2 authentication provider for external services",
|
||||||
|
|
|
@ -508,116 +508,11 @@ func TestLoader_Load(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoader_Load_ExternalRegistration(t *testing.T) {
|
func TestLoader_Load_ExternalRegistration(t *testing.T) {
|
||||||
boolPtr := func(b bool) *bool { return &b }
|
|
||||||
stringPtr := func(s string) *string { return &s }
|
stringPtr := func(s string) *string { return &s }
|
||||||
|
|
||||||
t.Run("Load a plugin with oauth client registration", func(t *testing.T) {
|
|
||||||
cfg := &config.Cfg{
|
|
||||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth),
|
|
||||||
PluginsAllowUnsigned: []string{"grafana-test-datasource"},
|
|
||||||
AWSAssumeRoleEnabled: true,
|
|
||||||
}
|
|
||||||
pluginPaths := []string{filepath.Join(testDataDir(t), "oauth-external-registration")}
|
|
||||||
expected := []*plugins.Plugin{
|
|
||||||
{JSONData: plugins.JSONData{
|
|
||||||
ID: "grafana-test-datasource",
|
|
||||||
Type: plugins.TypeDataSource,
|
|
||||||
Name: "Test",
|
|
||||||
Backend: true,
|
|
||||||
Executable: "gpx_test_datasource",
|
|
||||||
Info: plugins.Info{
|
|
||||||
Author: plugins.InfoLink{
|
|
||||||
Name: "Grafana Labs",
|
|
||||||
URL: "https://grafana.com",
|
|
||||||
},
|
|
||||||
Version: "1.0.0",
|
|
||||||
Logos: plugins.Logos{
|
|
||||||
Small: "public/plugins/grafana-test-datasource/img/ds.svg",
|
|
||||||
Large: "public/plugins/grafana-test-datasource/img/ds.svg",
|
|
||||||
},
|
|
||||||
Updated: "2023-08-03",
|
|
||||||
Screenshots: []plugins.Screenshots{},
|
|
||||||
},
|
|
||||||
Dependencies: plugins.Dependencies{
|
|
||||||
GrafanaVersion: "*",
|
|
||||||
Plugins: []plugins.Dependency{},
|
|
||||||
},
|
|
||||||
IAM: &plugindef.IAM{
|
|
||||||
Impersonation: &plugindef.Impersonation{
|
|
||||||
Groups: boolPtr(true),
|
|
||||||
Permissions: []plugindef.Permission{
|
|
||||||
{
|
|
||||||
Action: "read",
|
|
||||||
Scope: stringPtr("datasource"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Permissions: []plugindef.Permission{
|
|
||||||
{
|
|
||||||
Action: "read",
|
|
||||||
Scope: stringPtr("datasource"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
FS: mustNewStaticFSForTests(t, pluginPaths[0]),
|
|
||||||
Class: plugins.ClassExternal,
|
|
||||||
Signature: plugins.SignatureStatusUnsigned,
|
|
||||||
Module: "public/plugins/grafana-test-datasource/module.js",
|
|
||||||
BaseURL: "public/plugins/grafana-test-datasource",
|
|
||||||
ExternalService: &auth.ExternalService{
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "secretz",
|
|
||||||
PrivateKey: "priv@t3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
backendFactoryProvider := fakes.NewFakeBackendProcessProvider()
|
|
||||||
backendFactoryProvider.BackendFactoryFunc = func(ctx context.Context, plugin *plugins.Plugin) backendplugin.PluginFactoryFunc {
|
|
||||||
return func(pluginID string, logger log.Logger, env func() []string) (backendplugin.Plugin, error) {
|
|
||||||
require.Equal(t, "grafana-test-datasource", pluginID)
|
|
||||||
require.Equal(t, []string{
|
|
||||||
"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=",
|
|
||||||
"GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=",
|
|
||||||
"GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz",
|
|
||||||
"GF_PLUGIN_APP_PRIVATE_KEY=priv@t3", "GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth",
|
|
||||||
}, env())
|
|
||||||
return &fakes.FakeBackendPlugin{}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
l := newLoaderWithOpts(t, cfg, loaderDepOpts{
|
|
||||||
authServiceRegistry: &fakes.FakeAuthService{
|
|
||||||
Result: &auth.ExternalService{
|
|
||||||
ClientID: "client-id",
|
|
||||||
ClientSecret: "secretz",
|
|
||||||
PrivateKey: "priv@t3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
backendFactoryProvider: backendFactoryProvider,
|
|
||||||
})
|
|
||||||
got, err := l.Load(context.Background(), &fakes.FakePluginSource{
|
|
||||||
PluginClassFunc: func(ctx context.Context) plugins.Class {
|
|
||||||
return plugins.ClassExternal
|
|
||||||
},
|
|
||||||
PluginURIsFunc: func(ctx context.Context) []string {
|
|
||||||
return pluginPaths
|
|
||||||
},
|
|
||||||
DefaultSignatureFunc: func(ctx context.Context) (plugins.Signature, bool) {
|
|
||||||
return plugins.Signature{}, false
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
if !cmp.Equal(got, expected, compareOpts...) {
|
|
||||||
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Load a plugin with service account registration", func(t *testing.T) {
|
t.Run("Load a plugin with service account registration", func(t *testing.T) {
|
||||||
cfg := &config.Cfg{
|
cfg := &config.Cfg{
|
||||||
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth),
|
Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAccounts),
|
||||||
PluginsAllowUnsigned: []string{"grafana-test-datasource"},
|
PluginsAllowUnsigned: []string{"grafana-test-datasource"},
|
||||||
AWSAssumeRoleEnabled: true,
|
AWSAssumeRoleEnabled: true,
|
||||||
}
|
}
|
||||||
|
@ -676,7 +571,7 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
|
||||||
"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=",
|
"GF_VERSION=", "GF_EDITION=", "GF_ENTERPRISE_LICENSE_PATH=",
|
||||||
"GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=",
|
"GF_ENTERPRISE_APP_URL=", "GF_ENTERPRISE_LICENSE_TEXT=", "GF_APP_URL=",
|
||||||
"GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz",
|
"GF_PLUGIN_APP_CLIENT_ID=client-id", "GF_PLUGIN_APP_CLIENT_SECRET=secretz",
|
||||||
"GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAuth",
|
"GF_INSTANCE_FEATURE_TOGGLES_ENABLE=externalServiceAccounts",
|
||||||
}, env())
|
}, env())
|
||||||
return &fakes.FakeBackendPlugin{}, nil
|
return &fakes.FakeBackendPlugin{}, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ type Service struct {
|
||||||
|
|
||||||
func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service {
|
func ProvideService(cfg *config.Cfg, reg extsvcauth.ExternalServiceRegistry, settingsSvc pluginsettings.Service) *Service {
|
||||||
s := &Service{
|
s := &Service{
|
||||||
featureEnabled: cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) || cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
|
featureEnabled: cfg.Features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
|
||||||
log: log.New("plugins.external.registration"),
|
log: log.New("plugins.external.registration"),
|
||||||
reg: reg,
|
reg: reg,
|
||||||
settingsSvc: settingsSvc,
|
settingsSvc: settingsSvc,
|
||||||
|
@ -58,18 +58,6 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string,
|
||||||
|
|
||||||
enabled = (settings != nil) && settings.Enabled
|
enabled = (settings != nil) && settings.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
impersonation := extsvcauth.ImpersonationCfg{}
|
|
||||||
if svc.Impersonation != nil {
|
|
||||||
impersonation.Permissions = toAccessControlPermissions(svc.Impersonation.Permissions)
|
|
||||||
impersonation.Enabled = enabled
|
|
||||||
if svc.Impersonation.Groups != nil {
|
|
||||||
impersonation.Groups = *svc.Impersonation.Groups
|
|
||||||
} else {
|
|
||||||
impersonation.Groups = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self := extsvcauth.SelfCfg{}
|
self := extsvcauth.SelfCfg{}
|
||||||
self.Enabled = enabled
|
self.Enabled = enabled
|
||||||
if len(svc.Permissions) > 0 {
|
if len(svc.Permissions) > 0 {
|
||||||
|
@ -77,16 +65,9 @@ func (s *Service) RegisterExternalService(ctx context.Context, pluginID string,
|
||||||
}
|
}
|
||||||
|
|
||||||
registration := &extsvcauth.ExternalServiceRegistration{
|
registration := &extsvcauth.ExternalServiceRegistration{
|
||||||
Name: pluginID,
|
Name: pluginID,
|
||||||
Impersonation: impersonation,
|
Self: self,
|
||||||
Self: self,
|
AuthProvider: extsvcauth.ServiceAccounts,
|
||||||
}
|
|
||||||
|
|
||||||
// Default authProvider now is ServiceAccounts
|
|
||||||
registration.AuthProvider = extsvcauth.ServiceAccounts
|
|
||||||
if svc.Impersonation != nil {
|
|
||||||
registration.AuthProvider = extsvcauth.OAuth2Server
|
|
||||||
registration.OAuthProviderCfg = &extsvcauth.OAuthProviderCfg{Key: &extsvcauth.KeyOption{Generate: true}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extSvc, err := s.reg.SaveExternalService(ctx, registration)
|
extSvc, err := s.reg.SaveExternalService(ctx, registration)
|
||||||
|
|
|
@ -48,7 +48,7 @@ func NewServiceAccountsAPI(
|
||||||
RouterRegister: routerRegister,
|
RouterRegister: routerRegister,
|
||||||
log: log.New("serviceaccounts.api"),
|
log: log.New("serviceaccounts.api"),
|
||||||
permissionService: permissionService,
|
permissionService: permissionService,
|
||||||
isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth),
|
isExternalSAEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,7 @@ func ProvideExtSvcAccountsService(acSvc ac.Service, bus bus.Bus, db db.DB, featu
|
||||||
tracer: tracer,
|
tracer: tracer,
|
||||||
}
|
}
|
||||||
|
|
||||||
if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
if features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) {
|
||||||
// Register the metrics
|
// Register the metrics
|
||||||
esa.metrics = newMetrics(reg, saSvc, logger)
|
esa.metrics = newMetrics(reg, saSvc, logger)
|
||||||
|
|
||||||
|
@ -133,7 +133,7 @@ func (esa *ExtSvcAccountsService) GetExternalServiceNames(ctx context.Context) (
|
||||||
// SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions.
|
// SaveExternalService creates, updates or delete a service account (and its token) with the requested permissions.
|
||||||
func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
|
func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *extsvcauth.ExternalServiceRegistration) (*extsvcauth.ExternalService, error) {
|
||||||
// This is double proofing, we should never reach here anyway the flags have already been checked.
|
// This is double proofing, we should never reach here anyway the flags have already been checked.
|
||||||
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
|
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||||
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
|
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
@ -148,10 +148,6 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *
|
||||||
|
|
||||||
slug := slugify.Slugify(cmd.Name)
|
slug := slugify.Slugify(cmd.Name)
|
||||||
|
|
||||||
if cmd.Impersonation.Enabled {
|
|
||||||
esa.logger.Warn("Impersonation setup skipped. It is not possible to impersonate with a service account token.", "service", slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{
|
saID, err := esa.ManageExtSvcAccount(ctx, &sa.ManageExtSvcAccountCmd{
|
||||||
ExtSvcSlug: slug,
|
ExtSvcSlug: slug,
|
||||||
Enabled: cmd.Self.Enabled,
|
Enabled: cmd.Self.Enabled,
|
||||||
|
@ -181,7 +177,7 @@ func (esa *ExtSvcAccountsService) SaveExternalService(ctx context.Context, cmd *
|
||||||
|
|
||||||
func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error {
|
func (esa *ExtSvcAccountsService) RemoveExternalService(ctx context.Context, name string) error {
|
||||||
// This is double proofing, we should never reach here anyway the flags have already been checked.
|
// This is double proofing, we should never reach here anyway the flags have already been checked.
|
||||||
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
|
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||||
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
|
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -220,7 +216,7 @@ func (esa *ExtSvcAccountsService) RemoveExtSvcAccount(ctx context.Context, orgID
|
||||||
// ManageExtSvcAccount creates, updates or deletes the service account associated with an external service
|
// ManageExtSvcAccount creates, updates or deletes the service account associated with an external service
|
||||||
func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) {
|
func (esa *ExtSvcAccountsService) ManageExtSvcAccount(ctx context.Context, cmd *sa.ManageExtSvcAccountCmd) (int64, error) {
|
||||||
// This is double proofing, we should never reach here anyway the flags have already been checked.
|
// This is double proofing, we should never reach here anyway the flags have already been checked.
|
||||||
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) && !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAuth) {
|
if !esa.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||||
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
|
esa.logger.Warn("This feature is behind a feature flag, please set it if you want to save external services")
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ func ProvideServiceAccountsProxy(
|
||||||
s := &ServiceAccountsProxy{
|
s := &ServiceAccountsProxy{
|
||||||
log: log.New("serviceaccounts.proxy"),
|
log: log.New("serviceaccounts.proxy"),
|
||||||
proxiedService: proxiedService,
|
proxiedService: proxiedService,
|
||||||
isProxyEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth),
|
isProxyEnabled: features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAccounts),
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService, features)
|
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService, features)
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/anonservice"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/oauthserver"
|
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/signingkeys"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/signingkeys"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ssosettings"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
|
||||||
|
@ -95,9 +94,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||||
AddExternalAlertmanagerToDatasourceMigration(mg)
|
AddExternalAlertmanagerToDatasourceMigration(mg)
|
||||||
|
|
||||||
addFolderMigrations(mg)
|
addFolderMigrations(mg)
|
||||||
if oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagExternalServiceAuth) {
|
|
||||||
oauthserver.AddMigration(mg)
|
|
||||||
}
|
|
||||||
|
|
||||||
anonservice.AddMigration(mg)
|
anonservice.AddMigration(mg)
|
||||||
signingkeys.AddMigration(mg)
|
signingkeys.AddMigration(mg)
|
||||||
|
|
|
@ -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 },
|
{ label: 'Disabled', value: ServiceAccountStateFilter.Disabled },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.featureToggles.externalServiceAccounts || config.featureToggles.externalServiceAuth) {
|
if (config.featureToggles.externalServiceAccounts) {
|
||||||
availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External });
|
availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -79,7 +79,6 @@ golang.org/x/oauth2@v0.8.0
|
||||||
github.com/drone/drone-cli@v1.6.1
|
github.com/drone/drone-cli@v1.6.1
|
||||||
github.com/google/go-github/v45@v45.2.0
|
github.com/google/go-github/v45@v45.2.0
|
||||||
github.com/Masterminds/semver/v3@v3.1.1
|
github.com/Masterminds/semver/v3@v3.1.1
|
||||||
github.com/ory/fosite@v0.44.1-0.20230317114349-45a6785cc54f
|
|
||||||
gopkg.in/square/go-jose.v2@v2.6.0
|
gopkg.in/square/go-jose.v2@v2.6.0
|
||||||
filippo.io/age@v1.1.1
|
filippo.io/age@v1.1.1
|
||||||
github.com/docker/docker@v23.0.4+incompatible
|
github.com/docker/docker@v23.0.4+incompatible
|
||||||
|
|
Loading…
Reference in New Issue