From e138054cb945055a4d3d6e14e01322ee3f9d0c66 Mon Sep 17 00:00:00 2001 From: Caleb Doxsey Date: Mon, 17 May 2021 15:30:51 -0600 Subject: [PATCH] Pomerium Policy Language (#2202) * policy: add parser and generator for Pomerium Policy Language * add criteria * add additional criteria --- pkg/policy/criteria/accept.go | 35 ++ pkg/policy/criteria/accept_test.go | 18 + pkg/policy/criteria/authenticated_user.go | 37 +++ .../criteria/authenticated_user_test.go | 39 +++ pkg/policy/criteria/claims.go | 64 ++++ pkg/policy/criteria/claims_test.go | 72 ++++ pkg/policy/criteria/cors_preflight.go | 36 ++ pkg/policy/criteria/cors_preflight_test.go | 38 +++ pkg/policy/criteria/criteria.go | 41 +++ pkg/policy/criteria/criteria_test.go | 137 ++++++++ pkg/policy/criteria/domains.go | 58 ++++ pkg/policy/criteria/domains_test.go | 67 ++++ pkg/policy/criteria/emails.go | 58 ++++ pkg/policy/criteria/emails_test.go | 67 ++++ pkg/policy/criteria/groups.go | 84 +++++ pkg/policy/criteria/groups_test.go | 95 ++++++ .../criteria/invalid_client_certificate.go | 37 +++ pkg/policy/criteria/pomerium_routes.go | 37 +++ pkg/policy/criteria/reject.go | 35 ++ pkg/policy/criteria/reject_test.go | 18 + pkg/policy/criteria/users.go | 55 ++++ pkg/policy/generator/conditionals.go | 132 ++++++++ pkg/policy/generator/criterion.go | 43 +++ pkg/policy/generator/generator.go | 104 ++++++ pkg/policy/generator/generator_test.go | 147 +++++++++ pkg/policy/parser/grammar.go | 308 ++++++++++++++++++ pkg/policy/parser/json.go | 272 ++++++++++++++++ pkg/policy/parser/json_test.go | 117 +++++++ pkg/policy/parser/parser.go | 126 +++++++ pkg/policy/parser/parser_test.go | 172 ++++++++++ pkg/policy/parser/schema.json | 45 +++ pkg/policy/policy.go | 47 +++ pkg/policy/rules/rules.go | 117 +++++++ 33 files changed, 2758 insertions(+) create mode 100644 pkg/policy/criteria/accept.go create mode 100644 pkg/policy/criteria/accept_test.go create mode 100644 pkg/policy/criteria/authenticated_user.go create mode 100644 pkg/policy/criteria/authenticated_user_test.go create mode 100644 pkg/policy/criteria/claims.go create mode 100644 pkg/policy/criteria/claims_test.go create mode 100644 pkg/policy/criteria/cors_preflight.go create mode 100644 pkg/policy/criteria/cors_preflight_test.go create mode 100644 pkg/policy/criteria/criteria.go create mode 100644 pkg/policy/criteria/criteria_test.go create mode 100644 pkg/policy/criteria/domains.go create mode 100644 pkg/policy/criteria/domains_test.go create mode 100644 pkg/policy/criteria/emails.go create mode 100644 pkg/policy/criteria/emails_test.go create mode 100644 pkg/policy/criteria/groups.go create mode 100644 pkg/policy/criteria/groups_test.go create mode 100644 pkg/policy/criteria/invalid_client_certificate.go create mode 100644 pkg/policy/criteria/pomerium_routes.go create mode 100644 pkg/policy/criteria/reject.go create mode 100644 pkg/policy/criteria/reject_test.go create mode 100644 pkg/policy/criteria/users.go create mode 100644 pkg/policy/generator/conditionals.go create mode 100644 pkg/policy/generator/criterion.go create mode 100644 pkg/policy/generator/generator.go create mode 100644 pkg/policy/generator/generator_test.go create mode 100644 pkg/policy/parser/grammar.go create mode 100644 pkg/policy/parser/json.go create mode 100644 pkg/policy/parser/json_test.go create mode 100644 pkg/policy/parser/parser.go create mode 100644 pkg/policy/parser/parser_test.go create mode 100644 pkg/policy/parser/schema.json create mode 100644 pkg/policy/policy.go create mode 100644 pkg/policy/rules/rules.go diff --git a/pkg/policy/criteria/accept.go b/pkg/policy/criteria/accept.go new file mode 100644 index 000000000..3bd1c9a2a --- /dev/null +++ b/pkg/policy/criteria/accept.go @@ -0,0 +1,35 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +var acceptBody = ast.Body{ + ast.MustParseExpr(`v := true`), +} + +type acceptCriterion struct { + g *Generator +} + +func (acceptCriterion) Names() []string { + return []string{"accept"} +} + +func (c acceptCriterion) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) { + rule := c.g.NewRule("accept") + rule.Head.Value = ast.VarTerm("v") + rule.Body = acceptBody + return rule, nil, nil +} + +// Accept returns a Criterion which always returns true. +func Accept(generator *Generator) Criterion { + return acceptCriterion{g: generator} +} + +func init() { + Register(Accept) +} diff --git a/pkg/policy/criteria/accept_test.go b/pkg/policy/criteria/accept_test.go new file mode 100644 index 000000000..a95b5a9a5 --- /dev/null +++ b/pkg/policy/criteria/accept_test.go @@ -0,0 +1,18 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAccept(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - accept: 1 +`, []dataBrokerRecord{}, Input{}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) +} diff --git a/pkg/policy/criteria/authenticated_user.go b/pkg/policy/criteria/authenticated_user.go new file mode 100644 index 000000000..23ff9c1ff --- /dev/null +++ b/pkg/policy/criteria/authenticated_user.go @@ -0,0 +1,37 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +var authenticatedUserBody = ast.Body{ + ast.MustParseExpr(`session := get_session(input.session.id)`), + ast.MustParseExpr(`session.user_id != null`), + ast.MustParseExpr(`session.user_id != ""`), +} + +type authenticatedUserCriterion struct { + g *Generator +} + +func (authenticatedUserCriterion) Names() []string { + return []string{"authenticated_user"} +} + +func (c authenticatedUserCriterion) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) { + rule := c.g.NewRule("authenticated_user") + rule.Body = authenticatedUserBody + return rule, []*ast.Rule{rules.GetSession()}, nil +} + +// AuthenticatedUser returns a Criterion which returns true if the current user is logged in. +func AuthenticatedUser(generator *Generator) Criterion { + return authenticatedUserCriterion{g: generator} +} + +func init() { + Register(AuthenticatedUser) +} diff --git a/pkg/policy/criteria/authenticated_user_test.go b/pkg/policy/criteria/authenticated_user_test.go new file mode 100644 index 000000000..6f16ecc78 --- /dev/null +++ b/pkg/policy/criteria/authenticated_user_test.go @@ -0,0 +1,39 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/pkg/grpc/session" +) + +func TestAuthenticatedUser(t *testing.T) { + t.Run("no session", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - authenticated_user: 1 +`, []dataBrokerRecord{}, Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by domain", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - authenticated_user: 1 +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/claims.go b/pkg/policy/criteria/claims.go new file mode 100644 index 000000000..c3f561ce6 --- /dev/null +++ b/pkg/policy/criteria/claims.go @@ -0,0 +1,64 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +var claimsBody = ast.Body{ + ast.MustParseExpr(` + session := get_session(input.session.id) + `), + ast.MustParseExpr(` + session_claims := object.get(session, "claims", {}) + `), + ast.MustParseExpr(` + user := get_user(session) + `), + ast.MustParseExpr(` + user_claims := object.get(user, "claims", {}) + `), + ast.MustParseExpr(` + all_claims := object.union(session_claims, user_claims) + `), + ast.MustParseExpr(` + values := object_get(all_claims, rule_path, []) + `), + ast.MustParseExpr(` + rule_data == values[_] + `), +} + +type claimsCriterion struct { + g *Generator +} + +func (claimsCriterion) Names() []string { + return []string{"claim", "claims"} +} + +func (c claimsCriterion) GenerateRule(subPath string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + r := c.g.NewRule("claims") + r.Body = append(r.Body, + ast.Assign.Expr(ast.VarTerm("rule_data"), ast.NewTerm(data.RegoValue())), + ast.Assign.Expr(ast.VarTerm("rule_path"), ast.NewTerm(ast.MustInterfaceToValue(subPath))), + ) + r.Body = append(r.Body, claimsBody...) + + return r, []*ast.Rule{ + rules.GetSession(), + rules.GetUser(), + rules.ObjectGet(), + }, nil +} + +// Claims returns a Criterion on allowed IDP claims. +func Claims(generator *Generator) Criterion { + return claimsCriterion{g: generator} +} + +func init() { + Register(Claims) +} diff --git a/pkg/policy/criteria/claims_test.go b/pkg/policy/criteria/claims_test.go new file mode 100644 index 000000000..29150b0f2 --- /dev/null +++ b/pkg/policy/criteria/claims_test.go @@ -0,0 +1,72 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/pomerium/pomerium/pkg/grpc/session" + "github.com/pomerium/pomerium/pkg/grpc/user" +) + +func TestClaims(t *testing.T) { + t.Run("no session", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - claim/family_name: Smith +`, []dataBrokerRecord{}, Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by session claim", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - claim/family_name: Smith +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + Claims: map[string]*structpb.ListValue{ + "family_name": {Values: []*structpb.Value{structpb.NewStringValue("Smith")}}, + }, + }, + &user.User{ + Id: "USER_ID", + Email: "test@example.com", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by user claim", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - claim/family_name: Smith +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + &user.User{ + Id: "USER_ID", + Email: "test@example.com", + Claims: map[string]*structpb.ListValue{ + "family_name": {Values: []*structpb.Value{structpb.NewStringValue("Smith")}}, + }, + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/cors_preflight.go b/pkg/policy/criteria/cors_preflight.go new file mode 100644 index 000000000..0972e7bdf --- /dev/null +++ b/pkg/policy/criteria/cors_preflight.go @@ -0,0 +1,36 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +var corsPreflightBody = ast.Body{ + ast.MustParseExpr(`input.http.method == "OPTIONS"`), + ast.MustParseExpr(`count(object.get(input.http.headers, "Access-Control-Request-Method", [])) > 0`), + ast.MustParseExpr(`count(object.get(input.http.headers, "Origin", [])) > 0`), +} + +type corsPreflightCriterion struct { + g *Generator +} + +func (corsPreflightCriterion) Names() []string { + return []string{"cors_preflight"} +} + +func (c corsPreflightCriterion) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) { + rule := c.g.NewRule("cors_preflight") + rule.Body = corsPreflightBody + return rule, nil, nil +} + +// CORSPreflight returns a Criterion which returns true if the input request is a CORS preflight request. +func CORSPreflight(generator *Generator) Criterion { + return corsPreflightCriterion{g: generator} +} + +func init() { + Register(CORSPreflight) +} diff --git a/pkg/policy/criteria/cors_preflight_test.go b/pkg/policy/criteria/cors_preflight_test.go new file mode 100644 index 000000000..af7edbf4b --- /dev/null +++ b/pkg/policy/criteria/cors_preflight_test.go @@ -0,0 +1,38 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCORSPreflight(t *testing.T) { + t.Run("true", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - cors_preflight: 1 +`, []dataBrokerRecord{}, Input{HTTP: InputHTTP{ + Method: "OPTIONS", + Headers: map[string][]string{ + "Access-Control-Request-Method": {"GET"}, + "Origin": {"example.com"}, + }, + }}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("false", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - cors_preflight: 1 +`, []dataBrokerRecord{}, Input{HTTP: InputHTTP{ + Method: "OPTIONS", + }}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/criteria.go b/pkg/policy/criteria/criteria.go new file mode 100644 index 000000000..86ead3787 --- /dev/null +++ b/pkg/policy/criteria/criteria.go @@ -0,0 +1,41 @@ +// Package criteria contains all the pre-defined criteria as well as a registry to add new criteria. +package criteria + +import ( + "sync" + + "github.com/pomerium/pomerium/pkg/policy/generator" +) + +// re-exported types +type ( + // A Generator generates a rego script from a policy. + Generator = generator.Generator + // A Criterion generates rego rules based on data. + Criterion = generator.Criterion + // A CriterionConstructor is a function which returns a Criterion for a Generator. + CriterionConstructor = generator.CriterionConstructor +) + +var allCriteria struct { + sync.Mutex + a []CriterionConstructor +} + +// All returns all the known criterion constructors. +func All() []CriterionConstructor { + allCriteria.Lock() + a := allCriteria.a + allCriteria.Unlock() + return a +} + +// Register registers a criterion. +func Register(criterionConstructor CriterionConstructor) { + allCriteria.Lock() + a := make([]CriterionConstructor, 0, len(allCriteria.a)+1) + a = append(a, allCriteria.a...) + a = append(a, criterionConstructor) + allCriteria.a = a + allCriteria.Unlock() +} diff --git a/pkg/policy/criteria/criteria_test.go b/pkg/policy/criteria/criteria_test.go new file mode 100644 index 000000000..4a13b2ee0 --- /dev/null +++ b/pkg/policy/criteria/criteria_test.go @@ -0,0 +1,137 @@ +package criteria + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/format" + "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/types" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + + "github.com/pomerium/pomerium/pkg/policy/generator" + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +var testingNow = time.Date(2021, 5, 11, 13, 43, 0, 0, time.Local) + +type ( + Input struct { + HTTP InputHTTP `json:"http"` + Session InputSession `json:"session"` + } + InputHTTP struct { + Method string `json:"method"` + Headers map[string][]string `json:"headers"` + } + InputSession struct { + ID string `json:"id"` + } +) + +func generateRegoFromYAML(raw string) (string, error) { + var options []generator.Option + for _, newMatcher := range All() { + options = append(options, generator.WithCriterion(newMatcher)) + } + + g := generator.New(options...) + p := parser.New() + policy, err := p.ParseYAML(strings.NewReader(raw)) + if err != nil { + return "", err + } + m, err := g.Generate(policy) + if err != nil { + return "", err + } + bs, err := format.Ast(m) + if err != nil { + return "", err + } + return string(bs), nil +} + +type dataBrokerRecord interface { + proto.Message + GetId() string +} + +func evaluate(t *testing.T, + rawPolicy string, + dataBrokerRecords []dataBrokerRecord, + input Input, +) (rego.Vars, error) { + regoPolicy, err := generateRegoFromYAML(rawPolicy) + if err != nil { + return nil, fmt.Errorf("error parsing policy: %w", err) + } + + r := rego.New( + rego.Module("policy.rego", regoPolicy), + rego.Query("result = data.pomerium.policy"), + rego.Function2(®o.Function{ + Name: "get_databroker_record", + Decl: types.NewFunction([]types.Type{ + types.S, types.S, + }, types.A), + }, func(bctx rego.BuiltinContext, op1, op2 *ast.Term) (*ast.Term, error) { + recordType, ok := op1.Value.(ast.String) + if !ok { + return nil, fmt.Errorf("invalid type for record_type: %T", op1) + } + + recordID, ok := op2.Value.(ast.String) + if !ok { + return nil, fmt.Errorf("invalid type for record_id: %T", op2) + } + + for _, record := range dataBrokerRecords { + any, err := anypb.New(record) + if err != nil { + return nil, err + } + + if string(recordType) == any.GetTypeUrl() && + string(recordID) == record.GetId() { + bs, _ := json.Marshal(record) + v, err := ast.ValueFromReader(bytes.NewReader(bs)) + if err != nil { + return nil, err + } + return ast.NewTerm(v), nil + } + } + + return nil, nil + }), + rego.Input(input), + ) + preparedQuery, err := r.PrepareForEval(context.Background()) + if err != nil { + t.Log("source:", regoPolicy) + return nil, err + } + resultSet, err := preparedQuery.Eval(context.Background(), + // set the eval time so we get a consistent result + rego.EvalTime(testingNow)) + if err != nil { + t.Log("source:", regoPolicy) + return nil, err + } + if len(resultSet) == 0 { + return make(rego.Vars), nil + } + vars, ok := resultSet[0].Bindings["result"].(map[string]interface{}) + if !ok { + return make(rego.Vars), nil + } + return vars, nil +} diff --git a/pkg/policy/criteria/domains.go b/pkg/policy/criteria/domains.go new file mode 100644 index 000000000..915529372 --- /dev/null +++ b/pkg/policy/criteria/domains.go @@ -0,0 +1,58 @@ +package criteria + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +var domainsBody = ast.Body{ + ast.MustParseExpr(` + session := get_session(input.session.id) + `), + ast.MustParseExpr(` + user := get_user(session) + `), + ast.MustParseExpr(` + domain := split(get_user_email(session, user), "@")[1] + `), +} + +type domainsCriterion struct { + g *Generator +} + +func (domainsCriterion) Names() []string { + return []string{"domain", "domains"} +} + +func (c domainsCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + r := c.g.NewRule("domains") + r.Body = append(r.Body, ast.Assign.Expr(ast.VarTerm("rule_data"), ast.NewTerm(data.RegoValue()))) + r.Body = append(r.Body, domainsBody...) + + switch data.(type) { + case parser.String: + r.Body = append(r.Body, ast.MustParseExpr(`domain = rule_data`)) + default: + return nil, nil, fmt.Errorf("unsupported value type: %T", data) + } + + return r, []*ast.Rule{ + rules.GetSession(), + rules.GetUser(), + rules.GetUserEmail(), + }, nil +} + +// Domains returns a Criterion on a user's email address domain. +func Domains(generator *Generator) Criterion { + return domainsCriterion{g: generator} +} + +func init() { + Register(Domains) +} diff --git a/pkg/policy/criteria/domains_test.go b/pkg/policy/criteria/domains_test.go new file mode 100644 index 000000000..2afa7c3ea --- /dev/null +++ b/pkg/policy/criteria/domains_test.go @@ -0,0 +1,67 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/pomerium/pomerium/pkg/grpc/session" + "github.com/pomerium/pomerium/pkg/grpc/user" +) + +func TestDomains(t *testing.T) { + t.Run("no session", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - domain: example.com +`, []dataBrokerRecord{}, Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by domain", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - domain: example.com +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + &user.User{ + Id: "USER_ID", + Email: "test@example.com", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by impersonate email", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - domain: example.com +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + ImpersonateEmail: proto.String("test2@example.com"), + }, + &user.User{ + Id: "USER_ID", + Email: "test1@example.com", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/emails.go b/pkg/policy/criteria/emails.go new file mode 100644 index 000000000..a345c6c5a --- /dev/null +++ b/pkg/policy/criteria/emails.go @@ -0,0 +1,58 @@ +package criteria + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +var emailsBody = ast.Body{ + ast.MustParseExpr(` + session := get_session(input.session.id) + `), + ast.MustParseExpr(` + user := get_user(session) + `), + ast.MustParseExpr(` + email := get_user_email(session, user) + `), +} + +type emailsCriterion struct { + g *Generator +} + +func (emailsCriterion) Names() []string { + return []string{"email", "emails"} +} + +func (c emailsCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + r := c.g.NewRule("emails") + r.Body = append(r.Body, ast.Assign.Expr(ast.VarTerm("rule_data"), ast.NewTerm(data.RegoValue()))) + r.Body = append(r.Body, emailsBody...) + + switch data.(type) { + case parser.String: + r.Body = append(r.Body, ast.MustParseExpr(`email = rule_data`)) + default: + return nil, nil, fmt.Errorf("unsupported value type: %T", data) + } + + return r, []*ast.Rule{ + rules.GetSession(), + rules.GetUser(), + rules.GetUserEmail(), + }, nil +} + +// Emails returns a Criterion on a user's email address. +func Emails(generator *Generator) Criterion { + return emailsCriterion{g: generator} +} + +func init() { + Register(Emails) +} diff --git a/pkg/policy/criteria/emails_test.go b/pkg/policy/criteria/emails_test.go new file mode 100644 index 000000000..49f866f84 --- /dev/null +++ b/pkg/policy/criteria/emails_test.go @@ -0,0 +1,67 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/pomerium/pomerium/pkg/grpc/session" + "github.com/pomerium/pomerium/pkg/grpc/user" +) + +func TestEmails(t *testing.T) { + t.Run("no session", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - email: test@example.com +`, []dataBrokerRecord{}, Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by email", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - email: test@example.com +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + &user.User{ + Id: "USER_ID", + Email: "test@example.com", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by impersonate email", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - email: test2@example.com +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + ImpersonateEmail: proto.String("test2@example.com"), + }, + &user.User{ + Id: "USER_ID", + Email: "test1@example.com", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/groups.go b/pkg/policy/criteria/groups.go new file mode 100644 index 000000000..821d9047d --- /dev/null +++ b/pkg/policy/criteria/groups.go @@ -0,0 +1,84 @@ +package criteria + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +var groupsBody = ast.Body{ + ast.MustParseExpr(` + session := get_session(input.session.id) + `), + ast.MustParseExpr(` + directory_user := get_directory_user(session) + `), + ast.MustParseExpr(` + group_ids := get_group_ids(session, directory_user) + `), + ast.MustParseExpr(` + group_names := [directory_group.name | + some i + group_id := group_ids[i] + directory_group := get_directory_group(group_id) + directory_group != null + directory_group.name != null] + `), + ast.MustParseExpr(` + group_emails := [directory_group.email | + some i + group_id := group_ids[i] + directory_group := get_directory_group(group_id) + directory_group != null + directory_group.email != null] + `), + ast.MustParseExpr(` + groups = array.concat(group_ids, array.concat(group_names, group_emails)) + `), + ast.MustParseExpr(` + some group + `), + ast.MustParseExpr(` + group = groups[_] + `), +} + +type groupsCriterion struct { + g *Generator +} + +func (groupsCriterion) Names() []string { + return []string{"group", "groups"} +} + +func (c groupsCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + r := c.g.NewRule("groups") + r.Body = append(r.Body, ast.Assign.Expr(ast.VarTerm("rule_data"), ast.NewTerm(data.RegoValue()))) + r.Body = append(r.Body, groupsBody...) + + switch data.(type) { + case parser.String: + r.Body = append(r.Body, ast.MustParseExpr(`group = rule_data`)) + default: + return nil, nil, fmt.Errorf("unsupported value type: %T", data) + } + + return r, []*ast.Rule{ + rules.GetSession(), + rules.GetDirectoryUser(), + rules.GetDirectoryGroup(), + rules.GetGroupIDs(), + }, nil +} + +// Groups returns a Criterion on a user's group ids, names or emails. +func Groups(generator *Generator) Criterion { + return groupsCriterion{g: generator} +} + +func init() { + Register(Groups) +} diff --git a/pkg/policy/criteria/groups_test.go b/pkg/policy/criteria/groups_test.go new file mode 100644 index 000000000..6e1c47548 --- /dev/null +++ b/pkg/policy/criteria/groups_test.go @@ -0,0 +1,95 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pomerium/pomerium/pkg/grpc/directory" + "github.com/pomerium/pomerium/pkg/grpc/session" +) + +func TestGroups(t *testing.T) { + t.Run("no session", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - groups: group1 + - groups: group2 +`, []dataBrokerRecord{}, Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by id", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - groups: group1 +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + &directory.User{ + Id: "USER_ID", + GroupIds: []string{"group1"}, + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by email", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - groups: "group1@example.com" +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + &directory.User{ + Id: "USER_ID", + GroupIds: []string{"group1"}, + }, + &directory.Group{ + Id: "group1", + Email: "group1@example.com", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) + t.Run("by name", func(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - groups: "Group 1" +`, + []dataBrokerRecord{ + &session.Session{ + Id: "SESSION_ID", + UserId: "USER_ID", + }, + &directory.User{ + Id: "USER_ID", + GroupIds: []string{"group1"}, + }, + &directory.Group{ + Id: "group1", + Name: "Group 1", + }, + }, + Input{Session: InputSession{ID: "SESSION_ID"}}) + require.NoError(t, err) + require.Equal(t, true, res["allow"]) + require.Equal(t, false, res["deny"]) + }) +} diff --git a/pkg/policy/criteria/invalid_client_certificate.go b/pkg/policy/criteria/invalid_client_certificate.go new file mode 100644 index 000000000..d604ae3ef --- /dev/null +++ b/pkg/policy/criteria/invalid_client_certificate.go @@ -0,0 +1,37 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +var invalidClientCertificateBody = ast.Body{ + ast.MustParseExpr(`reason = [495, "invalid client certificate"]`), + ast.MustParseExpr(`is_boolean(input.is_valid_client_certificate)`), + ast.MustParseExpr(`not input.is_valid_client_certificate`), +} + +type invalidClientCertificateCriterion struct { + g *Generator +} + +func (invalidClientCertificateCriterion) Names() []string { + return []string{"invalid_client_certificate"} +} + +func (c invalidClientCertificateCriterion) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) { + rule := c.g.NewRule("invalid_client_certificate") + rule.Head.Value = ast.VarTerm("reason") + rule.Body = invalidClientCertificateBody + return rule, nil, nil +} + +// InvalidClientCertificate returns a Criterion which returns true if the client certificate is valid. +func InvalidClientCertificate(generator *Generator) Criterion { + return invalidClientCertificateCriterion{g: generator} +} + +func init() { + Register(InvalidClientCertificate) +} diff --git a/pkg/policy/criteria/pomerium_routes.go b/pkg/policy/criteria/pomerium_routes.go new file mode 100644 index 000000000..b5851a608 --- /dev/null +++ b/pkg/policy/criteria/pomerium_routes.go @@ -0,0 +1,37 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +var pomeriumRoutesBody = ast.Body{ + ast.MustParseExpr(` + contains(input.http.url, "/.pomerium/") + `), +} + +type pomeriumRoutesCriterion struct { + g *Generator +} + +func (pomeriumRoutesCriterion) Names() []string { + return []string{"pomerium_routes"} +} + +func (c pomeriumRoutesCriterion) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) { + r := c.g.NewRule("pomerium_routes") + r.Body = append(r.Body, pomeriumRoutesBody...) + + return r, nil, nil +} + +// PomeriumRoutes returns a Criterion on that allows access to pomerium routes. +func PomeriumRoutes(generator *Generator) Criterion { + return pomeriumRoutesCriterion{g: generator} +} + +func init() { + Register(PomeriumRoutes) +} diff --git a/pkg/policy/criteria/reject.go b/pkg/policy/criteria/reject.go new file mode 100644 index 000000000..aa2a3d2db --- /dev/null +++ b/pkg/policy/criteria/reject.go @@ -0,0 +1,35 @@ +package criteria + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +var rejectBody = ast.Body{ + ast.MustParseExpr(`v := false`), +} + +type rejectMatcher struct { + g *Generator +} + +func (rejectMatcher) Names() []string { + return []string{"reject"} +} + +func (m rejectMatcher) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) { + rule := m.g.NewRule("reject") + rule.Head.Value = ast.VarTerm("v") + rule.Body = rejectBody + return rule, nil, nil +} + +// Reject returns a Criterion which always returns false. +func Reject(generator *Generator) Criterion { + return rejectMatcher{g: generator} +} + +func init() { + Register(Reject) +} diff --git a/pkg/policy/criteria/reject_test.go b/pkg/policy/criteria/reject_test.go new file mode 100644 index 000000000..89b7ec74d --- /dev/null +++ b/pkg/policy/criteria/reject_test.go @@ -0,0 +1,18 @@ +package criteria + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReject(t *testing.T) { + res, err := evaluate(t, ` +allow: + and: + - reject: 1 +`, []dataBrokerRecord{}, Input{}) + require.NoError(t, err) + require.Equal(t, false, res["allow"]) + require.Equal(t, false, res["deny"]) +} diff --git a/pkg/policy/criteria/users.go b/pkg/policy/criteria/users.go new file mode 100644 index 000000000..495f649ff --- /dev/null +++ b/pkg/policy/criteria/users.go @@ -0,0 +1,55 @@ +package criteria + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" + "github.com/pomerium/pomerium/pkg/policy/rules" +) + +var usersBody = ast.Body{ + ast.MustParseExpr(` + session := get_session(input.session.id) + `), + ast.MustParseExpr(` + user := get_user(session) + `), +} + +type usersCriterion struct { + g *Generator +} + +func (usersCriterion) Names() []string { + return []string{"user", "users"} +} + +func (c usersCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) { + r := c.g.NewRule("users") + r.Body = append(r.Body, ast.Assign.Expr(ast.VarTerm("rule_data"), ast.NewTerm(data.RegoValue()))) + r.Body = append(r.Body, usersBody...) + + switch data.(type) { + case parser.String: + r.Body = append(r.Body, ast.MustParseExpr(`user_id = rule_data`)) + default: + return nil, nil, fmt.Errorf("unsupported value type: %T", data) + } + + return r, []*ast.Rule{ + rules.GetSession(), + rules.GetUser(), + rules.GetUserEmail(), + }, nil +} + +// UserIDs returns a Criterion on a user's id. +func UserIDs(generator *Generator) Criterion { + return usersCriterion{g: generator} +} + +func init() { + Register(UserIDs) +} diff --git a/pkg/policy/generator/conditionals.go b/pkg/policy/generator/conditionals.go new file mode 100644 index 000000000..fdfb65a46 --- /dev/null +++ b/pkg/policy/generator/conditionals.go @@ -0,0 +1,132 @@ +package generator + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +type conditionalGenerator func(dst *ast.RuleSet, policyCriteria []parser.Criterion) (*ast.Rule, error) + +func (g *Generator) generateAndRule(dst *ast.RuleSet, policyCriteria []parser.Criterion) (*ast.Rule, error) { + rule := g.NewRule("and") + + if len(policyCriteria) == 0 { + return rule, nil + } + + expressions, err := g.generateCriterionRules(dst, policyCriteria) + if err != nil { + return nil, err + } + + g.fillViaAnd(rule, expressions) + dst.Add(rule) + + return rule, nil +} + +func (g *Generator) generateNotRule(dst *ast.RuleSet, policyCriteria []parser.Criterion) (*ast.Rule, error) { + rule := g.NewRule("not") + + if len(policyCriteria) == 0 { + return rule, nil + } + + // NOT => (NOT A) AND (NOT B) + + expressions, err := g.generateCriterionRules(dst, policyCriteria) + if err != nil { + return nil, err + } + for _, expr := range expressions { + expr.Negated = true + } + + g.fillViaAnd(rule, expressions) + dst.Add(rule) + + return rule, nil +} + +func (g *Generator) generateOrRule(dst *ast.RuleSet, policyCriteria []parser.Criterion) (*ast.Rule, error) { + rule := g.NewRule("or") + + if len(policyCriteria) == 0 { + return rule, nil + } + + expressions, err := g.generateCriterionRules(dst, policyCriteria) + if err != nil { + return nil, err + } + + g.fillViaOr(rule, expressions) + dst.Add(rule) + + return rule, nil +} + +func (g *Generator) generateNorRule(dst *ast.RuleSet, policyCriteria []parser.Criterion) (*ast.Rule, error) { + rule := g.NewRule("nor") + + if len(policyCriteria) == 0 { + return rule, nil + } + + // NOR => (NOT A) OR (NOT B) + + expressions, err := g.generateCriterionRules(dst, policyCriteria) + if err != nil { + return nil, err + } + for _, expr := range expressions { + expr.Negated = true + } + + g.fillViaOr(rule, expressions) + dst.Add(rule) + + return rule, nil +} + +func (g *Generator) generateCriterionRules(dst *ast.RuleSet, policyCriteria []parser.Criterion) ([]*ast.Expr, error) { + var expressions []*ast.Expr + for _, policyCriterion := range policyCriteria { + criterion, ok := g.criteria[policyCriterion.Name] + if !ok { + return nil, fmt.Errorf("unknown policy criterion: %s", policyCriterion.Name) + } + mainRule, additionalRules, err := criterion.GenerateRule(policyCriterion.SubPath, policyCriterion.Data) + if err != nil { + return nil, fmt.Errorf("error generating criterion rules: %w", err) + } + *dst = dst.Merge(additionalRules) + dst.Add(mainRule) + + expr := ast.NewExpr(ast.VarTerm(string(mainRule.Head.Name))) + expressions = append(expressions, expr) + } + return expressions, nil +} + +func (g *Generator) fillViaAnd(rule *ast.Rule, expressions []*ast.Expr) { + for _, expr := range expressions { + rule.Body = append(rule.Body, expr) + } +} + +func (g *Generator) fillViaOr(rule *ast.Rule, expressions []*ast.Expr) { + currentRule := rule + for i, expr := range expressions { + if i > 0 { + currentRule.Else = &ast.Rule{ + Head: &ast.Head{}, + } + currentRule = currentRule.Else + } + currentRule.Body = ast.Body{expr} + } +} diff --git a/pkg/policy/generator/criterion.go b/pkg/policy/generator/criterion.go new file mode 100644 index 000000000..24df607f9 --- /dev/null +++ b/pkg/policy/generator/criterion.go @@ -0,0 +1,43 @@ +package generator + +import ( + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +// A Criterion generates rego rules based on data. +type Criterion interface { + Names() []string + GenerateRule(subPath string, data parser.Value) (rule *ast.Rule, additionalRules []*ast.Rule, err error) +} + +// A CriterionConstructor is a function which returns a Criterion for a Generator. +type CriterionConstructor func(*Generator) Criterion + +// A criterionFunc is a criterion implemented as a function and a list of names. +type criterionFunc struct { + names []string + generateRule func(subPath string, data parser.Value) (rule *ast.Rule, additionalRules []*ast.Rule, err error) +} + +// Names returns the names of the criterion. +func (c criterionFunc) Names() []string { + return c.names +} + +// GenerateRule calls the underlying generateRule function. +func (c criterionFunc) GenerateRule(subPath string, data parser.Value) (rule *ast.Rule, additionalRules []*ast.Rule, err error) { + return c.generateRule(subPath, data) +} + +// NewCriterionFunc creates a new Criterion from a function. +func NewCriterionFunc( + names []string, + f func(subPath string, data parser.Value) (rule *ast.Rule, additionalRules []*ast.Rule, err error), +) Criterion { + return criterionFunc{ + names: names, + generateRule: f, + } +} diff --git a/pkg/policy/generator/generator.go b/pkg/policy/generator/generator.go new file mode 100644 index 000000000..7ee65d7fa --- /dev/null +++ b/pkg/policy/generator/generator.go @@ -0,0 +1,104 @@ +// Package generator converts Pomerium Policy Language into Rego. +package generator + +import ( + "fmt" + + "github.com/open-policy-agent/opa/ast" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +// A Generator generates a rego script from a policy. +type Generator struct { + ids map[string]int + criteria map[string]Criterion +} + +// An Option configures the Generator. +type Option func(*Generator) + +// WithCriterion adds a Criterion to the generator's known criteria. +func WithCriterion(criterionConstructor CriterionConstructor) Option { + return func(g *Generator) { + c := criterionConstructor(g) + for _, name := range c.Names() { + g.criteria[name] = c + } + } +} + +// New creates a new Generator. +func New(options ...Option) *Generator { + g := &Generator{ + ids: make(map[string]int), + criteria: make(map[string]Criterion), + } + for _, o := range options { + o(g) + } + return g +} + +// GetCriterion gets a Criterion for the given name. +func (g *Generator) GetCriterion(name string) (Criterion, bool) { + c, ok := g.criteria[name] + return c, ok +} + +// Generate generates the rego module from a policy. +func (g *Generator) Generate(policy *parser.Policy) (*ast.Module, error) { + rules := ast.NewRuleSet() + rules.Add(ast.MustParseRule(`default allow = false`)) + rules.Add(ast.MustParseRule(`default deny = false`)) + + for _, policyRule := range policy.Rules { + rule := &ast.Rule{ + Head: &ast.Head{Name: ast.Var(policyRule.Action)}, + } + + fields := []struct { + criteria []parser.Criterion + generator conditionalGenerator + }{ + {policyRule.And, g.generateAndRule}, + {policyRule.Or, g.generateOrRule}, + {policyRule.Not, g.generateNotRule}, + {policyRule.Nor, g.generateNorRule}, + } + for _, field := range fields { + if len(field.criteria) == 0 { + continue + } + subRule, err := field.generator(&rules, field.criteria) + if err != nil { + return nil, err + } + rule.Body = append(rule.Body, ast.NewExpr(ast.VarTerm(string(subRule.Head.Name)))) + } + + rules.Add(rule) + } + + return &ast.Module{ + Package: &ast.Package{ + Path: ast.Ref{ + ast.StringTerm("policy.rego"), + ast.StringTerm("pomerium"), + ast.StringTerm("policy"), + }, + }, + Rules: rules, + }, nil +} + +// NewRule creates a new rule with a dynamically generated name. +func (g *Generator) NewRule(name string) *ast.Rule { + id := g.ids[name] + g.ids[name]++ + return &ast.Rule{ + Head: &ast.Head{ + Name: ast.Var(fmt.Sprintf("%s_%d", name, id)), + }, + } +} diff --git a/pkg/policy/generator/generator_test.go b/pkg/policy/generator/generator_test.go new file mode 100644 index 000000000..a4aa48da9 --- /dev/null +++ b/pkg/policy/generator/generator_test.go @@ -0,0 +1,147 @@ +package generator + +import ( + "testing" + + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/format" + "github.com/stretchr/testify/assert" + + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +func Test(t *testing.T) { + g := New(WithCriterion(func(g *Generator) Criterion { + return NewCriterionFunc([]string{"accept"}, func(subPath string, data parser.Value) (rule *ast.Rule, additionalRules []*ast.Rule, err error) { + rule = g.NewRule("accept") + rule.Body = append(rule.Body, ast.MustParseExpr("1 == 1")) + return rule, nil, nil + }) + })) + + mod, err := g.Generate(&parser.Policy{ + Rules: []parser.Rule{ + { + Action: parser.ActionAllow, + And: []parser.Criterion{ + {Name: "accept"}, + {Name: "accept"}, + {Name: "accept"}, + }, + Or: []parser.Criterion{ + {Name: "accept"}, + {Name: "accept"}, + {Name: "accept"}, + }, + Not: []parser.Criterion{ + {Name: "accept"}, + {Name: "accept"}, + {Name: "accept"}, + }, + Nor: []parser.Criterion{ + {Name: "accept"}, + {Name: "accept"}, + {Name: "accept"}, + }, + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, `package pomerium.policy + +default allow = false + +default deny = false + +accept_0 { + 1 == 1 +} + +accept_1 { + 1 == 1 +} + +accept_2 { + 1 == 1 +} + +and_0 { + accept_0 + accept_1 + accept_2 +} + +accept_3 { + 1 == 1 +} + +accept_4 { + 1 == 1 +} + +accept_5 { + 1 == 1 +} + +or_0 { + accept_3 +} + +else { + accept_4 +} + +else { + accept_5 +} + +accept_6 { + 1 == 1 +} + +accept_7 { + 1 == 1 +} + +accept_8 { + 1 == 1 +} + +not_0 { + not accept_6 + not accept_7 + not accept_8 +} + +accept_9 { + 1 == 1 +} + +accept_10 { + 1 == 1 +} + +accept_11 { + 1 == 1 +} + +nor_0 { + not accept_9 +} + +else { + not accept_10 +} + +else { + not accept_11 +} + +allow { + and_0 + or_0 + not_0 + nor_0 +} +`, string(format.MustAst(mod))) +} diff --git a/pkg/policy/parser/grammar.go b/pkg/policy/parser/grammar.go new file mode 100644 index 000000000..b7c639b22 --- /dev/null +++ b/pkg/policy/parser/grammar.go @@ -0,0 +1,308 @@ +package parser + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// A Policy is a policy made up of multiple allow or deny rules. +type Policy struct { + Rules []Rule +} + +// MarshalJSON marshals the policy as JSON. +func (p *Policy) MarshalJSON() ([]byte, error) { + return json.Marshal(p.ToJSON()) +} + +// String converts the policy to a string. +func (p *Policy) String() string { + str, _ := p.MarshalJSON() + return string(str) +} + +// ToJSON converts the policy to JSON. +func (p *Policy) ToJSON() Value { + var root Array + + for _, r := range p.Rules { + root = append(root, r.ToJSON()) + } + + return root +} + +// PolicyFromValue converts a value into a Policy. +func PolicyFromValue(v Value) (*Policy, error) { + rules, err := RulesFromValue(v) + if err != nil { + return nil, fmt.Errorf("invalid rules in policy: %w", err) + } + return &Policy{ + Rules: rules, + }, nil +} + +// A Rule is a policy rule with a corresponding action ("allow" or "deny"), +// and conditionals to determine if the rule matches or not. +type Rule struct { + Action Action + And []Criterion + Or []Criterion + Not []Criterion + Nor []Criterion +} + +// RulesFromValue converts a Value into a slice of Rules. Only Arrays or Objects +// are supported. +func RulesFromValue(v Value) ([]Rule, error) { + switch t := v.(type) { + case Array: + return RulesFromArray(t) + case Object: + return RulesFromObject(t) + default: + return nil, fmt.Errorf("unsupported type for rule: %T", v) + } +} + +// RulesFromArray converts an Array into a slice of Rules. Each element of the Array is +// converted using RulesFromObject and merged together. +func RulesFromArray(a Array) ([]Rule, error) { + var rules []Rule + for _, v := range a { + switch t := v.(type) { + case Object: + inner, err := RulesFromObject(t) + if err != nil { + return nil, err + } + rules = append(rules, inner...) + default: + return nil, fmt.Errorf("unsupported type for rules array: %T", v) + } + } + return rules, nil +} + +// RulesFromObject converts an Object into a slice of Rules. +// +// One form is supported: +// +// 1. An object where the keys are the actions and the values are an object with "and", "or", or "not" fields: +// `{ "allow": { "and": [ {"groups": "group1"} ] } }` +// +func RulesFromObject(o Object) ([]Rule, error) { + var rules []Rule + for k, v := range o { + action, err := ActionFromValue(String(k)) + if err != nil { + return nil, fmt.Errorf("invalid action in rule: %w", err) + } + + oo, ok := v.(Object) + if !ok { + return nil, fmt.Errorf("invalid value for action in rule, expected Object, got %T", v) + } + + rule := Rule{ + Action: action, + } + err = rule.fillConditionalsFromObject(oo) + if err != nil { + return nil, err + } + + rules = append(rules, rule) + } + // sort by action for deterministic ordering + sort.Slice(rules, func(i, j int) bool { + return rules[i].Action < rules[j].Action + }) + return rules, nil +} + +// MarshalJSON marshals the rule as JSON. +func (r *Rule) MarshalJSON() ([]byte, error) { + return json.Marshal(r.ToJSON()) +} + +// String converts the rule to a string. +func (r *Rule) String() string { + str, _ := r.MarshalJSON() + return string(str) +} + +// ToJSON converts the rule to JSON. +func (r *Rule) ToJSON() Value { + body := Object{} + + for _, op := range []struct { + operator string + criteria []Criterion + }{ + {"and", r.And}, + {"or", r.Or}, + {"not", r.Not}, + {"nor", r.Nor}, + } { + if len(op.criteria) == 0 { + continue + } + + var criteria Array + for _, c := range op.criteria { + criteria = append(criteria, c.ToJSON()) + } + + body[op.operator] = criteria + } + + return Object{ + string(r.Action): body, + } +} + +func (r *Rule) fillConditionalsFromObject(o Object) error { + conditionals := []struct { + Name string + Criteria *[]Criterion + }{ + {"and", &r.And}, + {"or", &r.Or}, + {"not", &r.Not}, + {"nor", &r.Nor}, + } + for _, cond := range conditionals { + if rawCriteria, ok := o[cond.Name]; ok { + criteria, err := CriteriaFromValue(rawCriteria) + if err != nil { + return fmt.Errorf("invalid criteria in \"%s\"): %w", cond.Name, err) + } + *cond.Criteria = criteria + } + } + for k := range o { + switch k { + case "and", "or", "not", "nor", "action": + default: + return fmt.Errorf("unsupported conditional \"%s\", only and, or, not, nor and action are allowed", k) + } + } + return nil +} + +// A Criterion is used by a rule to determine if the rule matches or not. +// +// Criteria RegoRulesGenerators are registered based on the specified name. +// Data is arbitrary JSON data sent to the generator. +type Criterion struct { + Name string + SubPath string + Data Value +} + +// CriteriaFromValue converts a Value into Criteria. Only Arrays are supported. +func CriteriaFromValue(v Value) ([]Criterion, error) { + switch t := v.(type) { + case Array: + return CriteriaFromArray(t) + default: + return nil, fmt.Errorf("unsupported type for criteria: %T", v) + } +} + +// CriteriaFromArray converts an Array into Criteria. Each element of the Array is +// converted using CriterionFromObject. +func CriteriaFromArray(a Array) ([]Criterion, error) { + var criteria []Criterion + for _, v := range a { + switch t := v.(type) { + case Object: + inner, err := CriterionFromObject(t) + if err != nil { + return nil, err + } + criteria = append(criteria, *inner) + default: + return nil, fmt.Errorf("unsupported type for criteria array: %T", v) + } + } + return criteria, nil +} + +// CriterionFromObject converts an Object into a Criterion. +// +// One form is supported: +// +// 1. An object where the keys are the names with a sub path and the values are the corresponding +// data for each Criterion: `{ "groups": "group1" }` +// +func CriterionFromObject(o Object) (*Criterion, error) { + if len(o) != 1 { + return nil, fmt.Errorf("each criteria may only contain a single key and value") + } + + for k, v := range o { + name := k + subPath := "" + if idx := strings.Index(k, "/"); idx >= 0 { + name, subPath = k[:idx], k[idx+1:] + } + return &Criterion{ + Name: name, + SubPath: subPath, + Data: v, + }, nil + } + + // this can't happen + panic("each criteria may only contain a single key and value") +} + +// MarshalJSON marshals the criterion as JSON. +func (c *Criterion) MarshalJSON() ([]byte, error) { + return json.Marshal(c.ToJSON()) +} + +// String converts the criterion to a string. +func (c *Criterion) String() string { + str, _ := c.MarshalJSON() + return string(str) +} + +// ToJSON converts the criterion to JSON. +func (c *Criterion) ToJSON() Value { + nm := c.Name + if c.SubPath != "" { + nm += "/" + c.SubPath + } + return Object{nm: c.Data} +} + +// An Action describe what to do when a rule matches, either "allow" or "deny". +type Action string + +// ActionFromValue converts a Value into an Action. +func ActionFromValue(value Value) (Action, error) { + s, ok := value.(String) + if !ok { + return "", fmt.Errorf("unsupported type for action: %T", value) + } + switch Action(s) { + case ActionAllow: + return ActionAllow, nil + case ActionDeny: + return ActionDeny, nil + } + + return "", fmt.Errorf("unsupported action: %s", s) +} + +// Actions +const ( + ActionAllow Action = "allow" + ActionDeny Action = "deny" +) diff --git a/pkg/policy/parser/json.go b/pkg/policy/parser/json.go new file mode 100644 index 000000000..c141a0c2d --- /dev/null +++ b/pkg/policy/parser/json.go @@ -0,0 +1,272 @@ +package parser + +import ( + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/open-policy-agent/opa/ast" +) + +// A Value is a JSON value. Either an object, array, string, number, boolean or null. +type Value interface { + isValue() + Clone() Value + RegoValue() ast.Value +} + +// ParseValue parses JSON into a value. +func ParseValue(r io.Reader) (Value, error) { + dec := json.NewDecoder(r) + dec.UseNumber() + + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } else if err != nil { + return nil, err + } + + v, err := parseValue(dec, tok) + if err != nil { + return v, err + } + if dec.More() { + return nil, fmt.Errorf("unexpected additional json value: offset=%d", dec.InputOffset()) + } + return v, nil +} + +func parseValue(dec *json.Decoder, tok json.Token) (Value, error) { + if d, ok := tok.(json.Delim); ok { + switch d { + case '[': + return parseArray(dec) + case '{': + return parseObject(dec) + default: + return nil, fmt.Errorf("unsupported json delimiter: %s", string(d)) + } + } + + return parseSimple(tok) +} + +func parseArray(dec *json.Decoder) (Value, error) { + var a Array + for { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } else if err != nil { + return nil, err + } + + if d, ok := tok.(json.Delim); ok && d == ']' { + return a, nil + } + + v, err := parseValue(dec, tok) + if err != nil { + return nil, err + } + a = append(a, v) + } +} + +func parseObject(dec *json.Decoder) (Value, error) { + o := make(Object) + k := "" + for i := 0; ; i++ { + tok, err := dec.Token() + if errors.Is(err, io.EOF) { + return nil, io.ErrUnexpectedEOF + } else if err != nil { + return nil, err + } + + if d, ok := tok.(json.Delim); ok && d == '}' { + return o, nil + } + + v, err := parseValue(dec, tok) + if err != nil { + return nil, err + } + + // if we're handling a key + if i%2 == 0 { + s, ok := v.(String) + if !ok { + return nil, fmt.Errorf("unsupported object key type: %T", v) + } + k = string(s) + } else { + o[k] = v + } + } +} + +func parseSimple(tok json.Token) (Value, error) { + switch t := tok.(type) { + case bool: + return Boolean(t), nil + case json.Number: + return Number(t), nil + case string: + return String(t), nil + case nil: + return Null{}, nil + } + + return nil, fmt.Errorf("unsupported json token type: %T", tok) +} + +// An Object is a map of strings to values. +type Object map[string]Value + +func (Object) isValue() {} + +// Clone clones the Object. +func (o Object) Clone() Value { + no := make(Object) + for k, v := range o { + no[k] = v + } + return no +} + +// RegoValue returns the Object as a rego Value. +func (o Object) RegoValue() ast.Value { + kvps := make([][2]*ast.Term, 0, len(o)) + for k, v := range o { + kvps = append(kvps, [2]*ast.Term{ + ast.StringTerm(k), + ast.NewTerm(v.RegoValue()), + }) + } + return ast.NewObject(kvps...) +} + +// String returns the JSON representation of the Object. +func (o Object) String() string { + bs, _ := json.Marshal(o) + return string(bs) +} + +// An Array is a slice of values. +type Array []Value + +func (Array) isValue() {} + +// Clone clones the array. +func (a Array) Clone() Value { + na := make(Array, len(a)) + copy(na, a) + return na +} + +// RegoValue returns the Array as a rego Value. +func (a Array) RegoValue() ast.Value { + var vs []*ast.Term + for _, v := range a { + vs = append(vs, ast.NewTerm(v.RegoValue())) + } + return ast.NewArray(vs...) +} + +// String returns the JSON representation of the Array. +func (a Array) String() string { + bs, _ := json.Marshal(a) + return string(bs) +} + +// A String is a wrapper around a string. +type String string + +func (String) isValue() {} + +// Clone clones the string. +func (s String) Clone() Value { + return s +} + +// RegoValue returns the String as a rego Value. +func (s String) RegoValue() ast.Value { + return ast.String(s) +} + +// String returns the JSON representation of the String. +func (s String) String() string { + bs, _ := json.Marshal(s) + return string(bs) +} + +// A Number is an integer or a floating point value stored in string representation. +type Number string + +func (Number) isValue() {} + +// Clone clones the number. +func (n Number) Clone() Value { + return n +} + +// RegoValue returns the Number as a rego Value. +func (n Number) RegoValue() ast.Value { + return ast.Number(n) +} + +// String returns the JSON representation of the Number. +func (n Number) String() string { + return string(n) +} + +// MarshalJSON marshals the number as JSON. +func (n Number) MarshalJSON() ([]byte, error) { + return []byte(n), nil +} + +// A Boolean is either true or false. +type Boolean bool + +func (Boolean) isValue() {} + +// Clone clones the boolean. +func (b Boolean) Clone() Value { + return b +} + +// RegoValue returns the Boolean as a rego Value. +func (b Boolean) RegoValue() ast.Value { + return ast.Boolean(b) +} + +// String returns the JSON representation of the Boolean. +func (b Boolean) String() string { + if b { + return "true" + } + return "false" +} + +// A Null is the nil value. +type Null struct{} + +func (Null) isValue() {} + +// Clone clones the null. +func (Null) Clone() Value { + return Null{} +} + +// RegoValue returns the Null as a rego Value. +func (Null) RegoValue() ast.Value { + return ast.Null{} +} + +// String returns JSON null. +func (Null) String() string { + return "null" +} diff --git a/pkg/policy/parser/json_test.go b/pkg/policy/parser/json_test.go new file mode 100644 index 000000000..06975762f --- /dev/null +++ b/pkg/policy/parser/json_test.go @@ -0,0 +1,117 @@ +package parser + +import ( + "testing" + + "github.com/open-policy-agent/opa/ast" + "github.com/stretchr/testify/assert" +) + +func TestArray(t *testing.T) { + var _ Value = Array{} + t.Run("Clone", func(t *testing.T) { + a1 := Array{Number("1"), Number("2"), Number("3")} + a2 := a1.Clone() + assert.Equal(t, a1, a2) + }) + t.Run("RegoValue", func(t *testing.T) { + a := Array{Number("1"), Number("2")} + assert.Equal(t, ast.NewArray( + ast.NumberTerm("1"), + ast.NumberTerm("2"), + ), a.RegoValue()) + }) + t.Run("String", func(t *testing.T) { + a := Array{Number("1"), Number("2"), Boolean(true)} + assert.Equal(t, `[1,2,true]`, a.String()) + }) +} + +func TestBoolean(t *testing.T) { + var _ Value = Boolean(true) + t.Run("Clone", func(t *testing.T) { + b1 := Boolean(true) + b2 := b1.Clone() + assert.Equal(t, b1, b2) + }) + t.Run("RegoValue", func(t *testing.T) { + b := Boolean(true) + assert.Equal(t, ast.Boolean(true), b.RegoValue()) + }) + t.Run("String", func(t *testing.T) { + b := Boolean(true) + assert.Equal(t, `true`, b.String()) + }) +} + +func TestNull(t *testing.T) { + var _ Value = Null{} + t.Run("Clone", func(t *testing.T) { + n1 := Null{} + n2 := n1.Clone() + assert.Equal(t, n1, n2) + }) + t.Run("RegoValue", func(t *testing.T) { + n := Null{} + assert.Equal(t, ast.Null{}, n.RegoValue()) + }) + t.Run("String", func(t *testing.T) { + n := Null{} + assert.Equal(t, `null`, n.String()) + }) +} + +func TestNumber(t *testing.T) { + var _ Value = Number("1") + t.Run("Clone", func(t *testing.T) { + n1 := Number("1") + n2 := n1.Clone() + assert.Equal(t, n1, n2) + }) + t.Run("RegoValue", func(t *testing.T) { + n := Number("1") + assert.Equal(t, ast.Number("1"), n.RegoValue()) + }) + t.Run("String", func(t *testing.T) { + n := Number("1") + assert.Equal(t, `1`, n.String()) + }) +} + +func TestObject(t *testing.T) { + var _ Value = Object{} + t.Run("Clone", func(t *testing.T) { + o1 := Object{"x": String("y")} + o2 := o1.Clone().(Object) + assert.Equal(t, o1, o2) + o2["x"] = String("z") + assert.NotEqual(t, o1, o2) + }) + t.Run("RegoValue", func(t *testing.T) { + o := Object{"x": String("y")} + assert.Equal(t, ast.NewObject( + [2]*ast.Term{ast.StringTerm("x"), ast.StringTerm("y")}, + ), o.RegoValue()) + }) + t.Run("String", func(t *testing.T) { + o1 := Object{"x": String("y")} + assert.Equal(t, `{"x":"y"}`, o1.String()) + }) +} + +func TestString(t *testing.T) { + var _ Value = String("test") + t.Run("Clone", func(t *testing.T) { + s1 := String("test") + s2 := s1.Clone() + assert.Equal(t, s1, s2) + }) + t.Run("RegoValue", func(t *testing.T) { + s := String("test") + assert.Equal(t, ast.String("test"), s.RegoValue()) + }) + t.Run("String", func(t *testing.T) { + s := String("test") + assert.Equal(t, `"test"`, s.String()) + }) +} diff --git a/pkg/policy/parser/parser.go b/pkg/policy/parser/parser.go new file mode 100644 index 000000000..608ea3674 --- /dev/null +++ b/pkg/policy/parser/parser.go @@ -0,0 +1,126 @@ +// Package parser contains a parser for Pomerium Policy Language. +// +// The Pomerium Policy Language is a JSON or YAML document containing rules, +// actions, logical operators and criteria. +// +// The document contains zero or more rules. +// +// A rule has an action and zero or more logical operators. +// +// An action is either "allow" or "deny". +// +// The logical operators are "and", "or" and "not" and contain zero or more criteria. +// +// A criterion has a name and arbitrary JSON data. +// +// An example policy: +// +// allow: +// and: +// - domain: example.com +// - group: admin +// deny: +// or: +// - user: user1@example.com +// - user: user2@example.com +// +// The JSON Schema for the language: +// +// { +// "$ref": "#/definitions/policy", +// "definitions": { +// "policy": { +// "anyOf": [ +// { "$ref": "#/definitions/rules" }, +// { +// "type": "array", +// "items": { "$ref": "#/definitions/rules" } +// } +// ] +// }, +// "rules": { +// "type": "object", +// "properties": { +// "allow": { "$ref": "#/definitions/rule_body" }, +// "deny": { "$ref": "#/definitions/rule_body" } +// } +// }, +// "rule_body": { +// "type": "object", +// "properties": { +// "and": { +// "type": "array", +// "items": { "$ref": "#/definitions/criteria" } +// }, +// "not": { +// "type": "array", +// "items": { "$ref": "#/definitions/criteria" } +// }, +// "or": { +// "type": "array", +// "items": { "$ref": "#/definitions/criteria" } +// } +// }, +// "additionalProperties": false +// }, +// "criteria": { +// "type": "object", +// "additionalProperties": true, +// "minProperties": 1, +// "maxProperties": 1 +// } +// } +// } +// +package parser + +import ( + "bytes" + "encoding/json" + "io" + + "gopkg.in/yaml.v3" +) + +// A Parser parses raw policy definitions into a Policy. +type Parser struct { +} + +// New creates a new Parser. +func New() *Parser { + p := &Parser{} + return p +} + +// ParseJSON parses a raw JSON document into a policy. +func (p *Parser) ParseJSON(r io.Reader) (*Policy, error) { + doc, err := ParseValue(r) + if err != nil { + return nil, err + } + return PolicyFromValue(doc) +} + +// ParseYAML parses a raw YAML document into a policy. +func (p *Parser) ParseYAML(r io.Reader) (*Policy, error) { + var obj interface{} + err := yaml.NewDecoder(r).Decode(&obj) + if err != nil { + return nil, err + } + bs, err := json.Marshal(obj) + if err != nil { + return nil, err + } + return p.ParseJSON(bytes.NewReader(bs)) +} + +// ParseJSON creates a parser and calls ParseJSON on it. +func ParseJSON(r io.Reader) (*Policy, error) { + return New().ParseJSON(r) +} + +// ParseYAML creates a parser and calls ParseYAML on it. +func ParseYAML(r io.Reader) (*Policy, error) { + return New().ParseYAML(r) +} diff --git a/pkg/policy/parser/parser_test.go b/pkg/policy/parser/parser_test.go new file mode 100644 index 000000000..f28507c35 --- /dev/null +++ b/pkg/policy/parser/parser_test.go @@ -0,0 +1,172 @@ +package parser + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseJSON(t *testing.T) { + t.Run("empty", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{}`)) + assert.NoError(t, err) + assert.Len(t, p.Rules, 0) + }) + t.Run("allow", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ "allow": {} }`)) + assert.NoError(t, err) + assert.Len(t, p.Rules, 1) + }) + t.Run("deny", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ "deny": {} }`)) + assert.NoError(t, err) + assert.Len(t, p.Rules, 1) + }) + t.Run("invalid rule type", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`1`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("invalid rule action", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ "some-other-action": {} }`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("rule array", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`[{ "deny": {} }]`)) + assert.NoError(t, err) + assert.Len(t, p.Rules, 1) + }) + t.Run("invalid rule array", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`[{ "some-other-action": {} }]`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("invalid rule array type", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`[1]`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("logical operators", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "and": [], + "or": [], + "not": [] + } + }`)) + assert.NoError(t, err) + assert.Len(t, p.Rules, 1) + }) + t.Run("invalid logical operator", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "iff": [] + } + }`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("criteria", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "and": [ + { "criterion1": 1 }, + { "criterion2": 2 }, + { "criterion3/sub": 3 } + ] + } + }`)) + assert.NoError(t, err) + assert.Equal(t, &Policy{ + Rules: []Rule{{ + Action: ActionAllow, + And: []Criterion{ + {Name: "criterion1", Data: Number("1")}, + {Name: "criterion2", Data: Number("2")}, + {Name: "criterion3", SubPath: "sub", Data: Number("3")}, + }, + }}, + }, p) + }) + t.Run("empty criteria", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "and": [ + { } + ] + } + }`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("invalid multiple criteria", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "and": [ + { "criterion1": 1, "criterion2": 1 } + ] + } + }`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("invalid criteria type", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "and": { "criterion1": 1 } + } + }`)) + assert.Error(t, err) + assert.Nil(t, p) + }) + t.Run("invalid criteria array type", func(t *testing.T) { + p, err := ParseJSON(strings.NewReader(`{ + "allow": { + "and": [1] + } + }`)) + assert.Error(t, err) + assert.Nil(t, p) + }) +} + +func TestParseYAML(t *testing.T) { + t.Run("valid", func(t *testing.T) { + p, err := ParseYAML(strings.NewReader(` +allow: + and: + - criterion1: 1 + - criterion2: 2 + - criterion3/sub: 3 + or: + - criterion4: 4 +deny: + not: + - criterion5: 5 +`)) + assert.NoError(t, err) + assert.Equal(t, &Policy{ + Rules: []Rule{ + { + Action: ActionAllow, + And: []Criterion{ + {Name: "criterion1", Data: Number("1")}, + {Name: "criterion2", Data: Number("2")}, + {Name: "criterion3", SubPath: "sub", Data: Number("3")}, + }, + Or: []Criterion{ + {Name: "criterion4", Data: Number("4")}, + }, + }, + { + Action: ActionDeny, + Not: []Criterion{ + {Name: "criterion5", Data: Number("5")}, + }, + }, + }, + }, p) + }) +} diff --git a/pkg/policy/parser/schema.json b/pkg/policy/parser/schema.json new file mode 100644 index 000000000..e00559d3e --- /dev/null +++ b/pkg/policy/parser/schema.json @@ -0,0 +1,45 @@ +{ + "$ref": "#/definitions/policy", + "definitions": { + "policy": { + "anyOf": [ + { "$ref": "#/definitions/rules" }, + { + "type": "array", + "items": { "$ref": "#/definitions/rules" } + } + ] + }, + "rules": { + "type": "object", + "properties": { + "allow": { "$ref": "#/definitions/rule_body" }, + "deny": { "$ref": "#/definitions/rule_body" } + } + }, + "rule_body": { + "type": "object", + "properties": { + "and": { + "type": "array", + "items": { "$ref": "#/definitions/criteria" } + }, + "not": { + "type": "array", + "items": { "$ref": "#/definitions/criteria" } + }, + "or": { + "type": "array", + "items": { "$ref": "#/definitions/criteria" } + } + }, + "additionalProperties": false + }, + "criteria": { + "type": "object", + "additionalProperties": true, + "minProperties": 1, + "maxProperties": 1 + } + } +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go new file mode 100644 index 000000000..c5b47415e --- /dev/null +++ b/pkg/policy/policy.go @@ -0,0 +1,47 @@ +// Package policy contains an implementation of the Pomerium Policy Language. +package policy + +import ( + "io" + + "github.com/open-policy-agent/opa/format" + + "github.com/pomerium/pomerium/pkg/policy/criteria" + "github.com/pomerium/pomerium/pkg/policy/generator" + "github.com/pomerium/pomerium/pkg/policy/parser" +) + +// re-exported types +type ( + // A Criterion generates rego rules based on data. + Criterion = generator.Criterion + // A CriterionConstructor is a function which returns a Criterion for a Generator. + CriterionConstructor = generator.CriterionConstructor +) + +// GenerateRegoFromPPL generates a rego script from raw Pomerium Policy Language. +func GenerateRegoFromPPL(r io.Reader) (string, error) { + p := parser.New() + var gOpts []generator.Option + for _, ctor := range criteria.All() { + gOpts = append(gOpts, generator.WithCriterion(ctor)) + } + g := generator.New(gOpts...) + + ppl, err := p.ParseYAML(r) + if err != nil { + return "", err + } + + mod, err := g.Generate(ppl) + if err != nil { + return "", err + } + + bs, err := format.Ast(mod) + if err != nil { + return "", err + } + + return string(bs), err +} diff --git a/pkg/policy/rules/rules.go b/pkg/policy/rules/rules.go new file mode 100644 index 000000000..5b845573c --- /dev/null +++ b/pkg/policy/rules/rules.go @@ -0,0 +1,117 @@ +// Package rules contains useful pre-defined rego AST rules. +package rules + +import "github.com/open-policy-agent/opa/ast" + +// GetSession the session for the given id. +func GetSession() *ast.Rule { + return ast.MustParseRule(` +get_session(id) = v { + v := get_databroker_record("type.googleapis.com/user.ServiceAccount", id) +} else = v { + v := get_databroker_record("type.googleapis.com/session.Session", id) +} else = v { + v := {} +} +`) +} + +// GetUser returns the user for the given session. +func GetUser() *ast.Rule { + return ast.MustParseRule(` +get_user(session) = v { + v := get_databroker_record("type.googleapis.com/user.User", session.impersonate_user_id) +} else = v { + v := get_databroker_record("type.googleapis.com/user.User", session.user_id) +} else = v { + v := {} +} +`) +} + +// GetUserEmail gets the user email, either the impersonate email, or the user email. +func GetUserEmail() *ast.Rule { + return ast.MustParseRule(` +get_user_email(session, user) = v { + v := session.impersonate_email +} else = v { + v := user.email +} else = v { + v := "" +} +`) +} + +// GetDirectoryUser returns the directory user for the given session. +func GetDirectoryUser() *ast.Rule { + return ast.MustParseRule(` +get_directory_user(session) = v { + v := get_databroker_record("type.googleapis.com/directory.User", session.impersonate_user_id) +} else = v { + v := get_databroker_record("type.googleapis.com/directory.User", session.user_id) +} else = v { + v := {} +} +`) +} + +// GetDirectoryGroup returns the directory group for the given id. +func GetDirectoryGroup() *ast.Rule { + return ast.MustParseRule(` +get_directory_group(id) = v { + v := get_databroker_record("type.googleapis.com/directory.Group", id) +} else = v { + v := {} +} +`) +} + +// GetGroupIDs returns the group ids for the given session or directory user. +func GetGroupIDs() *ast.Rule { + return ast.MustParseRule(` +get_group_ids(session, directory_user) = v { + v := session.impersonate_groups +} else = v { + v := directory_user.group_ids +} else = v { + v := [] +} +`) +} + +// ObjectGet recursively gets a value from an object. +func ObjectGet() *ast.Rule { + return ast.MustParseRule(` +# 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 +object_get(obj, key, def) = value { + segments := split(key, "/") + count(segments) == 2 + o1 := object.get(obj, segments[0], {}) + value = object.get(o1, segments[1], def) +} else = value { + segments := split(key, "/") + count(segments) == 3 + o1 := object.get(obj, segments[0], {}) + o2 := object.get(o1, segments[1], {}) + value = object.get(o2, segments[2], def) +} else = value { + segments := split(key, "/") + count(segments) == 4 + o1 := object.get(obj, segments[0], {}) + o2 := object.get(o1, segments[1], {}) + o3 := object.get(o2, segments[2], {}) + value = object.get(o3, segments[3], def) +} else = value { + segments := split(key, "/") + count(segments) == 5 + o1 := object.get(obj, segments[0], {}) + o2 := object.get(o1, segments[1], {}) + o3 := object.get(o2, segments[2], {}) + o4 := object.get(o3, segments[3], {}) + value = object.get(o4, segments[4], def) +} else = value { + value = object.get(obj, key, def) +} +`) +}