Commit 7d63b2c4 by Will Browne Committed by GitHub

Auth: Add Sigv4 auth option to datasources (#27552)

* create transport chain

* add frontend

* remove log

* inline field updates

* allow ARN, Credentials + Keys auth in frontend

* configure credentials

* add tests and refactor

* update frontend json field names

* fix tests

* fix comment

* add app config flag

* refactor tests

* add return field for tests

* add flag for UI display

* update comment

* move logic

* fix config

* pass config through props

* update docs

* pr feedback and add docs coverage

* shorten settings filename

* fix imports

* revert docs changes

* remove log line

* wrap up next as round tripper

* only propagate required config

* remove unused import

* remove ARN option and replace with default chain

* make ARN role assume as supplemental

* update docs

* refactor flow

* sign body when necessary

* remove unnecessary wrapper

* remove newline

* Apply suggestions from code review

* PR fixes

Co-authored-by: Arve Knudsen <arve.knudsen@gmail.com>
parent ab33e467
...@@ -307,6 +307,9 @@ oauth_state_cookie_max_age = 600 ...@@ -307,6 +307,9 @@ oauth_state_cookie_max_age = 600
# limit of api_key seconds to live before expiration # limit of api_key seconds to live before expiration
api_key_max_seconds_to_live = -1 api_key_max_seconds_to_live = -1
# Set to true to enable SigV4 authentication option for HTTP-based datasources
sigv4_auth_enabled = false
#################################### Anonymous Auth ###################### #################################### Anonymous Auth ######################
[auth.anonymous] [auth.anonymous]
# enable anonymous access # enable anonymous access
......
...@@ -306,6 +306,9 @@ ...@@ -306,6 +306,9 @@
# limit of api_key seconds to live before expiration # limit of api_key seconds to live before expiration
;api_key_max_seconds_to_live = -1 ;api_key_max_seconds_to_live = -1
# Set to true to enable SigV4 authentication option for HTTP-based datasources.
;sigv4_auth_enabled = false
#################################### Anonymous Auth ###################### #################################### Anonymous Auth ######################
[auth.anonymous] [auth.anonymous]
# enable anonymous access # enable anonymous access
......
...@@ -649,6 +649,12 @@ Administrators can increase this if they experience OAuth login state mismatch e ...@@ -649,6 +649,12 @@ Administrators can increase this if they experience OAuth login state mismatch e
Limit of API key seconds to live before expiration. Default is -1 (unlimited). Limit of API key seconds to live before expiration. Default is -1 (unlimited).
### sigv4_auth_enabled
> Only available in Grafana 7.3+.
Set to `true` to enable the AWS Signature Version 4 Authentication option for HTTP-based datasources. Default is `false`.
<hr /> <hr />
## [auth.anonymous] ## [auth.anonymous]
......
...@@ -83,6 +83,7 @@ export interface GrafanaConfig { ...@@ -83,6 +83,7 @@ export interface GrafanaConfig {
authProxyEnabled: boolean; authProxyEnabled: boolean;
exploreEnabled: boolean; exploreEnabled: boolean;
ldapEnabled: boolean; ldapEnabled: boolean;
sigV4AuthEnabled: boolean;
samlEnabled: boolean; samlEnabled: boolean;
autoAssignOrg: boolean; autoAssignOrg: boolean;
verifyEmailEnabled: boolean; verifyEmailEnabled: boolean;
......
...@@ -36,6 +36,7 @@ export class GrafanaBootConfig implements GrafanaConfig { ...@@ -36,6 +36,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
authProxyEnabled = false; authProxyEnabled = false;
exploreEnabled = false; exploreEnabled = false;
ldapEnabled = false; ldapEnabled = false;
sigV4AuthEnabled = false;
samlEnabled = false; samlEnabled = false;
autoAssignOrg = true; autoAssignOrg = true;
verifyEmailEnabled = false; verifyEmailEnabled = false;
......
...@@ -13,7 +13,7 @@ It is used in a `ConfigEditor` for data source plugins. You can find more exampl ...@@ -13,7 +13,7 @@ It is used in a `ConfigEditor` for data source plugins. You can find more exampl
### Example usage ### Example usage
```jsx ```jsx
export const ConfigEditor = (props: Props) => { export const ConfigEditor = (props: Props) => {
const { options, onOptionsChange } = props; const { options, onOptionsChange, config } = props;
return ( return (
<> <>
<DataSourceHttpSettings <DataSourceHttpSettings
...@@ -21,6 +21,7 @@ export const ConfigEditor = (props: Props) => { ...@@ -21,6 +21,7 @@ export const ConfigEditor = (props: Props) => {
dataSourceConfig={options} dataSourceConfig={options}
showAccessOptions={true} showAccessOptions={true}
onChange={onOptionsChange} onChange={onOptionsChange}
sigV4AuthEnabled={false}
/> />
{/* Additional configuration settings for your data source plugin.*/} {/* Additional configuration settings for your data source plugin.*/}
......
...@@ -12,6 +12,7 @@ import { Icon } from '../Icon/Icon'; ...@@ -12,6 +12,7 @@ import { Icon } from '../Icon/Icon';
import { FormField } from '../FormField/FormField'; import { FormField } from '../FormField/FormField';
import { InlineFormLabel } from '../FormLabel/FormLabel'; import { InlineFormLabel } from '../FormLabel/FormLabel';
import { TagsInput } from '../TagsInput/TagsInput'; import { TagsInput } from '../TagsInput/TagsInput';
import { SigV4AuthSettings } from './SigV4AuthSettings';
import { useTheme } from '../../themes'; import { useTheme } from '../../themes';
import { HttpSettingsProps } from './types'; import { HttpSettingsProps } from './types';
...@@ -55,7 +56,7 @@ const HttpAccessHelp = () => ( ...@@ -55,7 +56,7 @@ const HttpAccessHelp = () => (
); );
export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => { export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => {
const { defaultUrl, dataSourceConfig, onChange, showAccessOptions } = props; const { defaultUrl, dataSourceConfig, onChange, showAccessOptions, sigV4AuthToggleEnabled } = props;
let urlTooltip; let urlTooltip;
const [isAccessHelpVisible, setIsAccessHelpVisible] = useState(false); const [isAccessHelpVisible, setIsAccessHelpVisible] = useState(false);
const theme = useTheme(); const theme = useTheme();
...@@ -189,6 +190,21 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => { ...@@ -189,6 +190,21 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => {
/> />
</div> </div>
{sigV4AuthToggleEnabled && (
<div className="gf-form-inline">
<Switch
label="SigV4 auth"
labelClass="width-13"
checked={dataSourceConfig.jsonData.sigV4Auth || false}
onChange={event => {
onSettingsChange({
jsonData: { ...dataSourceConfig.jsonData, sigV4Auth: event!.currentTarget.checked },
});
}}
/>
</div>
)}
{dataSourceConfig.access === 'proxy' && ( {dataSourceConfig.access === 'proxy' && (
<HttpProxySettings <HttpProxySettings
dataSourceConfig={dataSourceConfig} dataSourceConfig={dataSourceConfig}
...@@ -205,6 +221,8 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => { ...@@ -205,6 +221,8 @@ export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => {
</> </>
)} )}
{dataSourceConfig.jsonData.sigV4Auth && <SigV4AuthSettings {...props} />}
{(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && ( {(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && (
<TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} /> <TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
)} )}
......
...@@ -12,4 +12,6 @@ export interface HttpSettingsProps extends HttpSettingsBaseProps { ...@@ -12,4 +12,6 @@ export interface HttpSettingsProps extends HttpSettingsBaseProps {
defaultUrl: string; defaultUrl: string;
/** Show the http access help box */ /** Show the http access help box */
showAccessOptions?: boolean; showAccessOptions?: boolean;
/** Show the SigV4 auth toggle option */
sigV4AuthToggleEnabled?: boolean;
} }
...@@ -203,6 +203,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i ...@@ -203,6 +203,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"alertingMinInterval": setting.AlertingMinInterval, "alertingMinInterval": setting.AlertingMinInterval,
"autoAssignOrg": setting.AutoAssignOrg, "autoAssignOrg": setting.AutoAssignOrg,
"verifyEmailEnabled": setting.VerifyEmailEnabled, "verifyEmailEnabled": setting.VerifyEmailEnabled,
"sigV4AuthEnabled": setting.SigV4AuthEnabled,
"exploreEnabled": setting.ExploreEnabled, "exploreEnabled": setting.ExploreEnabled,
"googleAnalyticsId": setting.GoogleAnalyticsId, "googleAnalyticsId": setting.GoogleAnalyticsId,
"disableLoginForm": setting.DisableLoginForm, "disableLoginForm": setting.DisableLoginForm,
......
...@@ -69,6 +69,7 @@ type dataSourceTransport struct { ...@@ -69,6 +69,7 @@ type dataSourceTransport struct {
datasourceName string datasourceName string
headers map[string]string headers map[string]string
transport *http.Transport transport *http.Transport
next http.RoundTripper
} }
func instrumentRoundtrip(datasourceName string, next http.RoundTripper) promhttp.RoundTripperFunc { func instrumentRoundtrip(datasourceName string, next http.RoundTripper) promhttp.RoundTripperFunc {
...@@ -108,7 +109,7 @@ func (d *dataSourceTransport) RoundTrip(req *http.Request) (*http.Response, erro ...@@ -108,7 +109,7 @@ func (d *dataSourceTransport) RoundTrip(req *http.Request) (*http.Response, erro
req.Header.Set(key, value) req.Header.Set(key, value)
} }
return instrumentRoundtrip(d.datasourceName, d.transport).RoundTrip(req) return instrumentRoundtrip(d.datasourceName, d.next).RoundTrip(req)
} }
type cachedTransport struct { type cachedTransport struct {
...@@ -133,6 +134,7 @@ func (ds *DataSource) GetHttpClient() (*http.Client, error) { ...@@ -133,6 +134,7 @@ func (ds *DataSource) GetHttpClient() (*http.Client, error) {
}, nil }, nil
} }
// Creates a HTTP Transport middleware chain
func (ds *DataSource) GetHttpTransport() (*dataSourceTransport, error) { func (ds *DataSource) GetHttpTransport() (*dataSourceTransport, error) {
ptc.Lock() ptc.Lock()
defer ptc.Unlock() defer ptc.Unlock()
...@@ -163,10 +165,19 @@ func (ds *DataSource) GetHttpTransport() (*dataSourceTransport, error) { ...@@ -163,10 +165,19 @@ func (ds *DataSource) GetHttpTransport() (*dataSourceTransport, error) {
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
} }
// Set default next round tripper to the default transport
next := http.RoundTripper(transport)
// Add SigV4 middleware if enabled, which will then defer to the default transport
if ds.JsonData != nil && ds.JsonData.Get("sigV4Auth").MustBool() && setting.SigV4AuthEnabled {
next = ds.sigV4Middleware(transport)
}
dsTransport := &dataSourceTransport{ dsTransport := &dataSourceTransport{
datasourceName: ds.Name,
headers: customHeaders, headers: customHeaders,
transport: transport, transport: transport,
datasourceName: ds.Name, next: next,
} }
ptc.cache[ds.Id] = cachedTransport{ ptc.cache[ds.Id] = cachedTransport{
...@@ -177,6 +188,23 @@ func (ds *DataSource) GetHttpTransport() (*dataSourceTransport, error) { ...@@ -177,6 +188,23 @@ func (ds *DataSource) GetHttpTransport() (*dataSourceTransport, error) {
return dsTransport, nil return dsTransport, nil
} }
func (ds *DataSource) sigV4Middleware(next http.RoundTripper) http.RoundTripper {
decrypted := ds.DecryptedValues()
return &SigV4Middleware{
Config: &Config{
AccessKey: decrypted["accessKey"],
SecretKey: decrypted["secretKey"],
Region: ds.JsonData.Get("region").MustString(),
AssumeRoleARN: ds.JsonData.Get("assumeRoleArn").MustString(),
AuthType: ds.JsonData.Get("authType").MustString(),
ExternalID: ds.JsonData.Get("externalId").MustString(),
Profile: ds.JsonData.Get("profile").MustString(),
},
Next: next,
}
}
func (ds *DataSource) GetTLSConfig() (*tls.Config, error) { func (ds *DataSource) GetTLSConfig() (*tls.Config, error) {
var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool var tlsSkipVerify, tlsClientAuth, tlsAuthWithCACert bool
if ds.JsonData != nil { if ds.JsonData != nil {
......
...@@ -291,6 +291,78 @@ func TestDataSourceDecryptionCache(t *testing.T) { ...@@ -291,6 +291,78 @@ func TestDataSourceDecryptionCache(t *testing.T) {
}) })
} }
func TestDataSourceSigV4Auth(t *testing.T) {
Convey("When caching a datasource proxy with middleware", t, func() {
clearDSProxyCache()
origEnabled := setting.SigV4AuthEnabled
setting.SigV4AuthEnabled = true
t.Cleanup(func() {
setting.SigV4AuthEnabled = origEnabled
})
json, err := simplejson.NewJson([]byte(`{ "sigV4Auth": true }`))
So(err, ShouldBeNil)
ds := DataSource{
JsonData: json,
}
t, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("SigV4 is in middleware chain if configured in JsonData", func() {
m1, ok := t.next.(*SigV4Middleware)
So(ok, ShouldEqual, true)
_, ok = m1.Next.(*http.Transport)
So(ok, ShouldEqual, true)
})
})
Convey("When caching a datasource proxy with middleware", t, func() {
clearDSProxyCache()
origEnabled := setting.SigV4AuthEnabled
setting.SigV4AuthEnabled = true
t.Cleanup(func() {
setting.SigV4AuthEnabled = origEnabled
})
ds := DataSource{}
t, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should not include sigV4 middleware if not configured in JsonData", func() {
_, ok := t.next.(*http.Transport)
So(ok, ShouldEqual, true)
})
})
Convey("When caching a datasource proxy with middleware", t, func() {
clearDSProxyCache()
origEnabled := setting.SigV4AuthEnabled
setting.SigV4AuthEnabled = false
t.Cleanup(func() {
setting.SigV4AuthEnabled = origEnabled
})
json, err := simplejson.NewJson([]byte(`{ "sigV4Auth": true }`))
So(err, ShouldBeNil)
ds := DataSource{
JsonData: json,
}
t, err := ds.GetHttpTransport()
So(err, ShouldBeNil)
Convey("Should not include sigV4 middleware if not configured in app config", func() {
_, ok := t.next.(*http.Transport)
So(ok, ShouldEqual, true)
})
})
}
func clearDSProxyCache() { func clearDSProxyCache() {
ptc.Lock() ptc.Lock()
defer ptc.Unlock() defer ptc.Unlock()
......
package models
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/aws/defaults"
"github.com/aws/aws-sdk-go/aws/credentials"
v4 "github.com/aws/aws-sdk-go/aws/signer/v4"
)
type AuthType string
const (
Default AuthType = "default"
Keys AuthType = "keys"
Credentials AuthType = "credentials"
)
type SigV4Middleware struct {
Config *Config
Next http.RoundTripper
}
type Config struct {
AuthType string
Profile string
AccessKey string
SecretKey string
AssumeRoleARN string
ExternalID string
Region string
}
func (m *SigV4Middleware) RoundTrip(req *http.Request) (*http.Response, error) {
_, err := m.signRequest(req)
if err != nil {
return nil, err
}
if m.Next == nil {
return http.DefaultTransport.RoundTrip(req)
}
return m.Next.RoundTrip(req)
}
func (m *SigV4Middleware) signRequest(req *http.Request) (http.Header, error) {
signer, err := m.signer()
if err != nil {
return nil, err
}
if req.Body != nil {
// consume entire request body so that the signer can generate a hash from the contents
body, err := ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
return signer.Sign(req, bytes.NewReader(body), "grafana", m.Config.Region, time.Now().UTC())
}
return signer.Sign(req, nil, "grafana", m.Config.Region, time.Now().UTC())
}
func (m *SigV4Middleware) signer() (*v4.Signer, error) {
c, err := m.credentials()
if err != nil {
return nil, err
}
if m.Config.AssumeRoleARN != "" {
s, err := session.NewSession(&aws.Config{
Region: aws.String(m.Config.Region),
Credentials: c},
)
if err != nil {
return nil, err
}
return v4.NewSigner(stscreds.NewCredentials(s, m.Config.AssumeRoleARN)), nil
}
return v4.NewSigner(c), nil
}
func (m *SigV4Middleware) credentials() (*credentials.Credentials, error) {
authType := AuthType(m.Config.AuthType)
switch authType {
case Default:
return defaults.CredChain(defaults.Config(), defaults.Handlers()), nil
case Keys:
return credentials.NewStaticCredentials(m.Config.AccessKey, m.Config.SecretKey, ""), nil
case Credentials:
return credentials.NewSharedCredentials("", m.Config.Profile), nil
}
return nil, fmt.Errorf("unrecognized authType: %s", authType)
}
...@@ -143,6 +143,7 @@ var ( ...@@ -143,6 +143,7 @@ var (
AdminPassword string AdminPassword string
LoginCookieName string LoginCookieName string
LoginMaxLifetime time.Duration LoginMaxLifetime time.Duration
SigV4AuthEnabled bool
AnonymousEnabled bool AnonymousEnabled bool
AnonymousOrgName string AnonymousOrgName string
...@@ -280,6 +281,7 @@ type Cfg struct { ...@@ -280,6 +281,7 @@ type Cfg struct {
LoginMaxInactiveLifetime time.Duration LoginMaxInactiveLifetime time.Duration
LoginMaxLifetime time.Duration LoginMaxLifetime time.Duration
TokenRotationIntervalMinutes int TokenRotationIntervalMinutes int
SigV4AuthEnabled bool
// OAuth // OAuth
OAuthCookieMaxAge int OAuthCookieMaxAge int
...@@ -990,6 +992,10 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) { ...@@ -990,6 +992,10 @@ func readAuthSettings(iniFile *ini.File, cfg *Cfg) (err error) {
cfg.OAuthCookieMaxAge = auth.Key("oauth_state_cookie_max_age").MustInt(600) cfg.OAuthCookieMaxAge = auth.Key("oauth_state_cookie_max_age").MustInt(600)
SignoutRedirectUrl = valueAsString(auth, "signout_redirect_url", "") SignoutRedirectUrl = valueAsString(auth, "signout_redirect_url", "")
// SigV4
SigV4AuthEnabled = auth.Key("sigv4_auth_enabled").MustBool(false)
cfg.SigV4AuthEnabled = SigV4AuthEnabled
// SAML auth // SAML auth
cfg.SAMLEnabled = iniFile.Section("auth.saml").Key("enabled").MustBool(false) cfg.SAMLEnabled = iniFile.Section("auth.saml").Key("enabled").MustBool(false)
......
...@@ -41,7 +41,7 @@ export class ConfigEditor extends PureComponent<Props, State> { ...@@ -41,7 +41,7 @@ export class ConfigEditor extends PureComponent<Props, State> {
this.loadRegionsPromise = makePromiseCancelable(this.loadRegions()); this.loadRegionsPromise = makePromiseCancelable(this.loadRegions());
this.loadRegionsPromise.promise.catch(({ isCanceled }) => { this.loadRegionsPromise.promise.catch(({ isCanceled }) => {
if (isCanceled) { if (isCanceled) {
console.warn('Cloud Watch ConfigEditor has unmounted, intialization was canceled'); console.warn('Cloud Watch ConfigEditor has unmounted, initialization was canceled');
} }
}); });
} }
......
...@@ -5,6 +5,7 @@ import { ElasticsearchOptions } from '../types'; ...@@ -5,6 +5,7 @@ import { ElasticsearchOptions } from '../types';
import { defaultMaxConcurrentShardRequests, ElasticDetails } from './ElasticDetails'; import { defaultMaxConcurrentShardRequests, ElasticDetails } from './ElasticDetails';
import { LogsConfig } from './LogsConfig'; import { LogsConfig } from './LogsConfig';
import { DataLinks } from './DataLinks'; import { DataLinks } from './DataLinks';
import { config } from 'app/core/config';
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>; export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>;
export const ConfigEditor = (props: Props) => { export const ConfigEditor = (props: Props) => {
...@@ -34,6 +35,7 @@ export const ConfigEditor = (props: Props) => { ...@@ -34,6 +35,7 @@ export const ConfigEditor = (props: Props) => {
dataSourceConfig={options} dataSourceConfig={options}
showAccessOptions={true} showAccessOptions={true}
onChange={onOptionsChange} onChange={onOptionsChange}
sigV4AuthToggleEnabled={config.sigV4AuthEnabled}
/> />
<ElasticDetails value={options} onChange={onOptionsChange} /> <ElasticDetails value={options} onChange={onOptionsChange} />
......
...@@ -3,6 +3,7 @@ import { DataSourceHttpSettings } from '@grafana/ui'; ...@@ -3,6 +3,7 @@ import { DataSourceHttpSettings } from '@grafana/ui';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { PromSettings } from './PromSettings'; import { PromSettings } from './PromSettings';
import { PromOptions } from '../types'; import { PromOptions } from '../types';
import { config } from 'app/core/config';
export type Props = DataSourcePluginOptionsEditorProps<PromOptions>; export type Props = DataSourcePluginOptionsEditorProps<PromOptions>;
export const ConfigEditor = (props: Props) => { export const ConfigEditor = (props: Props) => {
...@@ -14,6 +15,7 @@ export const ConfigEditor = (props: Props) => { ...@@ -14,6 +15,7 @@ export const ConfigEditor = (props: Props) => {
dataSourceConfig={options} dataSourceConfig={options}
showAccessOptions={true} showAccessOptions={true}
onChange={onOptionsChange} onChange={onOptionsChange}
sigV4AuthToggleEnabled={config.sigV4AuthEnabled}
/> />
<PromSettings options={options} onOptionsChange={onOptionsChange} /> <PromSettings options={options} onOptionsChange={onOptionsChange} />
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment