Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ROX-23255: add auth implementation for emailsender API #1826

Merged
merged 7 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions config/emailsender-authz.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# For development we use ocm tokens issued by RH SSO to authenticate to emailsender API
# for prod we use serviceaccount issued by the OSD cluster for centrals
# this file should be replaced by a secret/configmap mounted to emailsender
# with the fitting values per cluster through the fleetshard addon
---
jwks_urls:
- "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/certs"
allowed_issuers:
- "https://sso.redhat.com/auth/realms/redhat-external"
allowed_org_ids:
# RH ACS Organization (returned for personal tokens obtained by ocm token).
- "11009103"
allowed_audiences:
- "cloud-services"
10 changes: 5 additions & 5 deletions emailsender/cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import (
"os"
"os/signal"

"github.com/gorilla/mux"

"golang.org/x/sys/unix"

"github.com/golang/glog"
Expand Down Expand Up @@ -50,9 +48,11 @@ func main() {
emailSender := email.NewEmailSender(temporarySenderName, sesClient)
emailHandler := api.NewEmailHandler(emailSender)

// base router
router := mux.NewRouter()
api.SetupRoutes(router, emailHandler)
router, err := api.SetupRoutes(cfg.AuthConfig, emailHandler)
if err != nil {
glog.Errorf("Failed to set up router: %v", err)
os.Exit(1)
}

server := http.Server{Addr: cfg.ServerAddress, Handler: router}

Expand Down
37 changes: 37 additions & 0 deletions emailsender/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
package config

import (
"fmt"

"github.com/caarlos0/env/v6"
"gopkg.in/yaml.v2"

"github.com/pkg/errors"
"github.com/stackrox/acs-fleet-manager/pkg/shared"
"github.com/stackrox/rox/pkg/errorhelpers"
)

Expand All @@ -16,6 +20,8 @@ type Config struct {
HTTPSCertFile string `env:"HTTPS_CERT_FILE" envDefault:""`
HTTPSKeyFile string `env:"HTTPS_KEY_FILE" envDefault:""`
MetricsAddress string `env:"METRICS_ADDRESS" envDefault:":9090"`
AuthConfigFile string `env:"AUTH_CONFIG_FILE" envDefault:"config/emailsender-authz.yaml"`
AuthConfig AuthConfig
}

// GetConfig retrieves the current runtime configuration from the environment and returns it.
Expand All @@ -37,8 +43,39 @@ func GetConfig() (*Config, error) {
}
}

auth := &AuthConfig{file: c.AuthConfigFile}
if err := auth.ReadFile(); err != nil {
configErrors.AddError(err)
}

c.AuthConfig = *auth

if cfgErr := configErrors.ToError(); cfgErr != nil {
return nil, errors.Wrap(cfgErr, "invalid configuration settings")
}
return &c, nil
}

// AuthConfig is the configuration for authn/authz for the emailsender
type AuthConfig struct {
file string
JwksURLs []string `yaml:"jwks_urls"`
AllowedIssuer []string `yaml:"allowed_issuers"`
AllowedOrgIDs []string `yaml:"allowed_org_ids"`
AllowedAudiences []string `yaml:"allowed_audiences"`
}

// ReadFile reads the config
func (c *AuthConfig) ReadFile() error {
fileContents, err := shared.ReadFile(c.file)
if err != nil {
return fmt.Errorf("failed to read emailsender authz config: %w", err)
}

err = yaml.UnmarshalStrict([]byte(fileContents), c)
if err != nil {
return fmt.Errorf("failed to unmarshal emailsender authz config: %w", err)
}

return nil
}
21 changes: 20 additions & 1 deletion emailsender/pkg/api/emailhandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ package api
import (
"encoding/json"
"fmt"
"net/http"

"github.com/golang/glog"
"github.com/stackrox/acs-fleet-manager/emailsender/pkg/email"
"net/http"
"github.com/stackrox/acs-fleet-manager/pkg/auth"
"github.com/stackrox/acs-fleet-manager/pkg/errors"
"github.com/stackrox/acs-fleet-manager/pkg/shared"
)

