feature/databroker: user data and session refactor project (#926)

* databroker: add databroker, identity manager, update cache (#864)

* databroker: add databroker, identity manager, update cache

* fix cache tests

* directory service (#885)

* directory: add google and okta

* add onelogin

* add directory provider

* initialize before sync, upate google provider, remove dead code

* add azure provider

* fix azure provider

* fix gitlab

* add gitlab test, fix azure test

* hook up okta

* remove dead code

* fix tests

* fix flaky test

* authorize: use databroker data for rego policy (#904)

* wip

* add directory provider

* initialize before sync, upate google provider, remove dead code

* fix flaky test

* update authorize to use databroker data

* implement signed jwt

* wait for session and user to appear

* fix test

* directory service (#885)

* directory: add google and okta

* add onelogin

* add directory provider

* initialize before sync, upate google provider, remove dead code

* add azure provider

* fix azure provider

* fix gitlab

* add gitlab test, fix azure test

* hook up okta

* remove dead code

* fix tests

* fix flaky test

* remove log line

* only redirect when no session id exists

* prepare rego query as part of create

* return on ctx done

* retry on disconnect for sync

* move jwt signing

* use !=

* use parent ctx for wait

* remove session state, remove logs

* rename function

* add log message

* pre-allocate slice

* use errgroup

* return nil on eof for sync

* move check

* disable timeout on gRPC requests in envoy

* fix gitlab test

* use v4 backoff

* authenticate: databroker changes (#914)

* wip

* add directory provider

* initialize before sync, upate google provider, remove dead code

* fix flaky test

* update authorize to use databroker data

* implement signed jwt

* wait for session and user to appear

* fix test

* directory service (#885)

* directory: add google and okta

* add onelogin

* add directory provider

* initialize before sync, upate google provider, remove dead code

* add azure provider

* fix azure provider

* fix gitlab

* add gitlab test, fix azure test

* hook up okta

* remove dead code

* fix tests

* fix flaky test

* remove log line

* only redirect when no session id exists

* prepare rego query as part of create

* return on ctx done

* retry on disconnect for sync

* move jwt signing

* use !=

* use parent ctx for wait

* remove session state, remove logs

* rename function

* add log message

* pre-allocate slice

* use errgroup

* return nil on eof for sync

* move check

* disable timeout on gRPC requests in envoy

* fix dashboard

* delete session on logout

* permanently delete sessions once they are marked as deleted

* remove permanent delete

* fix tests

* remove groups and refresh test

* databroker: remove dead code, rename cache url, move dashboard (#925)

* wip

* add directory provider

* initialize before sync, upate google provider, remove dead code

* fix flaky test

* update authorize to use databroker data

* implement signed jwt

* wait for session and user to appear

* fix test

* directory service (#885)

* directory: add google and okta

* add onelogin

* add directory provider

* initialize before sync, upate google provider, remove dead code

* add azure provider

* fix azure provider

* fix gitlab

* add gitlab test, fix azure test

* hook up okta

* remove dead code

* fix tests

* fix flaky test

* remove log line

* only redirect when no session id exists

* prepare rego query as part of create

* return on ctx done

* retry on disconnect for sync

* move jwt signing

* use !=

* use parent ctx for wait

* remove session state, remove logs

* rename function

* add log message

* pre-allocate slice

* use errgroup

* return nil on eof for sync

* move check

* disable timeout on gRPC requests in envoy

* fix dashboard

* delete session on logout

* permanently delete sessions once they are marked as deleted

* remove permanent delete

* fix tests

* remove cache service

* remove kv

* remove refresh docs

* remove obsolete cache docs

* add databroker url option

* cache: use memberlist to detect multiple instances

* add databroker service url

* remove cache service

* remove kv

* remove refresh docs

* remove obsolete cache docs

* add databroker url option

* cache: use memberlist to detect multiple instances

* add databroker service url

* wip

* remove groups and refresh test

* fix redirect, signout

* remove databroker client from proxy

* remove unused method

* remove user dashboard test

* handle missing session ids

* session: reject sessions with no id

* sessions: invalidate old sessions via databroker server version (#930)

* session: add a version field tied to the databroker server version that can be used to invalidate sessions

* fix tests

* add log

* authenticate: create user record immediately, call "get" directly in authorize (#931)
This commit is contained in:
Caleb Doxsey 2020-06-19 07:52:44 -06:00 committed by GitHub
parent 39cdb31170
commit dbd7f55b20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 8479 additions and 3584 deletions

View file

@ -4,48 +4,321 @@ package evaluator
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
pb "github.com/pomerium/pomerium/internal/grpc/authorize"
"github.com/golang/protobuf/proto"
"github.com/golang/protobuf/ptypes"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/storage/inmem"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/known/anypb"
"gopkg.in/square/go-jose.v2"
"github.com/pomerium/pomerium/config"
"github.com/pomerium/pomerium/internal/cryptutil"
"github.com/pomerium/pomerium/internal/directory"
"github.com/pomerium/pomerium/internal/grpc/databroker"
"github.com/pomerium/pomerium/internal/grpc/session"
"github.com/pomerium/pomerium/internal/grpc/user"
"github.com/pomerium/pomerium/internal/log"
)
// Evaluator specifies the interface for a policy engine.
type Evaluator interface {
IsAuthorized(ctx context.Context, req *Request) (*pb.IsAuthorizedReply, error)
PutData(ctx context.Context, data map[string]interface{}) error
type Evaluator struct {
rego *rego.Rego
query rego.PreparedEvalQuery
clientCA string
authenticateHost string
jwk interface{}
}
// A Request represents an evaluable request with an associated user, device,
// and request context.
type Request struct {
// User context
//
// User contains the associated user's JWT created by the authenticate
// service
User string `json:"user,omitempty"`
// New creates a new Evaluator.
func New(options *config.Options) (*Evaluator, error) {
e := &Evaluator{
authenticateHost: options.AuthenticateURL.Host,
}
if options.ClientCA != "" {
e.clientCA = options.ClientCA
} else if options.ClientCAFile != "" {
bs, err := ioutil.ReadFile(options.ClientCAFile)
if err != nil {
return nil, err
}
e.clientCA = string(bs)
}
// Request context
//
// Method specifies the HTTP method (GET, POST, PUT, etc.).
Method string `json:"method,omitempty"`
// URL specifies either the URI being requested.
URL string `json:"url,omitempty"`
// Header contains the request header fields either received
// by the server or to be sent by the client.
Header map[string][]string `json:"headers,omitempty"`
// Host specifies the host on which the URL is sought.
Host string `json:"host,omitempty"`
// RequestURI is the unmodified request-target of the
// Request-Line (RFC 7230, Section 3.1.1) as sent by the client
// to a server. Usually the URL field should be used instead.
// It is an error to set this field in an HTTP client request.
RequestURI string `json:"request_uri,omitempty"`
if options.SigningKey == "" {
key, err := cryptutil.NewSigningKey()
if err != nil {
return nil, fmt.Errorf("authorize: couldn't generate signing key: %w", err)
}
e.jwk = key
pubKeyBytes, err := cryptutil.EncodePublicKey(&key.PublicKey)
if err != nil {
return nil, fmt.Errorf("authorize: encode public key: %w", err)
}
log.Info().Interface("PublicKey", pubKeyBytes).Msg("authorize: ecdsa public key")
} else {
decodedCert, err := base64.StdEncoding.DecodeString(options.SigningKey)
if err != nil {
return nil, fmt.Errorf("authorize: failed to decode certificate cert %v: %w", decodedCert, err)
}
keyBytes, err := cryptutil.DecodePrivateKey((decodedCert))
if err != nil {
return nil, fmt.Errorf("authorize: couldn't generate signing key: %w", err)
}
e.jwk = keyBytes
}
// Connection context
//
// ClientCertificate is the PEM-encoded public certificate used for the user's TLS connection.
ClientCertificate string `json:"client_certificate"`
authzPolicy, err := readPolicy("/authz.rego")
if err != nil {
return nil, fmt.Errorf("error loading rego policy: %w", err)
}
// Device context
//
// todo(bdd): Use the peer TLS certificate to bind device state with a request
e.rego = rego.New(
rego.Store(inmem.NewFromObject(map[string]interface{}{
"admins": options.Administrators,
"route_policies": options.Policies,
})),
rego.Module("pomerium.authz", string(authzPolicy)),
rego.Query("result = data.pomerium.authz"),
)
e.query, err = e.rego.PrepareForEval(context.Background())
if err != nil {
return nil, fmt.Errorf("error preparing rego query: %w", err)
}
return e, nil
}
// Evaluate evaluates the policy against the request.
func (e *Evaluator) Evaluate(ctx context.Context, req *Request) (*Result, error) {
isValid, err := isValidClientCertificate(e.clientCA, req.HTTP.ClientCertificate)
if err != nil {
return nil, fmt.Errorf("error validating client certificate: %w", err)
}
res, err := e.query.Eval(ctx, rego.EvalInput(e.newInput(req, isValid)))
if err != nil {
return nil, fmt.Errorf("error evaluating rego policy: %w", err)
}
deny := getDenyVar(res[0].Bindings.WithoutWildcards())
if len(deny) > 0 {
return &deny[0], nil
}
signedJWT, err := e.getSignedJWT(req)
if err != nil {
return nil, fmt.Errorf("error signing JWT: %w", err)
}
allow := allowed(res[0].Bindings.WithoutWildcards())
if allow {
return &Result{
Status: http.StatusOK,
Message: "OK",
SignedJWT: signedJWT,
}, nil
}
if req.Session.ID == "" {
return &Result{
Status: http.StatusUnauthorized,
Message: "login required",
SignedJWT: signedJWT,
}, nil
}
return &Result{
Status: http.StatusForbidden,
Message: "forbidden",
SignedJWT: signedJWT,
}, nil
}
func (e *Evaluator) getSignedJWT(req *Request) (string, error) {
signer, err := jose.NewSigner(jose.SigningKey{
Algorithm: jose.ES256,
Key: e.jwk,
}, nil)
if err != nil {
return "", err
}
payload := map[string]interface{}{
"iss": e.authenticateHost,
}
if u, err := url.Parse(req.HTTP.URL); err == nil {
payload["aud"] = u.Hostname()
}
if s, ok := req.DataBrokerData.Get("type.googleapis.com/session.Session", req.Session.ID).(*session.Session); ok {
if tm, err := ptypes.Timestamp(s.GetIdToken().GetExpiresAt()); err == nil {
payload["exp"] = tm.Unix()
}
if tm, err := ptypes.Timestamp(s.GetIdToken().GetIssuedAt()); err == nil {
payload["iat"] = tm.Unix()
}
if u, ok := req.DataBrokerData.Get("type.googleapis.com/user.User", s.GetUserId()).(*user.User); ok {
payload["sub"] = u.GetId()
payload["email"] = u.GetEmail()
}
if du, ok := req.DataBrokerData.Get("type.googleapis.com/directory.User", s.GetUserId()).(*directory.User); ok {
payload["groups"] = du.GetGroups()
}
}
bs, err := json.Marshal(payload)
if err != nil {
return "", err
}
jws, err := signer.Sign(bs)
if err != nil {
return "", err
}
return jws.CompactSerialize()
}
type input struct {
DataBrokerData DataBrokerData `json:"databroker_data"`
HTTP RequestHTTP `json:"http"`
Session RequestSession `json:"session"`
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
}
func (e *Evaluator) newInput(req *Request, isValidClientCertificate bool) *input {
i := new(input)
i.DataBrokerData = req.DataBrokerData
i.HTTP = req.HTTP
i.Session = req.Session
i.IsValidClientCertificate = isValidClientCertificate
return i
}
type (
// Request is the request data used for the evaluator.
Request struct {
DataBrokerData DataBrokerData `json:"databroker_data"`
HTTP RequestHTTP `json:"http"`
Session RequestSession `json:"session"`
}
// RequestHTTP is the HTTP field in the request.
RequestHTTP struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ClientCertificate string `json:"client_certificate"`
}
// RequestSession is the session field in the request.
RequestSession struct {
ID string `json:"id"`
ImpersonateEmail string `json:"impersonate_email"`
ImpersonateGroups []string `json:"impersonate_groups"`
}
)
// Result is the result of evaluation.
type Result struct {
Status int
Message string
SignedJWT string
}
func allowed(vars rego.Vars) bool {
result, ok := vars["result"].(map[string]interface{})
if !ok {
return false
}
allow, ok := result["allow"].(bool)
if !ok {
return false
}
return allow
}
func getDenyVar(vars rego.Vars) []Result {
result, ok := vars["result"].(map[string]interface{})
if !ok {
return nil
}
denials, ok := result["deny"].([]interface{})
if !ok {
return nil
}
results := make([]Result, 0, len(denials))
for _, denial := range denials {
denial, ok := denial.([]interface{})
if !ok || len(denial) != 2 {
continue
}
status, err := strconv.Atoi(fmt.Sprint(denial[0]))
if err != nil {
log.Error().Err(err).Msg("invalid type in deny")
continue
}
msg := fmt.Sprint(denial[1])
results = append(results, Result{
Status: status,
Message: msg,
})
}
return results
}
// DataBrokerData stores the data broker data by type => id => record
type DataBrokerData map[string]map[string]interface{}
// Get gets a record from the DataBrokerData.
func (dbd DataBrokerData) Get(typeURL, id string) interface{} {
m, ok := dbd[typeURL]
if !ok {
return nil
}
return m[id]
}
// Update updates a record in the DataBrokerData.
func (dbd DataBrokerData) Update(record *databroker.Record) {
db, ok := dbd[record.GetType()]
if !ok {
db = make(map[string]interface{})
dbd[record.GetType()] = db
}
if record.GetDeletedAt() != nil {
delete(db, record.GetId())
} else {
if obj, err := unmarshalAny(record.GetData()); err == nil {
db[record.GetId()] = obj
} else {
log.Warn().Err(err).Msg("failed to unmarshal unknown any type")
delete(db, record.GetId())
}
}
}
func unmarshalAny(any *anypb.Any) (proto.Message, error) {
messageType, err := protoregistry.GlobalTypes.FindMessageByURL(any.GetTypeUrl())
if err != nil {
return nil, err
}
msg := proto.MessageV1(messageType.New())
return msg, ptypes.UnmarshalAny(any, msg)
}

View file

@ -0,0 +1,67 @@
package evaluator
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/pomerium/pomerium/internal/grpc/directory"
)
func TestJSONMarshal(t *testing.T) {
dbd := DataBrokerData{
"type.googleapis.com/directory.User": map[string]interface{}{
"user1": &directory.User{
Id: "user1",
Groups: []string{"group1", "group2"},
},
},
"type.googleapis.com/session.Session": map[string]interface{}{},
"type.googleapis.com/user.User": map[string]interface{}{},
}
bs, _ := json.Marshal(input{
DataBrokerData: dbd,
HTTP: RequestHTTP{
Method: "GET",
URL: "https://example.com",
Headers: map[string]string{
"Accept": "application/json",
},
ClientCertificate: "CLIENT_CERTIFICATE",
},
Session: RequestSession{
ID: "SESSION_ID",
ImpersonateEmail: "y@example.com",
ImpersonateGroups: []string{"group1"},
},
IsValidClientCertificate: true,
})
assert.JSONEq(t, `{
"databroker_data": {
"type.googleapis.com/directory.User": {
"user1": {
"id": "user1",
"groups": ["group1", "group2"]
}
},
"type.googleapis.com/session.Session": {},
"type.googleapis.com/user.User": {}
},
"http": {
"client_certificate": "CLIENT_CERTIFICATE",
"headers": {
"Accept": "application/json"
},
"method": "GET",
"url": "https://example.com"
},
"session": {
"id": "SESSION_ID",
"impersonate_email": "y@example.com",
"impersonate_groups": ["group1"]
},
"is_valid_client_certificate": true
}`, string(bs))
}

View file

@ -1,11 +1,15 @@
package opa
package evaluator
import (
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
lru "github.com/hashicorp/golang-lru"
"github.com/rakyll/statik/fs"
_ "github.com/pomerium/pomerium/authorize/evaluator/opa/policy" // load static assets
)
var isValidClientCertificateCache, _ = lru.New2Q(100)
@ -56,3 +60,18 @@ func parseCertificate(pemStr string) (*x509.Certificate, error) {
}
return x509.ParseCertificate(block.Bytes)
}
const statikNamespace = "rego"
func readPolicy(fn string) ([]byte, error) {
statikFS, err := fs.NewWithNamespace(statikNamespace)
if err != nil {
return nil, err
}
r, err := statikFS.Open(fn)
if err != nil {
return nil, err
}
defer r.Close()
return ioutil.ReadAll(r)
}

View file

@ -1,4 +1,4 @@
package opa
package evaluator
import (
"testing"

View file

@ -1,278 +1,6 @@
//go:generate go run github.com/rakyll/statik -src=./policy -include=*.rego -ns rego -p policy
//go:generate go fmt ./policy/statik.go
// Package opa implements the policy evaluator interface to make authorization
// decisions.
package opa
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"strconv"
"sync"
"github.com/open-policy-agent/opa/rego"
"github.com/open-policy-agent/opa/storage"
"github.com/open-policy-agent/opa/storage/inmem"
"github.com/rakyll/statik/fs"
"github.com/pomerium/pomerium/authorize/evaluator"
_ "github.com/pomerium/pomerium/authorize/evaluator/opa/policy" // load static assets
pb "github.com/pomerium/pomerium/internal/grpc/authorize"
"github.com/pomerium/pomerium/internal/telemetry/trace"
)
const statikNamespace = "rego"
var _ evaluator.Evaluator = &PolicyEvaluator{}
// PolicyEvaluator implements the evaluator interface using the open policy
// agent framework. The Open Policy Agent (OPA, pronounced “oh-pa”) is an open
// source, general-purpose policy engine that unifies policy enforcement across
// the stack.
// https://www.openpolicyagent.org/docs/latest/
type PolicyEvaluator struct {
// The in-memory store supports multi-reader/single-writer concurrency with
// rollback so we leverage a RWMutex.
mu sync.RWMutex
store storage.Store
isAuthorized rego.PreparedEvalQuery
clientCA string
}
// Options represent OPA's evaluator configurations.
type Options struct {
// AuthorizationPolicy accepts custom rego code which can be used to
// apply custom authorization policy.
// Defaults to authorization policy defined in config.yaml's policy.
AuthorizationPolicy string
// Data maps data that will be bound and
Data map[string]interface{}
}
// New creates a new OPA policy evaluator.
func New(ctx context.Context, opts *Options) (*PolicyEvaluator, error) {
var pe PolicyEvaluator
pe.store = inmem.New()
if opts.Data == nil {
return nil, errors.New("opa: cannot create new evaluator without data")
}
if opts.AuthorizationPolicy == "" {
b, err := readPolicy("/authz.rego")
if err != nil {
return nil, err
}
opts.AuthorizationPolicy = string(b)
}
if err := pe.PutData(ctx, opts.Data); err != nil {
return nil, err
}
if err := pe.UpdatePolicy(ctx, opts.AuthorizationPolicy); err != nil {
return nil, err
}
return &pe, nil
}
// UpdatePolicy takes authorization and privilege access management rego code
// as an input and updates the prepared policy evaluator.
func (pe *PolicyEvaluator) UpdatePolicy(ctx context.Context, authz string) error {
ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.UpdatePolicy")
defer span.End()
var err error
pe.mu.Lock()
defer pe.mu.Unlock()
r := rego.New(
rego.Store(pe.store),
rego.Module("pomerium.authz", authz),
rego.Query("result = data.pomerium.authz"),
)
pe.isAuthorized, err = r.PrepareForEval(ctx)
if err != nil {
return fmt.Errorf("opa: prepare policy: %w", err)
}
return nil
}
// IsAuthorized determines if a given request input is authorized.
func (pe *PolicyEvaluator) IsAuthorized(ctx context.Context, req *evaluator.Request) (*pb.IsAuthorizedReply, error) {
ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.IsAuthorized")
defer span.End()
return pe.runBoolQuery(ctx, req, pe.isAuthorized)
}
// PutData adds (or replaces if the mapping key is the same) contextual data
// for making policy decisions.
func (pe *PolicyEvaluator) PutData(ctx context.Context, data map[string]interface{}) error {
ctx, span := trace.StartSpan(ctx, "authorize.evaluator.opa.PutData")
defer span.End()
pe.mu.Lock()
defer pe.mu.Unlock()
if ca, ok := data["client_ca"].(string); ok {
pe.clientCA = ca
}
txn, err := pe.store.NewTransaction(ctx, storage.WriteParams)
if err != nil {
return fmt.Errorf("opa: bad transaction: %w", err)
}
if err := pe.store.Write(ctx, txn, storage.ReplaceOp, storage.Path{}, data); err != nil {
pe.store.Abort(ctx, txn)
return fmt.Errorf("opa: write failed %v : %w", data, err)
}
if err := pe.store.Commit(ctx, txn); err != nil {
return fmt.Errorf("opa: commit failed: %w", err)
}
return nil
}
func decisionFromInterface(i interface{}) (*pb.IsAuthorizedReply, error) {
var d pb.IsAuthorizedReply
var ok bool
m, ok := i.(map[string]interface{})
if !ok {
return nil, errors.New("interface must be a map")
}
if d.Allow, ok = m["allow"].(bool); !ok {
return nil, errors.New("allow should be bool")
}
if d.SessionExpired, ok = m["expired"].(bool); !ok {
return nil, errors.New("expired should be bool")
}
switch v := m["deny"].(type) {
case []interface{}:
for _, cause := range v {
if c, ok := cause.(string); ok {
d.DenyReasons = append(d.DenyReasons, c)
}
}
case string:
d.DenyReasons = []string{v}
}
if v, ok := m["user"].(string); ok {
d.User = v
}
if v, ok := m["email"].(string); ok {
d.Email = v
}
switch v := m["groups"].(type) {
case []interface{}:
for _, cause := range v {
if c, ok := cause.(string); ok {
d.Groups = append(d.Groups, c)
}
}
case string:
d.Groups = []string{v}
}
if v, ok := m["signed_jwt"].(string); ok {
d.SignedJwt = v
}
// http_status = [200, "OK", { "HEADER": "VALUE" }]
if v, ok := m["http_status"].([]interface{}); ok {
d.HttpStatus = new(pb.HTTPStatus)
if len(v) > 0 {
d.HttpStatus.Code = int32(anyToInt(v[0]))
}
if len(v) > 1 {
if msg, ok := v[1].(string); ok {
d.HttpStatus.Message = msg
}
}
if len(v) > 2 {
if headers, ok := v[2].(map[string]interface{}); ok {
d.HttpStatus.Headers = make(map[string]string)
for hk, hv := range headers {
d.HttpStatus.Headers[hk] = fmt.Sprint(hv)
}
}
}
}
return &d, nil
}
func (pe *PolicyEvaluator) runBoolQuery(ctx context.Context, req *evaluator.Request, q rego.PreparedEvalQuery) (*pb.IsAuthorizedReply, error) {
pe.mu.RLock()
defer pe.mu.RUnlock()
// `opa test` doesn't support custom function, so we'll pre-compute is_valid_client_certificate
isValid, err := isValidClientCertificate(pe.clientCA, req.ClientCertificate)
if err != nil {
return nil, fmt.Errorf("certificate error: %w", err)
}
input := struct {
*evaluator.Request
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
}{
Request: req,
IsValidClientCertificate: isValid,
}
rs, err := q.Eval(ctx, rego.EvalInput(input))
if err != nil {
return nil, fmt.Errorf("eval query: %w", err)
} else if len(rs) == 0 {
return nil, fmt.Errorf("empty eval result set %v", rs)
}
bindings := rs[0].Bindings.WithoutWildcards()["result"]
return decisionFromInterface(bindings)
}
func readPolicy(fn string) ([]byte, error) {
statikFS, err := fs.NewWithNamespace(statikNamespace)
if err != nil {
return nil, err
}
r, err := statikFS.Open(fn)
if err != nil {
return nil, err
}
defer r.Close()
return ioutil.ReadAll(r)
}
func anyToInt(obj interface{}) int {
switch v := obj.(type) {
case int:
return v
case int64:
return int(v)
case int32:
return int(v)
case int16:
return int(v)
case int8:
return int(v)
case uint64:
return int(v)
case uint32:
return int(v)
case uint16:
return int(v)
case uint8:
return int(v)
case json.Number:
i, _ := v.Int64()
return int(i)
case string:
i, _ := strconv.Atoi(v)
return i
default:
i, _ := strconv.Atoi(fmt.Sprint(v))
return i
}
}
//go:generate go run github.com/rakyll/statik -src=./policy -include=*.rego -ns rego -p policy
//go:generate go fmt ./policy/statik.go

View file

@ -1,123 +0,0 @@
package opa
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/pomerium/pomerium/authorize/evaluator"
"github.com/pomerium/pomerium/config"
)
func Test_Eval(t *testing.T) {
t.Parallel()
type Identity struct {
User string `json:"user,omitempty"`
Email string `json:"email,omitempty"`
Groups []string `json:"groups,omitempty"`
ImpersonateEmail string `json:"impersonate_email,omitempty"`
ImpersonateGroups []string `json:"impersonate_groups,omitempty"`
}
tests := []struct {
name string
policies []config.Policy
route string
Identity *Identity
admins []string
secret string
want bool
}{
{"valid domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, "secret", true},
{"valid domain with admins", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, []string{"admin@example.com"}, "secret", true},
{"invalid domain prepend", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "a@1example.com"}, nil, "secret", false},
{"invalid domain postpend", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com2"}, nil, "secret", false},
{"valid group", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"admin"}}, nil, "secret", true},
{"invalid group", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyone"}}, nil, "secret", false},
{"invalid empty", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{""}}, nil, "secret", false},
{"valid group multiple", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyone", "admin"}}, nil, "secret", true},
{"invalid group multiple", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"admin"}}}, "from.example", &Identity{Email: "user@example.com", Groups: []string{"everyones", "sadmin"}}, nil, "secret", false},
{"valid user email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, "secret", true},
{"invalid user email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "user2@example.com"}, nil, "secret", false},
{"empty everything", []config.Policy{{From: "https://from.example", To: "https://to.example"}}, "from.example", &Identity{Email: "user2@example.com"}, nil, "secret", false},
{"empty policy", []config.Policy{}, "from.example", &Identity{Email: "user2@example.com"}, nil, "secret", false},
// impersonation related
{"admin not impersonating allowed", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@example.com"}, []string{"admin@example.com"}, "secret", true},
{"admin not impersonating denied", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com"}, []string{"admin@admin-domain.com"}, "secret", false},
{"impersonating match domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, "secret", true},
{"impersonating does not match domain", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, "secret", false},
{"impersonating match email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@example.com"}, []string{"admin@admin-domain.com"}, "secret", true},
{"impersonating does not match email", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedUsers: []string{"user@example.com"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateEmail: "user@not-example.com"}, []string{"admin@admin-domain.com"}, "secret", false},
{"impersonating match groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"support"}}, []string{"admin@admin-domain.com"}, "secret", true},
{"impersonating match many groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"a", "b", "c", "support"}}, []string{"admin@admin-domain.com"}, "secret", true},
{"impersonating does not match groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support"}}, []string{"admin@admin-domain.com"}, "secret", false},
{"impersonating does not match many groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{"not support", "b", "c"}}, []string{"admin@admin-domain.com"}, "secret", false},
{"impersonating does not match empty groups", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedGroups: []string{"support"}}}, "from.example", &Identity{Email: "admin@admin-domain.com", ImpersonateGroups: []string{""}}, []string{"admin@admin-domain.com"}, "secret", false},
// jwt validation
{"bad jwt shared secret", []config.Policy{{From: "https://from.example", To: "https://to.example", AllowedDomains: []string{"example.com"}}}, "from.example", &Identity{Email: "user@example.com"}, nil, "bad-secret", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for i := range tt.policies {
if err := (&tt.policies[i]).Validate(); err != nil {
t.Fatal(err)
}
}
key := []byte("secret")
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key},
(&jose.SignerOptions{}).WithType("JWT"))
if err != nil {
t.Fatal(err)
}
cl := jwt.Claims{
NotBefore: jwt.NewNumericDate(time.Now()),
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Audience: jwt.Audience{tt.route},
}
rawJWT, err := jwt.Signed(sig).Claims(cl).Claims(tt.Identity).CompactSerialize()
if err != nil {
t.Fatal(err)
}
data := map[string]interface{}{
"route_policies": tt.policies,
"admins": tt.admins,
"shared_key": tt.secret,
}
pe, err := New(context.Background(), &Options{Data: data})
if err != nil {
t.Fatal(err)
}
req := &evaluator.Request{
Host: tt.route,
URL: "https://" + tt.route,
User: rawJWT,
}
got, err := pe.IsAuthorized(context.TODO(), req)
if err != nil {
t.Fatal(err)
}
if got.GetAllow() != tt.want {
t.Errorf("pe.Eval() = %v, want %v", got.GetAllow(), tt.want)
}
})
}
}
func Test_anyToInt(t *testing.T) {
assert.Equal(t, 5, anyToInt("5"))
assert.Equal(t, 7, anyToInt(7))
assert.Equal(t, 9, anyToInt(int8(9)))
assert.Equal(t, 9, anyToInt(int16(9)))
assert.Equal(t, 9, anyToInt(int32(9)))
assert.Equal(t, 9, anyToInt(int64(9)))
assert.Equal(t, 11, anyToInt(uint8(11)))
assert.Equal(t, 11, anyToInt(uint16(11)))
assert.Equal(t, 11, anyToInt(uint32(11)))
assert.Equal(t, 11, anyToInt(uint64(11)))
assert.Equal(t, 13, anyToInt(13.0))
}

View file

@ -1,84 +1,91 @@
package pomerium.authz
import data.route_policies
import data.shared_key
default allow = false
route := first_allowed_route(input.url)
http_status = [495, "invalid client certificate"]{
not input.is_valid_client_certificate
}
route := first_allowed_route(input.http.url)
session := input.databroker_data["type.googleapis.com/session.Session"][input.session.id]
user := input.databroker_data["type.googleapis.com/user.User"][session.user_id]
directory_user := input.databroker_data["type.googleapis.com/directory.User"][session.user_id]
# allow public
allow {
route_policies[route].AllowPublicUnauthenticatedAccess == true
data.route_policies[route].AllowPublicUnauthenticatedAccess == true
}
# allow cors preflight
allow {
route_policies[route].CORSAllowPreflight == true
input.method == "OPTIONS"
count(object.get(input.headers, "Access-Control-Request-Method", [])) > 0
count(object.get(input.headers, "Origin", [])) > 0
data.route_policies[route].CORSAllowPreflight == true
input.http.method == "OPTIONS"
count(object.get(input.http.headers, "Access-Control-Request-Method", [])) > 0
count(object.get(input.http.headers, "Origin", [])) > 0
}
# allow by email
allow {
token.payload.email = route_policies[route].allowed_users[_]
token.valid
count(deny)==0
user.email == data.route_policies[route].allowed_users[_]
}
# allow group
allow {
some group
token.payload.groups[group] == route_policies[route].allowed_groups[_]
token.valid
count(deny)==0
directory_user.groups[_] = group
data.route_policies[route].allowed_groups[_] = group
}
# allow by impersonate email
allow {
token.payload.impersonate_email = route_policies[route].allowed_users[_]
token.valid
count(deny)==0
data.route_policies[route].allowed_users[_] = input.session.impersonate_email
}
# allow by impersonate group
allow {
some group
token.payload.impersonate_groups[group] == route_policies[route].allowed_groups[_]
token.valid
count(deny)==0
input.session.impersonate_groups[_] = group
data.route_policies[route].allowed_groups[_] = group
}
# allow by domain
allow {
some domain
email_in_domain(token.payload.email, route_policies[route].allowed_domains[domain])
token.valid
count(deny)==0
email_in_domain(user.email, data.route_policies[route].allowed_domains[domain])
}
# allow by impersonate domain
allow {
some domain
email_in_domain(token.payload.impersonate_email, route_policies[route].allowed_domains[domain])
token.valid
count(deny)==0
email_in_domain(input.session.impersonate_email, data.route_policies[route].allowed_domains[domain])
}
# allow pomerium urls
allow {
contains(input.url, "/.pomerium/")
not contains(input.url,"/.pomerium/admin")
contains(input.http.url, "/.pomerium/")
not contains(input.http.url, "/.pomerium/admin")
}
# allow user is admin
allow {
element_in_list(data.admins, input.user.email)
contains(input.http.url, ".pomerium/admin")
}
# deny non-admin users from accesing admin routes
deny[reason] {
reason = [403, "user is not admin"]
not element_in_list(data.admins, user.email)
contains(input.http.url,".pomerium/admin")
}
deny[reason] {
reason = [495, "invalid client certificate"]
is_boolean(input.is_valid_client_certificate)
not input.is_valid_client_certificate
}
# returns the first matching route
first_allowed_route(input_url) = route {
route := [route | some route ; allowed_route(input.url, route_policies[route])][0]
route := [route | some route ; allowed_route(input.http.url, data.route_policies[route])][0]
}
allowed_route(input_url, policy){
@ -142,53 +149,6 @@ email_in_domain(email, domain) {
x[1] == domain
}
default expired = false
expired {
now_seconds:=time.now_ns()/1e9
expiry < now_seconds
}
deny["token is expired (exp)"]{
expired
}
deny[sprintf("token has bad audience (aud): %s not in %+v",[input.host,audiences])]{
not element_in_list(audiences,input.host)
}
# allow user is admin
allow {
element_in_list(data.admins, token.payload.email)
token.valid
count(deny)==0
contains(input.url,".pomerium/admin")
}
# deny non-admin users from accesing admin routes
deny["user is not admin"]{
not element_in_list(data.admins, token.payload.email)
contains(input.url,".pomerium/admin")
}
token = {"payload": payload, "valid": valid} {
[valid, header, payload] := io.jwt.decode_verify(
input.user, {
"secret": shared_key,
"aud": input.host,
}
)
}
user:=token.payload.user
email:=token.payload.email
groups:=token.payload.groups
audiences:=token.payload.aud
expiry:=token.payload.exp
signed_jwt:=io.jwt.encode_sign({"alg": "ES256"}, token.payload, data.signing_key)
element_in_list(list, elem) {
list[_] = elem
}

View file

@ -1,143 +1,131 @@
package pomerium.authz
jwt_header := {
"typ": "JWT",
"alg": "HS256"
}
signing_key := {
"kty": "oct",
"k": "OkFmqMK9U0dmPhMCW0VYy6D_raJKwEJsMdxqdnukThzko3D_XrsihwYE0pxrUSpm0JTrW2QpIz4rT1vdEvZw67WP4xrqjiwyd7PgpPTD5xvQBM7TIKiSW0X2R0pfq_OItszPQRtb7VirrSbGJiLNS-NJMMrYVKWWtUbVSTXEjL7VcFqML5PiSe7XDmyCZjpgEpfE5Q82zIeXM2sLrz6HW2A9IwGk7mWS0c57R_2JGyFO2tCA4zEIYhWvLE62Os2tZ6YrrwdB8n35jlPpgUE6poEvIU20lPLaocozXYMqAku-KJnloJlAzKg2Xa_0iSiSgSAumx44B3n7DQjg3jPhRg"
}
shared_key := base64url.decode(signing_key.k)
test_email_allowed {
user := io.jwt.encode_sign(jwt_header, {
"aud": ["example.com"],
"email": "joe@example.com"
}, signing_key)
allow with data.route_policies as [{
"source": "example.com",
"allowed_users": ["joe@example.com"]
}] with data.signing_key as signing_key with data.shared_key as shared_key with input as {
"url": "http://example.com",
"host": "example.com",
"user": user
}
allow with
data.route_policies as [{
"source": "example.com",
"allowed_users": ["x@example.com"]
}] with
input.databroker_data as {
"type.googleapis.com/session.Session": {
"session1": {
"user_id": "user1"
}
},
"type.googleapis.com/user.User": {
"user1": {
"email": "x@example.com"
}
}
} with
input.http as { "url": "http://example.com" } with
input.session as { "id": "session1" }
}
test_example {
user := io.jwt.encode_sign(jwt_header, {
"aud": ["example.com"],
"email": "joe@example.com"
}, signing_key)
not allow with data.route_policies as [
{
"source": "http://example.com",
"path": "/a",
"allowed_domains": ["example.com"]
},
{
"source": "http://example.com",
"path": "/b",
"allowed_users": ["noone@pomerium.com"]
},
] with data.signing_key as signing_key with data.shared_key as shared_key with input as {
"url": "http://example.com/b",
"host": "example.com",
"user": user
}
not allow with
data.route_policies as [
{
"source": "http://example.com",
"path": "/a",
"allowed_domains": ["example.com"]
},
{
"source": "http://example.com",
"path": "/b",
"allowed_users": ["noone@pomerium.com"]
},
] with
input.http as { "url": "http://example.com/b" } with
input.user as { "id": "1", "email": "joe@example.com" }
}
test_email_denied {
user := io.jwt.encode_sign(jwt_header, {
"aud": ["example.com"],
"email": "joe@example.com"
}, signing_key)
not allow with data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with data.signing_key as signing_key with data.shared_key as shared_key with input as {
"url": "http://example.com",
"host": "example.com",
"user": user
}
not allow with
data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with
input.http as { "url": "http://example.com" } with
input.user as { "id": "1", "email": "joe@example.com" }
}
test_public_allowed {
allow with data.route_policies as [{
"source": "example.com",
"AllowPublicUnauthenticatedAccess": true
}] with input as {
"url": "http://example.com",
"host": "example.com"
}
}
test_public_denied {
not allow with data.route_policies as [
{
"source": "example.com",
"prefix": "/by-user",
"allowed_users": ["bob@example.com"]
},
{
allow with
data.route_policies as [{
"source": "example.com",
"AllowPublicUnauthenticatedAccess": true
}] with
input.http as { "url": "http://example.com" }
}
test_public_denied {
not allow with
data.route_policies as [
{
"source": "example.com",
"prefix": "/by-user",
"allowed_users": ["bob@example.com"]
},
{
"source": "example.com",
"AllowPublicUnauthenticatedAccess": true
}
] with
input.http as {
"url": "http://example.com/by-user"
}
] with input as {
"url": "http://example.com/by-user",
"host": "example.com"
}
}
test_pomerium_allowed {
allow with data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with input as {
"url": "http://example.com/.pomerium/",
"host": "example.com"
}
allow with
data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with
input.http as { "url": "http://example.com/.pomerium/" }
}
test_pomerium_denied {
not allow with data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with input as {
"url": "http://example.com/.pomerium/admin",
"host": "example.com"
}
not allow with
data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with
input.http as {
"url": "http://example.com/.pomerium/admin",
"host": "example.com"
}
}
test_cors_preflight_allowed {
allow with data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"],
"CORSAllowPreflight": true
}] with input as {
"url": "http://example.com/",
"host": "example.com",
"method": "OPTIONS",
"headers": {
"Origin": ["someorigin"],
"Access-Control-Request-Method": ["GET"]
allow with
data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"],
"CORSAllowPreflight": true
}] with
input.http as {
"method": "OPTIONS",
"url": "http://example.com/",
"headers": {
"Origin": ["someorigin"],
"Access-Control-Request-Method": ["GET"]
}
}
}
}
test_cors_preflight_denied {
not allow with data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with input as {
"url": "http://example.com/",
"host": "example.com",
"method": "OPTIONS",
"headers": {
"Origin": ["someorigin"],
"Access-Control-Request-Method": ["GET"]
not allow with
data.route_policies as [{
"source": "example.com",
"allowed_users": ["bob@example.com"]
}] with
input.http as {
"method": "OPTIONS",
"url": "http://example.com/",
"headers": {
"Origin": ["someorigin"],
"Access-Control-Request-Method": ["GET"]
}
}
}
}
test_parse_url {

File diff suppressed because one or more lines are too long