mirror of
https://github.com/pomerium/pomerium.git
synced 2025-08-06 10:21:05 +02:00
policy: add ssh PPL criteria (#5658)
Add five new PPL criteria for use with SSH, matching username and public key. Username can be matched against a String Matcher, user's email address, or a custom claim from the IdP claims. Public key can be matched against a list of keys or a trusted CA.
This commit is contained in:
parent
9363457849
commit
93ff662e1f
8 changed files with 679 additions and 1 deletions
|
@ -37,8 +37,9 @@ type Request struct {
|
||||||
IsInternal bool
|
IsInternal bool
|
||||||
Policy *config.Policy
|
Policy *config.Policy
|
||||||
HTTP RequestHTTP
|
HTTP RequestHTTP
|
||||||
Session RequestSession
|
SSH RequestSSH
|
||||||
MCP RequestMCP
|
MCP RequestMCP
|
||||||
|
Session RequestSession
|
||||||
EnvoyRouteChecksum uint64
|
EnvoyRouteChecksum uint64
|
||||||
EnvoyRouteID string
|
EnvoyRouteID string
|
||||||
}
|
}
|
||||||
|
@ -129,6 +130,11 @@ func getClientCertificateInfo(
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RequestSSH struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
PublicKey []byte `json:"publickey"`
|
||||||
|
}
|
||||||
|
|
||||||
// RequestSession is the session field in the request.
|
// RequestSession is the session field in the request.
|
||||||
type RequestSession struct {
|
type RequestSession struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
@ -374,6 +380,7 @@ func (e *Evaluator) evaluatePolicy(ctx context.Context, req *Request) (*PolicyRe
|
||||||
|
|
||||||
return policyEvaluator.Evaluate(ctx, &PolicyRequest{
|
return policyEvaluator.Evaluate(ctx, &PolicyRequest{
|
||||||
HTTP: req.HTTP,
|
HTTP: req.HTTP,
|
||||||
|
SSH: req.SSH,
|
||||||
MCP: req.MCP,
|
MCP: req.MCP,
|
||||||
Session: req.Session,
|
Session: req.Session,
|
||||||
IsValidClientCertificate: isValidClientCertificate,
|
IsValidClientCertificate: isValidClientCertificate,
|
||||||
|
|
|
@ -21,6 +21,7 @@ import (
|
||||||
// PolicyRequest is the input to policy evaluation.
|
// PolicyRequest is the input to policy evaluation.
|
||||||
type PolicyRequest struct {
|
type PolicyRequest struct {
|
||||||
HTTP RequestHTTP `json:"http"`
|
HTTP RequestHTTP `json:"http"`
|
||||||
|
SSH RequestSSH `json:"ssh"`
|
||||||
MCP RequestMCP `json:"mcp"`
|
MCP RequestMCP `json:"mcp"`
|
||||||
Session RequestSession `json:"session"`
|
Session RequestSession `json:"session"`
|
||||||
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
|
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
|
||||||
|
@ -161,6 +162,7 @@ func NewPolicyEvaluator(
|
||||||
rego.Query("result = data.pomerium.policy"),
|
rego.Query("result = data.pomerium.policy"),
|
||||||
rego.EnablePrintStatements(true),
|
rego.EnablePrintStatements(true),
|
||||||
getGoogleCloudServerlessHeadersRegoOption,
|
getGoogleCloudServerlessHeadersRegoOption,
|
||||||
|
criteria.SSHVerifyUserCert,
|
||||||
store.GetDataBrokerRecordOption(),
|
store.GetDataBrokerRecordOption(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -173,6 +175,7 @@ func NewPolicyEvaluator(
|
||||||
rego.Query("result = data.pomerium.policy"),
|
rego.Query("result = data.pomerium.policy"),
|
||||||
rego.EnablePrintStatements(true),
|
rego.EnablePrintStatements(true),
|
||||||
getGoogleCloudServerlessHeadersRegoOption,
|
getGoogleCloudServerlessHeadersRegoOption,
|
||||||
|
criteria.SSHVerifyUserCert,
|
||||||
store.GetDataBrokerRecordOption(),
|
store.GetDataBrokerRecordOption(),
|
||||||
)
|
)
|
||||||
q, err = r.PrepareForEval(ctx)
|
q, err = r.PrepareForEval(ctx)
|
||||||
|
|
|
@ -31,6 +31,7 @@ var testingNow = time.Date(2021, 5, 11, 13, 43, 0, 0, time.Local)
|
||||||
type (
|
type (
|
||||||
Input struct {
|
Input struct {
|
||||||
HTTP InputHTTP `json:"http"`
|
HTTP InputHTTP `json:"http"`
|
||||||
|
SSH InputSSH `json:"ssh"`
|
||||||
Session InputSession `json:"session"`
|
Session InputSession `json:"session"`
|
||||||
MCP InputMCP `json:"mcp"`
|
MCP InputMCP `json:"mcp"`
|
||||||
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
|
IsValidClientCertificate bool `json:"is_valid_client_certificate"`
|
||||||
|
@ -41,6 +42,10 @@ type (
|
||||||
Headers map[string][]string `json:"headers"`
|
Headers map[string][]string `json:"headers"`
|
||||||
ClientCertificate ClientCertificateInfo `json:"client_certificate"`
|
ClientCertificate ClientCertificateInfo `json:"client_certificate"`
|
||||||
}
|
}
|
||||||
|
InputSSH struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
PublicKey []byte `json:"publickey"`
|
||||||
|
}
|
||||||
InputSession struct {
|
InputSession struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
}
|
}
|
||||||
|
@ -150,6 +155,7 @@ func evaluate(t *testing.T,
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}),
|
}),
|
||||||
|
SSHVerifyUserCert,
|
||||||
rego.Input(input),
|
rego.Input(input),
|
||||||
rego.SetRegoVersion(ast.RegoV1),
|
rego.SetRegoVersion(ast.RegoV1),
|
||||||
)
|
)
|
||||||
|
|
|
@ -39,6 +39,10 @@ const (
|
||||||
ReasonPomeriumRoute = "pomerium-route"
|
ReasonPomeriumRoute = "pomerium-route"
|
||||||
ReasonReject = "reject"
|
ReasonReject = "reject"
|
||||||
ReasonRouteNotFound = "route-not-found"
|
ReasonRouteNotFound = "route-not-found"
|
||||||
|
ReasonSSHPublickeyOK = "ssh-publickey-ok"
|
||||||
|
ReasonSSHPublickeyUnauthorized = "ssh-publickey-unauthorized"
|
||||||
|
ReasonSSHUsernameOK = "ssh-username-ok"
|
||||||
|
ReasonSSHUsernameUnauthorized = "ssh-username-unauthorized"
|
||||||
ReasonUserOK = "user-ok"
|
ReasonUserOK = "user-ok"
|
||||||
ReasonUserUnauthenticated = "user-unauthenticated" // user needs to log in
|
ReasonUserUnauthenticated = "user-unauthenticated" // user needs to log in
|
||||||
ReasonUserUnauthorized = "user-unauthorized" // user does not have access
|
ReasonUserUnauthorized = "user-unauthorized" // user does not have access
|
||||||
|
|
196
pkg/policy/criteria/ssh_publickey.go
Normal file
196
pkg/policy/criteria/ssh_publickey.go
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
package criteria
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/open-policy-agent/opa/ast"
|
||||||
|
"github.com/open-policy-agent/opa/rego"
|
||||||
|
"github.com/open-policy-agent/opa/types"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
|
"github.com/pomerium/pomerium/pkg/policy/generator"
|
||||||
|
"github.com/pomerium/pomerium/pkg/policy/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SSHVerifyUserCert = rego.Function3(®o.Function{
|
||||||
|
Name: "ssh_verify_user_cert",
|
||||||
|
Decl: types.NewFunction(
|
||||||
|
types.Args(types.S, types.S, types.NewArray(nil, types.S)),
|
||||||
|
types.B,
|
||||||
|
),
|
||||||
|
}, func(_ rego.BuiltinContext, op1, op2, op3 *ast.Term) (*ast.Term, error) {
|
||||||
|
// The first argument should be an ssh principal name.
|
||||||
|
principal, ok := op1.Value.(ast.String)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected string value, got %T", op1.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second argument should be a base64-encoded ssh certificate in the wire format.
|
||||||
|
s, ok := op2.Value.(ast.String)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected string value, got %T", op2.Value)
|
||||||
|
}
|
||||||
|
keyBytes, err := base64.StdEncoding.DecodeString(string(s))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key, err := ssh.ParsePublicKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cert, ok := key.(*ssh.Certificate)
|
||||||
|
if !ok {
|
||||||
|
return ast.BooleanTerm(false), nil // not an ssh certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
// The third argument should be an array of CAs, also base64-encoded in the wire format.
|
||||||
|
arr, ok := op3.Value.(*ast.Array)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected array value, got: %T", op3.Value)
|
||||||
|
}
|
||||||
|
userCAs := make(map[string]struct{})
|
||||||
|
if arr.Iter(func(t *ast.Term) error {
|
||||||
|
s, ok := t.Value.(ast.String)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("expected string value, got: %T", t.Value)
|
||||||
|
}
|
||||||
|
userCABytes, err := base64.StdEncoding.DecodeString(string(s))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userCAs[string(userCABytes)] = struct{}{}
|
||||||
|
return nil
|
||||||
|
}) != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := ssh.CertChecker{
|
||||||
|
IsUserAuthority: func(auth ssh.PublicKey) bool {
|
||||||
|
_, ok := userCAs[string(auth.Marshal())]
|
||||||
|
return ok
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err = checker.Authenticate(usernameConnMetadata{username: string(principal)}, cert)
|
||||||
|
return ast.BooleanTerm(err == nil), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
type usernameConnMetadata struct {
|
||||||
|
ssh.ConnMetadata
|
||||||
|
username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m usernameConnMetadata) User() string {
|
||||||
|
return m.username
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshPublicKeyCriterion struct {
|
||||||
|
g *Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshPublicKeyCriterion) DataType() generator.CriterionDataType {
|
||||||
|
return generator.CriterionDataTypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshPublicKeyCriterion) Name() string {
|
||||||
|
return "ssh_publickey"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a single ssh key from the authorized_keys format to base64-encoded wire format.
|
||||||
|
// Returns an error if the input is not a [parser.String] or the key could not be parsed.
|
||||||
|
func parseAuthorizedKey(v parser.Value) (*ast.Term, error) {
|
||||||
|
s, ok := v.(parser.String)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected string value, got: %T", v)
|
||||||
|
}
|
||||||
|
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(s)) //nolint:dogsled
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ast.StringTerm(base64.StdEncoding.EncodeToString(key.Marshal())), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a single ssh key or a list of ssh keys from the authorized_keys format to
|
||||||
|
// an array of keys in the base64-encoded wire format.
|
||||||
|
func parseAuthorizedKeys(data parser.Value) (*ast.Term, error) {
|
||||||
|
var arr parser.Array
|
||||||
|
switch v := data.(type) {
|
||||||
|
case parser.String:
|
||||||
|
arr = parser.Array{v}
|
||||||
|
case parser.Array:
|
||||||
|
arr = v
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("expected string or array of strings, got: %T", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each key to ssh wire format for comparison.
|
||||||
|
keys := make([]*ast.Term, len(arr))
|
||||||
|
for i, v := range arr {
|
||||||
|
key, err := parseAuthorizedKey(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys[i] = key
|
||||||
|
}
|
||||||
|
return ast.NewTerm(ast.NewArray(keys...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c sshPublicKeyCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) {
|
||||||
|
keys, err := parseAuthorizedKeys(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := ast.Body{
|
||||||
|
ast.Member.Expr(ast.VarTerm("input.ssh.publickey"), keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := NewCriterionRule(c.g, c.Name(),
|
||||||
|
ReasonSSHPublickeyOK, ReasonSSHPublickeyUnauthorized,
|
||||||
|
body)
|
||||||
|
|
||||||
|
return rule, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SSHPublicKey(generator *Generator) Criterion {
|
||||||
|
return sshPublicKeyCriterion{g: generator}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshUserCACriterion struct {
|
||||||
|
g *Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUserCACriterion) DataType() generator.CriterionDataType {
|
||||||
|
return generator.CriterionDataTypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUserCACriterion) Name() string {
|
||||||
|
return "ssh_ca"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c sshUserCACriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) {
|
||||||
|
keys, err := parseAuthorizedKeys(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := ast.Body{
|
||||||
|
ast.Assign.Expr(ast.VarTerm("userCAs"), keys),
|
||||||
|
ast.MustParseExpr("ssh_verify_user_cert(input.ssh.username, input.ssh.publickey, userCAs)"),
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := NewCriterionRule(c.g, c.Name(),
|
||||||
|
ReasonSSHPublickeyOK, ReasonSSHPublickeyUnauthorized,
|
||||||
|
body)
|
||||||
|
|
||||||
|
return rule, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SSHUserCA(generator *Generator) Criterion {
|
||||||
|
return sshUserCACriterion{g: generator}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(SSHPublicKey)
|
||||||
|
Register(SSHUserCA)
|
||||||
|
}
|
126
pkg/policy/criteria/ssh_publickey_test.go
Normal file
126
pkg/policy/criteria/ssh_publickey_test.go
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
package criteria
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHPublicKey(t *testing.T) {
|
||||||
|
key1, _ := base64.StdEncoding.DecodeString("AAAAC3NzaC1lZDI1NTE5AAAAIIeAQ7VbiYJdPaxsMYTW/q5QpqtyHMtHHRBUJOcQMaLE")
|
||||||
|
|
||||||
|
t.Run("single ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_publickey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIeAQ7VbiYJdPaxsMYTW/q5QpqtyHMtHHRBUJOcQMaLE key-1'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{PublicKey: key1}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHPublickeyOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("single unauthorized", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_publickey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzK2WQZ0NU52W8Bk/8po+4V4oUEdCklf3GtQmiYQB/9 key-2'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{PublicKey: key1}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHPublickeyUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("multiple ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_publickey:
|
||||||
|
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIeAQ7VbiYJdPaxsMYTW/q5QpqtyHMtHHRBUJOcQMaLE key-1'
|
||||||
|
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzK2WQZ0NU52W8Bk/8po+4V4oUEdCklf3GtQmiYQB/9 key-2'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{PublicKey: key1}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHPublickeyOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("multiple unauthorized", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_publickey:
|
||||||
|
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzK2WQZ0NU52W8Bk/8po+4V4oUEdCklf3GtQmiYQB/9 key-2'
|
||||||
|
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMC+uqlilwtfsI5koXGKSBU4vQRZUrse8w4+ea9BsK2v key-3'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{PublicKey: key1}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHPublickeyUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHUserCA(t *testing.T) {
|
||||||
|
user1key, _ := base64.StdEncoding.DecodeString(
|
||||||
|
"AAAAC3NzaC1lZDI1NTE5AAAAICRpwMbj13mXdSMzHJBiMLln0Wx0iCepff5N/W8vi0ta")
|
||||||
|
user1cert, _ := base64.StdEncoding.DecodeString(
|
||||||
|
"AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIKNKPd0B1q9Jx7h/" +
|
||||||
|
"tnFFBtbMybwlSmxGoE308BkWuJIrAAAAICRpwMbj13mXdSMzHJBiMLln0Wx0iCepff5N" +
|
||||||
|
"/W8vi0taAAAAAAAAAAAAAAABAAAABXVzZXIxAAAAAAAAAAAAAAAA//////////8AAAAA" +
|
||||||
|
"AAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1m" +
|
||||||
|
"b3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJt" +
|
||||||
|
"aXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAAAMwAAAAtzc2gtZWQy" +
|
||||||
|
"NTUxOQAAACBIntPLN0pEWLpPTktfLuUeKloK97ysMJRvf0f1FigxJQAAAFMAAAALc3No" +
|
||||||
|
"LWVkMjU1MTkAAABABkLudnDsTEw3aPbgqP5NvuAtZrqzknCadFMjIL+hXoFXFitJq+u9" +
|
||||||
|
"cAl9KIE+2ZQTf2ISbrQDh8Vw+5pivxGZBA==")
|
||||||
|
|
||||||
|
t.Run("single ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_ca: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEie08s3SkRYuk9OS18u5R4qWgr3vKwwlG9/R/UWKDEl ca_user_key'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{
|
||||||
|
PublicKey: user1cert,
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHPublickeyOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("single unauthorized", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_ca: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJjxHDM2CpDj4QfY5NxhDPQaOtAebNXzzFsn61kX0LCF other_key'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{
|
||||||
|
PublicKey: user1cert,
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHPublickeyUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("not a cert", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_ca: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJjxHDM2CpDj4QfY5NxhDPQaOtAebNXzzFsn61kX0LCF other_key'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{
|
||||||
|
PublicKey: user1key,
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHPublickeyUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("multiple ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_ca:
|
||||||
|
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJjxHDM2CpDj4QfY5NxhDPQaOtAebNXzzFsn61kX0LCF other_key'
|
||||||
|
- 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEie08s3SkRYuk9OS18u5R4qWgr3vKwwlG9/R/UWKDEl ca_user_key'
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{
|
||||||
|
PublicKey: user1cert,
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHPublickeyOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
}
|
120
pkg/policy/criteria/ssh_username.go
Normal file
120
pkg/policy/criteria/ssh_username.go
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
package criteria
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/open-policy-agent/opa/ast"
|
||||||
|
|
||||||
|
"github.com/pomerium/pomerium/pkg/policy/generator"
|
||||||
|
"github.com/pomerium/pomerium/pkg/policy/parser"
|
||||||
|
"github.com/pomerium/pomerium/pkg/policy/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sshUsernameCriterion struct {
|
||||||
|
g *Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUsernameCriterion) DataType() generator.CriterionDataType {
|
||||||
|
return CriterionDataTypeStringMatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUsernameCriterion) Name() string {
|
||||||
|
return "ssh_username"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c sshUsernameCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) {
|
||||||
|
var body ast.Body
|
||||||
|
|
||||||
|
err := matchString(&body, ast.VarTerm("input.ssh.username"), data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := NewCriterionRule(c.g, c.Name(),
|
||||||
|
ReasonSSHUsernameOK, ReasonSSHUsernameUnauthorized,
|
||||||
|
body)
|
||||||
|
|
||||||
|
return rule, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SSHUsername(generator *Generator) Criterion {
|
||||||
|
return sshUsernameCriterion{g: generator}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshUsernameMatchesEmailCriterion struct {
|
||||||
|
g *Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUsernameMatchesEmailCriterion) DataType() generator.CriterionDataType {
|
||||||
|
return generator.CriterionDataTypeUnused
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUsernameMatchesEmailCriterion) Name() string {
|
||||||
|
return "ssh_username_matches_email"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c sshUsernameMatchesEmailCriterion) GenerateRule(_ string, _ parser.Value) (*ast.Rule, []*ast.Rule, error) {
|
||||||
|
var body ast.Body
|
||||||
|
body = append(body, emailBody...)
|
||||||
|
body = append(body, ast.MustParseExpr(`username := split(email, "@")[0]`))
|
||||||
|
body = append(body, ast.Equal.Expr(ast.VarTerm("input.ssh.username"), ast.VarTerm("username")))
|
||||||
|
|
||||||
|
rule := NewCriterionSessionRule(c.g, c.Name(),
|
||||||
|
ReasonSSHUsernameOK, ReasonSSHUsernameUnauthorized,
|
||||||
|
body)
|
||||||
|
|
||||||
|
return rule, []*ast.Rule{
|
||||||
|
rules.GetSession(),
|
||||||
|
rules.GetUser(),
|
||||||
|
rules.GetUserEmail(),
|
||||||
|
rules.GetDirectoryUser(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SSHUsernameMatchesEmail(generator *Generator) Criterion {
|
||||||
|
return sshUsernameMatchesEmailCriterion{g: generator}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshUsernameMatchesClaimCriterion struct {
|
||||||
|
g *Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUsernameMatchesClaimCriterion) DataType() generator.CriterionDataType {
|
||||||
|
return generator.CriterionDataTypeUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sshUsernameMatchesClaimCriterion) Name() string {
|
||||||
|
return "ssh_username_matches_claim"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c sshUsernameMatchesClaimCriterion) GenerateRule(_ string, data parser.Value) (*ast.Rule, []*ast.Rule, error) {
|
||||||
|
claimName, ok := data.(parser.String)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("expected string value, got: %T", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body ast.Body
|
||||||
|
body = append(body, ast.Assign.Expr(ast.VarTerm("rule_path"), ast.NewTerm(ast.String(claimName))))
|
||||||
|
body = append(body, claimBody...)
|
||||||
|
body = append(body, ast.Member.Expr(ast.VarTerm("input.ssh.username"), ast.VarTerm("values")))
|
||||||
|
|
||||||
|
rule := NewCriterionSessionRule(c.g, c.Name(),
|
||||||
|
ReasonSSHUsernameOK, ReasonSSHUsernameUnauthorized,
|
||||||
|
body)
|
||||||
|
|
||||||
|
return rule, []*ast.Rule{
|
||||||
|
rules.GetSession(),
|
||||||
|
rules.GetUser(),
|
||||||
|
rules.ObjectGet(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SSHUsernameMatchesClaim(generator *Generator) Criterion {
|
||||||
|
return sshUsernameMatchesClaimCriterion{g: generator}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(SSHUsername)
|
||||||
|
Register(SSHUsernameMatchesEmail)
|
||||||
|
Register(SSHUsernameMatchesClaim)
|
||||||
|
}
|
216
pkg/policy/criteria/ssh_username_test.go
Normal file
216
pkg/policy/criteria/ssh_username_test.go
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
package criteria
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|
||||||
|
"github.com/pomerium/datasource/pkg/directory"
|
||||||
|
"github.com/pomerium/pomerium/pkg/grpc/databroker"
|
||||||
|
"github.com/pomerium/pomerium/pkg/grpc/session"
|
||||||
|
"github.com/pomerium/pomerium/pkg/grpc/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHUsername(t *testing.T) {
|
||||||
|
t.Run("ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username:
|
||||||
|
is: example-username
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{Username: "example-username"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHUsernameOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("shorthand", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username: example-username
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{Username: "example-username"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHUsernameOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("multiple ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username:
|
||||||
|
in: [user1, user2, user3]
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{Username: "user2"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHUsernameOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("multiple unauthorized", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username:
|
||||||
|
in: [user1, user2, user3]
|
||||||
|
`, []*databroker.Record{}, Input{SSH: InputSSH{Username: "user4"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHUsernameUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHUsernameFromEmail(t *testing.T) {
|
||||||
|
policy := `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username_matches_email:`
|
||||||
|
t.Run("matches email from user", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, policy,
|
||||||
|
[]*databroker.Record{
|
||||||
|
makeRecord(&session.Session{
|
||||||
|
Id: "SESSION_ID",
|
||||||
|
UserId: "USER_ID",
|
||||||
|
}),
|
||||||
|
makeRecord(&user.User{
|
||||||
|
Id: "USER_ID",
|
||||||
|
Email: "my-user@example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Input{
|
||||||
|
SSH: InputSSH{Username: "my-user"},
|
||||||
|
Session: InputSession{ID: "SESSION_ID"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHUsernameOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("matches email from directory", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, policy,
|
||||||
|
[]*databroker.Record{
|
||||||
|
makeRecord(&session.Session{
|
||||||
|
Id: "SESSION_ID",
|
||||||
|
UserId: "USER_ID",
|
||||||
|
}),
|
||||||
|
makeStructRecord(directory.UserRecordType, "USER_ID", map[string]any{
|
||||||
|
"email": "my-user@example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Input{
|
||||||
|
SSH: InputSSH{Username: "my-user"},
|
||||||
|
Session: InputSession{ID: "SESSION_ID"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHUsernameOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("does not match", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, policy,
|
||||||
|
[]*databroker.Record{
|
||||||
|
makeRecord(&session.Session{
|
||||||
|
Id: "SESSION_ID",
|
||||||
|
UserId: "USER_ID",
|
||||||
|
}),
|
||||||
|
makeStructRecord(directory.UserRecordType, "USER_ID", map[string]any{
|
||||||
|
"email": "my-user@example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Input{
|
||||||
|
SSH: InputSSH{Username: "not-my-user"},
|
||||||
|
Session: InputSession{ID: "SESSION_ID"},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHUsernameUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHUsernameFromClaim(t *testing.T) {
|
||||||
|
t.Run("session claim ok", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username_matches_claim: username
|
||||||
|
`,
|
||||||
|
[]*databroker.Record{
|
||||||
|
makeRecord(&session.Session{
|
||||||
|
Id: "SESSION_ID",
|
||||||
|
UserId: "USER_ID",
|
||||||
|
Claims: map[string]*structpb.ListValue{
|
||||||
|
"username": {Values: []*structpb.Value{
|
||||||
|
structpb.NewStringValue("root"),
|
||||||
|
structpb.NewStringValue("admin"),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
makeRecord(&user.User{
|
||||||
|
Id: "USER_ID",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Input{
|
||||||
|
SSH: InputSSH{Username: "admin"},
|
||||||
|
Session: InputSession{ID: "SESSION_ID"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{true, A{ReasonSSHUsernameOK}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("session claim unauthorized", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username_matches_claim: username
|
||||||
|
`,
|
||||||
|
[]*databroker.Record{
|
||||||
|
makeRecord(&session.Session{
|
||||||
|
Id: "SESSION_ID",
|
||||||
|
UserId: "USER_ID",
|
||||||
|
Claims: map[string]*structpb.ListValue{
|
||||||
|
"username": {Values: []*structpb.Value{
|
||||||
|
structpb.NewStringValue("root"),
|
||||||
|
structpb.NewStringValue("admin"),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
makeRecord(&user.User{
|
||||||
|
Id: "USER_ID",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Input{
|
||||||
|
SSH: InputSSH{Username: "other-username"},
|
||||||
|
Session: InputSession{ID: "SESSION_ID"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHUsernameUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
t.Run("claim missing", func(t *testing.T) {
|
||||||
|
res, err := evaluate(t, `
|
||||||
|
allow:
|
||||||
|
and:
|
||||||
|
- ssh_username_matches_claim: username
|
||||||
|
`,
|
||||||
|
[]*databroker.Record{
|
||||||
|
makeRecord(&session.Session{
|
||||||
|
Id: "SESSION_ID",
|
||||||
|
UserId: "USER_ID",
|
||||||
|
}),
|
||||||
|
makeRecord(&user.User{
|
||||||
|
Id: "USER_ID",
|
||||||
|
Email: "test@example.com",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Input{
|
||||||
|
SSH: InputSSH{Username: "admin"},
|
||||||
|
Session: InputSession{ID: "SESSION_ID"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, A{false, A{ReasonSSHUsernameUnauthorized}, M{}}, res["allow"])
|
||||||
|
assert.Equal(t, A{false, A{}}, res["deny"])
|
||||||
|
})
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue