feat: acl grants

This commit is contained in:
Johan Siebens
2024-01-24 07:59:16 +01:00
parent 3fccde2932
commit 8f998b05f7
5 changed files with 647 additions and 297 deletions
+24 -225
View File
@@ -8,6 +8,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/schema"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
@@ -35,6 +36,7 @@ type ACLPolicy struct {
AutoApprovers *AutoApprovers `json:"autoApprovers,omitempty"`
SSHRules []SSHRule `json:"ssh,omitempty"`
NodeAttrs []NodeAttr `json:"nodeAttrs,omitempty"`
Grants []Grant `json:"grants,omitempty"`
}
type ACL struct {
@@ -57,6 +59,13 @@ type NodeAttr struct {
Attr []string `json:"attr"`
}
type Grant struct {
Src []string `json:"src"`
Dst []string `json:"dst"`
IP []tailcfg.ProtoPortRange `json:"ip"`
App tailcfg.PeerCapMap `json:"app"`
}
func DefaultACLPolicy() ACLPolicy {
return ACLPolicy{
ACLs: []ACL{
@@ -109,7 +118,7 @@ func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string
return false
}
autoApprovedIPs := []netip.Prefix{}
var autoApprovedIPs []netip.Prefix
for route, autoApprovers := range a.AutoApprovers.Routes {
candidate, err := netip.ParsePrefix(route)
if err != nil {
@@ -121,7 +130,7 @@ func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string
}
}
result := []netip.Prefix{}
var result []netip.Prefix
for _, c := range routableIPs {
if c.Bits() == 0 && matches(a.AutoApprovers.ExitNode) {
result = append(result, c)
@@ -134,15 +143,6 @@ func (a ACLPolicy) FindAutoApprovedIPs(routableIPs []netip.Prefix, tags []string
return result
}
func (a ACLPolicy) IsTagOwner(tags []string, p *User) bool {
for _, t := range tags {
if a.isTagOwner(t, p) {
return true
}
}
return false
}
func (a ACLPolicy) CheckTagOwners(tags []string, p *User) error {
var result *multierror.Error
for _, t := range tags {
@@ -158,53 +158,18 @@ func (a ACLPolicy) isTagOwner(tag string, p *User) bool {
return true
}
if tagOwners, ok := a.TagOwners[tag]; ok {
return a.validateTagOwners(tagOwners, p)
}
return false
}
func (a ACLPolicy) validateTagOwners(tagOwners []string, p *User) bool {
for _, alias := range tagOwners {
if strings.HasPrefix(alias, "group:") {
if group, ok := a.Groups[alias]; ok {
for _, groupMember := range group {
if groupMember == p.Name {
return true
}
for _, alias := range tagOwners {
if strings.HasPrefix(alias, "group:") {
if group, ok := a.Groups[alias]; ok {
return slices.Contains(group, p.Name)
}
}
} else {
if alias == p.Name {
return true
}
}
}
return false
}
func (a ACLPolicy) IsValidPeer(src *Machine, dest *Machine) bool {
if !src.HasTags() && !dest.HasTags() && dest.HasUser(src.User.Name) {
return true
}
for _, acl := range a.ACLs {
selfDestPorts, allDestPorts := a.expandMachineToDstPorts(dest, acl.Dst)
if len(selfDestPorts) != 0 {
for _, alias := range acl.Src {
if len(a.expandMachineAlias(src, alias, true, &dest.User)) != 0 {
return true
}
}
}
if len(allDestPorts) != 0 {
for _, alias := range acl.Src {
if len(a.expandMachineAlias(src, alias, true, nil)) != 0 {
} else {
if alias == p.Name {
return true
}
}
}
}
return false
}
@@ -252,182 +217,12 @@ func (a ACLPolicy) NodeCapabilities(m *Machine) []tailcfg.NodeCapability {
return caps
}
func (a ACLPolicy) BuildFilterRules(srcs []Machine, dst *Machine) []tailcfg.FilterRule {
var rules = make([]tailcfg.FilterRule, 0)
appendRules := func(rules []tailcfg.FilterRule, proto string, src []string, destPorts []tailcfg.NetPortRange, u *User) []tailcfg.FilterRule {
var allSrcIPsSet = &StringSet{}
for _, alias := range src {
for _, src := range srcs {
srcIPs := a.expandMachineAlias(&src, alias, true, u)
allSrcIPsSet.Add(srcIPs...)
}
}
allSrcIPs := allSrcIPsSet.Items()
if len(allSrcIPs) == 0 {
return rules
}
return append(rules, tailcfg.FilterRule{
SrcIPs: allSrcIPs,
DstPorts: destPorts,
IPProto: parseProtocol(proto),
})
}
for _, acl := range a.ACLs {
selfDestPorts, allDestPorts := a.expandMachineToDstPorts(dst, acl.Dst)
if len(selfDestPorts) != 0 {
rules = appendRules(rules, acl.Proto, acl.Src, selfDestPorts, &dst.User)
}
if len(allDestPorts) != 0 {
rules = appendRules(rules, acl.Proto, acl.Src, allDestPorts, nil)
}
}
return rules
}
func (a ACLPolicy) expandMachineToDstPorts(m *Machine, ports []string) ([]tailcfg.NetPortRange, []tailcfg.NetPortRange) {
selfDestRanges := []tailcfg.NetPortRange{}
otherDestRanges := []tailcfg.NetPortRange{}
for _, d := range ports {
self, ranges := a.expandMachineDestToNetPortRanges(m, d)
if self {
selfDestRanges = append(selfDestRanges, ranges...)
} else {
otherDestRanges = append(otherDestRanges, ranges...)
}
}
return selfDestRanges, otherDestRanges
}
func (a ACLPolicy) expandMachineDestToNetPortRanges(m *Machine, dest string) (bool, []tailcfg.NetPortRange) {
lastInd := strings.LastIndex(dest, ":")
if lastInd == -1 {
return false, nil
}
alias := dest[:lastInd]
portRange := dest[lastInd+1:]
ports, err := a.expandValuePortToPortRange(portRange)
if err != nil {
return false, nil
}
ips := a.expandMachineAlias(m, alias, false, nil)
if len(ips) == 0 {
return false, nil
}
var netPortRanges []tailcfg.NetPortRange
for _, d := range ips {
for _, p := range ports {
pr := tailcfg.NetPortRange{
IP: d,
Ports: p,
}
netPortRanges = append(netPortRanges, pr)
}
}
return alias == AutoGroupSelf, netPortRanges
}
func (a ACLPolicy) expandMachineAlias(m *Machine, alias string, src bool, u *User) []string {
if u != nil && m.HasTags() {
return []string{}
}
if u != nil && !m.HasUser(u.Name) {
return []string{}
}
if alias == "*" && u != nil {
return m.IPs()
}
if alias == "*" {
return []string{"*"}
}
if alias == AutoGroupMember || alias == AutoGroupMembers || alias == AutoGroupSelf {
if !m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupTagged {
if m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupInternet && m.IsExitNode() {
return autogroupInternetRanges()
}
if strings.Contains(alias, "@") && !m.HasTags() && m.HasUser(alias) {
return m.IPs()
}
if strings.HasPrefix(alias, "group:") && !m.HasTags() {
users, ok := a.Groups[alias]
if !ok {
return []string{}
}
for _, u := range users {
if m.HasUser(u) {
return m.IPs()
}
}
return []string{}
}
if strings.HasPrefix(alias, "tag:") && m.HasTag(alias) {
return m.IPs()
}
if h, ok := a.Hosts[alias]; ok {
alias = h
}
if src {
ip, err := netip.ParseAddr(alias)
if err == nil && m.HasIP(ip) {
return []string{ip.String()}
}
} else {
ip, err := netip.ParseAddr(alias)
if err == nil && m.IsAllowedIP(ip) {
return []string{ip.String()}
}
prefix, err := netip.ParsePrefix(alias)
if err == nil && m.IsAllowedIPPrefix(prefix) {
return []string{prefix.String()}
}
}
return []string{}
}
func (a ACLPolicy) expandValuePortToPortRange(s string) ([]tailcfg.PortRange, error) {
func (a ACLPolicy) parsePortRanges(s string) ([]tailcfg.PortRange, error) {
if s == "*" {
return []tailcfg.PortRange{{First: 0, Last: 65535}}, nil
return []tailcfg.PortRange{tailcfg.PortRangeAny}, nil
}
ports := []tailcfg.PortRange{}
var ports []tailcfg.PortRange
for _, p := range strings.Split(s, ",") {
rang := strings.Split(p, "-")
if len(rang) == 1 {
@@ -582,6 +377,10 @@ func (s *StringSet) Items() []string {
return items
}
func (s *StringSet) Empty() bool {
return len(s.items) == 0
}
func autogroupInternetRanges() []string {
return []string{
"0.0.0.0/5",
+346
View File
@@ -0,0 +1,346 @@
package domain
import (
"net/netip"
"strings"
"tailscale.com/tailcfg"
)
func (a ACLPolicy) IsValidPeer(src *Machine, dest *Machine) bool {
if !src.HasTags() && !dest.HasTags() && dest.HasUser(src.User.Name) {
return true
}
for _, acl := range a.ACLs {
selfDestPorts, allDestPorts := a.translateDestinationAliasesToMachineNetPortRanges(acl.Dst, dest)
if len(selfDestPorts) != 0 {
for _, alias := range acl.Src {
if len(a.translateSourceAliasToMachineIPs(alias, src, &dest.User)) != 0 {
return true
}
}
}
if len(allDestPorts) != 0 {
for _, alias := range acl.Src {
if len(a.translateSourceAliasToMachineIPs(alias, src, nil)) != 0 {
return true
}
}
}
}
for _, grant := range a.Grants {
selfIps, otherIps := a.translateDestinationAliasesToMachineIPs(grant.Dst, dest)
if len(selfIps) != 0 {
for _, alias := range grant.Src {
if len(a.translateSourceAliasToMachineIPs(alias, src, &dest.User)) != 0 {
return true
}
}
}
if len(otherIps) != 0 {
for _, alias := range grant.Src {
if len(a.translateSourceAliasToMachineIPs(alias, src, nil)) != 0 {
return true
}
}
}
}
return false
}
func (a ACLPolicy) BuildFilterRules(peers []Machine, dst *Machine) []tailcfg.FilterRule {
var rules = make([]tailcfg.FilterRule, 0)
matchSourceAndAppendRule := func(rules []tailcfg.FilterRule, aliases []string, preparedRules []tailcfg.FilterRule, u *User) []tailcfg.FilterRule {
if len(preparedRules) == 0 {
return rules
}
var allSrcIPsSet = &StringSet{}
for _, alias := range aliases {
for _, peer := range peers {
allSrcIPsSet.Add(a.translateSourceAliasToMachineIPs(alias, &peer, u)...)
}
}
if allSrcIPsSet.Empty() {
return rules
}
allSrcIPs := allSrcIPsSet.Items()
if len(allSrcIPs) == 0 {
return rules
}
for _, pr := range preparedRules {
rules = append(rules, tailcfg.FilterRule{
SrcIPs: allSrcIPs,
DstPorts: pr.DstPorts,
IPProto: pr.IPProto,
CapGrant: pr.CapGrant,
})
}
return rules
}
for _, acl := range a.ACLs {
self, other := a.prepareFilterRulesFromACL(dst, acl)
rules = matchSourceAndAppendRule(rules, acl.Src, self, &dst.User)
rules = matchSourceAndAppendRule(rules, acl.Src, other, nil)
}
for _, acl := range a.Grants {
self, other := a.prepareFilterRulesFromGrant(dst, acl)
rules = matchSourceAndAppendRule(rules, acl.Src, self, &dst.User)
rules = matchSourceAndAppendRule(rules, acl.Src, other, nil)
}
return rules
}
func (a ACLPolicy) prepareFilterRulesFromACL(candidate *Machine, acl ACL) ([]tailcfg.FilterRule, []tailcfg.FilterRule) {
proto := parseProtocol(acl.Proto)
selfDstPorts, otherDstPorts := a.translateDestinationAliasesToMachineNetPortRanges(acl.Dst, candidate)
var selfFilterRules []tailcfg.FilterRule
var otherFilterRules []tailcfg.FilterRule
if len(selfDstPorts) != 0 {
selfFilterRules = append(selfFilterRules, tailcfg.FilterRule{IPProto: proto, DstPorts: selfDstPorts})
}
if len(otherDstPorts) != 0 {
otherFilterRules = append(otherFilterRules, tailcfg.FilterRule{IPProto: proto, DstPorts: otherDstPorts})
}
return selfFilterRules, otherFilterRules
}
func (a ACLPolicy) prepareFilterRulesFromGrant(candidate *Machine, grant Grant) ([]tailcfg.FilterRule, []tailcfg.FilterRule) {
selfIPs, otherIPs := a.translateDestinationAliasesToMachineIPs(grant.Dst, candidate)
var selfFilterRules []tailcfg.FilterRule
var otherFilterRules []tailcfg.FilterRule
for _, ip := range grant.IP {
if len(selfIPs) != 0 {
ranges := make([]tailcfg.NetPortRange, len(selfIPs))
for i, s := range selfIPs {
ranges[i] = tailcfg.NetPortRange{IP: s, Ports: ip.Ports}
}
rule := tailcfg.FilterRule{DstPorts: ranges}
if ip.Proto != 0 {
rule.IPProto = []int{ip.Proto}
}
selfFilterRules = append(selfFilterRules, rule)
}
if len(otherIPs) != 0 {
ranges := make([]tailcfg.NetPortRange, len(otherIPs))
for i, s := range otherIPs {
ranges[i] = tailcfg.NetPortRange{IP: s, Ports: ip.Ports}
}
rule := tailcfg.FilterRule{DstPorts: ranges}
if ip.Proto != 0 {
rule.IPProto = []int{ip.Proto}
}
otherFilterRules = append(otherFilterRules, rule)
}
}
if len(grant.App) != 0 {
selfPrefixes, otherPrefixes := appGrantDstIpsToPrefixes(candidate, selfIPs, otherIPs)
if len(selfPrefixes) != 0 {
rule := tailcfg.FilterRule{CapGrant: []tailcfg.CapGrant{{Dsts: selfPrefixes, CapMap: grant.App}}}
selfFilterRules = append(selfFilterRules, rule)
}
if len(otherPrefixes) != 0 {
rule := tailcfg.FilterRule{CapGrant: []tailcfg.CapGrant{{Dsts: otherPrefixes, CapMap: grant.App}}}
otherFilterRules = append(otherFilterRules, rule)
}
}
return selfFilterRules, otherFilterRules
}
func appGrantDstIpsToPrefixes(m *Machine, self []string, other []string) ([]netip.Prefix, []netip.Prefix) {
translate := func(ips []string) []netip.Prefix {
var prefixes []netip.Prefix
for _, ip := range ips {
if ip == "*" {
prefixes = append(prefixes, netip.PrefixFrom(*m.IPv4.Addr, 32))
prefixes = append(prefixes, netip.PrefixFrom(*m.IPv6.Addr, 128))
} else {
addr, err := netip.ParseAddr(ip)
if err == nil && m.HasIP(addr) {
if addr.Is4() {
prefixes = append(prefixes, netip.PrefixFrom(addr, 32))
} else {
prefixes = append(prefixes, netip.PrefixFrom(addr, 128))
}
}
}
}
return prefixes
}
return translate(self), translate(other)
}
func (a ACLPolicy) translateDestinationAliasesToMachineIPs(aliases []string, m *Machine) ([]string, []string) {
var self = &StringSet{}
var other = &StringSet{}
for _, alias := range aliases {
ips := a.translateDestinationAliasToMachineIPs(alias, m)
if alias == AutoGroupSelf {
self.Add(ips...)
} else {
other.Add(ips...)
}
}
return self.Items(), other.Items()
}
func (a ACLPolicy) translateDestinationAliasesToMachineNetPortRanges(aliases []string, m *Machine) ([]tailcfg.NetPortRange, []tailcfg.NetPortRange) {
var self []tailcfg.NetPortRange
var other []tailcfg.NetPortRange
for _, alias := range aliases {
ranges := a.translationDestinationAliasToMachineNetPortRanges(alias, m)
if strings.HasPrefix(alias, AutoGroupSelf) {
self = append(self, ranges...)
} else {
other = append(other, ranges...)
}
}
return self, other
}
func (a ACLPolicy) translationDestinationAliasToMachineNetPortRanges(alias string, m *Machine) []tailcfg.NetPortRange {
lastInd := strings.LastIndex(alias, ":")
if lastInd == -1 {
return nil
}
ports := alias[lastInd+1:]
alias = alias[:lastInd]
portRanges, err := a.parsePortRanges(ports)
if err != nil {
return nil
}
ips := a.translateDestinationAliasToMachineIPs(alias, m)
if len(ips) == 0 {
return nil
}
var netPortRanges []tailcfg.NetPortRange
for _, d := range ips {
for _, p := range portRanges {
pr := tailcfg.NetPortRange{
IP: d,
Ports: p,
}
netPortRanges = append(netPortRanges, pr)
}
}
return netPortRanges
}
func (a ACLPolicy) translateDestinationAliasToMachineIPs(alias string, m *Machine) []string {
f := func(alias string, m *Machine) []string {
ip, err := netip.ParseAddr(alias)
if err == nil && m.IsAllowedIP(ip) {
return []string{ip.String()}
}
prefix, err := netip.ParsePrefix(alias)
if err == nil && m.IsAllowedIPPrefix(prefix) {
return []string{prefix.String()}
}
return make([]string, 0)
}
return a.translateAliasToMachineIPs(alias, m, nil, f)
}
func (a ACLPolicy) translateSourceAliasToMachineIPs(alias string, m *Machine, u *User) []string {
f := func(alias string, m *Machine) []string {
ip, err := netip.ParseAddr(alias)
if err == nil && m.HasIP(ip) {
return []string{ip.String()}
}
return make([]string, 0)
}
return a.translateAliasToMachineIPs(alias, m, u, f)
}
func (a ACLPolicy) translateAliasToMachineIPs(alias string, m *Machine, u *User, f func(string, *Machine) []string) []string {
if u != nil && m.HasTags() {
return []string{}
}
if u != nil && !m.HasUser(u.Name) {
return []string{}
}
if alias == "*" && u != nil {
return m.IPs()
}
if alias == "*" {
return []string{"*"}
}
if alias == AutoGroupMember || alias == AutoGroupMembers || alias == AutoGroupSelf {
if !m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupTagged {
if m.HasTags() {
return m.IPs()
} else {
return []string{}
}
}
if alias == AutoGroupInternet && m.IsExitNode() {
return autogroupInternetRanges()
}
if strings.Contains(alias, "@") && !m.HasTags() && m.HasUser(alias) {
return m.IPs()
}
if strings.HasPrefix(alias, "group:") && !m.HasTags() && a.isGroupMember(alias, m) {
return m.IPs()
}
if strings.HasPrefix(alias, "tag:") && m.HasTag(alias) {
return m.IPs()
}
if h, ok := a.Hosts[alias]; ok {
alias = h
}
return f(alias, m)
}
+88 -2
View File
@@ -1,8 +1,10 @@
package domain
import (
"encoding/json"
"github.com/jsiebens/ionscale/internal/addr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"net/netip"
"sort"
"tailscale.com/tailcfg"
@@ -841,7 +843,7 @@ func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
name: "no match",
userName: "nick@example.com",
routableIPs: []netip.Prefix{route1, route2, route3},
expected: []netip.Prefix{},
expected: nil,
},
{
name: "exit",
@@ -853,7 +855,7 @@ func TestACLPolicy_FindAutoApprovedIPs(t *testing.T) {
name: "exit no match",
userName: "john@example.com",
routableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")},
expected: []netip.Prefix{},
expected: nil,
},
}
@@ -900,3 +902,87 @@ func TestACLPolicy_BuildFilterRulesWithAdvertisedRoutes(t *testing.T) {
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWildcardGrants(t *testing.T) {
ranges, err := tailcfg.ParseProtoPortRanges([]string{"*"})
require.NoError(t, err)
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
policy := ACLPolicy{
Grants: []Grant{
{
Src: []string{"*"},
Dst: []string{"*"},
IP: ranges,
},
},
}
dst := createMachine("john@example.com")
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
DstPorts: []tailcfg.NetPortRange{
{
IP: "*",
Ports: tailcfg.PortRange{
First: 0,
Last: 65535,
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}
func TestACLPolicy_BuildFilterRulesWithAppGrants(t *testing.T) {
p1 := createMachine("john@example.com")
p2 := createMachine("jane@example.com")
dst := createMachine("john@example.com")
mycap := map[string]interface{}{
"channel": "alpha",
"ids": []string{"1", "2", "3"},
}
marshal, _ := json.Marshal(mycap)
policy := ACLPolicy{
Grants: []Grant{
{
Src: []string{"*"},
Dst: []string{"*"},
App: map[tailcfg.PeerCapability][]tailcfg.RawMessage{
tailcfg.PeerCapability("localtest.me/cap/test"): {tailcfg.RawMessage(marshal)},
},
},
},
}
actualRules := policy.BuildFilterRules([]Machine{*p1, *p2}, dst)
expectedRules := []tailcfg.FilterRule{
{
SrcIPs: []string{"*"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{
netip.PrefixFrom(*dst.IPv4.Addr, 32),
netip.PrefixFrom(*dst.IPv6.Addr, 128),
},
CapMap: map[tailcfg.PeerCapability][]tailcfg.RawMessage{
tailcfg.PeerCapability("localtest.me/cap/test"): {tailcfg.RawMessage(marshal)},
},
},
},
},
}
assert.Equal(t, expectedRules, actualRules)
}