Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ var hostRe = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5}
// access tokens, e.g. project_123_bot_ab12cd34 or group_9_bot.
var accessTokenBotRe = regexp.MustCompile(`^(?:project|group)_[0-9]+_bot(?:_[0-9a-f]+)?$`)

// accessTokenBotKindRe splits such a user name into its kind and resource id.
var accessTokenBotKindRe = regexp.MustCompile(`^(project|group)_([0-9]+)_bot(?:_[0-9a-f]+)?$`)

// nowStr returns the current UTC time as an RFC3339Nano string.
func nowStr() string { return time.Now().UTC().Format(time.RFC3339Nano) }

Expand Down
9 changes: 9 additions & 0 deletions backend/migrations/0003_identity_token_reach.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- What each access token covers: its kind (personal, project or group) and, for
-- a project or group token, the project or group it was issued for. Existing
-- rows keep the columns empty; the provider classifies them lazily from the
-- stored username the first time it loads them, and saves the result.
-- Run with search_path = plugin_data_com_paca_gitlab, public.

ALTER TABLE gitlab_identities ADD COLUMN IF NOT EXISTS token_type TEXT NOT NULL DEFAULT '';
ALTER TABLE gitlab_identities ADD COLUMN IF NOT EXISTS resource_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE gitlab_identities ADD COLUMN IF NOT EXISTS resource_path TEXT NOT NULL DEFAULT '';
12 changes: 12 additions & 0 deletions backend/spi_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ func (m *memStore) deleteIdentity(id string) error {
return nil
}

func (m *memStore) saveReach(id string, r tokenReach) error {
m.mu.Lock()
defer m.mu.Unlock()
rec, ok := m.identities[id]
if !ok {
return errNoSuchIdentity
}
rec.Reach = r
m.identities[id] = rec
return nil
}

