From af3e825e1605dfd1f2063c2c882edaf9746a62cf Mon Sep 17 00:00:00 2001 From: qiansc Date: Sat, 7 Nov 2026 10:43:43 +0000 Subject: [PATCH] feat: private repositories visible only to creators, managers and administrators public_read=false now hides a repository from everyone except its creator, its managers, site administrators signed in on the website and the deployment credential. Personal API keys read as their owner, an administrator's included. Hidden repositories answer like unregistered names, statistics keep only visible registered names, an administrator's key prepares only repositories it can see, and a taken registration name returns its registrant so the caller can ask to be added as a manager. Co-Authored-By: Claude Opus 5.5 --- docs/interfaces.md | 8 +- .../httpserver/private_repository_test.go | 199 ++++++++++++++++++ internal/httpserver/repository_access.go | 6 + internal/httpserver/repository_access_test.go | 12 +- internal/httpserver/runtime.go | 17 +- internal/identity/identity.go | 16 +- internal/service/prepare_access.go | 9 +- internal/service/service.go | 5 +- internal/service/visibility.go | 58 ++++- internal/service/visibility_test.go | 121 +++++++++++ web/assets/api.js | 2 +- web/assets/repo-page.js | 4 +- web/assets/repos-page.js | 4 +- web/repos.html | 2 +- 14 files changed, 440 insertions(+), 23 deletions(-) create mode 100644 internal/httpserver/private_repository_test.go create mode 100644 internal/service/visibility_test.go diff --git a/docs/interfaces.md b/docs/interfaces.md index 4e34f35..a756fec 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -50,11 +50,15 @@ Repository routes take `?repo=owner/repository`. Repository registration, maintenance, statistics and site settings live under `/sourcegraph/api/admin/` and are used by the website's administration pages. Use the Connect page for current request examples. +### Repository visibility + +Each repository is public or private (`public_read`, default `true`; repositories registered before the field existed stay public). Anonymous callers read public repositories only, and only while the site allows public reading. A private repository is visible only to its creator, its managers, site administrators signed in on the website and the deployment credential. Personal API keys read as their owner: public repositories plus the private ones the owner created or manages, even when the owner is a site administrator. Every other caller gets the same `404 REPOSITORY_NOT_FOUND` as for an unregistered name from details, search, read, list, diff, plugins, prepare and MCP tools, and catalogs list only visible repositories. Statistics omit all repository names for anonymous callers; other callers without site-wide read keep only registered repositories they can see plus the `_overflow` aggregate, so unregistered, deleted and hidden names look the same. Registering an existing name returns `409 REPOSITORY_EXISTS` with its registrant in `creator` (`id/name/email/department`); the website suggests asking them to be added as a manager, or a site administrator when no registrant is recorded. Background synchronization, indexing and cleanup are not narrowed by visibility. + ### Repository managers Site administrators manage every repository. The site setting **Let signed-in users register repositories** (`access.allow_user_repositories`) defaults to off; older settings without the field read as off, so upgrading does not change who can register. When it is on, any signed-in user can register a repository and becomes its only manager. Repositories registered before managers existed treat their creator as manager until the list is first edited; after that the stored list is used even when empty, leaving management to administrators. A repository has at most 50 managers. -Repository managers and administrators can use every `?repo=` management route on that repository: edit settings, preview, prepare and add indexes, list commit options, clean up, cancel jobs, delete and purge, and add or remove managers, including themselves. Others receive `403 REPOSITORY_MANAGEMENT_DENIED`; registration while the setting is off returns `403 REGISTRATION_DISABLED`. Users, site settings, global code-source tokens and other site-wide routes stay administrator-only. Personal API keys read what their owner reads and may call only the prepare route besides that. +Repository managers and administrators can use every `?repo=` management route on that repository: edit settings, preview, prepare and add indexes, list commit options, clean up, cancel jobs, delete and purge, and add or remove managers, including themselves. Others receive `403 REPOSITORY_MANAGEMENT_DENIED`; registration while the setting is off returns `403 REGISTRATION_DISABLED`. Users, site settings, global code-source tokens and other site-wide routes stay administrator-only. Personal API keys read what their owner reads (see [Repository visibility](#repository-visibility)) and may call only the prepare route besides that; a site administrator's key prepares only repositories it can see. | Route | Purpose | | --- | --- | @@ -92,7 +96,7 @@ sourcegraph-cli read -repo context4ai/context -path README.md -start-line 1 -end | `SOURCEGRAPH_URL` | Service URL, default `https://context4ai.org/sourcegraph`; must be a credential-free HTTPS URL | | `SOURCEGRAPH_API_TOKEN` | Optional bearer credential, at least 32 characters | -Personal API keys are created from a signed-in user's settings. A key can prepare (MCP `prepare` or `POST /api/admin/v1/repo/prepare`) a repository its owner manages, or any repository when its owner is currently a site administrator; this is checked on every call. MCP lists `prepare` only for keys whose owner is an administrator or manages at least one repository (refreshed within a minute). Keys are limited to 10 prepare calls a minute. +Personal API keys are created from a signed-in user's settings. A key can prepare (MCP `prepare` or `POST /api/admin/v1/repo/prepare`) a repository its owner manages, or any repository it can see when its owner is currently a site administrator; this is checked on every call. MCP lists `prepare` only for keys whose owner is an administrator or manages at least one repository (refreshed within a minute). Keys are limited to 10 prepare calls a minute. Manual preparation is paced per repository across the website, HTTP and MCP. A request identical to a queued or running job (same group, kind and revision) returns that job and adds no queue load. Any other new request within 30 seconds of the repository's last accepted manual request returns `429 RATE_LIMITED` with `Retry-After`; one request queuing every monorepo group is a single request. Scheduled work is not paced. diff --git a/internal/httpserver/private_repository_test.go b/internal/httpserver/private_repository_test.go new file mode 100644 index 0000000..8dd045e --- /dev/null +++ b/internal/httpserver/private_repository_test.go @@ -0,0 +1,199 @@ +package httpserver + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/context4ai/sourcegraph/internal/control" + "github.com/context4ai/sourcegraph/internal/sitesettings" + "github.com/context4ai/sourcegraph/internal/sso" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type bearerTransport string + +func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.Header.Set("Authorization", "Bearer "+string(b)) + return http.DefaultTransport.RoundTrip(r) +} + +// personalToken stores an API key for user and returns the raw token. +func (s *site) personalToken(user string) string { + raw := "sgk_" + hexID("key-" + user)[:43] + h := sha256.Sum256([]byte(raw)) + s.people.mu.Lock() + defer s.people.mu.Unlock() + if s.people.keys == nil { + s.people.keys = map[string]sso.APIKey{} + } + s.people.keys[hex.EncodeToString(h[:])] = sso.APIKey{ID: "k-" + user[:8], UserID: user, Hash: hex.EncodeToString(h[:]), Created: time.Now()} + return raw +} + +// mcpSees reports whether the repositories and availability tools expose repo. +func mcpSees(t *testing.T, endpoint, token, repo string) (listed, available bool) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + transport := &mcp.StreamableClientTransport{Endpoint: endpoint} + if token != "" { + transport.HTTPClient = &http.Client{Transport: bearerTransport(token)} + } + session, err := mcp.NewClient(&mcp.Implementation{Name: "visibility-test", Version: "1"}, nil).Connect(ctx, transport, nil) + if err != nil { + t.Fatal(err) + } + defer session.Close() + res, err := session.CallTool(ctx, &mcp.CallToolParams{Name: "repositories", Arguments: map[string]any{}}) + if err != nil || res.IsError { + t.Fatalf("repositories: %v %v", err, res) + } + body, _ := json.Marshal(res) + listed = strings.Contains(string(body), repo) + res, err = session.CallTool(ctx, &mcp.CallToolParams{Name: "availability", Arguments: map[string]any{"repo": repo}}) + if err != nil { + t.Fatal(err) + } + return listed, !res.IsError +} + +func TestPrivateRepositoryVisibility(t *testing.T) { + admin, alice, bob, carol := hexID("admin"), hexID("alice"), hexID("bob"), hexID("carol") + s := newSite(t, map[string]string{"admin": admin, "alice": alice, "bob": bob, "carol": carol}, admin) + rev := s.settings.Snapshot().Access.Revision + if _, err := s.settings.Update(context.Background(), sitesettings.AccessName, rev, []byte(`{"public_read":true,"allow_registration":true,"allow_user_repositories":true}`), "sso:admin"); err != nil { + t.Fatal(err) + } + private := `{"name":"context4ai/private","policy":{"branches":["main"],"count":10},"code_provider":"github","public_read":false}` + if code, body := s.call(alice, "POST", "/sourcegraph/api/admin/v1/repos", private, "Idempotency-Key", "register-private"); code != 202 { + t.Fatalf("register: %d %v", code, body) + } + // A taken name answers 409 with its registrant, so others can ask to be added as managers. + code, conflict := s.call(bob, "POST", "/sourcegraph/api/admin/v1/repos", private, "Idempotency-Key", "register-bob") + creator, _ := conflict["creator"].(map[string]any) + if code != 409 || errorCode(conflict) != "REPOSITORY_EXISTS" || creator["name"] != "alice" || creator["email"] != "alice@example.com" { + t.Fatalf("taken name: %d %v", code, conflict) + } + s.db.mu.Lock() + r := s.db.repos["context4ai/private"] + r.ManagersSet, r.Managers = true, []string{alice, carol} + s.db.repos[r.Name] = r + s.db.mu.Unlock() + // Registered before per-repository visibility: no public_read, still public. + legacy := "context4ai/legacy" + if err := s.db.Create(context.Background(), control.Repository{ID: control.ID(legacy), Name: legacy, Enabled: true, Revision: 1, Heads: map[string]string{}, Versions: []control.Version{}}); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(s.handler) + defer server.Close() + endpoint := server.URL + "/sourcegraph/mcp" + catalog := func(cookie, bearer string) string { + r := httptest.NewRequest("GET", "/sourcegraph/v1/repos", nil) + if cookie != "" { + r.AddCookie(&http.Cookie{Name: "sourcegraph_session", Value: "cookie-" + cookie}) + } + if bearer != "" { + r.Header.Set("Authorization", "Bearer "+bearer) + } + w := httptest.NewRecorder() + s.handler.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("catalog: %d %s", w.Code, w.Body.String()) + } + return w.Body.String() + } + availability := "/sourcegraph/v1/repo/availability?repo=context4ai%2Fprivate" + service := strings.Repeat("t", 32) + + // Website: the creator and site administrators see it, other users and anonymous visitors do not. + for _, c := range []struct { + name, user string + want bool + }{{"creator", alice, true}, {"manager", carol, true}, {"administrator", admin, true}, {"other user", bob, false}, {"anonymous", "", false}} { + code, _ := s.call(c.user, "GET", availability, "") + listed := strings.Contains(catalog(c.user, ""), "context4ai/private") + if (code == 200) != c.want || listed != c.want { + t.Errorf("website %s: availability %d, listed %v, want %v", c.name, code, listed, c.want) + } + if !c.want && code != 404 { + t.Errorf("website %s: hidden repository must answer 404, got %d", c.name, code) + } + if !strings.Contains(catalog(c.user, ""), legacy) { + t.Errorf("website %s: legacy repository hidden", c.name) + } + } + + // MCP and HTTP: a personal token reads as its owner, an administrator's included. + for _, c := range []struct { + name, token string + want bool + }{{"anonymous", "", false}, {"creator token", s.personalToken(alice), true}, {"other user token", s.personalToken(bob), false}, {"administrator token", s.personalToken(admin), false}, {"service credential", service, true}} { + listed, available := mcpSees(t, endpoint, c.token, "context4ai/private") + if listed != c.want || available != c.want { + t.Errorf("MCP %s: listed %v, available %v, want %v", c.name, listed, available, c.want) + } + if got := strings.Contains(catalog("", c.token), "context4ai/private"); got != c.want { + t.Errorf("HTTP %s: listed %v, want %v", c.name, got, c.want) + } + if listed, available := mcpSees(t, endpoint, c.token, legacy); !listed || !available { + t.Errorf("MCP %s: legacy repository hidden", c.name) + } + } + + // Every repository route answers a hidden repository exactly like an unregistered name. + send := func(cookie, bearer, method, path, body string) string { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if cookie != "" { + r.AddCookie(&http.Cookie{Name: "sourcegraph_session", Value: "cookie-" + cookie}) + r.Header.Set("Origin", testOrigin) + r.Header.Set("X-CSRF-Token", "csrf-"+cookie) + } + if bearer != "" { + r.Header.Set("Authorization", "Bearer "+bearer) + } + r.Header.Set("Idempotency-Key", "visibility-probe") + w := httptest.NewRecorder() + s.handler.ServeHTTP(w, r) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return http.StatusText(w.Code) + " " + errorCode(out) + } + routes := []struct{ method, path, body string }{ + {"POST", "/sourcegraph/api/search", `{"Pattern":"x"}`}, + {"POST", "/sourcegraph/v1/repo/read", `{"Path":"README.md"}`}, + {"POST", "/sourcegraph/v1/repo/list", `{}`}, + {"POST", "/sourcegraph/v1/repo/diff", `{"Base":"main","Head":"main"}`}, + {"POST", "/sourcegraph/v1/repo/resolve", `{}`}, + {"GET", "/sourcegraph/v1/repo/plugins", ""}, + {"GET", "/sourcegraph/v1/repo/commits", ""}, + {"POST", "/sourcegraph/api/admin/v1/repo/prepare", `{"kind":"sync"}`}, + } + for _, c := range []struct { + name, cookie, bearer string + want bool + }{ + {"creator session", alice, "", true}, {"manager session", carol, "", true}, {"administrator session", admin, "", true}, + {"creator token", "", s.personalToken(alice), true}, {"manager token", "", s.personalToken(carol), true}, {"service credential", "", service, true}, + {"other user session", bob, "", false}, {"other user token", "", s.personalToken(bob), false}, {"administrator token", "", s.personalToken(admin), false}, {"anonymous", "", "", false}, + } { + for _, route := range routes { + private := send(c.cookie, c.bearer, route.method, route.path+"?repo=context4ai%2Fprivate", route.body) + missing := send(c.cookie, c.bearer, route.method, route.path+"?repo=context4ai%2Fmissing", route.body) + if c.want && strings.HasSuffix(private, "REPOSITORY_NOT_FOUND") { + t.Errorf("%s %s: visible repository answered %s", c.name, route.path, private) + } + if !c.want && private != missing { + t.Errorf("%s %s: hidden repository answered %q, unregistered name %q", c.name, route.path, private, missing) + } + } + } +} diff --git a/internal/httpserver/repository_access.go b/internal/httpserver/repository_access.go index 72b4ed3..77044c0 100644 --- a/internal/httpserver/repository_access.go +++ b/internal/httpserver/repository_access.go @@ -63,6 +63,12 @@ func authorizeDelegated(ctx context.Context, r *http.Request, s *service.Service return nil } +// registrationConflict is the 409 for a taken name, with its registrant. +type registrationConflict struct { + contract.ErrorResponse + Creator *sso.Person `json:"creator"` +} + // repositoryView adds who created and manages a repository, and whether the // caller may manage it. People are shown to signed-in callers only. type repositoryView struct { diff --git a/internal/httpserver/repository_access_test.go b/internal/httpserver/repository_access_test.go index 1ea8eb4..2775365 100644 --- a/internal/httpserver/repository_access_test.go +++ b/internal/httpserver/repository_access_test.go @@ -32,6 +32,7 @@ type people struct { mu sync.Mutex users map[string]sso.User sessions map[string]sso.Session + keys map[string]sso.APIKey access sso.AccessControl } @@ -77,6 +78,14 @@ func (p *people) GetSession(_ context.Context, id string) (sso.Session, error) { return sso.Session{}, sso.ErrNotFound } func (p *people) DeleteSession(context.Context, string) error { return nil } +func (p *people) FindAPIKey(_ context.Context, hash string) (sso.APIKey, error) { + p.mu.Lock() + defer p.mu.Unlock() + if k, ok := p.keys[hash]; ok { + return k, nil + } + return sso.APIKey{}, sso.ErrNotFound +} // repositories is an in-memory service.Store. type repositories struct { @@ -126,6 +135,7 @@ type site struct { handler http.Handler settings *sitesettings.Service db *repositories + people *people } // fakeIssuer serves just enough OIDC discovery for sso.New; no login runs. @@ -167,7 +177,7 @@ func newSite(t *testing.T, users map[string]string, admins ...string) *site { t.Fatal(err) } t.Cleanup(s.Close) - return &site{t: t, handler: RuntimeWithSettings(config.Defaults(), slog.New(slog.NewTextHandler(io.Discard, nil)), s, strings.Repeat("t", 32), settings, auth), settings: settings, db: db} + return &site{t: t, handler: RuntimeWithSettings(config.Defaults(), slog.New(slog.NewTextHandler(io.Discard, nil)), s, strings.Repeat("t", 32), settings, auth), settings: settings, db: db, people: store} } // call sends a browser request as user (empty for anonymous) and decodes the JSON body. diff --git a/internal/httpserver/runtime.go b/internal/httpserver/runtime.go index dca745d..c12609e 100644 --- a/internal/httpserver/runtime.go +++ b/internal/httpserver/runtime.go @@ -271,6 +271,7 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string } var result any var err error + var existsCreator *sso.Person decode := func(v any) bool { b, e := io.ReadAll(http.MaxBytesReader(w, r.Body, c.MaxBodyBytes)) if e == nil { @@ -333,7 +334,15 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string if q.SyncIntervalMinutes != nil { interval = *q.SyncIntervalMinutes } - result, err = s.RegisterWithGitTimeout(r.Context(), q.Name, policy, interval, q.IndexMode, q.PathGroups, q.PublicRead, q.CodeProvider, q.GitTimeoutMinutes, r.Header.Get("Idempotency-Key")) + var row control.Repository + row, err = s.RegisterWithGitTimeout(r.Context(), q.Name, policy, interval, q.IndexMode, q.PathGroups, q.PublicRead, q.CodeProvider, q.GitTimeoutMinutes, r.Header.Get("Idempotency-Key")) + result = row + // A taken name names its registrant, so the caller can ask to become a manager. + var ce *contract.Error + if row.CreatorID != "" && browserAuth != nil && errors.As(err, &ce) && ce.Code == "REPOSITORY_EXISTS" { + creator := browserAuth.People(r.Context(), []string{row.CreatorID})[0] + existsCreator = &creator + } } status = 202 } @@ -495,7 +504,11 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string if ce.RetryAfterSeconds > 0 { w.Header().Set("Retry-After", strconv.Itoa(ce.RetryAfterSeconds)) } - writeError(w, id, status, ce.Code, ce.Message) + if existsCreator != nil { + writeJSON(w, status, registrationConflict{ErrorResponse: contract.ErrorResponse{Error: ce.Message, Meta: contract.Meta{Protocol: "zoekt-single-repo-v1", RequestID: id, Status: "error", Code: ce.Code}}, Creator: existsCreator}) + } else { + writeError(w, id, status, ce.Code, ce.Message) + } } else { status = 503 writeError(w, id, status, "DEPENDENCY_UNAVAILABLE", "Operation could not complete within its budget.") diff --git a/internal/identity/identity.go b/internal/identity/identity.go index d69991f..cead134 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -52,10 +52,24 @@ func IsAdmin(ctx context.Context) bool { return value } +// ReadsAllRepositories reports whether repository visibility does not narrow +// the caller: an administrator session, the deployment credential, or +// background work that carries no request identity. Every HTTP request is +// anonymous, a session or a personal token by the time it reaches the service, +// so an empty actor never comes from a caller. +func ReadsAllRepositories(ctx context.Context) bool { + if IsAdmin(ctx) { + return true + } + actor, _ := ctx.Value(actorKey{}).(string) + return actor == "" && !IsAnonymous(ctx) +} + type ownerAdminKey struct{} // WithTokenOwnerAdmin records that a personal token's owner is currently an -// administrator. It only widens index preparation, never site management. +// administrator. It only widens index preparation of repositories the token can +// see, never repository visibility or site management. func WithTokenOwnerAdmin(ctx context.Context) context.Context { return context.WithValue(ctx, ownerAdminKey{}, true) } diff --git a/internal/service/prepare_access.go b/internal/service/prepare_access.go index 48b697b..bfa8cdf 100644 --- a/internal/service/prepare_access.go +++ b/internal/service/prepare_access.go @@ -9,11 +9,16 @@ import ( // AuthorizePrepare admits site administrators, the deployment credential, a // personal token whose owner is currently an administrator, and the -// repository's managers through their session or personal token. +// repository's managers through their session or personal token. An +// administrator's token prepares only repositories it can see. func (s *Service) AuthorizePrepare(ctx context.Context, name string) error { - if identity.IsAdmin(ctx) || identity.TokenOwnerAdmin(ctx) { + if identity.IsAdmin(ctx) { return nil } + if identity.TokenOwnerAdmin(ctx) { + _, err := s.Repo(ctx, name) + return err + } user := identity.UserID(ctx) if user == "" || identity.IsAnonymous(ctx) { return contract.Fail("ACCESS_DENIED", "Index preparation requires a repository manager or an administrator.", 403) diff --git a/internal/service/service.go b/internal/service/service.go index 1206ba9..47c1311 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -299,8 +299,9 @@ func (s *Service) Repo(ctx context.Context, name string) (control.Repository, er return control.Repository{}, e } r, e := s.DB.Get(ctx, name) - if e == nil && identity.IsAnonymous(ctx) && !r.AllowsAnonymous() { - return control.Repository{}, contract.Fail("REPOSITORY_NOT_FOUND", "Repository is not available.", 404) + if e == nil && !visible(ctx, r) { + // Same response as an unregistered name, so private names do not leak. + return control.Repository{}, contract.Fail("REPOSITORY_NOT_FOUND", "Repository is not registered.", 404) } return projectRepository(s.markStorage(r)), e } diff --git a/internal/service/visibility.go b/internal/service/visibility.go index 5082a88..d7a41f1 100644 --- a/internal/service/visibility.go +++ b/internal/service/visibility.go @@ -6,9 +6,22 @@ import ( "github.com/context4ai/sourcegraph/internal/identity" ) +// visible reports whether the caller may list, search and read r. Repositories +// registered before per-repository visibility have no public_read and stay +// public. A private repository is visible to its creator and managers, site +// administrators and the deployment credential; a personal token reads as its +// owner, so an administrator's token sees only what the owner created or manages. +func visible(ctx context.Context, r control.Repository) bool { + if r.AllowsAnonymous() || identity.ReadsAllRepositories(ctx) { + return true + } + user := identity.UserID(ctx) + return user != "" && !identity.IsAnonymous(ctx) && (r.CreatorID == user || r.IsManager(user)) +} + // Fill a visible page, rather than making hidden rows truncate pagination. func (s *Service) catalogRows(ctx context.Context, after string) ([]control.Repository, error) { - if !identity.IsAnonymous(ctx) { + if identity.ReadsAllRepositories(ctx) { return s.DB.List(ctx, after, 100) } out := []control.Repository{} @@ -18,7 +31,7 @@ func (s *Service) catalogRows(ctx context.Context, after string) ([]control.Repo return nil, err } for _, r := range rows { - if r.AllowsAnonymous() { + if visible(ctx, r) { out = append(out, r) if len(out) == 100 { return out, nil @@ -35,15 +48,44 @@ func (s *Service) catalogRows(ctx context.Context, after string) ([]control.Repo } } -// Anonymous site statistics must not disclose names of private repositories. +// Site statistics must not disclose names of repositories the caller cannot +// see, nor whether a guessed name is registered. Callers that do not read every +// repository keep only rows of registered repositories they can see, plus the +// "_overflow" aggregate; anonymous callers get no per-repository rows. func (s *Service) StatisticsFor(ctx context.Context) any { result := s.Statistics().(map[string]any) - if identity.IsAnonymous(ctx) { - buckets := result["buckets"].([]Bucket) - for i := range buckets { - buckets[i].ByRepo = map[string]Metric{} + if identity.ReadsAllRepositories(ctx) { + return result + } + shown := map[string]bool{} + if !identity.IsAnonymous(ctx) { + shown = s.visibleNames(ctx) + shown["_overflow"] = true + } + buckets := result["buckets"].([]Bucket) + for i := range buckets { + for name := range buckets[i].ByRepo { + if !shown[name] { + delete(buckets[i].ByRepo, name) + } } - result["buckets"] = buckets } + result["buckets"] = buckets return result } + +// visibleNames returns the registered repositories the caller can see, or an +// empty set when the registry cannot be read completely. +func (s *Service) visibleNames(ctx context.Context) map[string]bool { + names := map[string]bool{} + err := s.eachRepository(ctx, func(r control.Repository) error { + if visible(ctx, r) { + names[r.Name] = true + } + return nil + }) + if err != nil { + return map[string]bool{} + } + return names +} diff --git a/internal/service/visibility_test.go b/internal/service/visibility_test.go new file mode 100644 index 0000000..9ed6750 --- /dev/null +++ b/internal/service/visibility_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/context4ai/sourcegraph/internal/contract" + "github.com/context4ai/sourcegraph/internal/control" + "github.com/context4ai/sourcegraph/internal/identity" +) + +func visibilityFixture() *Service { + private, public := false, true + return &Service{DB: &collectionStore{repos: map[string]control.Repository{ + "org/private": {Name: "org/private", Enabled: true, CreatorID: "alice", PublicRead: &private}, + "org/managed": {Name: "org/managed", Enabled: true, CreatorID: "alice", ManagersSet: true, Managers: []string{"carol"}, PublicRead: &private}, + "org/public": {Name: "org/public", Enabled: true, CreatorID: "alice", PublicRead: &public}, + "org/legacy": {Name: "org/legacy", Enabled: true}, + }}} +} + +func TestPrivateRepositoriesAreVisibleToCreatorManagersAndAdministrators(t *testing.T) { + s := visibilityFixture() + base := context.Background() + all := []string{"org/legacy", "org/managed", "org/private", "org/public"} + public := []string{"org/legacy", "org/public"} + cases := []struct { + name string + ctx context.Context + want []string + }{ + {"anonymous", identity.WithAnonymous(base, "anon"), public}, + {"other user session", identity.WithActor(base, "sso:bob"), public}, + {"other user token", identity.WithActor(base, "api-key:bob:k"), public}, + {"creator session", identity.WithActor(base, "sso:alice"), all}, + {"creator token", identity.WithActor(base, "api-key:alice:k"), all}, + {"manager token", identity.WithActor(base, "api-key:carol:k"), []string{"org/legacy", "org/managed", "org/public"}}, + {"administrator session", identity.WithAdmin(identity.WithActor(base, "sso:admin")), all}, + {"administrator token", identity.WithTokenOwnerAdmin(identity.WithActor(base, "api-key:admin:k")), public}, + {"service credential", identity.WithAdmin(base), all}, + {"background work", base, all}, + } + for _, c := range cases { + want := map[string]bool{} + for _, name := range c.want { + want[name] = true + } + rows, err := s.catalogRows(c.ctx, "") + if err != nil || len(rows) != len(c.want) { + t.Errorf("%s catalog: %d rows %v, want %v", c.name, len(rows), err, c.want) + } + for _, r := range rows { + if !want[r.Name] { + t.Errorf("%s catalog lists %s", c.name, r.Name) + } + } + for _, name := range all { + _, err := s.Repo(c.ctx, name) + var ce *contract.Error + if want[name] != (err == nil) { + t.Errorf("%s repo %s: %v", c.name, name, err) + } else if err != nil && (!errors.As(err, &ce) || ce.Code != "REPOSITORY_NOT_FOUND" || ce.Message != "Repository is not registered.") { + t.Errorf("%s repo %s: hidden repository must look unregistered, got %v", c.name, name, err) + } + } + } +} + +func TestAdministratorTokenPreparesOnlyVisibleRepositories(t *testing.T) { + s := visibilityFixture() + base := context.Background() + token := identity.WithTokenOwnerAdmin(identity.WithActor(base, "api-key:admin:k")) + if err := s.AuthorizePrepare(token, "org/public"); err != nil { + t.Fatalf("public repository: %v", err) + } + var ce *contract.Error + if err := s.AuthorizePrepare(token, "org/private"); !errors.As(err, &ce) || ce.Code != "REPOSITORY_NOT_FOUND" { + t.Fatalf("private repository: %v", err) + } + own := identity.WithTokenOwnerAdmin(identity.WithActor(base, "api-key:alice:k")) + if err := s.AuthorizePrepare(own, "org/private"); err != nil { + t.Fatalf("administrator's own repository: %v", err) + } + if err := s.AuthorizePrepare(identity.WithAdmin(identity.WithActor(base, "sso:admin")), "org/private"); err != nil { + t.Fatalf("administrator session: %v", err) + } + if err := s.AuthorizePrepare(identity.WithActor(base, "api-key:bob:k"), "org/private"); !errors.As(err, &ce) || ce.Code != "REPOSITORY_NOT_FOUND" { + t.Fatalf("other user's token: %v", err) + } +} + +func TestStatisticsOmitHiddenRepositoryNames(t *testing.T) { + s := visibilityFixture() + minute := time.Now().Unix() / 60 * 60 + s.metrics.buckets = map[int64]*Bucket{minute: {Minute: minute, ByOperation: map[string]Metric{"read": {Requests: 3}}, ByRepo: map[string]Metric{"org/private": {Requests: 1}, "org/public": {Requests: 1}, "org/guessed": {Requests: 1}, "_overflow": {Requests: 1}}}} + names := func(ctx context.Context) map[string]bool { + out := map[string]bool{} + for _, b := range s.StatisticsFor(ctx).(map[string]any)["buckets"].([]Bucket) { + for name := range b.ByRepo { + out[name] = true + } + } + return out + } + base := context.Background() + if got := names(identity.WithAnonymous(base, "anon")); len(got) != 0 { + t.Fatalf("anonymous: %v", got) + } + // A guessed unregistered name and a hidden private one look the same. + if got := names(identity.WithActor(base, "sso:bob")); got["org/private"] || got["org/guessed"] || !got["org/public"] || !got["_overflow"] { + t.Fatalf("other user: %v", got) + } + if got := names(identity.WithActor(base, "sso:alice")); !got["org/private"] { + t.Fatalf("creator: %v", got) + } + if got := names(identity.WithAdmin(base)); !got["org/private"] || !got["org/guessed"] { + t.Fatalf("administrator: %v", got) + } +} diff --git a/web/assets/api.js b/web/assets/api.js index 8bb589e..ff44866 100644 --- a/web/assets/api.js +++ b/web/assets/api.js @@ -66,7 +66,7 @@ if (!response.ok) { var code = body.Meta && body.Meta.Code || body.error || 'HTTP_' + response.status; var error = new Error(message(code, body.Error || body.message || code)); - error.code = code; error.status = response.status; error.requestId = response.headers.get('X-Request-ID') || body.Meta && body.Meta.RequestID; + error.code = code; error.status = response.status; error.creator = body.creator; error.requestId = response.headers.get('X-Request-ID') || body.Meta && body.Meta.RequestID; error.detail = body.Error || ''; error.retryAfter = response.headers.get('Retry-After'); throw error; } diff --git a/web/assets/repo-page.js b/web/assets/repo-page.js index 25a741e..c687b48 100644 --- a/web/assets/repo-page.js +++ b/web/assets/repo-page.js @@ -203,7 +203,7 @@ '

