From 7ec178a4140346a04a194676f5b6237ad0726238 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Wed, 28 Oct 2026 16:20:11 +0100 Subject: [PATCH 1/2] feat: recognize token types and their reach when resolving repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify each access token at setup (project, group, service account or personal) and store the type, the project or group id and its path on the identity, with a new migration for the columns. Project and group tokens are told by the bot user name GitLab gives them; any other bot user is a service account, whose name its creator may choose, and a human user holds a personal token. Identities stored before get classified lazily on first load. ResolveRepository skips connections whose token cannot cover the repository, requires at least the Developer role so a public project is never bound to a non-member token, and explains what each connection covers (or that its role is too low) when none qualifies. The hint no longer names only group access tokens, which GitLab.com offers on Premium and above. MintCredential refuses writes to non-members too. The display name now says what a connection covers, e.g. "Project token · acme/ui" or "Service account · ". --- backend/gitlab.go | 3 + .../migrations/0003_identity_token_reach.sql | 9 +++ backend/spi_dbstore.go | 15 +++- backend/spi_provider.go | 80 ++++++++++++++++++- backend/spi_setup.go | 77 ++++++++++++++++-- backend/spi_store.go | 23 ++++++ 6 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/0003_identity_token_reach.sql 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_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_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_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. From a7e0b21b514b14408833639ccecbf88cc23659c0 Mon Sep 17 00:00:00 2001 From: Alessandro Gorla Date: Wed, 28 Oct 2026 16:20:11 +0100 Subject: [PATCH 2/2] test: cover token types, reach filtering and the Developer requirement Extend the fake GitLab with project and group bot users, /groups/:id and per-token roles on /projects/:id, and test classification (including service accounts with a chosen name), display names, candidate filtering and ordering, lazy classification of existing rows, a token whose role on its own repository is too low, the error text for a service account, and the refusal of writes at access level 0. Co-Authored-By: Claude Sonnet 5.5 --- backend/spi_conformance_test.go | 12 ++ backend/spi_fakegitlab_test.go | 81 +++++++++-- backend/spi_reach_test.go | 239 ++++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 backend/spi_reach_test.go 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_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_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) + } +}