func (m *memStore) saveSetup(s setupSession) error {
m.mu.Lock()
defer m.mu.Unlock()
Expand Down
15 changes: 11 additions & 4 deletions backend/spi_dbstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type sqlExecutor interface {
func newDBStore(p *gitlabPlugin) dbStore { return dbStore{p: p, db: p.db} }

func (s dbStore) loadIdentity(id string) (identityRecord, error) {
r, err := s.db.Query(`SELECT id, host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at FROM gitlab_identities WHERE id = $1`, id)
r, err := s.db.Query(`SELECT id, host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, token_type, resource_id, resource_path FROM gitlab_identities WHERE id = $1`, id)
if err != nil {
return identityRecord{}, err
}
Expand Down Expand Up @@ -52,6 +52,7 @@ func (s dbStore) loadIdentity(id string) (identityRecord, error) {
rec := identityRecord{
ID: sc.str("id"), Host: sc.str("host"), UserID: sc.int64Val("user_id"), Username: sc.str("username"),
Name: sc.str("display_name"), Bot: sc.str("bot") == "true", Token: token, ReadToken: readToken, WebhookSecret: secret,
Reach: tokenReach{Type: sc.str("token_type"), ResourceID: sc.int64Val("resource_id"), Path: sc.str("resource_path")},
}
if exp := sc.str("expires_at"); exp != "" {
if t, perr := time.Parse(time.RFC3339Nano, exp); perr == nil {
Expand Down Expand Up @@ -86,9 +87,9 @@ func (s dbStore) createIdentity(x identityRecord) (string, error) {
}
now := nowStr()
r, err := s.db.Query(`
INSERT INTO gitlab_identities (host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'git-provider',$10,$10) RETURNING id`,
x.Host, x.UserID, x.Username, x.Name, x.Bot, token, readToken, secret, expires, now)
INSERT INTO gitlab_identities (host, user_id, username, display_name, bot, token_enc, read_token_enc, webhook_secret_enc, expires_at, token_type, resource_id, resource_path, created_by, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'git-provider',$13,$13) RETURNING id`,
x.Host, x.UserID, x.Username, x.Name, x.Bot, token, readToken, secret, expires, x.Reach.Type, x.Reach.ResourceID, x.Reach.Path, now)
if err != nil {
return "", err
}
Expand All @@ -103,6 +104,12 @@ func (s dbStore) deleteIdentity(id string) error {
return err
}

func (s dbStore) saveReach(id string, r tokenReach) error {
_, err := s.db.Exec(`UPDATE gitlab_identities SET token_type = $2, resource_id = $3, resource_path = $4, updated_at = $5 WHERE id = $1`,
id, r.Type, r.ResourceID, r.Path, nowStr())
return err
}

func (s dbStore) saveSetup(x setupSession) error {
_, err := s.db.Exec(`
INSERT INTO gitlab_setup_sessions (state, scope, agent_id, host, expires_at, created_at)
Expand Down
81 changes: 73 additions & 8 deletions backend/spi_fakegitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,18 @@ const (
fakeReadToken = "glpat-read-token"
fakeNewToken = "glpat-new-token"
fakeWebhookSecret = "hook-secret-value-123456"
fakeUsername = "paca-bot"
fakeIdentityID = "ident-1"
fakeHeadSHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"

// Tokens of the three kinds. The project token was issued for acme/ui, the
// group token for acme/frontend; the dev token is a personal one with the
// Developer role on acme/ui and the outsider belongs to nothing.
fakeProjectToken = "glpat-project-ui"
fakeGroupToken = "glpat-group-frontend"
fakeDevToken = "glpat-dev"
fakeReporterProjectToken = "glpat-project-reporter"
fakeOutsiderToken = "glpat-outsider"
fakeUsername = "paca-bot"
fakeIdentityID = "ident-1"
fakeHeadSHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
)

var fakeUsers = map[string]int64{"octocat": 1, "reader": 2, "owner": 3, "lead": 4, "bob": 5, fakeUsername: 77}
Expand All @@ -97,6 +106,28 @@ var fakeProjects = []fakeProject{
{3, "acme/platform/infra", false, 30},
}

// extraProjects exist for the token-reach tests only; the default tokens do not
// see them, and ListRepositories does not list them.
var extraProjects = []fakeProject{
{4, "acme/ui", false, 0},
{5, "acme/frontend/app", false, 0},
{6, "acme/frontend/sub/lib", false, 0},
}

// reachTokens maps each reach-test token to its user name and its role per
// project id. A project missing from the map is invisible unless public, and a
// public one is then read with no role at all.
var reachTokens = map[string]struct {
user string
levels map[int64]int
}{
fakeProjectToken: {"project_4_bot_ab12", map[int64]int{4: 40}},
fakeGroupToken: {"group_9_bot_cd34", map[int64]int{5: 30, 6: 30}},
fakeDevToken: {"dev", map[int64]int{4: 30}},
fakeReporterProjectToken: {"project_4_bot_ee56", map[int64]int{4: 20}},
fakeOutsiderToken: {"stranger", map[int64]int{}},
}

func newFakeGitLab(t *testing.T) *fakeGitLab {
t.Helper()
g := &fakeGitLab{
Expand Down Expand Up @@ -139,9 +170,11 @@ func (g *fakeGitLab) called(prefix string) int {
}

func (g *fakeGitLab) project(ref string) *fakeProject {
for i := range fakeProjects {
if ref == strconv.FormatInt(fakeProjects[i].ID, 10) || ref == fakeProjects[i].Path {
return &fakeProjects[i]
for _, list := range [][]fakeProject{fakeProjects, extraProjects} {
for i := range list {
if ref == strconv.FormatInt(list[i].ID, 10) || ref == list[i].Path {
return &list[i]
}
}
}
return nil
Expand All @@ -152,9 +185,14 @@ func projectJSON(p fakeProject) map[string]any {
if p.Public {
vis = "public"
}
// A user with no role on a public project gets no project_access, as on GitLab.
var access any
if p.Level > 0 {
access = map[string]any{"access_level": p.Level}
}
return map[string]any{
"id": p.ID, "path_with_namespace": p.Path, "default_branch": "main", "visibility": vis, "description": "d-" + p.Path,
"permissions": map[string]any{"project_access": map[string]any{"access_level": p.Level}},
"permissions": map[string]any{"project_access": access, "group_access": nil},
}
}

Expand Down Expand Up @@ -211,7 +249,8 @@ func (g *fakeGitLab) fetch(method, rawURL string, headers map[string]string, bod
return &plugin.FetchResponse{Status: f.status, Body: f.body, Headers: f.headers}, nil
}
tok := headers["PRIVATE-TOKEN"]
if tok != fakeToken && tok != fakeNewToken && tok != "glpat-readonly-scope" && tok != fakeReadToken {
_, reach := reachTokens[tok]
if tok != fakeToken && tok != fakeNewToken && tok != "glpat-readonly-scope" && tok != fakeReadToken && !reach {
return resp(401, map[string]string{"message": "401 Unauthorized"})
}
var payload map[string]any
Expand All @@ -225,6 +264,9 @@ func (g *fakeGitLab) route(method string, segs []string, q url.Values, tok strin
switch {
case method == "GET" && len(segs) == 1 && segs[0] == "user":
name := map[string]string{fakeToken: fakeUsername, fakeNewToken: "project_1_bot_ab12", "glpat-readonly-scope": "someone", fakeReadToken: fakeUsername}[tok]
if r, ok := reachTokens[tok]; ok {
name = r.user
}
return resp(200, map[string]any{"id": 77, "username": name, "name": "Paca Bot", "bot": strings.Contains(name, "_bot_")})
case method == "GET" && len(segs) == 2 && segs[0] == "personal_access_tokens" && segs[1] == "self":
if tok == fakeReadToken {
Expand All @@ -245,7 +287,30 @@ func (g *fakeGitLab) route(method string, segs []string, q url.Values, tok strin
return resp(200, []any{})
case method == "GET" && len(segs) == 1 && segs[0] == "projects":
return g.listProjects(q)
case method == "GET" && len(segs) == 2 && segs[0] == "groups":
if tok == fakeGroupToken && segs[1] == "9" {
return resp(200, map[string]any{"id": 9, "full_path": "acme/frontend", "path": "frontend"})
}
return notFound()
case method == "GET" && len(segs) == 2 && segs[0] == "projects" && reachTokens[tok].user != "":
// The reach-test tokens see projects through their own roles.
p := g.project(segs[1])
if p == nil {
return notFound()
}
lvl, member := reachTokens[tok].levels[p.ID]
if !member && !p.Public {
return notFound()
}
q := *p
q.Level = lvl
return resp(200, projectJSON(q))
case len(segs) >= 2 && segs[0] == "projects":
for _, x := range extraProjects {
if (segs[1] == x.Path || segs[1] == strconv.FormatInt(x.ID, 10)) && reachTokens[tok].user == "" {
return notFound() // the default tokens do not see these projects
}
}
return g.projectAPI(method, segs[1], segs[2:], q, payload)
}
return notFound()
Expand Down
80 changes: 77 additions & 3 deletions backend/spi_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ func (p *glProvider) loadIdentity(identityID string) (identityRecord, error) {
}
return identityRecord{}, gp.Errorf(gp.CodeInternal, "could not load the GitLab identity")
}
if rec.Reach.Type == "" {
// Stored before token types were recorded: classify from the username
// once and save it. A failed save only means doing this again next time.
rec.Reach = classifyToken(rec.Username, rec.Bot)
if lookupReach(rec.api(), &rec.Reach) {
_ = p.store.saveReach(rec.ID, rec.Reach)
}
}
return rec, nil
}

Expand Down Expand Up @@ -247,8 +255,57 @@ func parseRepoInput(in string) (host, fullPath string, err error) {
return host, s, nil
}

// covers reports whether the identity's token can reach the repository at the
// given path, judged from what the token was issued for alone. A token whose
// path is unknown is given the benefit of the doubt; the membership check after
// the API call still applies.
func (r identityRecord) covers(fullPath string) bool {
switch r.Reach.Type {
case tokenProject:
return r.Reach.Path == "" || strings.EqualFold(r.Reach.Path, fullPath)
case tokenGroup:
return r.Reach.Path == "" || strings.EqualFold(r.Reach.Path, fullPath) ||
strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(r.Reach.Path)+"/")
}
return true
}

// coverage says what the connection reaches, for the error shown when no
// connection covers a repository.
func (r identityRecord) coverage() string {
switch r.Reach.Type {
case tokenProject:
if r.Reach.Path != "" {
return "The connected project token covers only " + r.Reach.Path
}
return "The connected project token (" + r.Username + ") covers a single other project"
case tokenGroup:
if r.Reach.Path != "" {
return "The connected group token covers only " + r.Reach.Path + " and its subgroups"
}
return "The connected group token (" + r.Username + ") covers a single other group"
}
if r.Reach.Type == tokenServiceAccount {
return "The connected service account " + r.Username + " is not a member of it with at least the Developer role"
}
return "The connected personal token of " + r.Username + " is not a member of it with at least the Developer role"
}

// tooLowRole says why a connection that does reach a repository still cannot be
// used for it: its role there is below Developer.
func (r identityRecord) tooLowRole() string {
switch r.Reach.Type {
case tokenProject:
return "The connected project token has less than the Developer role on it"
case tokenGroup:
return "The connected group token has less than the Developer role on it"
}
return r.coverage()
}

// ResolveRepository implements gp.Provider: the first candidate identity that
// can see the project wins.
// covers the project, can see it and is a member of it (Developer or above)
// wins.
func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequest) (gp.ResolvedRepository, error) {
urlHost, fullPath, err := parseRepoInput(req.PathOrURL)
if err != nil {
Expand All @@ -265,6 +322,7 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ
return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "no GitLab identity is available to look this repository up")
}
var last error
var skipped []string // what each connection that did not qualify covers
for _, c := range req.Candidates {
rec, err := p.loadIdentity(c.ID)
if err != nil {
Expand All @@ -278,6 +336,10 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ
if want != "" && !strings.EqualFold(rec.Host, want) {
continue // another host's identity cannot see this project
}
if !rec.covers(fullPath) {
skipped = append(skipped, rec.coverage())
continue
}
var g glProject
if err := rec.api().get("/projects/"+pathPart(fullPath), &g); err != nil {
if code := gp.AsError(err).Code; code == gp.CodeNotFound || code == gp.CodeForbidden {
Expand All @@ -287,12 +349,24 @@ func (p *glProvider) ResolveRepository(_ context.Context, req gp.ResolveRepoRequ
return gp.ResolvedRepository{}, err
}
// A public project answers any token, so being able to read it proves
// nothing about being a member; a credential is still minted for it.
// nothing about being a member: require a role that can push, which a
// project token for another project (or a user outside the project) lacks.
if rec.Reach.Type == tokenProject && rec.Reach.ResourceID != g.ID {
skipped = append(skipped, rec.coverage())
continue
}
if g.accessLevel() < developerLevel {
skipped = append(skipped, rec.tooLowRole())
continue
}
return gp.ResolvedRepository{
Repository: repositoryOf(rec.Host, g),
Identity: gp.IdentityRef{ID: rec.ID, Handle: rec.mentionHandle()},
}, nil
}
if len(skipped) > 0 {
return gp.ResolvedRepository{}, gp.Errorf(gp.CodeForbidden, "%s. Add a connection for %s, or use a token that is a member of it (a project or group access token, or a service account).", strings.Join(skipped, "; "), fullPath)
}
if last != nil && gp.AsError(last).Code == gp.CodeForbidden {
return gp.ResolvedRepository{}, last
}
Expand All @@ -319,7 +393,7 @@ func (p *glProvider) MintCredential(_ context.Context, req gp.MintRequest) (gp.C
if err := rec.api().get(proj, &g); err != nil {
return gp.Credential{}, err
}
if lvl := g.accessLevel(); req.Access == gp.AccessWrite && lvl > 0 && lvl < developerLevel {
if lvl := g.accessLevel(); req.Access == gp.AccessWrite && lvl < developerLevel {
return gp.Credential{}, gp.Errorf(gp.CodeForbidden, "the identity may not push to this repository")
}
secret := rec.Token
Expand Down
Loading
Loading