type EmailHandler struct {
Expand Down Expand Up @@ -37,6 +41,21 @@ func (eh *EmailHandler) SendEmail(w http.ResponseWriter, r *http.Request) {
return
}

claims, err := auth.GetClaimsFromContext(r.Context())
if err != nil {
shared.HandleError(r, w, errors.Unauthenticated("failed to get token claims"))
return
}

sub, err := claims.GetSubject()
if err != nil {
shared.HandleError(r, w, errors.Unauthenticated("failed to get sub claim"))
return
}

// TODO: use sub for rate limiting later on instead of printing it here
glog.Info(sub)

if err := eh.emailSender.Send(r.Context(), request.To, request.RawMessage); err != nil {
eh.errorResponse(w, "Cannot send email", http.StatusInternalServerError)
return
Expand Down
19 changes: 17 additions & 2 deletions emailsender/pkg/api/emailhandler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/stackrox/acs-fleet-manager/emailsender/pkg/email"
"net/http"
"net/http/httptest"
"testing"

"github.com/golang-jwt/jwt/v4"
"github.com/openshift-online/ocm-sdk-go/authentication"
"github.com/stackrox/acs-fleet-manager/emailsender/pkg/email"
)

type MockEmailSender struct {
Expand Down Expand Up @@ -43,6 +46,15 @@ func TestEmailHandler_SendEmail(t *testing.T) {
"invalid": "JSON",
})

defaultToken := &jwt.Token{
Claims: jwt.MapClaims{
"iss": "https://sso.redhat.com/auth/realms/redhat-external",
"aud": "test-audience",
"sub": "test-sub",
"org_id": "test-org",
},
}

tests := []struct {
name string
emailSender email.Sender
Expand Down Expand Up @@ -82,7 +94,10 @@ func TestEmailHandler_SendEmail(t *testing.T) {
emailSender: tt.emailSender,
}
resp := httptest.NewRecorder()
eh.SendEmail(resp, tt.req)

ctx := authentication.ContextWithToken(tt.req.Context(), defaultToken)
req := tt.req.WithContext(ctx)
eh.SendEmail(resp, req)

if resp.Result().StatusCode != tt.wantCode {
t.Errorf("expected status code %d, got %d", tt.wantCode, resp.Result().StatusCode)
Expand Down
65 changes: 65 additions & 0 deletions emailsender/pkg/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ package api
import (
"mime"
"net/http"
"regexp"

"github.com/gorilla/mux"
"github.com/stackrox/acs-fleet-manager/emailsender/config"
"github.com/stackrox/acs-fleet-manager/pkg/auth"
"github.com/stackrox/acs-fleet-manager/pkg/errors"
"github.com/stackrox/acs-fleet-manager/pkg/shared"
)

const ocmIssuer = "https://sso.redhat.com/auth/realms/redhat-external"
const centralServiceAccountRegEx = "system:serviceaccount:rhacs-[a-z0-9]*:central"

// EnsureJSONContentType enforces Content-Type: application/json header
func EnsureJSONContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -30,3 +40,58 @@ func EnsureJSONContentType(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

func emailsenderAuthorizationMiddleware(authConfig config.AuthConfig) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := auth.GetClaimsFromContext(ctx)
if err != nil {
shared.HandleError(req, w, errors.Unauthorized("invalid token claims"))
return
}

if claims.VerifyIssuer(ocmIssuer, true) {
// only check for org ID if we're using OCM tokens
next = auth.CheckAllowedOrgIDs(authConfig.AllowedOrgIDs)(next)
next = auth.NewRequireOrgIDMiddleware().RequireOrgID(errors.ErrorUnauthorized)(next)
} else {
// in this case we expect a k8s service account token
// so we need to check for the sub
next = checkCentralServiceAccountSubject()(next)
}

next = auth.CheckAudience(authConfig.AllowedAudiences)(next)
next = auth.NewRequireIssuerMiddleware().RequireIssuer(authConfig.AllowedIssuer, errors.ErrorUnauthorized)(next)

next.ServeHTTP(w, req)
})
}
}

func checkCentralServiceAccountSubject() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
claims, err := auth.GetClaimsFromContext(ctx)
if err != nil {
shared.HandleError(req, w, errors.Unauthorized("invalid token claims"))
return
}

sub, err := claims.GetSubject()
if err != nil {
shared.HandleError(req, w, errors.Unauthorized("failed to get subject from token claims"))
return
}

match, err := regexp.MatchString(centralServiceAccountRegEx, sub)
if err != nil || !match {
shared.HandleError(req, w, errors.Unauthorized("failed to match subject"))
return
}

next.ServeHTTP(w, req)
})
}
}
Loading
Loading