authorize: move headers and jwt signing to rego (#1856)

* wip

* wip

* wip

* remove SignedJWT field

* set google_cloud_serverless_authentication_service_account

* update jwt claim headers

* add mock get_google_cloud_serverless_headers for opa test

* swap issuer and audience

* add comment

* change default port in authz
This commit is contained in:
Caleb Doxsey 2021-02-08 10:53:21 -07:00 committed by GitHub
parent 2dc0be2ec9
commit 7d236ca1af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 492 additions and 675 deletions

View file

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strings" "strings"
envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
@ -20,22 +21,14 @@ import (
) )
func (a *Authorize) okResponse(reply *evaluator.Result) *envoy_service_auth_v2.CheckResponse { func (a *Authorize) okResponse(reply *evaluator.Result) *envoy_service_auth_v2.CheckResponse {
requestHeaders, err := a.getEnvoyRequestHeaders(reply.SignedJWT) var requestHeaders []*envoy_api_v2_core.HeaderValueOption
if err != nil { for k, v := range reply.Headers {
log.Warn().Err(err).Msg("authorize: error generating new request headers") requestHeaders = append(requestHeaders, mkHeader(k, v, false))
} }
// ensure request headers are sorted by key for deterministic output
requestHeaders = append(requestHeaders, sort.Slice(requestHeaders, func(i, j int) bool {
mkHeader(httputil.HeaderPomeriumJWTAssertion, reply.SignedJWT, false)) return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value
})
requestHeaders = append(requestHeaders, getKubernetesHeaders(reply)...)
if hdrs, err := a.getGoogleCloudServerlessAuthenticationHeaders(reply); err == nil {
requestHeaders = append(requestHeaders, hdrs...)
} else {
log.Warn().Err(err).Msg("error getting google cloud serverless authentication headers")
}
return &envoy_service_auth_v2.CheckResponse{ return &envoy_service_auth_v2.CheckResponse{
Status: &status.Status{Code: int32(codes.OK), Message: reply.Message}, Status: &status.Status{Code: int32(codes.OK), Message: reply.Message},
HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{ HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{
@ -181,22 +174,6 @@ func (a *Authorize) redirectResponse(in *envoy_service_auth_v2.CheckRequest) (*e
}) })
} }
func getKubernetesHeaders(reply *evaluator.Result) []*envoy_api_v2_core.HeaderValueOption {
var requestHeaders []*envoy_api_v2_core.HeaderValueOption
if reply.MatchingPolicy != nil && (reply.MatchingPolicy.KubernetesServiceAccountTokenFile != "" || reply.MatchingPolicy.KubernetesServiceAccountToken != "") {
requestHeaders = append(requestHeaders,
mkHeader("Authorization", "Bearer "+reply.MatchingPolicy.KubernetesServiceAccountToken, false))
if reply.UserEmail != "" {
requestHeaders = append(requestHeaders, mkHeader("Impersonate-User", reply.UserEmail, false))
}
for i, group := range reply.UserGroups {
requestHeaders = append(requestHeaders, mkHeader("Impersonate-Group", group, i > 0))
}
}
return requestHeaders
}
func mkHeader(k, v string, shouldAppend bool) *envoy_api_v2_core.HeaderValueOption { func mkHeader(k, v string, shouldAppend bool) *envoy_api_v2_core.HeaderValueOption {
return &envoy_api_v2_core.HeaderValueOption{ return &envoy_api_v2_core.HeaderValueOption{
Header: &envoy_api_v2_core.HeaderValue{ Header: &envoy_api_v2_core.HeaderValue{

View file

@ -3,10 +3,8 @@ package authorize
import ( import (
"html/template" "html/template"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"testing" "testing"
"time"
envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2"
@ -55,29 +53,6 @@ func TestAuthorize_okResponse(t *testing.T) {
pe, err := newPolicyEvaluator(opt, a.store) pe, err := newPolicyEvaluator(opt, a.store)
require.NoError(t, err) require.NoError(t, err)
a.state.Load().evaluator = pe a.state.Load().evaluator = pe
validJWT, _ := pe.SignedJWT(pe.JWTPayload(&evaluator.Request{
HTTP: evaluator.RequestHTTP{URL: "https://example.com"},
Session: evaluator.RequestSession{
ID: "SESSION_ID",
},
}))
originalGCPIdentityDocURL := gcpIdentityDocURL
defer func() {
gcpIdentityDocURL = originalGCPIdentityDocURL
gcpIdentityNow = time.Now
}()
now := time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC)
gcpIdentityNow = func() time.Time {
return now
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(now.Format(time.RFC3339)))
}))
defer srv.Close()
gcpIdentityDocURL = srv.URL
tests := []struct { tests := []struct {
name string name string
@ -86,107 +61,45 @@ func TestAuthorize_okResponse(t *testing.T) {
}{ }{
{ {
"ok reply", "ok reply",
&evaluator.Result{Status: 0, Message: "ok", SignedJWT: "valid-signed-jwt"}, &evaluator.Result{Status: 0, Message: "ok"},
&envoy_service_auth_v2.CheckResponse{ &envoy_service_auth_v2.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"}, Status: &status.Status{Code: 0, Message: "ok"},
HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v2.OkHttpResponse{
Headers: []*envoy_api_v2_core.HeaderValueOption{
mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false),
},
},
},
}, },
}, },
{ {
"ok reply with k8s svc", "ok reply with k8s svc",
&evaluator.Result{ &evaluator.Result{
Status: 0, Status: 0,
Message: "ok", Message: "ok",
SignedJWT: "valid-signed-jwt",
MatchingPolicy: &config.Policy{ MatchingPolicy: &config.Policy{
KubernetesServiceAccountToken: "k8s-svc-account", KubernetesServiceAccountToken: "k8s-svc-account",
}, },
}, },
&envoy_service_auth_v2.CheckResponse{ &envoy_service_auth_v2.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"}, Status: &status.Status{Code: 0, Message: "ok"},
HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v2.OkHttpResponse{
Headers: []*envoy_api_v2_core.HeaderValueOption{
mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false),
mkHeader("Authorization", "Bearer k8s-svc-account", false),
},
},
},
}, },
}, },
{ {
"ok reply with k8s svc impersonate", "ok reply with k8s svc impersonate",
&evaluator.Result{ &evaluator.Result{
Status: 0, Status: 0,
Message: "ok", Message: "ok",
SignedJWT: "valid-signed-jwt",
MatchingPolicy: &config.Policy{ MatchingPolicy: &config.Policy{
KubernetesServiceAccountToken: "k8s-svc-account", KubernetesServiceAccountToken: "k8s-svc-account",
}, },
UserEmail: "foo@example.com",
UserGroups: []string{"admin", "test"},
}, },
&envoy_service_auth_v2.CheckResponse{ &envoy_service_auth_v2.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"}, Status: &status.Status{Code: 0, Message: "ok"},
HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v2.OkHttpResponse{
Headers: []*envoy_api_v2_core.HeaderValueOption{
mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false),
mkHeader("Authorization", "Bearer k8s-svc-account", false),
mkHeader("Impersonate-User", "foo@example.com", false),
mkHeader("Impersonate-Group", "admin", false),
mkHeader("Impersonate-Group", "test", true),
},
},
},
},
},
{
"ok reply with google cloud serverless",
&evaluator.Result{
Status: 0,
Message: "ok",
SignedJWT: "valid-signed-jwt",
MatchingPolicy: &config.Policy{
EnableGoogleCloudServerlessAuthentication: true,
To: mustParseWeightedURLs(t, "https://example.com"),
},
},
&envoy_service_auth_v2.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"},
HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v2.OkHttpResponse{
Headers: []*envoy_api_v2_core.HeaderValueOption{
mkHeader("x-pomerium-jwt-assertion", "valid-signed-jwt", false),
mkHeader("Authorization", "Bearer 2020-01-01T01:00:00Z", false),
},
},
},
}, },
}, },
{ {
"ok reply with jwt claims header", "ok reply with jwt claims header",
&evaluator.Result{ &evaluator.Result{
Status: 0, Status: 0,
Message: "ok", Message: "ok",
SignedJWT: validJWT,
}, },
&envoy_service_auth_v2.CheckResponse{ &envoy_service_auth_v2.CheckResponse{
Status: &status.Status{Code: 0, Message: "ok"}, Status: &status.Status{Code: 0, Message: "ok"},
HttpResponse: &envoy_service_auth_v2.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v2.OkHttpResponse{
Headers: []*envoy_api_v2_core.HeaderValueOption{
mkHeader("x-pomerium-claim-email", "foo@example.com", false),
mkHeader("x-pomerium-jwt-assertion", validJWT, false),
},
},
},
}, },
}, },
} }

