diff --git a/docs/references/access-control.md b/docs/references/access-control.md index 99b7f4a..5673516 100644 --- a/docs/references/access-control.md +++ b/docs/references/access-control.md @@ -1 +1,55 @@ # Access Control + +Access control is configured by ACL rules of different types. A request/action +passes the access control check if it matches any of the applicable ACL rules. + +A typical ACL would looks like this: +```toml +access = [ + { githubUser="username" }, + { ipRange="127.0.0.1/32" } +] +``` + +## Authentication + +A GitHub user may authenticate through the `pageship login` command. Currently, +it will connect to the Pageship server through SSH protocol, and verify user's +identity through GitHub user's public key. + +GitHub Actions jobs would be authenticate automatically when `pageship` command +detected running in CI environment. It authenticates through GitHub Actions +OIDC token. + +## ACL Types + +### GitHub user + +```toml +{ githubUser = "username" } +``` + +Actions/requests from the specified GitHub user is allowed. + +### GitHub Actions repository +```toml +{ gitHubRepositoryActions = "oursky/pageship" } +{ gitHubRepositoryActions = "oursky/*" } +{ gitHubRepositoryActions = "*" } +``` + +Actions/requests from the specified GitHub Action jobs is allowed. Wildcard can +be specified for all repository of a user/organization, or any repository. + + +### IP Range + +```toml +{ ipRange = "127.0.0.1/32" } +{ ipRange = "192.168.0.0/16" } +{ ipRange = "0.0.0.0/0" } +{ ipRange = "::1/128" } +``` + +Actions/requests from the specified IP range (CIDR) is allowed. +IPv4 is mapped to IPv6 before matching. diff --git a/go.mod b/go.mod index 0bf81d9..522adce 100644 --- a/go.mod +++ b/go.mod @@ -69,6 +69,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.18.3 // indirect github.com/aws/smithy-go v1.13.5 // indirect github.com/chzyer/readline v1.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -108,11 +109,14 @@ require ( github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sirupsen/logrus v1.9.0 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect github.com/subosito/gotenv v1.4.2 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect diff --git a/go.sum b/go.sum index 46a76f9..3b4afb6 100644 --- a/go.sum +++ b/go.sum @@ -1965,6 +1965,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v0.0.0-20180303142811-b89eecf5ca5d/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -1979,6 +1980,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= diff --git a/internal/models/credential_id.go b/internal/models/credential_id.go index 70ceb55..04f78b0 100644 --- a/internal/models/credential_id.go +++ b/internal/models/credential_id.go @@ -1,7 +1,6 @@ package models import ( - "encoding/hex" "net/netip" "strings" @@ -76,100 +75,3 @@ func (c CredentialID) Matches(r *config.ACLSubjectRule) bool { return false } } - -type CredentialIndexKey string - -func MakeCredentialIDIndexKeys(id CredentialID) []CredentialIndexKey { - kind, data, found := strings.Cut(string(id), ":") - if !found { - kind = "" - } - - switch CredentialIDKind(kind) { - case CredentialIDKindUserID, - CredentialIDKindGitHubUser: - return []CredentialIndexKey{CredentialIndexKey(id)} - - case CredentialIDGitHubRepositoryActions: - return []CredentialIndexKey{ - CredentialIndexKey(id), - CredentialIndexKey(CredentialIDGitHubRepositoryActions + ":*"), - } - - case CredentialIDIP: - addr, err := netip.ParseAddr(data) - if err != nil { - return nil - } - - addr = netip.AddrFrom16(addr.As16()) - return makeIPKeys(addr, addr.BitLen()) - - default: - return nil - } -} - -func CollectCredentialIDIndexKeys(ids []CredentialID) []CredentialIndexKey { - var keys []CredentialIndexKey - for _, id := range ids { - keys = append(keys, MakeCredentialIDIndexKeys(id)...) - } - return keys -} - -func MakeCredentialRuleIndexKeys(r *config.ACLSubjectRule) []CredentialIndexKey { - switch { - case r.PageshipUser != "": - return MakeCredentialIDIndexKeys(CredentialUserID(r.PageshipUser)) - case r.GitHubUser != "": - return MakeCredentialIDIndexKeys(CredentialGitHubUser(r.GitHubUser)) - case r.GitHubRepositoryActions != "": - return MakeCredentialIDIndexKeys(CredentialGitHubRepositoryActions(r.GitHubRepositoryActions)) - case r.IpRange != "": - cidr, err := netip.ParsePrefix(r.IpRange) - if err != nil { - return nil - } - - bits := cidr.Bits() - addr := netip.AddrFrom16(cidr.Addr().As16()) - if cidr.Addr().Is4() { - // Map ipv4 CIDR to ipv6. - bits += 96 - } - - keys := makeIPKeys(addr, bits) - return []CredentialIndexKey{keys[len(keys)-1]} // Use longest key (i.e. last key) - } - return nil -} - -func makeIPKeys(addr netip.Addr, bits int) []CredentialIndexKey { - addrBytes := addr.As16() - bytes := addrBytes[:(bits/8)&(^1)] - - keys := []CredentialIndexKey{"ip:"} - var s strings.Builder - zeroes := 0 - for b := 0; b < len(bytes); b += 2 { - if bytes[b] == 0 && bytes[b+1] == 0 { - zeroes++ - continue - } - - if zeroes != 0 { - s.WriteByte(':') - zeroes = 0 - } - if b != 0 { - s.WriteByte(':') - } - s.WriteString(hex.EncodeToString(bytes[b : b+2])) - keys = append(keys, CredentialIndexKey("ip:"+s.String())) - } - if zeroes > 0 { - keys = append(keys, CredentialIndexKey("ip:"+s.String()+":")) - } - return keys -} diff --git a/internal/models/credential_index.go b/internal/models/credential_index.go new file mode 100644 index 0000000..f727803 --- /dev/null +++ b/internal/models/credential_index.go @@ -0,0 +1,109 @@ +package models + +import ( + "encoding/hex" + "net/netip" + "strings" + + "github.com/oursky/pageship/internal/config" +) + +type CredentialIndexKey string + +func MakeCredentialIDIndexKeys(id CredentialID) []CredentialIndexKey { + kind, data, found := strings.Cut(string(id), ":") + if !found { + kind = "" + } + + switch CredentialIDKind(kind) { + case CredentialIDKindUserID, + CredentialIDKindGitHubUser: + return []CredentialIndexKey{CredentialIndexKey(id)} + + case CredentialIDGitHubRepositoryActions: + owner, repo, ok := strings.Cut(data, "/") + if !ok { + return nil + } + prefix := string(CredentialIDGitHubRepositoryActions) + ":" + return []CredentialIndexKey{ + CredentialIndexKey(prefix + "*"), + CredentialIndexKey(prefix + owner), + CredentialIndexKey(prefix + owner + "/" + repo), + } + + case CredentialIDIP: + addr, err := netip.ParseAddr(data) + if err != nil { + return nil + } + + addr = netip.AddrFrom16(addr.As16()) + return makeIPKeys(addr, addr.BitLen()) + + default: + return nil + } +} + +func CollectCredentialIDIndexKeys(ids []CredentialID) []CredentialIndexKey { + var keys []CredentialIndexKey + for _, id := range ids { + keys = append(keys, MakeCredentialIDIndexKeys(id)...) + } + return keys +} + +func MakeCredentialRuleIndexKeys(r *config.ACLSubjectRule) []CredentialIndexKey { + switch { + case r.PageshipUser != "": + return MakeCredentialIDIndexKeys(CredentialUserID(r.PageshipUser)) + case r.GitHubUser != "": + return MakeCredentialIDIndexKeys(CredentialGitHubUser(r.GitHubUser)) + case r.GitHubRepositoryActions != "": + prefix := string(CredentialIDGitHubRepositoryActions) + ":" + if r.GitHubRepositoryActions == "*" { + return []CredentialIndexKey{CredentialIndexKey(prefix + "*")} + } + + owner, repo, ok := strings.Cut(r.GitHubRepositoryActions, "/") + if !ok { + return nil + } + if repo == "*" { + return []CredentialIndexKey{CredentialIndexKey(prefix + owner)} + } else { + return []CredentialIndexKey{CredentialIndexKey(prefix + owner + "/" + repo)} + } + case r.IpRange != "": + cidr, err := netip.ParsePrefix(r.IpRange) + if err != nil { + return nil + } + + bits := cidr.Bits() + addr := netip.AddrFrom16(cidr.Addr().As16()) + if cidr.Addr().Is4() { + // Map ipv4 CIDR to ipv6. + bits += 96 + } + + keys := makeIPKeys(addr, bits) + return []CredentialIndexKey{keys[len(keys)-1]} // Use longest key (i.e. last key) + } + return nil +} + +func makeIPKeys(addr netip.Addr, bits int) []CredentialIndexKey { + addrBytes := addr.As16() + bytes := addrBytes[:(bits/8)&(^1)] + + keys := []CredentialIndexKey{"ip:"} + var s strings.Builder + for b := 0; b < len(bytes); b += 2 { + s.WriteString(hex.EncodeToString(bytes[b : b+2])) + keys = append(keys, CredentialIndexKey("ip:"+s.String())) + } + return keys +} diff --git a/internal/models/credential_index_test.go b/internal/models/credential_index_test.go new file mode 100644 index 0000000..a950c86 --- /dev/null +++ b/internal/models/credential_index_test.go @@ -0,0 +1,161 @@ +package models_test + +import ( + "net/netip" + "testing" + + "github.com/oursky/pageship/internal/config" + "github.com/oursky/pageship/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +func matchRule(rule *config.ACLSubjectRule, id models.CredentialID) bool { + index := make(map[string]struct{}) + for _, key := range models.MakeCredentialRuleIndexKeys(rule) { + index[string(key)] = struct{}{} + } + + for _, key := range models.MakeCredentialIDIndexKeys(id) { + if _, ok := index[string(key)]; ok { + return true + } + } + return false +} + +type GitHubActionsCredentialsTestSuite struct { + suite.Suite +} + +func TestGitHubActionsCredentials(t *testing.T) { + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "oursky/pageship"}, + models.CredentialGitHubRepositoryActions("oursky/pageship"), + )) + assert.False(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "oursky/pageship"}, + models.CredentialGitHubRepositoryActions("oursky/other"), + )) + assert.False(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "oursky/pageship"}, + models.CredentialGitHubRepositoryActions("other/pageship"), + )) + + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "oursky/*"}, + models.CredentialGitHubRepositoryActions("oursky/pageship"), + )) + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "oursky/*"}, + models.CredentialGitHubRepositoryActions("oursky/other"), + )) + assert.False(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "oursky/*"}, + models.CredentialGitHubRepositoryActions("other/oursky"), + )) + + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "*"}, + models.CredentialGitHubRepositoryActions("oursky/pageship"), + )) + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "*"}, + models.CredentialGitHubRepositoryActions("oursky/other"), + )) + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubRepositoryActions: "*"}, + models.CredentialGitHubRepositoryActions("other/oursky"), + )) +} + +func TestGitHubUserCredentials(t *testing.T) { + assert.True(t, matchRule( + &config.ACLSubjectRule{GitHubUser: "oursky"}, + models.CredentialGitHubUser("oursky"), + )) + assert.False(t, matchRule( + &config.ACLSubjectRule{GitHubUser: "oursky"}, + models.CredentialGitHubUser("other"), + )) +} + +func TestIPCredentials(t *testing.T) { + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: "127.0.0.1/32"}, + models.CredentialIP("127.0.0.1"), + )) + assert.False(t, matchRule( + &config.ACLSubjectRule{IpRange: "127.0.0.1/32"}, + models.CredentialIP("127.0.0.2"), + )) + + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: "127.0.0.1/16"}, + models.CredentialIP("127.0.0.1"), + )) + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: "127.0.0.1/16"}, + models.CredentialIP("127.0.100.2"), + )) + + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: "2001:db8::/48"}, + models.CredentialIP("2001:db8::1"), + )) + assert.False(t, matchRule( + &config.ACLSubjectRule{IpRange: "2001:db8::/48"}, + models.CredentialIP("::1"), + )) + + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: "::/0"}, + models.CredentialIP("192.168.0.1"), + )) + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: "::ffff:192.168.0.1/120"}, + models.CredentialIP("192.168.0.255"), + )) +} + +func FuzzIPCredentials(f *testing.F) { + add := func(cidr string, ip string) { + prefix := netip.MustParsePrefix(cidr) + f.Add(prefix.Addr().AsSlice(), prefix.Bits(), netip.MustParseAddr(ip).AsSlice()) + } + add("0.0.0.0/0", "10.0.0.1") + add("192.168.0.1/24", "8.8.8.8") + add("127.0.0.1/32", "127.0.0.2") + add("::0/0", "192.168.0.1") + add("2001:db8::/48", "2001:db8::1") + add("::1/128", "::1") + + f.Fuzz(func(t *testing.T, ipPrefix []byte, bits int, ip []byte) { + ruleAddr, ok := netip.AddrFromSlice(ipPrefix) + if !ok { + return + } + ruleCIDR, err := ruleAddr.Prefix(bits) + if err != nil { + return + } + credIP, ok := netip.AddrFromSlice(ip) + if !ok { + return + } + + if !ruleCIDR.Contains(credIP) { + return + } + + rule := &config.ACLSubjectRule{IpRange: ruleCIDR.String()} + cred := models.CredentialIP(credIP.String()) + + assert.True(t, matchRule( + &config.ACLSubjectRule{IpRange: ruleCIDR.String()}, + models.CredentialIP(credIP.String()), + ), "range=%s;ip=%s;range_keys=%+v;cred_keys=%+v", + ruleCIDR.String(), credIP.String(), + models.MakeCredentialRuleIndexKeys(rule), models.MakeCredentialIDIndexKeys(cred)) + }) +}