diff options
| -rw-r--r-- | README.md | 25 | ||||
| -rw-r--r-- | cmd/main.go | 20 | ||||
| -rw-r--r-- | config.ini | 7 | ||||
| -rw-r--r-- | gitserver.yaml | 15 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 8 | ||||
| -rw-r--r-- | internal/admin/model.go | 194 | ||||
| -rw-r--r-- | internal/admin/model_test.go | 69 | ||||
| -rw-r--r-- | internal/admin/service.go | 37 | ||||
| -rw-r--r-- | internal/authz/middleware.go | 8 | ||||
| -rw-r--r-- | policy.csv | 19 |
11 files changed, 382 insertions, 23 deletions
@@ -26,3 +26,28 @@ Tools like gitea are great, but they require things like a DBMS. This increases * create repo * update gitweb config per repo * migration from gitolite + +## Admin + + +### Admin events + +triggered by handler? +triggered by hooks? + +* [ ] new repo +* [ ] admin push + +### Git Mgmt + +* [ ] git web export +* [ ] web description + +### Policy Mgmt + +* [x] policy generate +* [x] upsert policies + +## Hooks + +what's the env for this? diff --git a/cmd/main.go b/cmd/main.go index 962eaa1..94c5d13 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,8 +6,7 @@ import ( "log" "net/http" - "github.com/casbin/casbin/v2" - + "git.ofmax.li/go-git-server/internal/admin" "git.ofmax.li/go-git-server/internal/authz" "git.ofmax.li/go-git-server/internal/git" ) @@ -16,16 +15,15 @@ var ( reposDir = flag.String("r", "./repos", "Directory containing git repositories") backendCommand = flag.String("c", "git http-backend", "CGI binary to execute") addr = flag.String("l", ":8080", "Address/port to listen on") + modelPath = flag.String("m", "./auth_model.ini", "Authentication model") + policyPath = flag.String("p", "./policy.csv", "auth policy") + serverConfig = flag.String("s", "./gitserver.yaml", "serverconfig path") newToken = flag.Bool("t", false, "make a new token") + updatePolicies = flag.Bool("u", false, "update policies") ) func main() { - // TODO this should be configurable - enf, err := casbin.NewEnforcer("./auth_model.ini", "./policy.csv") - if err != nil { - log.Fatalf("Couldn't load the enforcer encountered the following error: %s", err) - } - flag.Parse() + admin.ReconcileGitConf("./config.ini") if *newToken { token, hash, err := authz.GenerateNewToken() if err != nil { @@ -34,8 +32,10 @@ func main() { fmt.Printf("token: %s\nhash: %s\n", token, hash) return } + adminSvc := admin.NewService(*modelPath, *policyPath, *serverConfig) + adminSvc.InitServer() tokens := authz.NewTokenMap() - err = tokens.LoadTokensFromFile("./tokens.csv") + err := tokens.LoadTokensFromFile("./tokens.csv") if err != nil { log.Fatal(err) } @@ -43,6 +43,6 @@ func main() { // TODO we don't want to use a global // de-reference args router.Handle("/", git.GitHttpBackendHandler(*reposDir, *backendCommand)) - mux := authz.Authentication(tokens, authz.Authorization(enf, router)) + mux := authz.Authentication(tokens, authz.Authorization(adminSvc, router)) log.Fatal(http.ListenAndServe(":8080", mux)) } diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..330c0d7 --- /dev/null +++ b/config.ini @@ -0,0 +1,7 @@ +[core] +repositoryformatversion = 0 +filemode = true +bare = true + +[gitweb] +app_mode = production diff --git a/gitserver.yaml b/gitserver.yaml new file mode 100644 index 0000000..1ffc59d --- /dev/null +++ b/gitserver.yaml @@ -0,0 +1,15 @@ +--- +name: "go-git-server" +version: "v1alpha1" +basepath: ./repos +repos: + - name: testmerepo + public: true + git_web_config: + owner: grumps + description: |- + A wrapper to git http-backend providing authentcation and authorization + Inspired by gitolite + permissions: + - role: maintainers + mode: 1 @@ -8,4 +8,7 @@ require ( github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect github.com/casbin/casbin v1.9.1 // indirect github.com/casbin/casbin/v2 v2.56.0 // indirect + github.com/subpop/go-ini v0.1.4 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) @@ -5,6 +5,9 @@ github.com/casbin/casbin v1.9.1/go.mod h1:z8uPsfBJGUsnkagrt3G8QvjgTKFMBJ32UP8HpZ github.com/casbin/casbin/v2 v2.56.0 h1:4qM+hDfj+i9M6lBbguafWKE/8tJA+9vRY5+l0ZB5WTo= github.com/casbin/casbin/v2 v2.56.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/subpop/go-ini v0.1.4 h1:+OVDOLyoQQCkk36v48bDcBscw2GCn9cesQc6PFLYdg8= +github.com/subpop/go-ini v0.1.4/go.mod h1:q0fhdlbGE3dI9dHPgUntXh1ggwR+SpfXL/kogOefaBE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20221012134737-56aed061732a h1:NmSIgad6KjE6VvHciPZuNRTKxGhlPfD6OA87W/PLkqg= golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= @@ -13,3 +16,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/admin/model.go b/internal/admin/model.go new file mode 100644 index 0000000..59f2498 --- /dev/null +++ b/internal/admin/model.go @@ -0,0 +1,194 @@ +// Package admin manage repos +package admin + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + + "gopkg.in/yaml.v2" + + "gopkg.in/ini.v1" +) + +const ( + // Read mode operations for repo + Read Action = 0 + // Write mode operations for repo + Write = 1 + // Admin mode operations for repo + Admin = 2 + // GitExportMagic magic file name for daemon export + GitExportMagic = "git-daemon-export-ok" +) + +// Action composite type for modes +type Action int + +// GitWeb git web configuration +type GitWeb struct { + Owner string `json:"owner"` + Description string `json:"description"` + Category string `json:"category"` + URL string `json:"url"` +} + +// Permission authorization controls +type Permission struct { + Role string `json:"role"` + Mode Action `json:"mode"` +} + +// GitRepo git repository +type GitRepo struct { + // Public "git-daemon-export-ok" magic file for git-http-backend + Public bool `json:"public"` + // Name game of repository + Name string `json:"name"` + // Web config settings + GitWebConfig *GitWeb `json:"git_web_config"` + // Permissions for authorization + Permissions []*Permission `json:"permissions"` +} + +// ServerRepos repos that are part of this server instance +type ServerRepos struct { + Name string `json:"name"` + Version string `json:"version"` + Repos []*GitRepo `json:"repos"` + BasePath string `json:"basepath"` +} + +func loadServerConfig(configPath string) *ServerRepos { + file, err := os.Open(configPath) + if err != nil { + log.Fatalf("Failed to open gitserver config %s", err) + } + defer file.Close() + b, err := io.ReadAll(file) + if err != nil { + log.Fatalf("Failed to read the gitserver config %s", err) + } + config := &ServerRepos{} + err = yaml.Unmarshal(b, &config) + if err != nil { + log.Fatalf("Failed to parse gitserver config %s", err) + } + return config +} + +// ServerPolicies generate casbin policies +func (s *ServerRepos) ServerPolicies() [][]string { + policies := [][]string{} + for _, repo := range s.Repos { + policies = append(policies, repo.CasbinPolicies()...) + } + return policies +} + +// ConfigureRepos run reconciler for all repos +func (s *ServerRepos) ConfigureRepos() { + for _, repo := range s.Repos { + repo.ReconcileRepo(s.BasePath) + } +} + +func readOnlyPaths(role, repoName string) [][]string { + return [][]string{ + []string{role, fmt.Sprintf("/%s/info/refs", repoName), "GET"}, + []string{role, fmt.Sprintf("/%s/git-upload-pack", repoName), "POST"}, + } +} +func writePaths(role, repoName string) [][]string { + return [][]string{[]string{role, fmt.Sprintf("/%s/git-recieve-pack", repoName), "POST"}} +} + +// Policy generate policy for repo base on mode +func (p *Permission) Policy(repoName string) [][]string { + policies := [][]string{} + // if read mode or greater e.g. write mode + if p.Mode >= Read { + policies = append(policies, readOnlyPaths(p.Role, repoName)...) + } + // if write mode + if p.Mode >= Write { + policies = append(policies, writePaths(p.Role, repoName)...) + } + return policies +} + +// CasbinPolicies generate all policies +func (r *GitRepo) CasbinPolicies() [][]string { + policies := [][]string{} + for _, perm := range r.Permissions { + policies = append(policies, perm.Policy(r.Name)...) + } + return policies +} + +// ReconcileRepo update repo export settings, update web config +func (r *GitRepo) ReconcileRepo(basePath string) { + // if exist -> continue + repoBase := filepath.Join(basePath, r.Name, ".git") + _, err := os.Stat(repoBase) + if errors.Is(err, fs.ErrNotExist) { + // if no exist -> init bare + } + r.ConfigureExport(repoBase) + + if r.GitWebConfig == nil { + r.GitWebConfig = &GitWeb{} + } + r.GitWebConfig.ReconcileGitConf(repoBase) +} + +// ConfigureExport setup repo for sharing and configure web settings +func (r *GitRepo) ConfigureExport(basePath string) { + // do nothing on public repos + repoBase := fmt.Sprintf("%s.git", r.Name) + okExport := filepath.Join(repoBase, r.Name, GitExportMagic) + _, err := os.Stat(okExport) + // Not public but the export setting is setting exists + if !r.Public && err == nil { + // delete file + os.Remove(okExport) + return + } + // Not public and the file doesn't exist + if !r.Public && errors.Is(err, fs.ErrNotExist) { + return + } + // + f, err := os.Create(okExport) + defer f.Close() + if err != nil { + log.Fatalf("git-daemon-export-ok coudln't be created %s", err) + return + } +} + +// ReconcileGitConf reconcile gitweb configuration section of gitconfig +func (r *GitWeb) ReconcileGitConf(repoBase string) { + confPath := filepath.Join(repoBase, "conf") + cfg, err := ini.Load(confPath) + if err != nil { + log.Fatal("Coudln't read gitconfig") + } + // check if empty, delete + if (GitWeb{} == *r) { + if cfg.HasSection("gitweb") { + cfg.DeleteSection("gitweb") + } + return + } + section := cfg.Section("gitweb") + section.Key("description").SetValue(r.Description) + section.Key("owner").SetValue(r.Owner) + section.Key("url").SetValue(r.URL) + section.Key("category").SetValue(r.Category) + cfg.SaveTo(confPath) +} diff --git a/internal/admin/model_test.go b/internal/admin/model_test.go new file mode 100644 index 0000000..b13bfad --- /dev/null +++ b/internal/admin/model_test.go @@ -0,0 +1,69 @@ +package admin + +import ( + "fmt" + "testing" +) + +func TestCasbinPolicies(t *testing.T) { + roleName := "mr:role" + repoName := "myrepo" + pRO := &Permission{ + Role: roleName, + Mode: 0, + } + pW := &Permission{ + Role: "my:admin", + Mode: 1, + } + + t.Run("test read only policies", func(t *testing.T) { + roPolicies := readOnlyPaths(roleName, repoName) + for _, v := range roPolicies { + if v[0] != roleName { + t.Fatalf("Missing rolename in policy %s %s", v[0], v[1]) + } + } + if roPolicies[0][1] != fmt.Sprintf("/%s/info/refs", repoName) { + t.Fatal("missing info/refs policy") + } + if roPolicies[1][1] != fmt.Sprintf("/%s/git-upload-pack", repoName) { + t.Fatal("missing git-upload-pack policy") + } + if roPolicies[0][2] != "GET" { + t.Fatal("missing info/refs policy") + } + if roPolicies[1][2] != "POST" { + t.Fatal("missing git-upload-pack policy") + } + }) + t.Run("testing write policies", func(t *testing.T) { + wPolicies := writePaths(roleName, repoName) + if wPolicies[0][0] != roleName { + t.Fatal("Role name doesn't match") + } + if wPolicies[0][1] != fmt.Sprintf("/%s/git-recieve-pack", repoName) { + t.Fatal("Policy missing write path") + } + }) + + t.Run("testing mode build policies", func(t *testing.T) { + rOPolicy := pRO.Policy(roleName) + wPolicy := pW.Policy(roleName) + if len(rOPolicy) != 2 { + t.Fatal("Didn't provide correct number of read policies") + } + if len(wPolicy) != 3 { + t.Fatal("Didn't provide correct number of write policies") + } + }) + t.Run("testing repo level policies", func(t *testing.T) { + repo := &GitRepo{ + Permissions: []*Permission{pRO, pW}, + } + policies := repo.CasbinPolicies() + if len(policies) != 5 { + t.Fatal("Repo was expected to have 5 policies generated") + } + }) +} diff --git a/internal/admin/service.go b/internal/admin/service.go new file mode 100644 index 0000000..c09ad66 --- /dev/null +++ b/internal/admin/service.go @@ -0,0 +1,37 @@ +package admin + +import ( + "log" + + casbin "github.com/casbin/casbin/v2" +) + +// Servicer container for dependencies and functions +type Servicer struct { + *casbin.SyncedEnforcer + Conf *ServerRepos +} + +// InitServer initialize a git server and configure +func (s *Servicer) InitServer() { + policies := s.Conf.ServerPolicies() + s.AddPolicies(policies) + s.SavePolicy() + s.LoadPolicy() + s.Conf.ServerPolicies() +} + +// NewService create a new admin service, load config, and generate policies +func NewService(modelPath, policyPath, serverConfigPath string) *Servicer { + enf, err := casbin.NewSyncedEnforcer(modelPath, policyPath) + if err != nil { + log.Fatalf("Couldn't load the enforcer encountered the following error: %s", err) + } + conf := loadServerConfig(serverConfigPath) + svc := &Servicer{ + enf, + conf, + } + svc.InitServer() + return svc +} diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go index 1dd06e3..41672f2 100644 --- a/internal/authz/middleware.go +++ b/internal/authz/middleware.go @@ -7,11 +7,11 @@ import ( "log" "net/http" - "github.com/casbin/casbin/v2" + "git.ofmax.li/go-git-server/internal/admin" "golang.org/x/crypto/bcrypt" ) -func Authentication(authMap TokenMap, next http.HandlerFunc) http.Handler { +func Authentication(authMap TokenMap, next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { u, p, ok := req.BasicAuth() if !ok { @@ -40,13 +40,13 @@ func Authentication(authMap TokenMap, next http.HandlerFunc) http.Handler { } // Authorization middleware to enforce authoirzation of all requests. -func Authorization(enf *casbin.Enforcer, next http.HandlerFunc) http.Handler { +func Authorization(adminSvc *admin.Service, next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() urn := ctx.Value("urn") repo := req.URL.Path action := req.Method - ok, err := enf.Enforce(urn, repo, action) + ok, err := adminSvc.Enforce(urn, repo, action) if err != nil { log.Printf("error running enforce %s", err) http.Error(rw, "Bad Request", http.StatusBadRequest) @@ -1,9 +1,10 @@ -p,role:admin,config,admin -p,role:maintainers,/cch/info/refs,GET -p,role:maintainers,/cch/git-upload-pack,POST -p,role:maintainers,*,write - -g,role:admin,role:maintainers - -g,admin,role:admin -g,uid:grumps,role:maintainers +p, role:admin, config, admin +p, role:maintainers, /go-git-server/git-upload-pack, POST +p, role:maintainers, /go-git-server/info/refs, GET +p, maintainers, /go-git-server/info/refs, GET +p, maintainers, /go-git-server/git-upload-pack, POST +p, maintainers, /go-git-server/git-recieve-pack, POST +g, role:admin, role:maintainers +g, admin, role:admin +g, uid:grumps, role:maintainers +g, aid:argo, role:bots
\ No newline at end of file |