View file

@ -24,6 +24,7 @@ type CustomEvaluatorResponse struct {
Allowed bool Allowed bool
Denied bool Denied bool
Reason string Reason string
Headers map[string]string
} }
// A CustomEvaluator evaluates custom rego policies. // A CustomEvaluator evaluates custom rego policies.
@ -65,7 +66,9 @@ func (ce *CustomEvaluator) Evaluate(ctx context.Context, req *CustomEvaluatorReq
vars = make(map[string]interface{}) vars = make(map[string]interface{})
} }
res := &CustomEvaluatorResponse{} res := &CustomEvaluatorResponse{
Headers: getHeadersVar(resultSet[0].Bindings.WithoutWildcards()),
}
res.Allowed, _ = vars["allow"].(bool) res.Allowed, _ = vars["allow"].(bool)
if v, ok := vars["deny"]; ok { if v, ok := vars["deny"]; ok {
// support `deny = true` // support `deny = true`

View file

@ -5,8 +5,6 @@ package evaluator
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -26,22 +24,16 @@ type Evaluator struct {
query rego.PreparedEvalQuery query rego.PreparedEvalQuery
policies []config.Policy policies []config.Policy
store *Store store *Store
authenticateHost string
jwk *jose.JSONWebKey
signer jose.Signer
} }
// New creates a new Evaluator. // New creates a new Evaluator.
func New(options *config.Options, store *Store) (*Evaluator, error) { func New(options *config.Options, store *Store) (*Evaluator, error) {
e := &Evaluator{ e := &Evaluator{
custom: NewCustomEvaluator(store.opaStore), custom: NewCustomEvaluator(store.opaStore),
authenticateHost: options.AuthenticateURL.Host, policies: options.GetAllPolicies(),
policies: options.GetAllPolicies(), store: store,
store: store,
} }
var err error jwk, err := getJWK(options)
e.signer, e.jwk, err = newSigner(options)
if err != nil { if err != nil {
return nil, fmt.Errorf("authorize: couldn't create signer: %w", err) return nil, fmt.Errorf("authorize: couldn't create signer: %w", err)
} }
@ -51,12 +43,17 @@ func New(options *config.Options, store *Store) (*Evaluator, error) {
return nil, fmt.Errorf("error loading rego policy: %w", err) return nil, fmt.Errorf("error loading rego policy: %w", err)
} }
store.UpdateIssuer(options.AuthenticateURL.Host)
store.UpdateGoogleCloudServerlessAuthenticationServiceAccount(options.GoogleCloudServerlessAuthenticationServiceAccount)
store.UpdateJWTClaimHeaders(options.JWTClaimsHeaders)
store.UpdateRoutePolicies(options.GetAllPolicies()) store.UpdateRoutePolicies(options.GetAllPolicies())
store.UpdateSigningKey(jwk)
e.rego = rego.New( e.rego = rego.New(
rego.Store(store.opaStore), rego.Store(store.opaStore),
rego.Module("pomerium.authz", string(authzPolicy)), rego.Module("pomerium.authz", string(authzPolicy)),
rego.Query("result = data.pomerium.authz"), rego.Query("result = data.pomerium.authz"),
getGoogleCloudServerlessHeadersRegoOption,
) )
e.query, err = e.rego.PrepareForEval(context.Background()) e.query, err = e.rego.PrepareForEval(context.Background())
@ -84,25 +81,12 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error)
return &deny[0], nil return &deny[0], nil
} }
payload := e.JWTPayload(req)
signedJWT, err := e.SignedJWT(payload)
if err != nil {
return nil, fmt.Errorf("error signing JWT: %w", err)
}
evalResult := &Result{ evalResult := &Result{
MatchingPolicy: getMatchingPolicy(res[0].Bindings.WithoutWildcards(), e.policies), MatchingPolicy: getMatchingPolicy(res[0].Bindings.WithoutWildcards(), e.policies),
SignedJWT: signedJWT, Headers: getHeadersVar(res[0].Bindings.WithoutWildcards()),
}
if e, ok := payload["email"].(string); ok {
evalResult.UserEmail = e
}
if gs, ok := payload["groups"].([]string); ok {
evalResult.UserGroups = gs
} }
allow := allowed(res[0].Bindings.WithoutWildcards()) allow := getAllowVar(res[0].Bindings.WithoutWildcards())
// evaluate any custom policies // evaluate any custom policies
if allow { if allow {
for _, src := range req.CustomPolicies { for _, src := range req.CustomPolicies {
@ -118,6 +102,9 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error)
if cres.Reason != "" { if cres.Reason != "" {
evalResult.Message = cres.Reason evalResult.Message = cres.Reason
} }
for k, v := range cres.Headers {
evalResult.Headers[k] = v
}
} }
} }
if allow { if allow {
@ -139,41 +126,23 @@ func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error)
return evalResult, nil return evalResult, nil
} }
// ParseSignedJWT parses the input signature and return its payload. func getJWK(options *config.Options) (*jose.JSONWebKey, error) {
func (e *Evaluator) ParseSignedJWT(signature string) ([]byte, error) {
object, err := jose.ParseSigned(signature)
if err != nil {
return nil, err
}
return object.Verify(e.jwk.Public())
}
// JWTPayload returns the JWT payload for a request.
func (e *Evaluator) JWTPayload(req *Request) map[string]interface{} {
payload := map[string]interface{}{
"iss": e.authenticateHost,
}
req.fillJWTPayload(e.store, payload)
return payload
}
func newSigner(options *config.Options) (jose.Signer, *jose.JSONWebKey, error) {
var decodedCert []byte var decodedCert []byte
// if we don't have a signing key, generate one // if we don't have a signing key, generate one
if options.SigningKey == "" { if options.SigningKey == "" {
key, err := cryptutil.NewSigningKey() key, err := cryptutil.NewSigningKey()
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("couldn't generate signing key: %w", err) return nil, fmt.Errorf("couldn't generate signing key: %w", err)
} }
decodedCert, err = cryptutil.EncodePrivateKey(key) decodedCert, err = cryptutil.EncodePrivateKey(key)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("bad signing key: %w", err) return nil, fmt.Errorf("bad signing key: %w", err)
} }
} else { } else {
var err error var err error
decodedCert, err = base64.StdEncoding.DecodeString(options.SigningKey) decodedCert, err = base64.StdEncoding.DecodeString(options.SigningKey)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("bad signing key: %w", err) return nil, fmt.Errorf("bad signing key: %w", err)
} }
} }
signingKeyAlgorithm := options.SigningKeyAlgorithm signingKeyAlgorithm := options.SigningKeyAlgorithm
@ -183,41 +152,14 @@ func newSigner(options *config.Options) (jose.Signer, *jose.JSONWebKey, error) {
jwk, err := cryptutil.PrivateJWKFromBytes(decodedCert, jose.SignatureAlgorithm(signingKeyAlgorithm)) jwk, err := cryptutil.PrivateJWKFromBytes(decodedCert, jose.SignatureAlgorithm(signingKeyAlgorithm))
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("couldn't generate signing key: %w", err) return nil, fmt.Errorf("couldn't generate signing key: %w", err)
} }
log.Info().Str("Algorithm", jwk.Algorithm). log.Info().Str("Algorithm", jwk.Algorithm).
Str("KeyID", jwk.KeyID). Str("KeyID", jwk.KeyID).
Interface("Public Key", jwk.Public()). Interface("Public Key", jwk.Public()).
Msg("authorize: signing key") Msg("authorize: signing key")
signerOpt := &jose.SignerOptions{} return jwk, nil
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.SignatureAlgorithm(jwk.Algorithm),
Key: jwk,
}, signerOpt.WithHeader("kid", jwk.KeyID))
if err != nil {
return nil, nil, fmt.Errorf("couldn't create signer: %w", err)
}
return signer, jwk, nil
}
// SignedJWT returns the signature of given request.
func (e *Evaluator) SignedJWT(payload map[string]interface{}) (string, error) {
if e.signer == nil {
return "", errors.New("evaluator: signer cannot be nil")
}
bs, err := json.Marshal(payload)
if err != nil {
return "", err
}
jws, err := e.signer.Sign(bs)
if err != nil {
return "", err
}
return jws.CompactSerialize()
} }
type input struct { type input struct {
@ -238,11 +180,8 @@ func (e *Evaluator) newInput(req *Request, isValidClientCertificate bool) *input
type Result struct { type Result struct {
Status int Status int
Message string Message string
SignedJWT string Headers map[string]string
MatchingPolicy *config.Policy MatchingPolicy *config.Policy
UserEmail string
UserGroups []string
} }
func getMatchingPolicy(vars rego.Vars, policies []config.Policy) *config.Policy { func getMatchingPolicy(vars rego.Vars, policies []config.Policy) *config.Policy {
@ -263,7 +202,7 @@ func getMatchingPolicy(vars rego.Vars, policies []config.Policy) *config.Policy
return &policies[idx] return &policies[idx]
} }
func allowed(vars rego.Vars) bool { func getAllowVar(vars rego.Vars) bool {
result, ok := vars["result"].(map[string]interface{}) result, ok := vars["result"].(map[string]interface{})
if !ok { if !ok {
return false return false
@ -308,3 +247,23 @@ func getDenyVar(vars rego.Vars) []Result {
} }
return results return results
} }
func getHeadersVar(vars rego.Vars) map[string]string {
headers := make(map[string]string)
result, ok := vars["result"].(map[string]interface{})
if !ok {
return headers
}
m, ok := result["identity_headers"].(map[string]interface{})
if !ok {
return headers
}
for k, v := range m {
headers[k] = fmt.Sprint(v)
}
return headers
}

View file

@ -12,8 +12,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/pkg/grpc/databroker" "github.com/pomerium/pomerium/pkg/grpc/databroker"
@ -73,212 +71,6 @@ func TestJSONMarshal(t *testing.T) {
}`, string(bs)) }`, string(bs))
} }
func TestEvaluator_SignedJWT(t *testing.T) {
opt := config.NewDefaultOptions()
opt.AuthenticateURL = mustParseURL("https://authenticate.example.com")
e, err := New(opt, NewStore())
require.NoError(t, err)
req := &Request{
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://example.com",
},
}
signedJWT, err := e.SignedJWT(e.JWTPayload(req))
require.NoError(t, err)
assert.NotEmpty(t, signedJWT)
payload, err := e.ParseSignedJWT(signedJWT)
require.NoError(t, err)
assert.NotEmpty(t, payload)
}
func TestEvaluator_JWTWithKID(t *testing.T) {
opt := config.NewDefaultOptions()
opt.AuthenticateURL = mustParseURL("https://authenticate.example.com")
opt.SigningKey = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUpCMFZkbko1VjEvbVlpYUlIWHhnd2Q0Yzd5YWRTeXMxb3Y0bzA1b0F3ekdvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFVUc1eENQMEpUVDFINklvbDhqS3VUSVBWTE0wNENnVzlQbEV5cE5SbVdsb29LRVhSOUhUMwpPYnp6aktZaWN6YjArMUt3VjJmTVRFMTh1dy82MXJVQ0JBPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo="
e, err := New(opt, NewStore())
require.NoError(t, err)
req := &Request{
HTTP: RequestHTTP{
Method: http.MethodGet,
URL: "https://example.com",
},
}
signedJWT, err := e.SignedJWT(e.JWTPayload(req))
require.NoError(t, err)
assert.NotEmpty(t, signedJWT)
tok, err := jwt.ParseSigned(signedJWT)
require.NoError(t, err)
require.Len(t, tok.Headers, 1)
assert.Equal(t, "5b419ade1895fec2d2def6cd33b1b9a018df60db231dc5ecb85cbed6d942813c", tok.Headers[0].KeyID)
}
func TestEvaluator_JWTPayload(t *testing.T) {
nowPb := ptypes.TimestampNow()
now, _ := ptypes.Timestamp(nowPb)
tests := []struct {
name string
store *Store
req *Request
want map[string]interface{}
}{
{
"iss and aud",
NewStore(),
&Request{
HTTP: RequestHTTP{URL: "https://example.com"},
},
map[string]interface{}{
"iss": "authn.example.com",
"aud": "example.com",
},
},
{
"with session",
NewStoreFromProtos(&session.Session{
Id: "SESSION_ID",
IdToken: &session.IDToken{
ExpiresAt: nowPb,
IssuedAt: nowPb,
},
ExpiresAt: nowPb,
}),
&Request{
HTTP: RequestHTTP{URL: "https://example.com"},
Session: RequestSession{
ID: "SESSION_ID",
},
},
map[string]interface{}{
"iss": "authn.example.com",
"jti": "SESSION_ID",
"aud": "example.com",
"exp": now.Unix(),
"iat": now.Unix(),
},
},
{
"with service account",
NewStoreFromProtos(&user.ServiceAccount{
Id: "SERVICE_ACCOUNT_ID",
IssuedAt: nowPb,
ExpiresAt: nowPb,
}),
&Request{
HTTP: RequestHTTP{URL: "https://example.com"},
Session: RequestSession{
ID: "SERVICE_ACCOUNT_ID",
},
},
map[string]interface{}{
"iss": "authn.example.com",
"jti": "SERVICE_ACCOUNT_ID",
"aud": "example.com",
"exp": now.Unix(),
"iat": now.Unix(),
},
},
{
"with user",
NewStoreFromProtos(&session.Session{
Id: "SESSION_ID",
UserId: "USER_ID",
}, &user.User{
Id: "USER_ID",
Name: "foo",
Email: "foo@example.com",
}),
&Request{
HTTP: RequestHTTP{URL: "https://example.com"},
Session: RequestSession{
ID: "SESSION_ID",
},
},
map[string]interface{}{
"iss": "authn.example.com",
"jti": "SESSION_ID",
"aud": "example.com",
"sub": "USER_ID",
"user": "USER_ID",
"email": "foo@example.com",
},
},
{
"with directory user",
NewStoreFromProtos(
&session.Session{
Id: "SESSION_ID",
UserId: "USER_ID",
},
&directory.User{
Id: "USER_ID",
GroupIds: []string{"group1", "group2"},
},
&directory.Group{
Id: "group1",
Name: "admin",
Email: "admin@example.com",
},
&directory.Group{
Id: "group2",
Name: "test",
Email: "test@example.com",
},
),
&Request{
HTTP: RequestHTTP{URL: "https://example.com"},
Session: RequestSession{
ID: "SESSION_ID",
},
},
map[string]interface{}{
"iss": "authn.example.com",
"jti": "SESSION_ID",
"aud": "example.com",
"groups": []string{"group1", "group2", "admin", "test"},
},
},
{
"with impersonate",
NewStoreFromProtos(
&session.Session{
Id: "SESSION_ID",
UserId: "USER_ID",
ImpersonateEmail: proto.String("user@example.com"),
ImpersonateGroups: []string{"admin", "test"},
},
),
&Request{
HTTP: RequestHTTP{URL: "https://example.com"},
Session: RequestSession{
ID: "SESSION_ID",
},
},
map[string]interface{}{
"iss": "authn.example.com",
"jti": "SESSION_ID",
"aud": "example.com",
"email": "user@example.com",
"groups": []string{"admin", "test"},
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e, err := New(&config.Options{
AuthenticateURL: mustParseURL("https://authn.example.com"),
}, tc.store)
require.NoError(t, err)
assert.Equal(t, tc.want, e.JWTPayload(tc.req))
})
}
}
func TestEvaluator_Evaluate(t *testing.T) { func TestEvaluator_Evaluate(t *testing.T) {
sessionID := uuid.New().String() sessionID := uuid.New().String()
userID := uuid.New().String() userID := uuid.New().String()

View file

@ -1,4 +1,4 @@
package authorize package evaluator
import ( import (
"context" "context"
@ -12,19 +12,49 @@ import (
"sync" "sync"
"time" "time"
envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core" "github.com/open-policy-agent/opa/ast"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/types"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
"google.golang.org/api/idtoken" "google.golang.org/api/idtoken"
"github.com/pomerium/pomerium/authorize/evaluator"
) )
// GCP pre-defined values.
var ( var (
gpcIdentityTokenExpiration = time.Minute * 45 // tokens expire after one hour according to the GCP docs GCPIdentityTokenExpiration = time.Minute * 45 // tokens expire after one hour according to the GCP docs
gcpIdentityDocURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" GCPIdentityDocURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity"
gcpIdentityNow = time.Now GCPIdentityNow = time.Now
gcpIdentityMaxBodySize int64 = 1024 * 1024 * 10 GCPIdentityMaxBodySize int64 = 1024 * 1024 * 10
getGoogleCloudServerlessHeadersRegoOption = rego.Function2(&rego.Function{
Name: "get_google_cloud_serverless_headers",
Decl: types.NewFunction(
types.Args(types.S, types.S),
types.NewObject(nil, types.NewDynamicProperty(types.S, types.S)),
),
}, func(bctx rego.BuiltinContext, op1 *ast.Term, op2 *ast.Term) (*ast.Term, error) {
serviceAccount, ok := op1.Value.(ast.String)
if !ok {
return nil, fmt.Errorf("invalid service account type: %T", op1)
}
audience, ok := op2.Value.(ast.String)
if !ok {
return nil, fmt.Errorf("invalid audience type: %T", op2)
}
headers, err := getGoogleCloudServerlessHeaders(string(serviceAccount), string(audience))
if err != nil {
return nil, fmt.Errorf("failed to get google cloud serverless headers: %w", err)
}
var kvs [][2]*ast.Term
for k, v := range headers {
kvs = append(kvs, [2]*ast.Term{ast.StringTerm(k), ast.StringTerm(v)})
}
return ast.ObjectTerm(kvs...), nil
})
) )
type gcpIdentityTokenSource struct { type gcpIdentityTokenSource struct {
@ -34,7 +64,7 @@ type gcpIdentityTokenSource struct {
func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) { func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) {
res, err, _ := src.singleflight.Do("", func() (interface{}, error) { res, err, _ := src.singleflight.Do("", func() (interface{}, error) {
req, err := http.NewRequestWithContext(context.Background(), "GET", gcpIdentityDocURL+"?"+url.Values{ req, err := http.NewRequestWithContext(context.Background(), "GET", GCPIdentityDocURL+"?"+url.Values{
"format": {"full"}, "format": {"full"},
"audience": {src.audience}, "audience": {src.audience},
}.Encode(), nil) }.Encode(), nil)
@ -49,7 +79,7 @@ func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) {
} }
defer func() { _ = res.Body.Close() }() defer func() { _ = res.Body.Close() }()
bs, err := ioutil.ReadAll(io.LimitReader(res.Body, gcpIdentityMaxBodySize)) bs, err := ioutil.ReadAll(io.LimitReader(res.Body, GCPIdentityMaxBodySize))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -62,7 +92,7 @@ func (src *gcpIdentityTokenSource) Token() (*oauth2.Token, error) {
return &oauth2.Token{ return &oauth2.Token{
AccessToken: strings.TrimSpace(res.(string)), AccessToken: strings.TrimSpace(res.(string)),
TokenType: "bearer", TokenType: "bearer",
Expiry: gcpIdentityNow().Add(gpcIdentityTokenExpiration), Expiry: GCPIdentityNow().Add(GCPIdentityTokenExpiration),
}, nil }, nil
} }
@ -127,18 +157,7 @@ func getGoogleCloudServerlessTokenSource(serviceAccount, audience string) (oauth
return src, nil return src, nil
} }
func (a *Authorize) getGoogleCloudServerlessAuthenticationHeaders(reply *evaluator.Result) ([]*envoy_api_v2_core.HeaderValueOption, error) { func getGoogleCloudServerlessHeaders(serviceAccount, audience string) (map[string]string, error) {
if reply.MatchingPolicy == nil || !reply.MatchingPolicy.EnableGoogleCloudServerlessAuthentication {
return nil, nil
}
serviceAccount := a.currentOptions.Load().GoogleCloudServerlessAuthenticationServiceAccount
var hostname string
if len(reply.MatchingPolicy.To) > 0 {
hostname = reply.MatchingPolicy.To[0].URL.Hostname()
}
audience := fmt.Sprintf("https://%s", hostname)
src, err := getGoogleCloudServerlessTokenSource(serviceAccount, audience) src, err := getGoogleCloudServerlessTokenSource(serviceAccount, audience)
if err != nil { if err != nil {
return nil, err return nil, err
@ -149,7 +168,7 @@ func (a *Authorize) getGoogleCloudServerlessAuthenticationHeaders(reply *evaluat
return nil, err return nil, err
} }
return []*envoy_api_v2_core.HeaderValueOption{ return map[string]string{
mkHeader("Authorization", "Bearer "+tok.AccessToken, false), "Authorization": "Bearer " + tok.AccessToken,
}, nil }, nil
} }

View file

@ -1,4 +1,4 @@
package authorize package evaluator
import ( import (
"net/http" "net/http"
@ -9,34 +9,38 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGCPIdentityTokenSource(t *testing.T) { func withMockGCP(t *testing.T, f func()) {
originalGCPIdentityDocURL := gcpIdentityDocURL originalGCPIdentityDocURL := GCPIdentityDocURL
defer func() { defer func() {
gcpIdentityDocURL = originalGCPIdentityDocURL GCPIdentityDocURL = originalGCPIdentityDocURL
gcpIdentityNow = time.Now GCPIdentityNow = time.Now
}() }()
now := time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC) now := time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC)
gcpIdentityNow = func() time.Time { GCPIdentityNow = func() time.Time {
return now return now
} }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Google", r.Header.Get("Metadata-Flavor")) assert.Equal(t, "Google", r.Header.Get("Metadata-Flavor"))
assert.Equal(t, "full", r.URL.Query().Get("format")) assert.Equal(t, "full", r.URL.Query().Get("format"))
assert.Equal(t, "example", r.URL.Query().Get("audience"))
_, _ = w.Write([]byte(now.Format(time.RFC3339))) _, _ = w.Write([]byte(now.Format(time.RFC3339)))
})) }))
defer srv.Close() defer srv.Close()
gcpIdentityDocURL = srv.URL GCPIdentityDocURL = srv.URL
f()
}
src, err := getGoogleCloudServerlessTokenSource("", "example") func TestGCPIdentityTokenSource(t *testing.T) {
assert.NoError(t, err) withMockGCP(t, func() {
src, err := getGoogleCloudServerlessTokenSource("", "example")
assert.NoError(t, err)
token, err := src.Token() token, err := src.Token()
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "2020-01-01T01:00:00Z", token.AccessToken) assert.Equal(t, "2020-01-01T01:00:00Z", token.AccessToken)
})
} }
func Test_normalizeServiceAccount(t *testing.T) { func Test_normalizeServiceAccount(t *testing.T) {

View file

@ -37,7 +37,7 @@ directory_user = du {
} }
group_ids = gs { group_ids = gs {
gs = session.impersonate_group_ids gs = session.impersonate_groups
gs != null gs != null
} else = gs { } else = gs {
gs = directory_user.group_ids gs = directory_user.group_ids
@ -145,6 +145,138 @@ deny[reason] {
not input.is_valid_client_certificate not input.is_valid_client_certificate
} }
jwt_headers = {
"typ": "JWT",
"alg": data.signing_key.alg,
"kid": data.signing_key.kid,
}
jwt_payload_aud = v {
v = parse_url(input.http.url).hostname
} else = "" {
true
}
jwt_payload_iss = data.issuer
jwt_payload_jti = v {
v = session.id
} else = "" {
true
}
jwt_payload_exp = v {
v = session.expires_at.seconds
} else = null {
true
}
jwt_payload_iat = v {
# sessions store the issued_at on the id_token
v = session.id_token.issued_at.seconds
} else = v {
# service accounts store the issued at directly
v = session.issued_at.seconds
} else = null {
true
}
jwt_payload_sub = v {
v = user.id
} else = "" {
true
}
jwt_payload_user = v {
v = user.id
} else = "" {
true
}
jwt_payload_email = v {
v = session.impersonate_email
} else = v {
v = directory_user.email
} else = v {
v = user.email
} else = "" {
true
}
jwt_payload_groups = v {
v = array.concat(group_ids, get_databroker_group_names(group_ids))
} else = [] {
true
}
jwt_claims := [
["iss", jwt_payload_iss],
["aud", jwt_payload_aud],
["jti", jwt_payload_jti],
["exp", jwt_payload_exp],
["iat", jwt_payload_iat],
["sub", jwt_payload_sub],
["user", jwt_payload_user],
["email", jwt_payload_email],
["groups", jwt_payload_groups],
]
jwt_payload = {key: value |
# use a comprehension over an array to remove nil values
[key, value] := jwt_claims[_]
value != null
}
signed_jwt = io.jwt.encode_sign(jwt_headers, jwt_payload, data.signing_key)
kubernetes_headers = h {
route_policy.KubernetesServiceAccountToken != ""
h := [
["Authorization", concat(" ", ["Bearer", route_policy.KubernetesServiceAccountToken])],
["Impersonate-User", jwt_payload_email],
["Impersonate-Group", get_header_string_value(jwt_payload_groups)],
]
} else = [] {
true
}
google_cloud_serverless_authentication_service_account = s {
s := data.google_cloud_serverless_authentication_service_account
} else = "" {
true
}
google_cloud_serverless_headers = h {
route_policy.EnableGoogleCloudServerlessAuthentication
[hostname, _] := parse_host_port(route_policy.To[0].URL.Host)
audience := concat("", ["https://", hostname])
h := get_google_cloud_serverless_headers(google_cloud_serverless_authentication_service_account, audience)
} else = {} {
true
}
identity_headers := {key: value |
h1 := [["x-pomerium-jwt-assertion", signed_jwt]]
h2 := [[k, v] |
[claim_key, claim_value] := jwt_claims[_]
claim_value != null
# only include those headers requested by the user
available := data.jwt_claim_headers[_]
available == claim_key
# create the header key and value
k := concat("", ["x-pomerium-claim-", claim_key])
v := get_header_string_value(claim_value)
]
h3 := kubernetes_headers
h4 := [[k, v] | v := google_cloud_serverless_headers[k]]
h := array.concat(array.concat(array.concat(h1, h2), h3), h4)
[key, value] := h[_]
}
# returns the first matching route # returns the first matching route
first_allowed_route_policy_idx(input_url) = first_policy_idx { first_allowed_route_policy_idx(input_url) = first_policy_idx {
first_policy_idx := [idx | some idx, policy; policy = data.route_policies[idx]; allowed_route(input.http.url, policy)][0] first_policy_idx := [idx | some idx, policy; policy = data.route_policies[idx]; allowed_route(input.http.url, policy)][0]
@ -195,12 +327,20 @@ allowed_route_regex(input_url_obj, policy) {
re_match(policy.regex, input_url_obj.path) re_match(policy.regex, input_url_obj.path)
} }
parse_url(str) = {"scheme": scheme, "host": host, "path": path} { parse_url(str) = {"scheme": scheme, "host": host, "hostname": hostname, "path": path} {
[_, scheme, host, rawpath] = regex.find_all_string_submatch_n(`(?:((?:tcp[+])?http[s]?)://)?([^/]+)([^?#]*)`, str, 1)[0] [_, scheme, host, rawpath] = regex.find_all_string_submatch_n(`(?:((?:tcp[+])?http[s]?)://)?([^/]+)([^?#]*)`, str, 1)[0]
[hostname, _] = parse_host_port(host)
path = normalize_url_path(rawpath) path = normalize_url_path(rawpath)
} }
parse_host_port(str) = [host, port] {
contains(str, ":")
[host, port] = split(str, ":")
} else = [host, port] {
host = str
port = "443"
}
normalize_url_path(str) = "/" { normalize_url_path(str) = "/" {
str == "" str == ""
} }
@ -255,6 +395,13 @@ get_databroker_group_emails(ids) = gs {
gs := [email | id := ids[i]; group := data.databroker_data["type.googleapis.com"]["directory.Group"][id]; email := group.email] gs := [email | id := ids[i]; group := data.databroker_data["type.googleapis.com"]["directory.Group"][id]; email := group.email]
} }
get_header_string_value(obj) = s {
is_array(obj)
s := concat(",", obj)
} else = s {
s := concat(",", [obj])
}
# object_get is like object.get, but supports converting "/" in keys to separate lookups # object_get is like object.get, but supports converting "/" in keys to separate lookups
# rego doesn't support recursion, so we hard code a limited number of /'s # rego doesn't support recursion, so we hard code a limited number of /'s
object_get(obj, key, def) = value { object_get(obj, key, def) = value {

View file

@ -1,5 +1,11 @@
package pomerium.authz package pomerium.authz
get_google_cloud_serverless_headers(serviceAccount, audience) = h {
h := {
"Authorization": "Bearer xxx"
}
}
test_email_allowed { test_email_allowed {
allow with data.route_policies as [{ allow with data.route_policies as [{
"source": "example.com", "source": "example.com",

File diff suppressed because one or more lines are too long

View file

@ -4,13 +4,18 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"testing" "testing"
"time"
"github.com/open-policy-agent/opa/rego" "github.com/open-policy-agent/opa/rego"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/pkg/cryptutil"
"github.com/pomerium/pomerium/pkg/grpc/directory" "github.com/pomerium/pomerium/pkg/grpc/directory"
"github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
@ -20,15 +25,28 @@ func TestOPA(t *testing.T) {
type A = []interface{} type A = []interface{}
type M = map[string]interface{} type M = map[string]interface{}
signingKey, err := cryptutil.NewSigningKey()
require.NoError(t, err)
encodedSigningKey, err := cryptutil.EncodePrivateKey(signingKey)
require.NoError(t, err)
privateJWK, err := cryptutil.PrivateJWKFromBytes(encodedSigningKey, jose.ES256)
require.NoError(t, err)
publicJWK, err := cryptutil.PublicJWKFromBytes(encodedSigningKey, jose.ES256)
require.NoError(t, err)
eval := func(policies []config.Policy, data []proto.Message, req *Request, isValidClientCertificate bool) rego.Result { eval := func(policies []config.Policy, data []proto.Message, req *Request, isValidClientCertificate bool) rego.Result {
authzPolicy, err := readPolicy("/authz.rego") authzPolicy, err := readPolicy("/authz.rego")
require.NoError(t, err) require.NoError(t, err)
store := NewStoreFromProtos(data...) store := NewStoreFromProtos(data...)
store.UpdateIssuer("authenticate.example.com")
store.UpdateJWTClaimHeaders([]string{"email", "groups", "user"})
store.UpdateRoutePolicies(policies) store.UpdateRoutePolicies(policies)
store.UpdateSigningKey(privateJWK)
r := rego.New( r := rego.New(
rego.Store(store.opaStore), rego.Store(store.opaStore),
rego.Module("pomerium.authz", string(authzPolicy)), rego.Module("pomerium.authz", string(authzPolicy)),
rego.Query("result = data.pomerium.authz"), rego.Query("result = data.pomerium.authz"),
getGoogleCloudServerlessHeadersRegoOption,
) )
q, err := r.PrepareForEval(context.Background()) q, err := r.PrepareForEval(context.Background())
require.NoError(t, err) require.NoError(t, err)
@ -46,11 +64,168 @@ func TestOPA(t *testing.T) {
A{A{json.Number("495"), "invalid client certificate"}}, A{A{json.Number("495"), "invalid client certificate"}},
res.Bindings["result"].(M)["deny"]) res.Bindings["result"].(M)["deny"])
}) })
t.Run("identity_headers", func(t *testing.T) {
t.Run("kubernetes", func(t *testing.T) {
res := eval([]config.Policy{{
Source: &config.StringURL{URL: mustParseURL("https://from.example.com")},
To: config.WeightedURLs{
{URL: *mustParseURL("https://to.example.com")},
},
KubernetesServiceAccountToken: "KUBERNETES",
}}, []proto.Message{
&session.Session{
Id: "session1",
UserId: "user1",
ImpersonateGroups: []string{"i1", "i2"},
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
}, &Request{
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: "GET",
URL: "https://from.example.com",
},
}, true)
headers := res.Bindings["result"].(M)["identity_headers"].(M)
assert.NotEmpty(t, headers["Authorization"])
assert.Equal(t, "a@example.com", headers["Impersonate-User"])
assert.Equal(t, "i1,i2", headers["Impersonate-Group"])
})
t.Run("google_cloud_serverless", func(t *testing.T) {
withMockGCP(t, func() {
res := eval([]config.Policy{{
Source: &config.StringURL{URL: mustParseURL("https://from.example.com")},
To: config.WeightedURLs{
{URL: *mustParseURL("https://to.example.com")},
},
EnableGoogleCloudServerlessAuthentication: true,
}}, []proto.Message{
&session.Session{
Id: "session1",
UserId: "user1",
ImpersonateGroups: []string{"i1", "i2"},
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
}, &Request{
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: "GET",
URL: "https://from.example.com",
},
}, true)
headers := res.Bindings["result"].(M)["identity_headers"].(M)
assert.NotEmpty(t, headers["Authorization"])
})
})
})
t.Run("jwt", func(t *testing.T) {
evalJWT := func(msgs ...proto.Message) M {
res := eval([]config.Policy{{
Source: &config.StringURL{URL: mustParseURL("https://from.example.com:8000")},
To: config.WeightedURLs{
{URL: *mustParseURL("https://to.example.com")},
},
}}, msgs, &Request{
Session: RequestSession{
ID: "session1",
},
HTTP: RequestHTTP{
Method: "GET",
URL: "https://from.example.com:8000",
},
}, true)
signedCompactJWTStr := res.Bindings["result"].(M)["signed_jwt"].(string)
authJWT, err := jwt.ParseSigned(signedCompactJWTStr)
require.NoError(t, err)
var claims M
err = authJWT.Claims(publicJWK, &claims)
require.NoError(t, err)
return claims
}
t.Run("impersonate groups", func(t *testing.T) {
payload := evalJWT(
&session.Session{
Id: "session1",
UserId: "user1",
ImpersonateGroups: []string{"i1", "i2"},
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
&directory.User{
Id: "user1",
GroupIds: []string{"group1"},
},
&directory.Group{
Id: "group1",
Name: "group1name",
Email: "group1@example.com",
},
)
assert.Equal(t, M{
"aud": "from.example.com",
"iss": "authenticate.example.com",
"jti": "session1",
"sub": "user1",
"user": "user1",
"email": "a@example.com",
"groups": []interface{}{"i1", "i2"},
}, payload)
})
t.Run("directory", func(t *testing.T) {
payload := evalJWT(
&session.Session{
Id: "session1",
UserId: "user1",
ExpiresAt: timestamppb.New(time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC)),
IdToken: &session.IDToken{
IssuedAt: timestamppb.New(time.Date(2021, 2, 1, 1, 1, 1, 1, time.UTC)),
},
},
&user.User{
Id: "user1",
Email: "a@example.com",
},
&directory.User{
Id: "user1",
GroupIds: []string{"group1"},
},
&directory.Group{
Id: "group1",
Name: "group1name",
Email: "group1@example.com",
},
)
assert.Equal(t, M{
"aud": "from.example.com",
"iss": "authenticate.example.com",
"jti": "session1",
"exp": 1609462861.0,
"iat": 1612141261.0,
"sub": "user1",
"user": "user1",
"email": "a@example.com",
"groups": A{"group1", "group1name"},
}, payload)
})
})
t.Run("email", func(t *testing.T) { t.Run("email", func(t *testing.T) {
t.Run("allowed", func(t *testing.T) { t.Run("allowed", func(t *testing.T) {
res := eval([]config.Policy{ res := eval([]config.Policy{
{ {
Source: &config.StringURL{URL: mustParseURL("https://from.example.com")}, Source: &config.StringURL{URL: mustParseURL("https://from.example.com:8000")},
To: config.WeightedURLs{ To: config.WeightedURLs{
{URL: *mustParseURL("https://to.example.com")}, {URL: *mustParseURL("https://to.example.com")},
}, },
@ -71,7 +246,7 @@ func TestOPA(t *testing.T) {
}, },
HTTP: RequestHTTP{ HTTP: RequestHTTP{
Method: "GET", Method: "GET",
URL: "https://from.example.com", URL: "https://from.example.com:8000",
}, },
}, true) }, true)
assert.True(t, res.Bindings["result"].(M)["allow"].(bool)) assert.True(t, res.Bindings["result"].(M)["allow"].(bool))

View file

@ -1,15 +1,5 @@
package evaluator package evaluator
import (
"net/url"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/pomerium/pomerium/internal/directory"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
)
type ( type (
// Request is the request data used for the evaluator. // Request is the request data used for the evaluator.
Request struct { Request struct {
@ -32,69 +22,3 @@ type (
ID string `json:"id"` ID string `json:"id"`
} }
) )
type sessionOrServiceAccount interface {
GetId() string
GetExpiresAt() *timestamppb.Timestamp
GetIssuedAt() *timestamppb.Timestamp
GetUserId() string
GetImpersonateEmail() string
GetImpersonateGroups() []string
GetImpersonateUserId() string
}
func (req *Request) fillJWTPayload(store *Store, payload map[string]interface{}) {
if u, err := url.Parse(req.HTTP.URL); err == nil {
payload["aud"] = u.Hostname()
}
if s, ok := store.GetRecordData("type.googleapis.com/session.Session", req.Session.ID).(*session.Session); ok {
req.fillJWTPayloadSessionOrServiceAccount(store, payload, s)
}
if sa, ok := store.GetRecordData("type.googleapis.com/user.ServiceAccount", req.Session.ID).(*user.ServiceAccount); ok {
req.fillJWTPayloadSessionOrServiceAccount(store, payload, sa)
}
}
func (req *Request) fillJWTPayloadSessionOrServiceAccount(store *Store, payload map[string]interface{}, s sessionOrServiceAccount) {
payload["jti"] = s.GetId()
if s.GetExpiresAt().IsValid() {
payload["exp"] = s.GetExpiresAt().AsTime().Unix()
}
if s.GetIssuedAt().IsValid() {
payload["iat"] = s.GetIssuedAt().AsTime().Unix()
}
userID := s.GetUserId()
if s.GetImpersonateUserId() != "" {
userID = s.GetImpersonateUserId()
}
if u, ok := store.GetRecordData("type.googleapis.com/user.User", userID).(*user.User); ok {
payload["sub"] = u.GetId()
payload["user"] = u.GetId()
payload["email"] = u.GetEmail()
}
if du, ok := store.GetRecordData("type.googleapis.com/directory.User", userID).(*directory.User); ok {
if du.GetEmail() != "" {
payload["email"] = du.GetEmail()
}
var groupNames []string
for _, groupID := range du.GetGroupIds() {
if dg, ok := store.GetRecordData("type.googleapis.com/directory.Group", groupID).(*directory.Group); ok {
groupNames = append(groupNames, dg.Name)
}
}
var groups []string
groups = append(groups, du.GetGroupIds()...)
groups = append(groups, groupNames...)
payload["groups"] = groups
}
if s.GetImpersonateEmail() != "" {
payload["email"] = s.GetImpersonateEmail()
}
if len(s.GetImpersonateGroups()) > 0 {
payload["groups"] = s.GetImpersonateGroups()
}
}

View file

@ -11,6 +11,7 @@ import (
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb" "google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"gopkg.in/square/go-jose.v2"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/log"
@ -89,6 +90,22 @@ func (s *Store) GetRecordData(typeURL, id string) proto.Message {
return msg return msg
} }
// UpdateIssuer updates the issuer in the store. The issuer is used as part of JWT construction.
func (s *Store) UpdateIssuer(issuer string) {
s.write("/issuer", issuer)
}
// UpdateGoogleCloudServerlessAuthenticationServiceAccount updates the google cloud serverless authentication
// service account in the store.
func (s *Store) UpdateGoogleCloudServerlessAuthenticationServiceAccount(serviceAccount string) {
s.write("/google_cloud_serverless_authentication_service_account", serviceAccount)
}
// UpdateJWTClaimHeaders updates the jwt claim headers in the store.
func (s *Store) UpdateJWTClaimHeaders(jwtClaimHeaders []string) {
s.write("/jwt_claim_headers", jwtClaimHeaders)
}
// UpdateRoutePolicies updates the route policies in the store. // UpdateRoutePolicies updates the route policies in the store.
func (s *Store) UpdateRoutePolicies(routePolicies []config.Policy) { func (s *Store) UpdateRoutePolicies(routePolicies []config.Policy) {
s.write("/route_policies", routePolicies) s.write("/route_policies", routePolicies)
@ -144,6 +161,12 @@ func (s *Store) delete(rawPath string) {
} }
} }
// UpdateSigningKey updates the signing key stored in the database. Signing operations
// in rego use JWKs, so we take in that format.
func (s *Store) UpdateSigningKey(signingKey *jose.JSONWebKey) {
s.write("/signing_key", signingKey)
}
func (s *Store) get(rawPath string) (value interface{}) { func (s *Store) get(rawPath string) (value interface{}) {
p, ok := storage.ParsePath(rawPath) p, ok := storage.ParsePath(rawPath)
if !ok { if !ok {

View file

@ -23,7 +23,6 @@ import (
"github.com/pomerium/pomerium/pkg/grpc/session" "github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user" "github.com/pomerium/pomerium/pkg/grpc/user"
envoy_api_v2_core "github.com/envoyproxy/go-control-plane/envoy/api/v2/core"
envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2"
) )
@ -58,7 +57,8 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
rawJWT, _ := loadRawSession(hreq, a.currentOptions.Load(), state.encoder) rawJWT, _ := loadRawSession(hreq, a.currentOptions.Load(), state.encoder)
sessionState, _ := loadSession(state.encoder, rawJWT) sessionState, _ := loadSession(state.encoder, rawJWT)
if err := a.forceSync(ctx, sessionState); err != nil { u, err := a.forceSync(ctx, sessionState)
if err != nil {
log.Warn().Err(err).Msg("clearing session due to force sync failed") log.Warn().Err(err).Msg("clearing session due to force sync failed")
sessionState = nil sessionState = nil
} }
@ -74,7 +74,7 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
log.Error().Err(err).Msg("error during OPA evaluation") log.Error().Err(err).Msg("error during OPA evaluation")
return nil, err return nil, err
} }
logAuthorizeCheck(ctx, in, reply) logAuthorizeCheck(ctx, in, reply, u)
switch { switch {
case reply.Status == http.StatusOK: case reply.Status == http.StatusOK:
@ -88,18 +88,18 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v2.CheckRe
return a.deniedResponse(in, int32(reply.Status), reply.Message, nil) return a.deniedResponse(in, int32(reply.Status), reply.Message, nil)
} }
func (a *Authorize) forceSync(ctx context.Context, ss *sessions.State) error { func (a *Authorize) forceSync(ctx context.Context, ss *sessions.State) (*user.User, error) {
ctx, span := trace.StartSpan(ctx, "authorize.forceSync") ctx, span := trace.StartSpan(ctx, "authorize.forceSync")
defer span.End() defer span.End()
if ss == nil { if ss == nil {
return nil return nil, nil
} }
s := a.forceSyncSession(ctx, ss.ID) s := a.forceSyncSession(ctx, ss.ID)
if s == nil { if s == nil {
return errors.New("session not found") return nil, errors.New("session not found")
} }
a.forceSyncUser(ctx, s.GetUserId()) u := a.forceSyncUser(ctx, s.GetUserId())
return nil return u, nil
} }
func (a *Authorize) forceSyncSession(ctx context.Context, sessionID string) interface{ GetUserId() string } { func (a *Authorize) forceSyncSession(ctx context.Context, sessionID string) interface{ GetUserId() string } {
@ -163,20 +163,6 @@ func (a *Authorize) forceSyncUser(ctx context.Context, userID string) *user.User
return u return u
} }
func (a *Authorize) getEnvoyRequestHeaders(signedJWT string) ([]*envoy_api_v2_core.HeaderValueOption, error) {
var hvos []*envoy_api_v2_core.HeaderValueOption
hdrs, err := a.getJWTClaimHeaders(a.currentOptions.Load(), signedJWT)
if err != nil {
return nil, err
}
for k, v := range hdrs {
hvos = append(hvos, mkHeader(k, v, false))
}
return hvos, nil
}
func getForwardAuthURL(r *http.Request) *url.URL { func getForwardAuthURL(r *http.Request) *url.URL {
urqQuery := r.URL.Query().Get("uri") urqQuery := r.URL.Query().Get("uri")
u, _ := urlutil.ParseAndValidateURL(urqQuery) u, _ := urlutil.ParseAndValidateURL(urqQuery)
@ -334,6 +320,7 @@ func logAuthorizeCheck(
ctx context.Context, ctx context.Context,
in *envoy_service_auth_v2.CheckRequest, in *envoy_service_auth_v2.CheckRequest,
reply *evaluator.Result, reply *evaluator.Result,
u *user.User,
) { ) {
hdrs := getCheckRequestHeaders(in) hdrs := getCheckRequestHeaders(in)
hattrs := in.GetAttributes().GetRequest().GetHttp() hattrs := in.GetAttributes().GetRequest().GetHttp()
@ -350,8 +337,8 @@ func logAuthorizeCheck(
evt = evt.Bool("allow", reply.Status == http.StatusOK) evt = evt.Bool("allow", reply.Status == http.StatusOK)
evt = evt.Int("status", reply.Status) evt = evt.Int("status", reply.Status)
evt = evt.Str("message", reply.Message) evt = evt.Str("message", reply.Message)
evt = evt.Str("user", reply.UserEmail) evt = evt.Str("user", u.GetId())
evt = evt.Strs("groups", reply.UserGroups) evt = evt.Str("email", u.GetEmail())
} }
// potentially sensitive, only log if debug mode // potentially sensitive, only log if debug mode

View file

@ -437,7 +437,8 @@ func TestSync(t *testing.T) {
a, err := New(&config.Config{Options: o}) a, err := New(&config.Config{Options: o})
require.NoError(t, err) require.NoError(t, err)
a.state.Load().dataBrokerClient = dbdClient a.state.Load().dataBrokerClient = dbdClient
assert.True(t, (a.forceSync(ctx, tc.sessionState) != nil) == tc.wantErr) _, err = a.forceSync(ctx, tc.sessionState)
assert.True(t, (err != nil) == tc.wantErr)
}) })
} }
} }

View file

@ -1,12 +1,10 @@
package authorize package authorize
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/encoding" "github.com/pomerium/pomerium/internal/encoding"
@ -85,41 +83,3 @@ func getJWTSetCookieHeaders(cookieStore sessions.SessionStore, rawjwt []byte) (m
} }
return hdrs, nil return hdrs, nil
} }
func (a *Authorize) getJWTClaimHeaders(options *config.Options, signedJWT string) (map[string]string, error) {
if len(signedJWT) == 0 {
return make(map[string]string), nil
}
state := a.state.Load()
var claims map[string]interface{}
payload, err := state.evaluator.ParseSignedJWT(signedJWT)
if err != nil {
return nil, err
}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, err
}
hdrs := make(map[string]string)
for _, name := range options.JWTClaimsHeaders {
if claim, ok := claims[name]; ok {
switch value := claim.(type) {
case string:
hdrs["x-pomerium-claim-"+name] = value
case []interface{}:
hdrs["x-pomerium-claim-"+name] = strings.Join(toSliceStrings(value), ",")
}
}
}
return hdrs, nil
}
func toSliceStrings(sliceIfaces []interface{}) []string {
sliceStrings := make([]string, 0, len(sliceIfaces))
for _, e := range sliceIfaces {
sliceStrings = append(sliceStrings, fmt.Sprint(e))
}
return sliceStrings
}

View file

@ -7,15 +7,10 @@ import (
envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2" envoy_service_auth_v2 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pomerium/pomerium/authorize/evaluator"
"github.com/pomerium/pomerium/config" "github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/directory"
"github.com/pomerium/pomerium/internal/encoding/jws" "github.com/pomerium/pomerium/internal/encoding/jws"
"github.com/pomerium/pomerium/internal/sessions" "github.com/pomerium/pomerium/internal/sessions"
"github.com/pomerium/pomerium/pkg/grpc/session"
"github.com/pomerium/pomerium/pkg/grpc/user"
) )
func TestLoadSession(t *testing.T) { func TestLoadSession(t *testing.T) {
@ -105,71 +100,3 @@ func TestLoadSession(t *testing.T) {
assert.NotNil(t, sess) assert.NotNil(t, sess)
}) })
} }
func TestAuthorize_getJWTClaimHeaders(t *testing.T) {
opt := &config.Options{
AuthenticateURL: mustParseURL("https://authenticate.example.com"),
Policies: []config.Policy{{
Source: &config.StringURL{URL: &url.URL{Host: "example.com"}},
SubPolicies: []config.SubPolicy{{
Rego: []string{"allow = true"},
}},
}},
}
a := &Authorize{currentOptions: config.NewAtomicOptions(), state: newAtomicAuthorizeState(new(authorizeState))}
encoder, _ := jws.NewHS256Signer([]byte{0, 0, 0, 0})
a.state.Load().encoder = encoder
a.currentOptions.Store(opt)
a.store = evaluator.NewStoreFromProtos(
&session.Session{
Id: "SESSION_ID",
UserId: "USER_ID",
},
&user.User{
Id: "USER_ID",
Name: "foo",
Email: "foo@example.com",
},
&directory.User{
Id: "USER_ID",
GroupIds: []string{"admin_id", "test_id"},
},
&directory.Group{
Id: "admin_id",
Name: "admin",
},
&directory.Group{
Id: "test_id",
Name: "test",
},
)
pe, err := newPolicyEvaluator(opt, a.store)
require.NoError(t, err)
a.state.Load().evaluator = pe
signedJWT, _ := pe.SignedJWT(pe.JWTPayload(&evaluator.Request{
HTTP: evaluator.RequestHTTP{URL: "https://example.com"},
Session: evaluator.RequestSession{
ID: "SESSION_ID",
},
}))
tests := []struct {
name string
signedJWT string
jwtHeaders []string
expectedHeaders map[string]string
}{
{"good with email", signedJWT, []string{"email"}, map[string]string{"x-pomerium-claim-email": "foo@example.com"}},
{"good with groups", signedJWT, []string{"groups"}, map[string]string{"x-pomerium-claim-groups": "admin_id,test_id,admin,test"}},
{"empty signed JWT", "", nil, make(map[string]string)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
opt.JWTClaimsHeaders = tc.jwtHeaders
gotHeaders, err := a.getJWTClaimHeaders(opt, tc.signedJWT)
require.NoError(t, err)
assert.Equal(t, tc.expectedHeaders, gotHeaders)
})
}
}