Add organization restriction on Heroku provider

This allows operators to permit access to Chronograf only to users belonging
to a set of specific Heroku organizations. This is controlled using the
HEROKU_ORGS env or the --heroku-organizations switch.
pull/922/head
Tim Raymond 2017-02-21 13:04:17 -05:00
parent c3ada06c93
commit 3af13aa490
6 changed files with 117 additions and 36 deletions

View File

@ -95,3 +95,11 @@ The equivalent command line switches are:
* `--heroku-client-id` * `--heroku-client-id`
* `--heroku-secret` * `--heroku-secret`
#### Optional Heroku Organizations
Like the other OAuth2 providers, access to Chronograf via Heroku can be restricted to members of specific Heroku organizations. This is controlled using the `HEROKU_ORGS` ENV or the `--heroku-organizations` switch and is comma-separated. If we wanted to permit access from the `hill-valley-preservation-society` orgization and `the-pinheads` organization, we would use the following ENV:
```sh
export HEROKU_ORGS=hill-valley-preservation-sociey,the-pinheads
```

View File

@ -25,6 +25,8 @@ type Heroku struct {
ClientID string ClientID string
ClientSecret string ClientSecret string
Organizations []string // set of organizations permitted to access the protected resource. Empty means "all"
Logger chronograf.Logger Logger chronograf.Logger
} }
@ -50,6 +52,15 @@ func (h *Heroku) Name() string {
// PrincipalID returns the Heroku email address of the user. // PrincipalID returns the Heroku email address of the user.
func (h *Heroku) PrincipalID(provider *http.Client) (string, error) { func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
type DefaultOrg struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Account struct {
Email string `json:"email"`
DefaultOrganization DefaultOrg `json:"default_organization"`
}
resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE) resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE)
if err != nil { if err != nil {
h.Logger.Error("Unable to communicate with Heroku. err:", err) h.Logger.Error("Unable to communicate with Heroku. err:", err)
@ -57,15 +68,25 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
d := json.NewDecoder(resp.Body) d := json.NewDecoder(resp.Body)
var account struct { var account Account
Email string `json:"email"`
}
if err := d.Decode(&account); err != nil { if err := d.Decode(&account); err != nil {
h.Logger.Error("Unable to decode response from Heroku. err:", err) h.Logger.Error("Unable to decode response from Heroku. err:", err)
return "", err return "", err
} }
// check if member of org
if len(h.Organizations) > 0 {
for _, org := range h.Organizations {
if account.DefaultOrganization.Name == org {
return account.Email, nil return account.Email, nil
} }
}
h.Logger.Error(ErrOrgMembership)
return "", ErrOrgMembership
} else {
return account.Email, nil
}
}
// Scopes for heroku is "identity" which grants access to user account // Scopes for heroku is "identity" which grants access to user account
// information. This will grant us access to the user's email address which is // information. This will grant us access to the user's email address which is

View File

@ -53,3 +53,50 @@ func Test_Heroku_PrincipalID_ExtractsEmailAddress(t *testing.T) {
t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email) t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email)
} }
} }
func Test_Heroku_PrincipalID_RestrictsByOrganization(t *testing.T) {
t.Parallel()
expected := struct {
Email string `json:"email"`
DefaultOrganization map[string]string `json:"default_organization"`
}{
"martymcfly@example.com",
map[string]string{
"id": "a85eac89-56cc-498e-9a89-d8f49f6aed71",
"name": "hill-valley-preservation-society",
},
}
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/account" {
rw.WriteHeader(http.StatusNotFound)
return
}
enc := json.NewEncoder(rw)
rw.WriteHeader(http.StatusOK)
_ = enc.Encode(expected)
}))
defer mockAPI.Close()
logger := clog.New(clog.ParseLevel("debug"))
prov := oauth2.Heroku{
Logger: logger,
Organizations: []string{"enchantment-under-the-sea-dance-committee"},
}
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
if err != nil {
t.Fatal("Error initializing TestTripper: err:", err)
}
tc := &http.Client{
Transport: tt,
}
_, err = prov.PrincipalID(tc)
if err == nil {
t.Fatal("Expected error while authenticating user with mismatched orgs, but received none")
}
}

View File

@ -20,6 +20,7 @@ const (
var ( var (
/* Errors */ /* Errors */
ErrAuthentication = errors.New("user not authenticated") ErrAuthentication = errors.New("user not authenticated")
ErrOrgMembership = errors.New("Not a member of the required organization")
) )
/* Types */ /* Types */

View File

@ -33,6 +33,7 @@ type MuxOpts struct {
GoogleDomains []string // GoogleDomains is the list of domains a user may be a member of GoogleDomains []string // GoogleDomains is the list of domains a user may be a member of
HerokuClientID string // HerokuClientID is the Heroku OAuth id HerokuClientID string // HerokuClientID is the Heroku OAuth id
HerokuSecret string // HerokuSecret is the Heroku OAuth secret HerokuSecret string // HerokuSecret is the Heroku OAuth secret
HerokuOrganizations []string // HerokuOrganizations is the set of organizations permitted to access Chronograf
PublicURL string // PublicURL is the public facing URL for the server PublicURL string // PublicURL is the public facing URL for the server
} }
@ -208,6 +209,7 @@ func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
heroku := oauth2.Heroku{ heroku := oauth2.Heroku{
ClientID: opts.HerokuClientID, ClientID: opts.HerokuClientID,
ClientSecret: opts.HerokuSecret, ClientSecret: opts.HerokuSecret,
Organizations: opts.HerokuOrganizations,
Logger: opts.Logger, Logger: opts.Logger,
} }

View File

@ -56,6 +56,7 @@ type Server struct {
HerokuClientID string `long:"heroku-client-id" description:"Heroku Client ID for OAuth 2 support" env:"HEROKU_CLIENT_ID"` HerokuClientID string `long:"heroku-client-id" description:"Heroku Client ID for OAuth 2 support" env:"HEROKU_CLIENT_ID"`
HerokuSecret string `long:"heroku-secret" description:"Heroku Secret for OAuth 2 support" env:"HEROKU_SECRET"` HerokuSecret string `long:"heroku-secret" description:"Heroku Secret for OAuth 2 support" env:"HEROKU_SECRET"`
HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","`
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"` ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
@ -95,6 +96,7 @@ func (s *Server) Serve() error {
GoogleDomains: s.GoogleDomains, GoogleDomains: s.GoogleDomains,
HerokuClientID: s.HerokuClientID, HerokuClientID: s.HerokuClientID,
HerokuSecret: s.HerokuSecret, HerokuSecret: s.HerokuSecret,
HerokuOrganizations: s.HerokuOrganizations,
PublicURL: s.PublicURL, PublicURL: s.PublicURL,
Logger: logger, Logger: logger,
UseAuth: s.useAuth(), UseAuth: s.useAuth(),