Skip to content

Commit 837fe8a

Browse files
committed
feat: add multitenancy
The goal of this change is to support the ability to let any user create repos, similar to popular code forges. Repos are created adhoc when a user uploads a patchset. As part of this change, we are deprecating the previous way to configure repos. Repos can be thought of as containers for patch requests and not much more. They don't need to strictly map to a git repo. Multitenancy is opt-in via the `git-pr.toml` field: ``` create_repo = "user" ```
1 parent bbadbe0 commit 837fe8a

24 files changed

+990
-461
lines changed

Makefile

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ test:
1616
go test ./...
1717
.PHONY: test
1818

19+
snapshot:
20+
UPDATE_SNAPS=true go test ./...
21+
.PHONY: snapshot
22+
1923
build:
2024
go build -o ./build/ssh ./cmd/ssh
2125
go build -o ./build/web ./cmd/web

__snapshots__/e2e_test.snap

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
2+
[TestE2E - 1]
3+
ID RepoID Name Status Patchsets User Date
4+
1 test feat: lets build an rnn [open] 1 admin
5+
6+
---
7+
8+
[TestE2E - 2]
9+
PR submitted! Use the ID for interacting with this PR.
10+
Info
11+
====
12+
URL: https://localhost/prs/8
13+
Repo: contributor/bin
14+
15+
ID Name Status Date
16+
8 feat: lets build an rnn [open]
17+
18+
Patchsets
19+
====
20+
ID Type User Date
21+
ps-12 contributor
22+
23+
Patches from latest patchset
24+
====
25+
Idx Title Commit Author Date
26+
0 feat: lets build an rnn 5945657 Eric Bower <me@erock.io>
27+
28+
---
29+
30+
[TestE2E - 3]
31+
ID RepoID Name Status Patchsets User Date
32+
8 contributor/bin feat: lets build an rnn [open] 1 contributor
33+
7 admin/ai feat: lets build an rnn [accepted] 2 contributor
34+
6 contributor/test Closed patch with review [closed] 2 contributor
35+
5 contributor/test Accepted patch with review [accepted] 2 contributor
36+
4 contributor/test Reviewed patch [reviewed] 2 contributor
37+
3 contributor/test Closed patch (contributor) [closed] 1 contributor
38+
2 contributor/test Closed patch (admin) [closed] 1 contributor
39+
1 contributor/test Accepted patch [accepted] 1 contributor
40+
41+
---
42+
43+
[TestE2E - 4]
44+
RepoID PrID PatchsetID Event Created Data
45+
admin/ai 7 ps-10 pr_created
46+
admin/ai 7 ps-11 pr_patchset_added
47+
admin/ai 7 pr_status_changed {"status":"accepted"}
48+
49+
---

backend.go

