Skip to content

Commit

Permalink
Add IP matching to ACL
Browse files Browse the repository at this point in the history
  • Loading branch information
rojer committed Nov 30, 2015
1 parent 6eaa12a commit 77670ba
Show file tree
Hide file tree
Showing 6 changed files with 195 additions and 8 deletions.
87 changes: 80 additions & 7 deletions auth_server/authz/acl.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package authz

import (
"encoding/json"
"fmt"
"net"
"path"
"regexp"
"strings"
Expand All @@ -21,14 +23,69 @@ type MatchConditions struct {
Account *string `yaml:"account,omitempty" json:"account,omitempty"`
Type *string `yaml:"type,omitempty" json:"type,omitempty"`
Name *string `yaml:"name,omitempty" json:"name,omitempty"`
IP *string `yaml:"ip,omitempty" json:"ip,omitempty"`
}

type aclAuthorizer struct {
acl ACL
}

func validatePattern(p string) error {
if len(p) > 2 && p[0] == '/' && p[len(p)-1] == '/' {
_, err := regexp.Compile(p[1 : len(p)-1])
if err != nil {
return fmt.Errorf("invalid regex pattern: %s", err)
}
}
return nil
}

func parseIPPattern(ipp string) (*net.IPNet, error) {
ipnet := net.IPNet{}
ipnet.IP = net.ParseIP(ipp)
if ipnet.IP != nil {
if ipnet.IP.To4() != nil {
ipnet.Mask = net.CIDRMask(32, 32)
} else {
ipnet.Mask = net.CIDRMask(128, 128)
}
return &ipnet, nil
} else {
_, ipnet, err := net.ParseCIDR(ipp)
if err != nil {
return nil, err
}
return ipnet, nil
}
}

func validateMatchConditions(mc *MatchConditions) error {
for _, p := range []*string{mc.Account, mc.Type, mc.Name} {
if p == nil {
continue
}
err := validatePattern(*p)
if err != nil {
return fmt.Errorf("invalid pattern %q: %s", *p, err)
}
}
if mc.IP != nil {
_, err := parseIPPattern(*mc.IP)
if err != nil {
return fmt.Errorf("invalid IP pattern: %s", err)
}
}
return nil
}

// NewACLAuthorizer Creates a new static authorizer with ACL that have been read from the config file
func NewACLAuthorizer(acl ACL) (Authorizer, error) {
for i, e := range acl {
err := validateMatchConditions(e.Match)
if err != nil {
return nil, fmt.Errorf("entry %d, invalid match conditions: %s", i, err)
}
}
glog.V(1).Infof("Created ACL Authorizer with %d entries", len(acl))
return &aclAuthorizer{acl: acl}, nil
}
Expand Down Expand Up @@ -78,17 +135,33 @@ func matchString(pp *string, s string, vars []string) bool {
return err == nil && matched
}

func (e *ACLEntry) Matches(ai *AuthRequestInfo) bool {
func matchIP(ipp *string, ip net.IP) bool {
if ipp == nil {
return true
}
if ip == nil {
return false
}
ipnet, err := parseIPPattern(*ipp)
if err != nil { // Can't happen, it supposed to have been validated
glog.Fatalf("Invalid IP pattern: %s", *ipp)
}
return ipnet.Contains(ip)
}

func (mc *MatchConditions) Matches(ai *AuthRequestInfo) bool {
vars := []string{
"${account}", regexp.QuoteMeta(ai.Account),
"${type}", regexp.QuoteMeta(ai.Type),
"${name}", regexp.QuoteMeta(ai.Name),
"${service}", regexp.QuoteMeta(ai.Service),
}
if matchString(e.Match.Account, ai.Account, vars) &&
matchString(e.Match.Type, ai.Type, vars) &&
matchString(e.Match.Name, ai.Name, vars) {
return true
}
return false
return matchString(mc.Account, ai.Account, vars) &&
matchString(mc.Type, ai.Type, vars) &&
matchString(mc.Name, ai.Name, vars) &&
matchIP(mc.IP, ai.IP)
}

func (e *ACLEntry) Matches(ai *AuthRequestInfo) bool {
return e.Match.Matches(ai)
}
86 changes: 86 additions & 0 deletions auth_server/authz/acl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package authz

import (
"net"
"testing"
)

func sp(s string) *string {
return &s
}

func TestValidation(t *testing.T) {
cases := []struct {
mc MatchConditions
ok bool
}{
// Valid stuff
{MatchConditions{}, true},
{MatchConditions{Account: sp("foo")}, true},
{MatchConditions{Account: sp("foo?*")}, true},
{MatchConditions{Account: sp("/foo.*/")}, true},
{MatchConditions{Type: sp("foo")}, true},
{MatchConditions{Type: sp("foo?*")}, true},
{MatchConditions{Type: sp("/foo.*/")}, true},
{MatchConditions{Name: sp("foo")}, true},
{MatchConditions{Name: sp("foo?*")}, true},
{MatchConditions{Name: sp("/foo.*/")}, true},
{MatchConditions{IP: sp("192.168.0.1")}, true},
{MatchConditions{IP: sp("192.168.0.0/16")}, true},
{MatchConditions{IP: sp("2001:db8::1")}, true},
{MatchConditions{IP: sp("2001:db8::/48")}, true},
// Invalid stuff
{MatchConditions{Account: sp("/foo?*/")}, false},
{MatchConditions{Type: sp("/foo?*/")}, false},
{MatchConditions{Name: sp("/foo?*/")}, false},
{MatchConditions{IP: sp("192.168.0.1/100")}, false},
{MatchConditions{IP: sp("192.168.0.*")}, false},
{MatchConditions{IP: sp("foo")}, false},
{MatchConditions{IP: sp("2001:db8::/222")}, false},
}
for i, c := range cases {
result := validateMatchConditions(&c.mc)
if c.ok && result != nil {
t.Errorf("%d: %q: expected to pass, got %s", i, c.mc, result)
} else if !c.ok && result == nil {
t.Errorf("%d: %q: expected to fail, but it passed", i, c.mc)
}
}
}

func TestMatching(t *testing.T) {
ai1 := AuthRequestInfo{Account: "foo", Type: "bar", Name: "baz"}
cases := []struct {
mc MatchConditions
ai AuthRequestInfo
matches bool
}{
{MatchConditions{}, ai1, true},
{MatchConditions{Account: sp("foo")}, ai1, true},
{MatchConditions{Account: sp("foo"), Type: sp("bar")}, ai1, true},
{MatchConditions{Account: sp("foo"), Type: sp("baz")}, ai1, false},
{MatchConditions{Account: sp("fo?"), Type: sp("b*"), Name: sp("/z$/")}, ai1, true},
{MatchConditions{Account: sp("fo?"), Type: sp("b*"), Name: sp("/^z/")}, ai1, false},
{MatchConditions{Name: sp("${account}")}, AuthRequestInfo{Account: "foo", Name: "foo"}, true}, // Var subst
{MatchConditions{Name: sp("/${account}_.*/")}, AuthRequestInfo{Account: "foo", Name: "foo_x"}, true},
{MatchConditions{Name: sp("/${account}_.*/")}, AuthRequestInfo{Account: ".*", Name: "foo_x"}, false}, // Quoting
// IP matching
{MatchConditions{IP: sp("127.0.0.1")}, AuthRequestInfo{IP: nil}, false},
{MatchConditions{IP: sp("127.0.0.1")}, AuthRequestInfo{IP: net.IPv4(127, 0, 0, 1)}, true},
{MatchConditions{IP: sp("127.0.0.1")}, AuthRequestInfo{IP: net.IPv4(127, 0, 0, 2)}, false},
{MatchConditions{IP: sp("127.0.0.2")}, AuthRequestInfo{IP: net.IPv4(127, 0, 0, 1)}, false},
{MatchConditions{IP: sp("127.0.0.0/8")}, AuthRequestInfo{IP: net.IPv4(127, 0, 0, 1)}, true},
{MatchConditions{IP: sp("127.0.0.0/8")}, AuthRequestInfo{IP: net.IPv4(127, 0, 0, 2)}, true},
{MatchConditions{IP: sp("2001:db8::1")}, AuthRequestInfo{IP: nil}, false},
{MatchConditions{IP: sp("2001:db8::1")}, AuthRequestInfo{IP: net.ParseIP("2001:db8::1")}, true},
{MatchConditions{IP: sp("2001:db8::1")}, AuthRequestInfo{IP: net.ParseIP("2001:db8::2")}, false},
{MatchConditions{IP: sp("2001:db8::2")}, AuthRequestInfo{IP: net.ParseIP("2001:db8::1")}, false},
{MatchConditions{IP: sp("2001:db8::/48")}, AuthRequestInfo{IP: net.ParseIP("2001:db8::1")}, true},
{MatchConditions{IP: sp("2001:db8::/48")}, AuthRequestInfo{IP: net.ParseIP("2001:db8::2")}, true},
}
for i, c := range cases {
if result := c.mc.Matches(&c.ai); result != c.matches {
t.Errorf("%d: %#v vs %#v: expected %t, got %t", i, c.mc, c.ai, c.matches, result)
}
}
}
2 changes: 2 additions & 0 deletions auth_server/authz/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package authz
import (
"errors"
"fmt"
"net"
"strings"
)

Expand Down Expand Up @@ -36,6 +37,7 @@ type AuthRequestInfo struct {
Type string
Name string
Service string
IP net.IP
Actions []string
}

Expand Down
2 changes: 1 addition & 1 deletion auth_server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func ServeOnce(c *server.Config, cf string, hd *httpdown.HTTP) (*server.AuthServ
if err != nil {
glog.Exitf("Failed to set up listener: %s", err)
}
glog.Infof("Serving")
glog.Infof("Serving on %s", c.Server.ListenAddress)
return as, s
}

Expand Down
18 changes: 18 additions & 0 deletions auth_server/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"encoding/json"
"fmt"
"math/rand"
"net"
"net/http"
"sort"
"strings"
Expand Down Expand Up @@ -90,8 +91,25 @@ func NewAuthServer(c *Config) (*AuthServer, error) {
return as, nil
}

func parseRemoteAddr(ra string) net.IP {
colonIndex := strings.LastIndex(ra, ":")
if colonIndex == -1 {
return nil
}
ra = ra[:colonIndex]
if ra[0] == '[' && ra[len(ra)-1] == ']' { // IPv6
ra = ra[1 : len(ra)-1]
}
res := net.ParseIP(ra)
return res
}

func (as *AuthServer) ParseRequest(req *http.Request) (*AuthRequest, error) {
ar := &AuthRequest{RemoteAddr: req.RemoteAddr}
ar.ai.IP = parseRemoteAddr(req.RemoteAddr)
if ar.ai.IP == nil {
return nil, fmt.Errorf("unable to parse remote addr %s", req.RemoteAddr)
}
user, password, haveBasicAuth := req.BasicAuth()
if haveBasicAuth {
ar.User = user
Expand Down
8 changes: 8 additions & 0 deletions examples/reference.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ ldap_auth:
# so "foobar", "f??bar", "f*bar" are all valid. For even more flexibility
# match patterns can be evaluated as regexes by enclosing them in //, e.g.
# "/(foo|bar)/".
# * IP match can be single IP address or a subnet in the "prefix/mask" notation.
# * ACL is evaluated in the order it is defined until a match is found.
# * Empty match clause matches anything, it only makes sense at the end of the
# list and can be used as a way of specifying default permissions.
Expand All @@ -99,6 +100,13 @@ ldap_auth:
# * ${type} - the type of the entity, normally "repository".
# * ${name} - the name of the repository (i.e. image), e.g. centos.
acl:
# Any manipulations from localhost are allowed.
- match: {ip: "127.0.0.0/8"}
actions: ["*"]
comment: "Allow everything from localhost (IPv4)"
- match: {ip: "::1"}
actions: ["*"]
comment: "Allow everything from localhost (IPv6)"
- match: {account: "admin"}
actions: ["*"]
comment: "Admin has full access to everything."
Expand Down

0 comments on commit 77670ba

Please sign in to comment.