'+t('默认继承全局;用于 Git 拉取及文件对象补拉,新任务启动时生效。','Inherit global by default; used for Git fetching and object hydration when a new task starts.')+'

'+ '

'+t('包含 Git、索引和任务临时文件;达到预算会停止新增准备。','Includes Git, indexes and temporary job files; reaching the budget stops new preparation.')+'

'+ '

'+t('手动准备的版本自动固定。删除此处的 SHA 后,该版本重新受保留策略约束。','Manually prepared commits are pinned automatically. Removing a SHA here returns it to ordinary retention.')+'

'+ - ''+ + ''+ ''+ ''+ ''+ @@ -226,7 +226,7 @@ const shrink=cur.days&&next.days?next.days{if(e.key==='Escape')close();if(e.key==='Tab'&&!$('#drawer').classList.contains('hidden')){const ns=[...$('#drawer').querySelectorAll('button,input,select')].filter(n=>!n.disabled);const a=ns[0],z=ns[ns.length-1];if(e.shiftKey&&document.activeElement===a){e.preventDefault();z.focus();}else if(!e.shiftKey&&document.activeElement===z){e.preventDefault();a.focus();}}}); $('#f-name').addEventListener('input',()=>{$('#form-error').textContent='';}); $('#f-sync-interval').addEventListener('change',()=>{$('#form-error').textContent='';}); + // A name registered by someone else may be hidden; point to its registrant instead. + function taken(c){const who=c&&c.name?c.name+(c.email?' ('+c.email+')':''):'';return who?t('该仓库已由 '+who+' 登记。如需管理,请联系对方将你加入仓库管理员','This repository is already registered by '+who+'. To manage it, ask them to add you as a repository manager'):t('该仓库已登记。如需管理,请联系站点管理员将你加入仓库管理员','This repository is already registered. To manage it, ask a site administrator to add you as a repository manager');} async function submit(e){if(e)e.preventDefault();if(submitting||!M.manage(canRegister())||!$('#form').reportValidity())return;let body; try {const name=M.repositoryName($('#f-name').value);if(rows.some(r=>r.name===name))throw new Error(t('该仓库已登记,请到详情页修改','This repository is already registered. Edit it from its details page'));body={name,git_timeout_minutes:M.gitTimeout($('#f-git-timeout').value),code_provider:$('#f-code-provider').value,index_mode:$('#f-index-mode').value,public_read:$('#f-ack').checked,policy:{branches:branchInput.value(),...(branchInput.usesDefault()?{default_branch:true}:{})},sync_interval_minutes:M.syncInterval($('#f-sync-interval').value)};if(body.index_mode==='monorepo')body.path_groups=pathInput.value();} catch(e){$('#form-error').textContent=e.message;return;} submitting=true;$('#submit').disabled=true;$('#submit').textContent=t('提交中','Submitting');$('#form-error').textContent=''; try {const r=await API.request('/api/admin/v1/repos',{method:'POST',body,headers:{'Idempotency-Key':keys.get(body)}});keys.done(body);A.toast(t('已登记,首轮同步已排队','Registered, first sync queued'));location.assign('/sourcegraph/repo?repo='+encodeURIComponent(r.name)+'&tab=jobs');} - catch(e){$('#form-error').textContent=M.error(e);} + catch(e){$('#form-error').textContent=e.code==='REPOSITORY_EXISTS'?taken(e.creator):M.error(e);} finally{submitting=false;$('#submit').disabled=false;$('#submit').textContent=t('登记并准备','Register and prepare');} } $('#submit').onclick=submit;$('#form').onsubmit=submit; diff --git a/web/repos.html b/web/repos.html index 4d429c5..ce1f88b 100644 --- a/web/repos.html +++ b/web/repos.html @@ -73,7 +73,7 @@

默认继承全局,可单独设置,最多 60 分钟。

- +