+84-8
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import (
44
"encoding/base64"
55
"fmt"
66
"log/slog"
7-
"path/filepath"
7+
"strings"
88

9-
"github.com/charmbracelet/soft-serve/pkg/utils"
109
"github.com/charmbracelet/ssh"
1110
"github.com/jmoiron/sqlx"
1211
gossh "golang.org/x/crypto/ssh"
@@ -18,16 +17,56 @@ type Backend struct {
1817
Cfg *GitCfg
1918
}
2019

21-
func (be *Backend) ReposDir() string {
22-
return filepath.Join(be.Cfg.DataDir, "repos")
20+
var ErrRepoNoNamespace = fmt.Errorf("repo must be namespaced by username")
21+
22+
// Repo Namespace.
23+
func (be *Backend) CreateRepoNs(userName, repoName string) string {
24+
if be.Cfg.CreateRepo == "admin" {
25+
return repoName
26+
}
27+
return fmt.Sprintf("%s/%s", userName, repoName)
2328
}
2429

25-
func (be *Backend) RepoName(id string) string {
26-
return utils.SanitizeRepo(id)
30+
func (be *Backend) ValidateRepoNs(repoNs string) error {
31+
_, repoID := be.SplitRepoNs(repoNs)
32+
if strings.Contains(repoID, "/") {
33+
return fmt.Errorf("repo can only contain a single forward-slash")
34+
}
35+
return nil
2736
}
2837

29-
func (be *Backend) RepoID(name string) string {
30-
return name + ".git"
38+
func (be *Backend) SplitRepoNs(repoNs string) (string, string) {
39+
results := strings.SplitN(repoNs, "/", 2)
40+
if len(results) == 1 {
41+
return "", results[0]
42+
}
43+
44+
return results[0], results[1]
45+
}
46+
47+
func (be *Backend) CanCreateRepo(repo *Repo, requester *User) error {
48+
pubkey, err := be.PubkeyToPublicKey(requester.Pubkey)
49+
if err != nil {
50+
return err
51+
}
52+
isAdmin := be.IsAdmin(pubkey)
53+
if isAdmin {
54+
return nil
55+
}
56+
57+
// can create repo is a misnomer since we are saying it's ok to create
58+
// a repo even though one already exists. this is a hack since this function
59+
// is used exclusively inside pr creation flow.
60+
if repo != nil {
61+
return nil
62+
}
63+
64+
if be.Cfg.CreateRepo == "user" {
65+
return nil
66+
}
67+
68+
// new repo with cfg indicating only admins can create prs/repos
69+
return fmt.Errorf("you are not authorized to create repo")
3170
}
3271

3372
func (be *Backend) Pubkey(pk ssh.PublicKey) string {
@@ -64,3 +103,40 @@ func (be *Backend) IsAdmin(pk ssh.PublicKey) bool {
64103
func (be *Backend) IsPrOwner(pka, pkb int64) bool {
65104
return pka == pkb
66105
}
106+
107+
type PrAcl struct {
108+
CanModify bool
109+
CanReview bool
110+
CanDelete bool
111+
}
112+
113+
func (be *Backend) GetPatchRequestAcl(prq *PatchRequest, requester *User) *PrAcl {
114+
acl := &PrAcl{}
115+
pubkey, err := be.PubkeyToPublicKey(requester.Pubkey)
116+
if err != nil {
117+
return acl
118+
}
119+
120+
isAdmin := be.IsAdmin(pubkey)
121+
// admin can do it all
122+
if isAdmin {
123+
acl.CanModify = true
124+
acl.CanReview = true
125+
acl.CanDelete = true
126+
return acl
127+
}
128+
129+
// pr creator have special priv
130+
if requester != nil && be.IsPrOwner(prq.UserID, requester.ID) {
131+
acl.CanModify = true
132+
acl.CanReview = false
133+
acl.CanDelete = true
134+
return acl
135+
}
136+
137+
acl.CanModify = false
138+
acl.CanReview = false
139+
acl.CanDelete = false
140+
141+
return acl
142+
}

cfg.go

+11-24
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,23 @@ import (
1414
"github.com/knadh/koanf/v2"
1515
)
1616

17-
type Repo struct {
18-
ID string `koanf:"id"`
19-
Desc string `koanf:"desc"`
20-
CloneAddr string `koanf:"clone_addr"`
21-
DefaultBranch string `koanf:"default_branch"`
22-
}
23-
2417
var k = koanf.New(".")
2518

2619
type GitCfg struct {
2720
DataDir string `koanf:"data_dir"`
28-
Repos []*Repo `koanf:"repo"`
2921
Url string `koanf:"url"`
3022
Host string `koanf:"host"`
3123
SshPort string `koanf:"ssh_port"`
3224
WebPort string `koanf:"web_port"`
3325
AdminsStr []string `koanf:"admins"`
3426
Admins []ssh.PublicKey `koanf:"admins_pk"`
27+
CreateRepo string `koanf:"create_repo"`
3528
Theme string `koanf:"theme"`
3629
TimeFormat string `koanf:"time_format"`
3730
Logger *slog.Logger
3831
}
3932

40-
func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
33+
func LoadConfigFile(fpath string, logger *slog.Logger) {
4134
fpp, err := filepath.Abs(fpath)
4235
if err != nil {
4336
panic(err)
@@ -47,8 +40,10 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
4740
if err := k.Load(file.Provider(fpp), toml.Parser()); err != nil {
4841
panic(fmt.Sprintf("error loading config: %v", err))
4942
}
43+
}
5044

51-
err = k.Load(env.Provider("GITPR_", ".", func(s string) string {
45+
func NewGitCfg(logger *slog.Logger) *GitCfg {
46+
err := k.Load(env.Provider("GITPR_", ".", func(s string) string {
5247
keyword := strings.ToLower(strings.TrimPrefix(s, "GITPR_"))
5348
return keyword
5449
}), nil)
@@ -63,7 +58,7 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
6358
}
6459

6560
if len(out.AdminsStr) > 0 {
66-
keys, err := getAuthorizedKeys(out.AdminsStr)
61+
keys, err := GetAuthorizedKeys(out.AdminsStr)
6762
if err == nil {
6863
out.Admins = keys
6964
} else {
@@ -104,6 +99,10 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
10499
out.TimeFormat = time.RFC3339
105100
}
106101

102+
if out.CreateRepo == "" {
103+
out.CreateRepo = "admin"
104+
}
105+
107106
logger.Info(
108107
"config",
109108
"url", out.Url,
@@ -113,25 +112,13 @@ func NewGitCfg(fpath string, logger *slog.Logger) *GitCfg {
113112
"web_port", out.WebPort,
114113
"theme", out.Theme,
115114
"time_format", out.TimeFormat,
115+
"create_repo", out.CreateRepo,
116116
)
117117

118118
for _, pubkey := range out.AdminsStr {
119119
logger.Info("admin", "pubkey", pubkey)
120120
}
121121

122-
for _, repo := range out.Repos {
123-
if repo.DefaultBranch == "" {
124-
repo.DefaultBranch = "main"
125-
}
126-
logger.Info(
127-
"repo",
128-
"id", repo.ID,
129-
"desc", repo.Desc,
130-
"clone_addr", repo.CloneAddr,
131-
"default_branch", repo.DefaultBranch,
132-
)
133-
}
134-
135122
out.Logger = logger
136123
return &out
137124
}

0 commit comments

Comments
 (0)