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-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
ClientSecret string
Organizations []string // set of organizations permitted to access the protected resource. Empty means "all"
Logger chronograf.Logger
}
@ -50,6 +52,15 @@ func (h *Heroku) Name() string {
// PrincipalID returns the Heroku email address of the user.
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)
if err != nil {
h.Logger.Error("Unable to communicate with Heroku. err:", err)
@ -57,14 +68,24 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
}
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
var account struct {
Email string `json:"email"`
}
var account Account
if err := d.Decode(&account); err != nil {
h.Logger.Error("Unable to decode response from Heroku. err:", err)
return "", err
}
return account.Email, nil
// check if member of org
if len(h.Organizations) > 0 {
for _, org := range h.Organizations {
if account.DefaultOrganization.Name == org {
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

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)
}
}
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 (
/* Errors */
ErrAuthentication = errors.New("user not authenticated")
ErrOrgMembership = errors.New("Not a member of the required organization")
)
/* Types */

View File

@ -20,20 +20,21 @@ const (
// MuxOpts are the options for the router. Mostly related to auth.
type MuxOpts struct {
Logger chronograf.Logger
Develop bool // Develop loads assets from filesystem instead of bindata
Basepath string // URL path prefix under which all chronograf routes will be mounted
UseAuth bool // UseAuth turns on Github OAuth and JWT
TokenSecret string // TokenSecret is the JWT secret
GithubClientID string // GithubClientID is the GH OAuth id
GithubClientSecret string // GithubClientSecret is the GH OAuth secret
GithubOrgs []string // GithubOrgs is the list of organizations a user may be a member of
GoogleClientID string // GoogleClientID is the Google OAuth id
GoogleClientSecret string // GoogleClientSecret is the Google OAuth secret
GoogleDomains []string // GoogleDomains is the list of domains a user may be a member of
HerokuClientID string // HerokuClientID is the Heroku OAuth id
HerokuSecret string // HerokuSecret is the Heroku OAuth secret
PublicURL string // PublicURL is the public facing URL for the server
Logger chronograf.Logger
Develop bool // Develop loads assets from filesystem instead of bindata
Basepath string // URL path prefix under which all chronograf routes will be mounted
UseAuth bool // UseAuth turns on Github OAuth and JWT
TokenSecret string // TokenSecret is the JWT secret
GithubClientID string // GithubClientID is the GH OAuth id
GithubClientSecret string // GithubClientSecret is the GH OAuth secret
GithubOrgs []string // GithubOrgs is the list of organizations a user may be a member of
GoogleClientID string // GoogleClientID is the Google OAuth id
GoogleClientSecret string // GoogleClientSecret is the Google OAuth secret
GoogleDomains []string // GoogleDomains is the list of domains a user may be a member of
HerokuClientID string // HerokuClientID is the Heroku OAuth id
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
}
func (m *MuxOpts) UseGithub() bool {
@ -206,9 +207,10 @@ func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
if opts.UseHeroku() {
heroku := oauth2.Heroku{
ClientID: opts.HerokuClientID,
ClientSecret: opts.HerokuSecret,
Logger: opts.Logger,
ClientID: opts.HerokuClientID,
ClientSecret: opts.HerokuSecret,
Organizations: opts.HerokuOrganizations,
Logger: opts.Logger,
}
hMux := oauth2.NewCookieMux(&heroku, &auth, opts.Logger)

View File

@ -54,8 +54,9 @@ type Server struct {
GoogleDomains []string `long:"google-domains" description:"Google email domain user is required to have active membership" env:"GOOGLE_DOMAINS" env-delim:","`
PublicURL string `long:"public-url" description:"Full public URL used to access Chronograf from a web browser. Used for Google OAuth2 authentication. (http://localhost:8888)" env:"PUBLIC_URL"`
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"`
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"`
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"`
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"`
@ -85,19 +86,20 @@ func (s *Server) Serve() error {
service := openService(s.BoltPath, s.CannedPath, logger, s.useAuth())
basepath = s.Basepath
s.handler = NewMux(MuxOpts{
Develop: s.Develop,
TokenSecret: s.TokenSecret,
GithubClientID: s.GithubClientID,
GithubClientSecret: s.GithubClientSecret,
GithubOrgs: s.GithubOrgs,
GoogleClientID: s.GoogleClientID,
GoogleClientSecret: s.GoogleClientSecret,
GoogleDomains: s.GoogleDomains,
HerokuClientID: s.HerokuClientID,
HerokuSecret: s.HerokuSecret,
PublicURL: s.PublicURL,
Logger: logger,
UseAuth: s.useAuth(),
Develop: s.Develop,
TokenSecret: s.TokenSecret,
GithubClientID: s.GithubClientID,
GithubClientSecret: s.GithubClientSecret,
GithubOrgs: s.GithubOrgs,
GoogleClientID: s.GoogleClientID,
GoogleClientSecret: s.GoogleClientSecret,
GoogleDomains: s.GoogleDomains,
HerokuClientID: s.HerokuClientID,
HerokuSecret: s.HerokuSecret,
HerokuOrganizations: s.HerokuOrganizations,
PublicURL: s.PublicURL,
Logger: logger,
UseAuth: s.useAuth(),
}, service)
s.handler = Version(s.BuildInfo.Version, s.handler)