this repo has no description

implement access levels

- new table tracks access levels between each DID and a repo
- creators of a repo are owners by default
- newly added members are writers by default
- introduces AccessLevel middleware to mask routes based on level

Akshay fe5a22dd e35be1d9

Changed files
+210 -3
db
routes
+80
db/access.go
··· 1 + package db 2 + 3 + import ( 4 + "log" 5 + "strings" 6 + ) 7 + 8 + // forms a poset 9 + type Level int 10 + 11 + const ( 12 + Reader Level = iota 13 + Writer 14 + Owner 15 + ) 16 + 17 + var ( 18 + levelMap = map[string]Level{ 19 + "writer": Writer, 20 + "owner": Owner, 21 + } 22 + ) 23 + 24 + func ParseLevel(str string) (Level, bool) { 25 + c, ok := levelMap[strings.ToLower(str)] 26 + return c, ok 27 + } 28 + 29 + func (l Level) String() string { 30 + switch l { 31 + case Owner: 32 + return "OWNER" 33 + case Writer: 34 + return "WRITER" 35 + case Reader: 36 + return "READER" 37 + default: 38 + return "READER" 39 + } 40 + } 41 + 42 + func (d *DB) SetAccessLevel(userDid string, repoDid string, repoName string, level Level) error { 43 + _, err := d.db.Exec( 44 + `insert 45 + into access_levels (repo_id, did, access) 46 + values ((select id from repos where did = $1 and name = $2), $3, $4) 47 + on conflict (repo_id, did) 48 + do update set access = $4;`, 49 + repoDid, repoName, userDid, level.String()) 50 + return err 51 + } 52 + 53 + func (d *DB) SetOwner(userDid string, repoDid string, repoName string) error { 54 + return d.SetAccessLevel(userDid, repoDid, repoName, Owner) 55 + } 56 + 57 + func (d *DB) SetWriter(userDid string, repoDid string, repoName string) error { 58 + return d.SetAccessLevel(userDid, repoDid, repoName, Writer) 59 + } 60 + 61 + func (d *DB) GetAccessLevel(userDid string, repoDid string, repoName string) (Level, error) { 62 + row := d.db.QueryRow(` 63 + select access_levels.access 64 + from repos 65 + join access_levels 66 + on repos.id = access_levels.repo_id 67 + where access_levels.did = ? and repos.did = ? and repos.name = ? 68 + `, userDid, repoDid, repoName) 69 + 70 + var levelStr string 71 + err := row.Scan(&levelStr) 72 + if err != nil { 73 + log.Println(err) 74 + return Reader, err 75 + } else { 76 + level, _ := ParseLevel(levelStr) 77 + return level, nil 78 + } 79 + 80 + }
+10 -1
db/init.go
··· 32 32 description text not null, 33 33 created timestamp default current_timestamp, 34 34 unique(did, name) 35 - ) 35 + ); 36 + create table if not exists access_levels ( 37 + id integer primary key autoincrement, 38 + repo_id integer not null, 39 + did text not null, 40 + access text not null check (access in ('OWNER', 'WRITER')), 41 + created timestamp default current_timestamp, 42 + unique(repo_id, did), 43 + foreign key (repo_id) references repos(id) on delete cascade 44 + ); 36 45 `) 37 46 if err != nil { 38 47 return nil, err
+35
routes/access.go
··· 1 + package routes 2 + 3 + import ( 4 + "github.com/go-chi/chi/v5" 5 + "github.com/icyphox/bild/db" 6 + auth "github.com/icyphox/bild/routes/auth" 7 + "log" 8 + "net/http" 9 + ) 10 + 11 + func (h *Handle) AccessLevel(level db.Level) func(http.Handler) http.Handler { 12 + return func(next http.Handler) http.Handler { 13 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 + repoOwnerHandle := chi.URLParam(r, "user") 15 + repoOwner, err := auth.ResolveIdent(r.Context(), repoOwnerHandle) 16 + if err != nil { 17 + log.Println("invalid did") 18 + http.Error(w, "invalid did", http.StatusNotFound) 19 + return 20 + } 21 + repoName := chi.URLParam(r, "name") 22 + session, _ := h.s.Get(r, "bild-session") 23 + did := session.Values["did"].(string) 24 + 25 + userLevel, err := h.db.GetAccessLevel(did, repoOwner.DID.String(), repoName) 26 + if err != nil || userLevel < level { 27 + log.Printf("unauthorized access: %s accessing %s/%s\n", did, repoOwnerHandle, repoName) 28 + log.Printf("wanted level: %s, got level %s", level.String(), userLevel.String()) 29 + http.Error(w, "Forbidden", http.StatusUnauthorized) 30 + return 31 + } 32 + next.ServeHTTP(w, r) 33 + }) 34 + } 35 + }
+10 -2
routes/handler.go
··· 11 11 "github.com/gorilla/sessions" 12 12 "github.com/icyphox/bild/auth" 13 13 "github.com/icyphox/bild/config" 14 - "github.com/icyphox/bild/db" 14 + database "github.com/icyphox/bild/db" 15 15 "github.com/icyphox/bild/routes/middleware" 16 16 "github.com/icyphox/bild/routes/tmpl" 17 17 ) ··· 38 38 } 39 39 } 40 40 41 - func Setup(c *config.Config, db *db.DB) (http.Handler, error) { 41 + func Setup(c *config.Config, db *database.DB) (http.Handler, error) { 42 42 r := chi.NewRouter() 43 43 s := sessions.NewCookieStore([]byte("TODO_CHANGE_ME")) 44 44 t, err := tmpl.Load(c.Dirs.Templates) ··· 99 99 r.Get("/archive/{file}", h.Archive) 100 100 r.Get("/commit/{ref}", h.Diff) 101 101 r.Get("/refs/", h.Refs) 102 + 103 + r.Group(func(r chi.Router) { 104 + // settings page is only accessible to owners 105 + r.Use(h.AccessLevel(database.Owner)) 106 + r.Route("/settings", func(r chi.Router) { 107 + r.Put("/collaborators", h.Collaborators) 108 + }) 109 + }) 102 110 103 111 // Catch-all routes 104 112 r.Get("/*", h.Multiplex)
+75
routes/routes.go
··· 431 431 } 432 432 } 433 433 434 + // func (h *Handle) addUserToRepo(w http.ResponseWriter, r *http.Request) { 435 + // repoOwnerHandle := chi.URLParam(r, "user") 436 + // repoOwner, err := auth.ResolveIdent(r.Context(), repoOwnerHandle) 437 + // if err != nil { 438 + // log.Println("invalid did") 439 + // http.Error(w, "invalid did", http.StatusNotFound) 440 + // return 441 + // } 442 + // repoName := chi.URLParam(r, "name") 443 + // session, _ := h.s.Get(r, "bild-session") 444 + // did := session.Values["did"].(string) 445 + // 446 + // err := h.db.SetWriter() 447 + // } 448 + func (h *Handle) Collaborators(w http.ResponseWriter, r *http.Request) { 449 + // put repo resolution in middleware 450 + repoOwnerHandle := chi.URLParam(r, "user") 451 + repoOwner, err := auth.ResolveIdent(r.Context(), repoOwnerHandle) 452 + if err != nil { 453 + log.Println("invalid did") 454 + http.Error(w, "invalid did", http.StatusNotFound) 455 + return 456 + } 457 + repoName := chi.URLParam(r, "name") 458 + 459 + switch r.Method { 460 + case http.MethodGet: 461 + // TODO fetch a list of collaborators and their access rights 462 + http.Error(w, "unimpl 1", http.StatusInternalServerError) 463 + return 464 + case http.MethodPut: 465 + newUser := r.FormValue("newUser") 466 + if newUser == "" { 467 + // TODO: htmx this 468 + http.Error(w, "unimpl 2", http.StatusInternalServerError) 469 + return 470 + } 471 + newUserIdentity, err := auth.ResolveIdent(r.Context(), newUser) 472 + if err != nil { 473 + // TODO: htmx this 474 + log.Println("invalid handle") 475 + http.Error(w, "unimpl 3", http.StatusBadRequest) 476 + return 477 + } 478 + err = h.db.SetWriter(newUserIdentity.DID.String(), repoOwner.DID.String(), repoName) 479 + if err != nil { 480 + // TODO: htmx this 481 + log.Println("failed to add collaborator") 482 + http.Error(w, "unimpl 4", http.StatusInternalServerError) 483 + return 484 + } 485 + 486 + log.Println("success") 487 + return 488 + 489 + } 490 + } 491 + 434 492 func (h *Handle) ServeStatic(w http.ResponseWriter, r *http.Request) { 435 493 f := chi.URLParam(r, "file") 436 494 f = filepath.Clean(filepath.Join(h.c.Dirs.Static, f)) ··· 554 612 return 555 613 } 556 614 615 + // For use by repoguard 616 + didPath := filepath.Join(repoPath, "did") 617 + err = os.WriteFile(didPath, []byte(did), 0644) 618 + if err != nil { 619 + h.WriteOOBNotice(w, "repo", "Error creating repo. Try again later.") 620 + return 621 + } 622 + 623 + // TODO: add repo & setting-to-owner must happen in the same transaction 557 624 err = h.db.AddRepo(did, name, description) 558 625 if err != nil { 626 + log.Println(err) 627 + h.WriteOOBNotice(w, "repo", "Error creating repo. Try again later.") 628 + return 629 + } 630 + // current user is set to owner of did/name repo 631 + err = h.db.SetOwner(did, did, name) 632 + if err != nil { 633 + log.Println(err) 559 634 h.WriteOOBNotice(w, "repo", "Error creating repo. Try again later.") 560 635 return 561 636 }