diff --git a/backend/gitlab.go b/backend/gitlab.go index f1991f6..c8b2632 100644 --- a/backend/gitlab.go +++ b/backend/gitlab.go @@ -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) } diff --git a/backend/migrations/0003_identity_token_reach.sql b/backend/migrations/0003_identity_token_reach.sql new file mode 100644 index 0000000..a2b922a --- /dev/null +++ b/backend/migrations/0003_identity_token_reach.sql @@ -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 ''; diff --git a/backend/spi_conformance_test.go b/backend/spi_conformance_test.go index 3058230..a883df9 100644 --- a/backend/spi_conformance_test.go +++ b/backend/spi_conformance_test.go @@ -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() diff --git a/backend/spi_dbstore.go b/backend/spi_dbstore.go index 7bed653..d9d34a0 100644 --- a/backend/spi_dbstore.go +++ b/backend/spi_dbstore.go @@ -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 } @@ -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 { @@ -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 } @@ -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) diff --git a/backend/spi_fakegitlab_test.go b/backend/spi_fakegitlab_test.go index 28b5f89..8a4149e 100644 --- a/backend/spi_fakegitlab_test.go +++ b/backend/spi_fakegitlab_test.go @@ -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} @@ -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{ @@ -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 @@ -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}, } } @@ -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 @@ -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 { @@ -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() diff --git a/backend/spi_provider.go b/backend/spi_provider.go index 818623a..ada8901 100644 --- a/backend/spi_provider.go +++ b/backend/spi_provider.go @@ -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 } @@ -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 { @@ -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 { @@ -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 { @@ -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 } @@ -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 diff --git a/backend/spi_reach_test.go b/backend/spi_reach_test.go new file mode 100644 index 0000000..1e40d85 --- /dev/null +++ b/backend/spi_reach_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "strings" + "testing" + + gp "github.com/Paca-AI/plugin-sdk-go/gitprovider" +) + +// reachIdentity seeds an identity holding a token of the given kind. +func reachIdentity(st *memStore, id, token, username string, reach tokenReach) gp.IdentityRef { + st.identities[id] = identityRecord{ID: id, Host: fakeHost, UserID: 77, Username: username, Token: token, Bot: reach.Type != tokenPersonal, Reach: reach} + return gp.IdentityRef{ID: id, Handle: username} +} + +var ( + projectReach = tokenReach{Type: tokenProject, ResourceID: 4, Path: "acme/ui"} + groupReach = tokenReach{Type: tokenGroup, ResourceID: 9, Path: "acme/frontend"} +) + +func TestClassifyToken(t *testing.T) { + for _, tc := range []struct { + name string + bot bool + want tokenReach + }{ + {"project_4_bot_ab12", true, tokenReach{Type: tokenProject, ResourceID: 4}}, + {"project_12_bot", true, tokenReach{Type: tokenProject, ResourceID: 12}}, + {"group_9_bot_cd34", true, tokenReach{Type: tokenGroup, ResourceID: 9}}, + {"alice", false, tokenReach{Type: tokenPersonal}}, + {"project_x_bot", false, tokenReach{Type: tokenPersonal}}, + {"my_project_4_bot", false, tokenReach{Type: tokenPersonal}}, + // A service account's name may be chosen by its creator: the bot flag tells it. + {"service_account_group_143230090_44d957ff", true, tokenReach{Type: tokenServiceAccount}}, + {"reach-sa", true, tokenReach{Type: tokenServiceAccount}}, + } { + if got := classifyToken(tc.name, tc.bot); got != tc.want { + t.Errorf("%s (bot %v) → %+v, want %+v", tc.name, tc.bot, got, tc.want) + } + } +} + +func TestSetup_RecordsTheTokenTypeAndReach(t *testing.T) { + for name, tc := range map[string]struct { + token, display string + want tokenReach + }{ + "personal": {fakeToken, "Personal token · paca-bot", tokenReach{Type: tokenPersonal}}, + "project": {fakeProjectToken, "Project token · acme/ui", projectReach}, + "group": {fakeGroupToken, "Group token · acme/frontend", groupReach}, + } { + p, _, st := seededProvider(t) + step := beginShared(t, p) + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(tc.token)}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if id.DisplayName != tc.display { + t.Errorf("%s: display name %q, want %q", name, id.DisplayName, tc.display) + } + if stored, _ := st.loadIdentity(id.Ref.ID); stored.Reach != tc.want { + t.Errorf("%s: stored reach %+v, want %+v", name, stored.Reach, tc.want) + } + } +} + +func TestSetup_FailedLookupKeepsTheTypeWithoutAPath(t *testing.T) { + p, g, st := seededProvider(t) + g.forceError["GET /projects/4"] = fakeResponse{status: 403, body: `{"message":"403 Forbidden"}`} + step := beginShared(t, p) + id, err := p.CompleteIdentitySetup(bg, gp.CompleteSetupRequest{Scope: gp.SetupShared, Host: "gitlab.com", State: step.State, Params: completeParams(fakeProjectToken)}) + if err != nil { + t.Fatal(err) + } + if stored, _ := st.loadIdentity(id.Ref.ID); stored.Reach != (tokenReach{Type: tokenProject, ResourceID: 4}) { + t.Errorf("reach %+v: want the type and id, no path", stored.Reach) + } + if !strings.HasPrefix(id.DisplayName, "Project token") { + t.Errorf("display name %q", id.DisplayName) + } +} + +func TestResolveRepository_ProjectTokenIsSkippedForAnotherRepo(t *testing.T) { + p, g, st := seededProvider(t) + proj := reachIdentity(st, "proj", fakeProjectToken, "project_4_bot_ab12", projectReach) + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{proj}}) + if err != nil || r.Repository.Ref.ExternalID != "4" || r.Identity.ID != "proj" { + t.Fatalf("own project: %+v %v", r, err) + } + // acme/api is public: the API would answer, but the token was issued elsewhere. + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{proj}}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "covers only acme/ui") || !strings.Contains(err.Error(), "Add a connection for acme/api") { + t.Errorf("error %q should say what the connection covers and what to do", err) + } + if g.called("GET /projects/acme/api") != 0 { + t.Error("a token that cannot cover the repository must not be asked about it") + } +} + +func TestResolveRepository_GroupTokenCoversSubgroups(t *testing.T) { + p, _, st := seededProvider(t) + grp := reachIdentity(st, "grp", fakeGroupToken, "group_9_bot_cd34", groupReach) + for _, path := range []string{"acme/frontend/app", "acme/frontend/sub/lib"} { + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: path, Candidates: []gp.IdentityRef{grp}}) + if err != nil || r.Repository.Ref.FullPath != path { + t.Errorf("%s: %+v %v", path, r, err) + } + } + // A sibling that merely shares the name prefix is not below the group. + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/frontend-old/app", Candidates: []gp.IdentityRef{grp}}) + code(t, err, gp.CodeForbidden) + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{grp}}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "group token covers only acme/frontend") { + t.Errorf("error %q", err) + } +} + +func TestResolveRepository_PublicRepoIsNotBoundToANonMember(t *testing.T) { + p, g, st := seededProvider(t) + out := reachIdentity(st, "out", fakeOutsiderToken, "stranger", tokenReach{Type: tokenPersonal}) + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{out}}) + code(t, err, gp.CodeForbidden) + if g.called("GET /projects/acme/api") != 1 { + t.Error("a personal token is asked: only the answer's role can tell") + } + // A Reporter (level 20) can read but not push, so it does not win either. + _, err = p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{fixtureIdentity}}) + code(t, err, gp.CodeForbidden) +} + +// A project token for the very repository, but below Developer, must not be +// told it "covers only" that repository: it does, and the role is the problem. +func TestResolveRepository_LowRoleTokenIsToldItsRoleIsTooLow(t *testing.T) { + p, _, st := seededProvider(t) + rep := reachIdentity(st, "rep", fakeReporterProjectToken, "project_4_bot_ee56", projectReach) + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{rep}}) + code(t, err, gp.CodeForbidden) + if !strings.Contains(err.Error(), "less than the Developer role") || strings.Contains(err.Error(), "covers only") { + t.Errorf("error %q: want the role named, not the coverage", err) + } +} + +func TestResolveRepository_FirstQualifyingCandidateWins(t *testing.T) { + p, _, st := seededProvider(t) + proj := reachIdentity(st, "proj", fakeProjectToken, "project_4_bot_ab12", projectReach) + dev := reachIdentity(st, "dev", fakeDevToken, "dev", tokenReach{Type: tokenPersonal}) + out := reachIdentity(st, "out", fakeOutsiderToken, "stranger", tokenReach{Type: tokenPersonal}) + resolve := func(cands ...gp.IdentityRef) string { + t.Helper() + r, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: cands}) + if err != nil { + t.Fatal(err) + } + return r.Identity.ID + } + if got := resolve(dev, proj); got != "dev" { + t.Errorf("winner %s, want dev (listed first)", got) + } + if got := resolve(proj, dev); got != "proj" { + t.Errorf("winner %s, want proj (listed first)", got) + } + if got := resolve(out, dev, proj); got != "dev" { + t.Errorf("winner %s, want dev (the outsider cannot see it)", got) + } +} + +func TestIdentity_LegacyRowsAreClassifiedOnFirstLoad(t *testing.T) { + p, _, st := seededProvider(t) + ref := reachIdentity(st, "old", fakeProjectToken, "project_4_bot_ab12", tokenReach{}) + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{ref}}); err != nil { + t.Fatal(err) + } + if got := st.identities["old"].Reach; got != projectReach { + t.Errorf("saved reach %+v, want %+v", got, projectReach) + } + // The seeded identity predates the columns too: it is a bot that is neither a + // project nor a group token, so a service account. + if _, err := p.MintCredential(bg, gp.MintRequest{Target: fixtureTarget, Access: gp.AccessRead}); err != nil { + t.Fatal(err) + } + if got := st.identities[fakeIdentityID].Reach.Type; got != tokenServiceAccount { + t.Errorf("type %q, want service_account", got) + } + // A human user's token is personal. + st.identities["human"] = identityRecord{ID: "human", Host: fakeHost, UserID: 78, Username: "alice", Token: fakeDevToken} + if _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/ui", Candidates: []gp.IdentityRef{{ID: "human"}}}); err != nil { + t.Fatal(err) + } + if got := st.identities["human"].Reach.Type; got != tokenPersonal { + t.Errorf("type %q, want personal", got) + } +} + +func TestMintCredential_WriteIsRefusedToANonMember(t *testing.T) { + p, _, st := seededProvider(t) + out := reachIdentity(st, "out", fakeOutsiderToken, "stranger", tokenReach{Type: tokenPersonal}) + api := gp.RepoRef{Host: fakeHost, FullPath: "acme/api", ExternalID: "2"} + target := gp.Target{Identity: out, Repo: api} + if _, err := p.MintCredential(bg, gp.MintRequest{Target: target, Access: gp.AccessRead}); err != nil { + t.Errorf("a public repository can be cloned by anyone: %v", err) + } + _, err := p.MintCredential(bg, gp.MintRequest{Target: target, Access: gp.AccessWrite}) + code(t, err, gp.CodeForbidden) +} + +func TestIdentityOf_DisplayNames(t *testing.T) { + for want, rec := range map[string]identityRecord{ + "Project token · acme/ui": {Username: "project_4_bot_x", Reach: projectReach}, + "Group token · acme/frontend": {Username: "group_9_bot_x", Reach: groupReach}, + "Personal token · alice": {Username: "alice", Name: "Alice A", Reach: tokenReach{Type: tokenPersonal}}, + "Project token · project_4_bot_x": {Username: "project_4_bot_x", Reach: tokenReach{Type: tokenProject, ResourceID: 4}}, + "Service account · paca-reach": {Username: "service_account_group_9_ab12", Name: "paca-reach", Reach: tokenReach{Type: tokenServiceAccount}}, + "Service account · service_account_group_9_ab12": {Username: "service_account_group_9_ab12", Reach: tokenReach{Type: tokenServiceAccount}}, + } { + if got := identityOf(rec).DisplayName; got != want { + t.Errorf("display name %q, want %q", got, want) + } + } +} + +// A service account is judged by its memberships, like a personal token, but is +// named for what it is, and the hint to add a token does not name only group +// access tokens (GitLab.com offers those on Premium and above). +func TestResolveRepository_ServiceAccountOutsideTheRepositoryIsExplained(t *testing.T) { + p, _, st := seededProvider(t) + sa := reachIdentity(st, "sa", fakeOutsiderToken, "service_account_group_9_ab12", tokenReach{Type: tokenServiceAccount}) + _, err := p.ResolveRepository(bg, gp.ResolveRepoRequest{PathOrURL: "acme/api", Candidates: []gp.IdentityRef{sa}}) + code(t, err, gp.CodeForbidden) + for _, want := range []string{"service account service_account_group_9_ab12", "Add a connection for acme/api", "a service account"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q lacks %q", err, want) + } + } + if strings.Contains(err.Error(), "use a group access token") { + t.Errorf("error %q still points only at group access tokens", err) + } +} diff --git a/backend/spi_setup.go b/backend/spi_setup.go index f5f12ba..31e7d7c 100644 --- a/backend/spi_setup.go +++ b/backend/spi_setup.go @@ -190,8 +190,11 @@ func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSet if err != nil { return gp.Identity{}, err } + reach := classifyToken(user.Username, user.Bot) + lookupReach(api, &reach) rec := identityRecord{ - Host: s.Host, UserID: user.ID, Username: user.Username, Name: user.Name, Bot: user.Bot || accessTokenBotRe.MatchString(user.Username), + Reach: reach, + Host: s.Host, UserID: user.ID, Username: user.Username, Name: user.Name, Bot: user.Bot || accessTokenBotRe.MatchString(user.Username), Token: token, WebhookSecret: secret, ExpiresAt: info.expiry(), } if rt := strings.TrimSpace(req.Params[fieldReadToken]); rt != "" { @@ -220,15 +223,79 @@ func (p *glProvider) CompleteIdentitySetup(_ context.Context, req gp.CompleteSet return identityOf(rec), nil } +// classifyToken tells the kind of a token from its user name: GitLab names the +// bot user of a project or group access token "project__bot_…" or +// "group__bot_…". Any other bot user is a service account, whose name its +// creator may choose, so it is told by GitLab's bot flag; it reaches what its +// memberships allow, like a personal token. A human user holds a personal token. +// Path is not set. +func classifyToken(username string, bot bool) tokenReach { + m := accessTokenBotKindRe.FindStringSubmatch(username) + if m == nil { + if bot { + return tokenReach{Type: tokenServiceAccount} + } + return tokenReach{Type: tokenPersonal} + } + id, _ := strconv.ParseInt(m[2], 10, 64) + if m[1] == "group" { + return tokenReach{Type: tokenGroup, ResourceID: id} + } + return tokenReach{Type: tokenProject, ResourceID: id} +} + +// lookupReach fills in the path of the project or group a token was issued for. +// It reports false when the lookup was a transient failure worth retrying; any +// other failure (a token without read_api, say) keeps the type with no path. +func lookupReach(api *glAPI, r *tokenReach) bool { + var out struct { + Path string `json:"path_with_namespace"` + Full string `json:"full_path"` + } + switch r.Type { + case tokenProject: + if err := api.get("/projects/"+strconv.FormatInt(r.ResourceID, 10), &out); err != nil { + return gp.AsError(err).Code != gp.CodeTransient + } + r.Path = out.Path + case tokenGroup: + if err := api.get("/groups/"+strconv.FormatInt(r.ResourceID, 10), &out); err != nil { + return gp.AsError(err).Code != gp.CodeTransient + } + r.Path = out.Full + } + return true +} + +// label names what the token covers, e.g. "Project token · acme/ui". +func (r identityRecord) reachLabel() string { + switch r.Reach.Type { + case tokenProject, tokenGroup: + kind := "Project token" + if r.Reach.Type == tokenGroup { + kind = "Group token" + } + if r.Reach.Path != "" { + return kind + " · " + r.Reach.Path + } + return kind + " · " + r.Username + case tokenServiceAccount: + // The user name is long and generated; the name given on GitLab is the one + // people recognise. + if r.Name != "" { + return "Service account · " + r.Name + } + return "Service account · " + r.Username + } + return "Personal token · " + r.Username +} + func identityOf(rec identityRecord) gp.Identity { kind := gp.IdentityToken if rec.Bot { kind = gp.IdentityBotUser } - name := rec.Name - if name == "" { - name = rec.Username - } + name := rec.reachLabel() return gp.Identity{ Ref: gp.IdentityRef{ID: rec.ID, Handle: rec.mentionHandle(), ProviderRef: providerRef(map[string]any{"user_id": rec.UserID})}, Host: rec.Host, diff --git a/backend/spi_store.go b/backend/spi_store.go index f5d521b..0d23518 100644 --- a/backend/spi_store.go +++ b/backend/spi_store.go @@ -12,6 +12,8 @@ type spiStore interface { loadIdentity(id string) (identityRecord, error) createIdentity(r identityRecord) (id string, err error) deleteIdentity(id string) error + // saveReach records what an identity's token covers (see identityRecord). + saveReach(id string, r tokenReach) error saveSetup(s setupSession) error loadSetup(state string) (setupSession, error) @@ -39,6 +41,27 @@ type identityRecord struct { WebhookSecret string // ExpiresAt is the earliest expiry of the stored tokens; zero when unknown. ExpiresAt time.Time + // Reach is what the token covers; its Type is empty until classified. + Reach tokenReach +} + +// Token types: what kind of access token an identity holds. +const ( + tokenPersonal = "personal" + tokenProject = "project" + tokenGroup = "group" + tokenServiceAccount = "service_account" +) + +// tokenReach is what an access token can reach: a personal token the user's +// memberships, a project token one project, a group token a group and its +// subgroups. +type tokenReach struct { + Type string // personal | project | group; empty when not yet classified + // ResourceID and Path name the project or group of a project or group token. + // Path stays empty when the lookup failed (a token without read_api, say). + ResourceID int64 + Path string } // setupSession is one identity-setup attempt, keyed by its single-use state.