Pomerium Policy Language (#2202)

* policy: add parser and generator for Pomerium Policy Language

* add criteria

* add additional criteria
This commit is contained in:
Caleb Doxsey 2021-05-17 15:30:51 -06:00 committed by GitHub
parent 9fe941ccee
commit e138054cb9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 2758 additions and 0 deletions

View file

@ -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)
}

View file

@ -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"])
}

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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()
}

View file

@ -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(&rego.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
}

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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)
}

View file

@ -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"])
})
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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"])
}

View file

@ -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)
}

View file

@ -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}
}
}

View file

@ -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,
}
}

View file

@ -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)),
},
}
}

View file

@ -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)))
}

View file

@ -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"
)

272
pkg/policy/parser/json.go Normal file
View file

@ -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"
}

View file

@ -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())
})
}

126
pkg/policy/parser/parser.go Normal file
View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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
}
}
}

47
pkg/policy/policy.go Normal file
View file

@ -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
}

117
pkg/policy/rules/rules.go Normal file
View file

@ -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)
}
`)
}