From f9f6168979b7cff24d0c90d5ddf460fb9e8d8cdf Mon Sep 17 00:00:00 2001 From: qiansc Date: Tue, 27 Oct 2026 07:11:19 +0000 Subject: [PATCH 1/6] feat: let users register and manage their own repositories - Site setting access.allow_user_repositories (default off; older records read as off) lets signed-in users register repositories. - Per-repository managers: the creator manages by default; managers and site admins can use every ?repo= management route and add/remove managers, including themselves. - Repository details show the creator and managers to signed-in users; people search for choosing managers. - Clone failures distinguish a missing or invisible repository and guide the creator to their code source token. Co-Authored-By: Claude Opus 5.5 --- docs/authentication.md | 2 +- docs/interfaces.md | 15 + internal/control/managers_test.go | 21 ++ internal/control/model.go | 37 +- internal/gitstore/remote_error.go | 5 + internal/gitstore/remote_error_test.go | 24 ++ internal/httpserver/repository_access.go | 84 +++++ internal/httpserver/repository_access_test.go | 327 ++++++++++++++++++ internal/httpserver/runtime.go | 50 ++- internal/httpserver/server.go | 2 +- internal/httpserver/website_access.go | 4 +- internal/identity/identity.go | 12 + internal/service/code_credentials.go | 2 +- internal/service/managers.go | 72 ++++ internal/service/managers_test.go | 71 ++++ internal/sitesettings/access_test.go | 45 +++ internal/sitesettings/model.go | 16 +- internal/sitesettings/service.go | 7 +- internal/sso/handler.go | 10 +- internal/sso/people.go | 106 ++++++ web/assets/api.js | 18 +- web/assets/management.js | 5 +- web/assets/repo-maintenance.js | 2 +- web/assets/repo-page.js | 59 +++- web/assets/repos-page.js | 6 +- web/assets/settings-page.js | 4 +- web/settings.html | 1 + web/user-settings.html | 2 +- 28 files changed, 964 insertions(+), 45 deletions(-) create mode 100644 internal/control/managers_test.go create mode 100644 internal/gitstore/remote_error_test.go create mode 100644 internal/httpserver/repository_access.go create mode 100644 internal/httpserver/repository_access_test.go create mode 100644 internal/service/managers.go create mode 100644 internal/service/managers_test.go create mode 100644 internal/sitesettings/access_test.go create mode 100644 internal/sso/people.go diff --git a/docs/authentication.md b/docs/authentication.md index 0497f97..8bb7f50 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,7 +2,7 @@ Google OIDC and GitHub OAuth identify users by provider and stable subject ID. Matching email addresses do not merge identities or grant administrator access. The first verified identity atomically becomes the administrator. Configure one provider and complete the owner's first sign-in before enabling other providers or promoting the site. -The site setting **Allow other users to sign in or register** defaults to off. Administrators may still sign in. Rejected users receive no profile or session; existing non-administrator sessions and personal API keys also lose access. Anonymous reading of explicitly public repositories remains available. Administrators can edit both language notices; the refusal page returns to a validated local reading page after five seconds. +The site setting **Allow other users to sign in or register** defaults to off. Administrators may still sign in. Rejected users receive no profile or session; existing non-administrator sessions and personal API keys also lose access. Anonymous reading of explicitly public repositories remains available. A separate setting decides whether signed-in users may register repositories; see [Repository managers](interfaces.md#repository-managers). Administrators can edit both language notices; the refusal page returns to a validated local reading page after five seconds. ## Demo callback addresses diff --git a/docs/interfaces.md b/docs/interfaces.md index e78170d..debc81a 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -50,6 +50,21 @@ 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 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, and personal API keys keep their read-only scope. + +| Route | Purpose | +| --- | --- | +| `POST /sourcegraph/api/admin/v1/repo/managers?repo=` | Body `{"add":""}` or `{"remove":""}` with `If-Match: ""`; the added user must exist and be enabled (`404 USER_NOT_FOUND`) | +| `GET /sourcegraph/auth/users/search?q=` | Signed-in users find up to 20 enabled users by name or email (`id/name/email/department`) to choose a manager | + +Repository details (`/sourcegraph/v1/repo/availability`, `GET /sourcegraph/api/admin/v1/repo`) add `can_manage`, and for signed-in callers `creator` and `managers` with `id/name/email/department`. Anonymous public reads see neither. `GET /sourcegraph/access-policy` adds `user_repositories`. + +Fetching a user-registered repository uses the same credentials as any other: the creator's own code-source token first, then the site-wide token. Turning the setting on therefore lets signed-in users register any repository the site-wide token can read. Fetch failures name the cause (`GIT_REPOSITORY_UNAVAILABLE` for a missing repository or one the token cannot see, `GIT_ACCESS_DENIED`, `GIT_AUTH_FAILED`, `GIT_CREDENTIAL_UNAVAILABLE`, `GIT_FETCH_FAILED`), and the website links to the creator's code-source token settings. + ### Code sources Code sources, their settings and token order are described in [code sources and tokens](code-sources.md). diff --git a/internal/control/managers_test.go b/internal/control/managers_test.go new file mode 100644 index 0000000..b07c5a3 --- /dev/null +++ b/internal/control/managers_test.go @@ -0,0 +1,21 @@ +package control + +import "testing" + +func TestEffectiveManagersFallBackToTheCreatorUntilEdited(t *testing.T) { + legacy := Repository{CreatorID: "u1"} + if got := legacy.EffectiveManagers(); len(got) != 1 || got[0] != "u1" || !legacy.IsManager("u1") || legacy.IsManager("u2") { + t.Fatalf("legacy repository managers = %v", got) + } + if (Repository{}).IsManager("") || len((Repository{}).EffectiveManagers()) != 0 { + t.Fatal("service-registered repositories have no managers") + } + edited := Repository{CreatorID: "u1", ManagersSet: true} + if edited.IsManager("u1") || len(edited.EffectiveManagers()) != 0 { + t.Fatal("an emptied manager list must not fall back to the creator") + } + edited.Managers = []string{"u2"} + if !edited.IsManager("u2") || edited.IsManager("u1") { + t.Fatal("edited managers replace the creator fallback") + } +} diff --git a/internal/control/model.go b/internal/control/model.go index 56136a5..2f12331 100644 --- a/internal/control/model.go +++ b/internal/control/model.go @@ -113,8 +113,14 @@ type Repository struct { SyncChecks map[string]SyncCheck `json:"sync_checks,omitempty" bson:"sync_checks,omitempty"` CodeProvider string `json:"code_provider,omitempty" bson:"code_provider,omitempty"` // CodeHost pins the HTTPS origin chosen at registration; later source changes affect new repositories only. - CodeHost string `json:"code_host,omitempty" bson:"code_host,omitempty"` - CreatorID string `json:"created_by,omitempty" bson:"created_by,omitempty"` + CodeHost string `json:"code_host,omitempty" bson:"code_host,omitempty"` + CreatorID string `json:"created_by,omitempty" bson:"created_by,omitempty"` + // Managers lists user IDs that may manage this repository besides site + // administrators. Records saved before managers existed have ManagersSet + // false and fall back to their creator; once the list is edited it is kept + // as stored, even when empty. + Managers []string `json:"-" bson:"managers,omitempty"` + ManagersSet bool `json:"-" bson:"managers_set,omitempty"` LeaseRevision int64 `json:"-" bson:"lease_revision,omitempty"` DiskUsage *DiskUsage `json:"disk_usage,omitempty" bson:"disk_usage,omitempty"` DiskUsagePending bool `json:"-" bson:"disk_usage_pending,omitempty"` @@ -149,6 +155,33 @@ type Repository struct { ObservedAt time.Time `json:"observed_at" bson:"observed_at"` } +// MaxManagers bounds the per-repository manager list stored in one document. +const MaxManagers = 50 + +// EffectiveManagers returns the user IDs allowed to manage this repository in +// addition to site administrators. +func (r Repository) EffectiveManagers() []string { + if r.ManagersSet { + return append([]string(nil), r.Managers...) + } + if r.CreatorID != "" { + return []string{r.CreatorID} + } + return nil +} + +func (r Repository) IsManager(userID string) bool { + if userID == "" { + return false + } + for _, id := range r.EffectiveManagers() { + if id == userID { + return true + } + } + return false +} + func ID(name string) string { h := sha256.Sum256([]byte(name)); return hex.EncodeToString(h[:]) } func NewID() string { var b [16]byte diff --git a/internal/gitstore/remote_error.go b/internal/gitstore/remote_error.go index bb2b149..3aaa270 100644 --- a/internal/gitstore/remote_error.go +++ b/internal/gitstore/remote_error.go @@ -14,6 +14,11 @@ func remoteFailure(diagnostic, code, message string) error { if strings.Contains(text, "authentication failed") || httpStatus(text, "401") || strings.Contains(text, "invalid token") || strings.Contains(text, "expired token") { return contract.Fail("GIT_AUTH_FAILED", "Code-service credential was rejected; update the credential.", 401) } + // Code hosts answer "not found" both for missing repositories and for private + // ones the credential cannot see, so the message names both causes. + if httpStatus(text, "404") || strings.Contains(text, "repository not found") || (strings.Contains(text, "repository '") && strings.Contains(text, "' not found")) { + return contract.Fail("GIT_REPOSITORY_UNAVAILABLE", "Repository not found, or the code-service credential used cannot see it.", 404) + } return contract.Fail(code, message, 503) } diff --git a/internal/gitstore/remote_error_test.go b/internal/gitstore/remote_error_test.go new file mode 100644 index 0000000..dbdbe83 --- /dev/null +++ b/internal/gitstore/remote_error_test.go @@ -0,0 +1,24 @@ +package gitstore + +import ( + "errors" + "testing" + + "github.com/context4ai/sourcegraph/internal/contract" +) + +func TestRemoteFailureNamesMissingOrInvisibleRepositories(t *testing.T) { + for diagnostic, want := range map[string]string{ + "remote: Repository not found.\nfatal: repository 'https://code.example.com/a/b.git/' not found": "GIT_REPOSITORY_UNAVAILABLE", + "fatal: unable to access 'https://x/a/b.git/': The requested URL returned error: 404": "GIT_REPOSITORY_UNAVAILABLE", + "error: 404 Not Found": "GIT_REPOSITORY_UNAVAILABLE", + "fatal: Authentication failed for 'https://x'": "GIT_AUTH_FAILED", + "error: 403 Forbidden": "GIT_ACCESS_DENIED", + "fatal: unable to connect": "GIT_FETCH_FAILED", + } { + var ce *contract.Error + if err := remoteFailure(diagnostic, "GIT_FETCH_FAILED", "fetch failed"); !errors.As(err, &ce) || ce.Code != want { + t.Errorf("%q => %v, want %s", diagnostic, err, want) + } + } +} diff --git a/internal/httpserver/repository_access.go b/internal/httpserver/repository_access.go new file mode 100644 index 0000000..963963b --- /dev/null +++ b/internal/httpserver/repository_access.go @@ -0,0 +1,84 @@ +package httpserver + +import ( + "context" + "net/http" + + "github.com/context4ai/sourcegraph/internal/contract" + "github.com/context4ai/sourcegraph/internal/control" + "github.com/context4ai/sourcegraph/internal/identity" + "github.com/context4ai/sourcegraph/internal/service" + "github.com/context4ai/sourcegraph/internal/sitesettings" + "github.com/context4ai/sourcegraph/internal/sso" +) + +// repositoryManagementRoute lists the ?repo= management routes a repository +// manager may use on that repository. The site-wide routes stay admin-only. +func repositoryManagementRoute(path string) bool { + switch path { + case Prefix + "/api/admin/v1/repo", Prefix + "/api/admin/v1/repo/preview", Prefix + "/api/admin/v1/repo/prepare", + Prefix + "/api/admin/v1/repo/commit-options", Prefix + "/api/admin/v1/repo/index/preview", Prefix + "/api/admin/v1/repo/index", + Prefix + "/api/admin/v1/repo/cleanup/preview", Prefix + "/api/admin/v1/repo/cleanup", + Prefix + "/api/admin/v1/repo/delete/preview", Prefix + "/api/admin/v1/repo/delete", + Prefix + "/api/admin/v1/repo/jobs/cancel", Prefix + "/api/admin/v1/repo/purge", Prefix + "/api/admin/v1/repo/managers": + return true + } + return false +} + +func registrationRoute(r *http.Request) bool { + return r.Method == "POST" && r.URL.Path == Prefix+"/api/admin/v1/repos" +} + +// delegatedRoute is opened to signed-in users at the gate; the handler then +// requires site management, repository management or the registration setting. +func delegatedRoute(r *http.Request) bool { + return registrationRoute(r) || (repositoryManagementRoute(r.URL.Path) && !publicRead(r)) +} + +// authorizeDelegated runs after authentication. Site administrators and the +// deployment credential pass; others need the setting (registration) or to be +// a manager of the target repository. +func authorizeDelegated(ctx context.Context, r *http.Request, s *service.Service, settings *sitesettings.Service, repo string) error { + if identity.IsAdmin(ctx) { + return nil + } + user := identity.UserID(ctx) + if registrationRoute(r) { + if user != "" && settings.AllowUserRepositories() { + return nil + } + return contract.Fail("REGISTRATION_DISABLED", "Only administrators can register repositories on this site.", 403) + } + row, err := s.Repo(ctx, repo) + if err != nil { + return err + } + if !row.IsManager(user) { + return contract.Fail("REPOSITORY_MANAGEMENT_DENIED", "Only this repository's managers or a site administrator can manage it.", 403) + } + return nil +} + +// 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 { + control.Repository + CanManage bool `json:"can_manage"` + Creator *sso.Person `json:"creator,omitempty"` + Managers []sso.Person `json:"managers,omitempty"` +} + +func viewRepository(ctx context.Context, row control.Repository, auth *sso.Handler) repositoryView { + user := identity.UserID(ctx) + view := repositoryView{Repository: row, CanManage: identity.IsAdmin(ctx) || row.IsManager(user)} + if auth == nil || (user == "" && !identity.IsAdmin(ctx)) { + return view + } + if row.CreatorID != "" { + creator := auth.People(ctx, []string{row.CreatorID})[0] + view.Creator = &creator + } + view.Managers = auth.People(ctx, row.EffectiveManagers()) + return view +} diff --git a/internal/httpserver/repository_access_test.go b/internal/httpserver/repository_access_test.go new file mode 100644 index 0000000..aef5ae7 --- /dev/null +++ b/internal/httpserver/repository_access_test.go @@ -0,0 +1,327 @@ +package httpserver + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/context4ai/sourcegraph/internal/config" + "github.com/context4ai/sourcegraph/internal/contract" + "github.com/context4ai/sourcegraph/internal/control" + "github.com/context4ai/sourcegraph/internal/service" + "github.com/context4ai/sourcegraph/internal/sitesettings" + "github.com/context4ai/sourcegraph/internal/sso" +) + +const testOrigin = "https://site.test" + +func hexID(seed string) string { h := sha256.Sum256([]byte(seed)); return hex.EncodeToString(h[:]) } + +// people is an in-memory sso.Store holding users, sessions and the admin set. +type people struct { + mu sync.Mutex + users map[string]sso.User + sessions map[string]sso.Session + access sso.AccessControl +} + +func (p *people) GetAccess(context.Context) (sso.AccessControl, error) { return p.access, nil } +func (p *people) CreateAccess(context.Context, sso.AccessControl) (bool, error) { + return false, nil +} +func (p *people) ReplaceAccess(context.Context, int64, sso.AccessControl) (bool, error) { + return false, nil +} +func (p *people) ListUsers(_ context.Context, q sso.UserQuery, _ []string) ([]sso.User, error) { + p.mu.Lock() + defer p.mu.Unlock() + out := []sso.User{} + for _, u := range p.users { + if strings.Contains(strings.ToLower(u.Name+" "+u.Email), strings.ToLower(q.Q)) { + out = append(out, u) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} +func (p *people) PutFlow(context.Context, sso.Flow) error { return nil } +func (p *people) ConsumeFlow(context.Context, string, string, time.Time) (sso.Flow, error) { + return sso.Flow{}, sso.ErrNotFound +} +func (p *people) UpsertUser(_ context.Context, u sso.User) (sso.User, error) { return u, nil } +func (p *people) GetUser(_ context.Context, id string) (sso.User, error) { + p.mu.Lock() + defer p.mu.Unlock() + if u, ok := p.users[id]; ok { + return u, nil + } + return sso.User{}, sso.ErrNotFound +} +func (p *people) PutSession(context.Context, sso.Session) error { return nil } +func (p *people) GetSession(_ context.Context, id string) (sso.Session, error) { + p.mu.Lock() + defer p.mu.Unlock() + if s, ok := p.sessions[id]; ok { + return s, nil + } + return sso.Session{}, sso.ErrNotFound +} +func (p *people) DeleteSession(context.Context, string) error { return nil } + +// repositories is an in-memory service.Store. +type repositories struct { + mu sync.Mutex + repos map[string]control.Repository +} + +func (db *repositories) Get(_ context.Context, name string) (control.Repository, error) { + db.mu.Lock() + defer db.mu.Unlock() + if r, ok := db.repos[name]; ok { + return r, nil + } + return control.Repository{}, contract.Fail("REPOSITORY_NOT_FOUND", "Repository is not registered.", 404) +} +func (db *repositories) List(_ context.Context, after string, limit int) ([]control.Repository, error) { + db.mu.Lock() + defer db.mu.Unlock() + out := []control.Repository{} + for _, r := range db.repos { + if r.ID > after { + out = append(out, r) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} +func (db *repositories) Create(_ context.Context, r control.Repository) error { + db.mu.Lock() + defer db.mu.Unlock() + db.repos[r.Name] = r + return nil +} +func (db *repositories) Replace(_ context.Context, r control.Repository, _ int64) error { + return db.Create(context.Background(), r) +} +func (db *repositories) ReplaceLease(_ context.Context, r control.Repository, _ int64) error { + return db.Create(context.Background(), r) +} +func (db *repositories) NextGeneration(context.Context) (uint32, error) { return 1, nil } + +type site struct { + t *testing.T + handler http.Handler + settings *sitesettings.Service + db *repositories +} + +// fakeIssuer serves just enough OIDC discovery for sso.New; no login runs. +func fakeIssuer(t *testing.T) { + var issuer string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"issuer": issuer, "authorization_endpoint": issuer + "/auth", "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", "id_token_signing_alg_values_supported": []string{"HS256"}}) + })) + issuer = server.URL + previous := sso.Issuer + sso.Issuer = issuer + t.Cleanup(func() { sso.Issuer = previous; server.Close() }) +} + +func newSite(t *testing.T, users map[string]string, admins ...string) *site { + fakeIssuer(t) + store := &people{users: map[string]sso.User{}, sessions: map[string]sso.Session{}, access: sso.AccessControl{ID: "website", Revision: 1, AdminIDs: admins}} + for name, id := range users { + store.users[id] = sso.User{ID: id, Name: name, Email: name + "@example.com", Department: "Team", Enabled: true} + store.sessions[hexID("cookie-"+id)] = sso.Session{ID: hexID("cookie-" + id), UserID: id, CSRF: "csrf-" + id, Expires: time.Now().Add(time.Hour)} + } + auth, err := sso.New(context.Background(), sso.Config{Enabled: true, Origin: testOrigin, ClientID: "client", SigningAlgorithm: "HS256"}, "secret", store) + if err != nil { + t.Fatal(err) + } + settings, err := sitesettings.New(context.Background(), sitesettings.NewMemoryStore(), false) + if err != nil { + t.Fatal(err) + } + // Community sites admit non-administrators only while other users may sign in. + auth.Settings = settings + if _, err = settings.Update(context.Background(), sitesettings.AccessName, settings.Snapshot().Access.Revision, []byte(`{"public_read":false,"allow_registration":true}`), "sso:admin"); err != nil { + t.Fatal(err) + } + db := &repositories{repos: map[string]control.Repository{}} + s, err := service.New(db, t.TempDir(), "", "") + if err != nil { + 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} +} + +// call sends a browser request as user (empty for anonymous) and decodes the JSON body. +func (s *site) call(user, method, path, body string, headers ...string) (int, map[string]any) { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if user != "" { + r.AddCookie(&http.Cookie{Name: "sourcegraph_session", Value: "cookie-" + user}) + r.Header.Set("Origin", testOrigin) + r.Header.Set("X-CSRF-Token", "csrf-"+user) + } + for i := 0; i+1 < len(headers); i += 2 { + r.Header.Set(headers[i], headers[i+1]) + } + w := httptest.NewRecorder() + s.handler.ServeHTTP(w, r) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out +} + +func errorCode(body map[string]any) string { + if meta, ok := body["Meta"].(map[string]any); ok { + code, _ := meta["Code"].(string) + return code + } + code, _ := body["error"].(string) + return code +} + +func (s *site) allowUsers(on bool) { + rev := s.settings.Snapshot().Access.Revision + values, _ := json.Marshal(map[string]bool{"public_read": s.settings.PublicRead(), "allow_registration": true, "allow_user_repositories": on}) + if _, err := s.settings.Update(context.Background(), sitesettings.AccessName, rev, values, "sso:admin"); err != nil { + s.t.Fatal(err) + } +} + +const register = `{"name":"context4ai/agent-graph","policy":{"branches":["main"],"count":10},"code_provider":"github"}` + +func TestOrdinaryUsersRegisterAndManageTheirRepositories(t *testing.T) { + admin, alice, bob := hexID("admin"), hexID("alice"), hexID("bob") + s := newSite(t, map[string]string{"admin": admin, "alice": alice, "bob": bob}, admin) + repo := "/sourcegraph/api/admin/v1/repo" + q := "?repo=context4ai%2Fagent-graph" + + // Off by default: only administrators and the service credential register. + if code, body := s.call(alice, "POST", "/sourcegraph/api/admin/v1/repos", register, "Idempotency-Key", "register-k1"); code != 403 || errorCode(body) != "REGISTRATION_DISABLED" { + t.Fatalf("registration while off: %d %v", code, body) + } + if _, policy := s.call("", "GET", "/sourcegraph/access-policy", ""); policy["user_repositories"] != false { + t.Fatalf("access policy while off: %v", policy) + } + + s.allowUsers(true) + if _, policy := s.call("", "GET", "/sourcegraph/access-policy", ""); policy["user_repositories"] != true { + t.Fatalf("access policy while on: %v", policy) + } + if code, body := s.call(alice, "POST", "/sourcegraph/api/admin/v1/repos", register, "Idempotency-Key", "register-k1"); code != 202 || body["created_by"] != alice { + t.Fatalf("user registration: %d %v", code, body) + } + + // The creator sees themselves as creator and manager; others see the same people but cannot manage. + code, view := s.call(alice, "GET", "/sourcegraph/v1/repo/availability"+q, "") + creator, _ := view["creator"].(map[string]any) + if code != 200 || view["can_manage"] != true || creator["name"] != "alice" || len(view["managers"].([]any)) != 1 { + t.Fatalf("creator view: %d %v", code, view) + } + if _, view = s.call(bob, "GET", repo+q, ""); view["can_manage"] != false || view["creator"] == nil { + t.Fatalf("other user view: %v", view) + } + // With public read on, anonymous callers see the repository but not who manages it. + rev0 := s.settings.Snapshot().Access.Revision + if _, err := s.settings.Update(context.Background(), sitesettings.AccessName, rev0, []byte(`{"public_read":true,"allow_registration":true,"allow_user_repositories":true}`), "sso:admin"); err != nil { + t.Fatal(err) + } + if code, view := s.call("", "GET", "/sourcegraph/v1/repo/availability"+q, ""); code != 200 || view["creator"] != nil || view["managers"] != nil || view["can_manage"] != false { + t.Fatalf("anonymous view: %d %v", code, view) + } + + // Repository-scoped management: bob is refused, alice and the admin pass the gate. + cancel := `{"job_id":"missing"}` + if code, body := s.call(bob, "POST", repo+"/jobs/cancel"+q, cancel); code != 403 || errorCode(body) != "REPOSITORY_MANAGEMENT_DENIED" { + t.Fatalf("non-manager cancel: %d %v", code, body) + } + for _, who := range []string{alice, admin} { + if code, body := s.call(who, "POST", repo+"/jobs/cancel"+q, cancel); code == 403 { + t.Fatalf("manager gate refused %s: %v", who, body) + } + } + // Site-wide administration stays admin-only. + if code, _ := s.call(alice, "GET", "/sourcegraph/api/admin/v1/instances", ""); code != 403 { + t.Fatalf("instances for a repository manager: %d", code) + } + + // Managers add and remove managers, including themselves. + rev := func() string { + r, _ := s.db.Get(context.Background(), "context4ai/agent-graph") + return `"` + itoa(r.Revision) + `"` + } + if code, body := s.call(alice, "POST", repo+"/managers"+q, `{"add":"`+hexID("nobody")+`"}`, "If-Match", rev()); code != 404 || errorCode(body) != "USER_NOT_FOUND" { + t.Fatalf("unknown manager: %d %v", code, body) + } + if code, body := s.call(bob, "POST", repo+"/managers"+q, `{"add":"`+bob+`"}`, "If-Match", rev()); code != 403 { + t.Fatalf("non-manager added themselves: %d %v", code, body) + } + if code, body := s.call(alice, "POST", repo+"/managers"+q, `{"add":"`+bob+`"}`, "If-Match", rev()); code != 200 || len(body["managers"].([]any)) != 2 { + t.Fatalf("add manager: %d %v", code, body) + } + if code, body := s.call(bob, "POST", repo+"/managers"+q, `{"remove":"`+alice+`"}`, "If-Match", rev()); code != 200 || len(body["managers"].([]any)) != 1 { + t.Fatalf("manager removes the creator: %d %v", code, body) + } + if code, _ := s.call(alice, "POST", repo+"/jobs/cancel"+q, cancel); code != 403 { + t.Fatalf("a removed creator still manages: %d", code) + } + if code, body := s.call(bob, "POST", repo+"/managers"+q, `{"remove":"`+bob+`"}`, "If-Match", rev()); code != 200 || body["can_manage"] != false { + t.Fatalf("leave: %d %v", code, body) + } + if code, body := s.call(admin, "POST", repo+"/managers"+q, `{"add":"`+alice+`"}`, "If-Match", rev()); code != 200 { + t.Fatalf("admin restores a manager: %d %v", code, body) + } + + // Turning the setting off stops new registrations; existing managers keep their repositories. + s.allowUsers(false) + other := strings.Replace(register, "agent-graph", "context", 1) + if code, _ := s.call(alice, "POST", "/sourcegraph/api/admin/v1/repos", other, "Idempotency-Key", "register-k2"); code != 403 { + t.Fatalf("registration after turning off: %d", code) + } + if code, _ := s.call(alice, "POST", repo+"/jobs/cancel"+q, cancel); code == 403 { + t.Fatal("existing managers keep managing after the setting is turned off") + } + if code, _ := s.call(admin, "POST", "/sourcegraph/api/admin/v1/repos", other, "Idempotency-Key", "register-k3"); code != 202 { + t.Fatalf("admin registration: %d", code) + } + + // Any signed-in user can search people to choose a manager; anonymous callers cannot. + if code, body := s.call(bob, "GET", "/sourcegraph/auth/users/search?q=ali", ""); code != 200 || len(body["items"].([]any)) != 1 { + t.Fatalf("people search: %d %v", code, body) + } + if code, _ := s.call("", "GET", "/sourcegraph/auth/users/search?q=ali", ""); code == 200 { + t.Fatal("anonymous people search allowed") + } +} + +func TestServiceCredentialRegistersWhileUserRegistrationIsOff(t *testing.T) { + admin := hexID("admin") + s := newSite(t, map[string]string{"admin": admin}, admin) + r := httptest.NewRequest("POST", "/sourcegraph/api/admin/v1/repos", strings.NewReader(register)) + r.Header.Set("Authorization", "Bearer "+strings.Repeat("t", 32)) + r.Header.Set("Idempotency-Key", "register-svc") + w := httptest.NewRecorder() + s.handler.ServeHTTP(w, r) + if w.Code != 202 { + t.Fatalf("service registration: %d %s", w.Code, w.Body.String()) + } +} + +func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) } diff --git a/internal/httpserver/runtime.go b/internal/httpserver/runtime.go index 00905d6..d82ba78 100644 --- a/internal/httpserver/runtime.go +++ b/internal/httpserver/runtime.go @@ -138,7 +138,7 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string w.Header().Set("Allow", "GET") writeError(w, "", 405, "METHOD_NOT_ALLOWED", "Use GET.") } else { - writeJSON(w, 200, accessPolicy(c, true, browserAuth != nil)) + writeJSON(w, 200, accessPolicy(c, true, browserAuth != nil, browserAuth != nil && settings.AllowUserRepositories())) } return } @@ -223,7 +223,7 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string r = r.WithContext(identity.WithAnonymous(r.Context(), anonymousActor(r))) } else if browserAuth != nil && r.URL.Path != Prefix+"/mcp" { var ok bool - r, ok = browserAuth.Authorize(w, r, strings.HasPrefix(r.URL.Path, Prefix+"/api/admin/") && !publicRead(r)) + r, ok = browserAuth.Authorize(w, r, strings.HasPrefix(r.URL.Path, Prefix+"/api/admin/") && !publicRead(r) && !delegatedRoute(r)) if !ok { return } @@ -232,6 +232,9 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string return } } + if bearer { + r = r.WithContext(identity.WithAdmin(r.Context())) + } if r.URL.Path == Prefix+"/mcp" { r.Body = http.MaxBytesReader(w, r.Body, c.MaxBodyBytes) if personal && identity.CanPrepare(r.Context()) { @@ -253,6 +256,17 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string } } repo := r.URL.Query().Get("repo") + if delegatedRoute(r) { + if e := authorizeDelegated(r.Context(), r, s, settings, repo); e != nil { + var ce *contract.Error + if errors.As(e, &ce) { + writeError(w, id, ce.HTTPStatus, ce.Code, ce.Message) + } else { + writeError(w, id, 503, "CONTROL_STATE_UNAVAILABLE", "Control store unavailable.") + } + return + } + } var result any var err error decode := func(v any) bool { @@ -333,6 +347,9 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string row, err = s.Repo(r.Context(), repo) w.Header().Set("ETag", `"`+strconv.FormatInt(row.Revision, 10)+`"`) result = row + if (r.URL.Path == Prefix+"/api/admin/v1/repo" || r.URL.Path == Prefix+"/v1/repo/availability") && err == nil { + result = viewRepository(r.Context(), row, browserAuth) + } } else if r.URL.Path == Prefix+"/api/admin/v1/repo" && method("PATCH") { var q service.Update if decode(&q) { @@ -385,6 +402,35 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string result, err = s.CancelJob(r.Context(), repo, q.JobID) } } + case Prefix + "/api/admin/v1/repo/managers": + if method("POST") { + var q service.ManagerChange + if decode(&q) { + rev, e := strconv.ParseInt(strings.Trim(r.Header.Get("If-Match"), `"`), 10, 64) + if e != nil { + err = contract.Fail("PRECONDITION_REQUIRED", "If-Match revision is required.", 428) + } else if browserAuth == nil { + err = contract.Fail("SSO_DISABLED", "Managers are site users; browser login is not enabled.", 409) + } else { + if q.Add != "" { + if _, e := browserAuth.EnabledPerson(r.Context(), q.Add); e != nil { + err = contract.Fail("USER_NOT_FOUND", "Choose an existing, enabled user.", 404) + if !errors.Is(e, sso.ErrUserUnavailable) { + err = contract.Fail("AUTH_STORAGE_UNAVAILABLE", "User directory unavailable.", 503) + } + } + } + if err == nil { + var row control.Repository + row, err = s.UpdateManagers(r.Context(), repo, q, rev) + if err == nil { + w.Header().Set("ETag", `"`+strconv.FormatInt(row.Revision, 10)+`"`) + result = viewRepository(r.Context(), row, browserAuth) + } + } + } + } + } case Prefix + "/api/admin/v1/repo/purge": if method("POST") { rev, e := strconv.ParseInt(strings.Trim(r.Header.Get("If-Match"), `"`), 10, 64) diff --git a/internal/httpserver/server.go b/internal/httpserver/server.go index de5c5e6..0988445 100644 --- a/internal/httpserver/server.go +++ b/internal/httpserver/server.go @@ -58,7 +58,7 @@ func New(c config.Config, log *slog.Logger) http.Handler { status = 200 switch route { case "access-policy": - writeJSON(w, status, accessPolicy(c, false, false)) + writeJSON(w, status, accessPolicy(c, false, false, false)) case "health": writeJSON(w, status, map[string]any{"status": "alive"}) case "readiness": diff --git a/internal/httpserver/website_access.go b/internal/httpserver/website_access.go index 53eac25..5c9a965 100644 --- a/internal/httpserver/website_access.go +++ b/internal/httpserver/website_access.go @@ -31,8 +31,8 @@ func publicRead(r *http.Request) bool { return false } -func accessPolicy(c config.Config, backend, login bool) map[string]bool { - return map[string]bool{"public_read": backend && c.WebsitePublicRead, "public_prepare": false, "sso_enabled": backend && login} +func accessPolicy(c config.Config, backend, login, userRepositories bool) map[string]bool { + return map[string]bool{"public_read": backend && c.WebsitePublicRead, "public_prepare": false, "sso_enabled": backend && login, "user_repositories": backend && userRepositories} } func sameOrigin(r *http.Request, configured string) bool { diff --git a/internal/identity/identity.go b/internal/identity/identity.go index 5b535db..3e8d2d3 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -39,3 +39,15 @@ func UserID(ctx context.Context) string { } return "" } + +type adminKey struct{} + +// WithAdmin records that the authenticated caller holds site-wide management +// authority: an administrator session or the deployment service credential. +func WithAdmin(ctx context.Context) context.Context { + return context.WithValue(ctx, adminKey{}, true) +} +func IsAdmin(ctx context.Context) bool { + value, _ := ctx.Value(adminKey{}).(bool) + return value +} diff --git a/internal/service/code_credentials.go b/internal/service/code_credentials.go index f5d365d..15d039a 100644 --- a/internal/service/code_credentials.go +++ b/internal/service/code_credentials.go @@ -33,7 +33,7 @@ func (s *Service) withGitCredential(ctx context.Context, r control.Repository, o if err == nil { return nil } - if !errors.As(err, &ce) || (ce.Code != "GIT_AUTH_FAILED" && ce.Code != "GIT_ACCESS_DENIED" && ce.Code != "GIT_FETCH_FAILED" && ce.Code != "GIT_HYDRATION_FAILED") { + if !errors.As(err, &ce) || (ce.Code != "GIT_AUTH_FAILED" && ce.Code != "GIT_ACCESS_DENIED" && ce.Code != "GIT_REPOSITORY_UNAVAILABLE" && ce.Code != "GIT_FETCH_FAILED" && ce.Code != "GIT_HYDRATION_FAILED") { return err } } diff --git a/internal/service/managers.go b/internal/service/managers.go new file mode 100644 index 0000000..a837302 --- /dev/null +++ b/internal/service/managers.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "time" + + "github.com/context4ai/sourcegraph/internal/contract" + "github.com/context4ai/sourcegraph/internal/control" + "github.com/context4ai/sourcegraph/internal/identity" +) + +// ManagerChange adds or removes one repository manager. Callers resolve and +// validate the user before calling; authorization happens at the HTTP gate. +type ManagerChange struct { + Add string `json:"add,omitempty"` + Remove string `json:"remove,omitempty"` +} + +// UpdateManagers edits the per-repository manager list under the repository's +// revision. Removing the last manager is allowed: site administrators keep +// management, and a manager may remove themselves. +func (s *Service) UpdateManagers(ctx context.Context, name string, change ManagerChange, revision int64) (control.Repository, error) { + if (change.Add == "") == (change.Remove == "") { + return control.Repository{}, contract.Fail("INVALID_MANAGER_CHANGE", "Specify exactly one user to add or remove.", 400) + } + s.mu.Lock() + defer s.mu.Unlock() + r, e := s.DB.Get(ctx, name) + if e != nil { + return r, e + } + if r.Deleted { + return r, contract.Fail("REPOSITORY_DELETED", "Deleted repositories cannot change managers.", 409) + } + if r.Revision != revision { + return r, contract.Fail("REVISION_CONFLICT", "Repository changed; reload and try again.", 412) + } + managers := r.EffectiveManagers() + action := "" + if change.Add != "" { + if r.IsManager(change.Add) { + return projectRepository(s.markStorage(r)), nil + } + if len(managers) >= control.MaxManagers { + return r, contract.Fail("TOO_MANY_MANAGERS", "A repository can have at most 50 managers.", 409) + } + managers = append(managers, change.Add) + action = "managers.add:" + change.Add + } else { + kept := managers[:0] + for _, id := range managers { + if id != change.Remove { + kept = append(kept, id) + } + } + if len(kept) == len(managers) { + return projectRepository(s.markStorage(r)), nil + } + managers = kept + action = "managers.remove:" + change.Remove + } + r.Managers = managers + r.ManagersSet = true + r.Revision++ + r.Updated = time.Now().UTC() + r.Audit = append(r.Audit, control.Audit{Actor: identity.Actor(ctx), Action: action, At: r.Updated}) + if len(r.Audit) > 256 { + r.Audit = r.Audit[len(r.Audit)-256:] + } + e = s.DB.Replace(ctx, r, revision) + return projectRepository(s.markStorage(r)), e +} diff --git a/internal/service/managers_test.go b/internal/service/managers_test.go new file mode 100644 index 0000000..3127df9 --- /dev/null +++ b/internal/service/managers_test.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/context4ai/sourcegraph/internal/contract" + "github.com/context4ai/sourcegraph/internal/control" + "github.com/context4ai/sourcegraph/internal/identity" +) + +func managerCode(err error) string { + var ce *contract.Error + if errors.As(err, &ce) { + return ce.Code + } + return "" +} + +func TestUpdateManagers(t *testing.T) { + db := &sourceStore{repos: map[string]control.Repository{"org/repo": {Name: "org/repo", CreatorID: "creator", Revision: 3, Enabled: true}}} + s := &Service{DB: db, Root: t.TempDir()} + ctx := identity.WithActor(context.Background(), "sso:creator") + + if _, err := s.UpdateManagers(ctx, "org/repo", ManagerChange{Add: "a", Remove: "b"}, 3); managerCode(err) != "INVALID_MANAGER_CHANGE" { + t.Fatalf("both add and remove: %v", err) + } + if _, err := s.UpdateManagers(ctx, "org/repo", ManagerChange{Add: "second"}, 2); managerCode(err) != "REVISION_CONFLICT" { + t.Fatalf("stale revision: %v", err) + } + r, err := s.UpdateManagers(ctx, "org/repo", ManagerChange{Add: "second"}, 3) + if err != nil || r.Revision != 4 || !r.IsManager("creator") || !r.IsManager("second") { + t.Fatalf("add: %#v %v", r.EffectiveManagers(), err) + } + if got := db.repos["org/repo"].Audit; len(got) != 1 || got[0].Action != "managers.add:second" || got[0].Actor != "sso:creator" { + t.Fatalf("audit = %#v", got) + } + if r, err = s.UpdateManagers(ctx, "org/repo", ManagerChange{Add: "second"}, 4); err != nil || r.Revision != 4 { + t.Fatalf("adding an existing manager is a no-op: %d %v", r.Revision, err) + } + // The creator may remove themselves; the stored list stays authoritative. + if r, err = s.UpdateManagers(ctx, "org/repo", ManagerChange{Remove: "creator"}, 4); err != nil || r.IsManager("creator") || !r.IsManager("second") { + t.Fatalf("remove self: %v %v", r.EffectiveManagers(), err) + } + if r, err = s.UpdateManagers(ctx, "org/repo", ManagerChange{Remove: "second"}, 5); err != nil || len(r.EffectiveManagers()) != 0 || !db.repos["org/repo"].ManagersSet { + t.Fatalf("removing the last manager leaves site administrators only: %v %v", r.EffectiveManagers(), err) + } + full := control.Repository{Name: "org/full", Revision: 1, ManagersSet: true} + for i := 0; i < control.MaxManagers; i++ { + full.Managers = append(full.Managers, string(rune('a'+i%26))+string(rune('a'+i/26))) + } + db.repos["org/full"] = full + if _, err = s.UpdateManagers(ctx, "org/full", ManagerChange{Add: "new"}, 1); managerCode(err) != "TOO_MANY_MANAGERS" { + t.Fatalf("manager limit: %v", err) + } + db.repos["org/gone"] = control.Repository{Name: "org/gone", Revision: 1, Deleted: true} + if _, err = s.UpdateManagers(ctx, "org/gone", ManagerChange{Add: "new"}, 1); managerCode(err) != "REPOSITORY_DELETED" { + t.Fatalf("deleted repository: %v", err) + } +} + +func TestRegistrationRecordsTheCreatorAsManager(t *testing.T) { + db := &sourceStore{repos: map[string]control.Repository{}} + s := &Service{DB: db, Root: t.TempDir()} + ctx := identity.WithActor(context.Background(), "sso:creator") + r, err := s.RegisterWithProvider(ctx, "context4ai/agent-graph", control.Policy{Branches: []string{"main"}, Count: 10}, 60, "full", nil, nil, "github", "register") + if err != nil || r.CreatorID != "creator" || !r.IsManager("creator") { + t.Fatalf("registered = %q %v", r.CreatorID, err) + } +} diff --git a/internal/sitesettings/access_test.go b/internal/sitesettings/access_test.go new file mode 100644 index 0000000..7e6d75f --- /dev/null +++ b/internal/sitesettings/access_test.go @@ -0,0 +1,45 @@ +package sitesettings + +import ( + "context" + "testing" + "time" +) + +func TestUserRepositoriesSettingDefaultsOff(t *testing.T) { + ctx := context.Background() + store := NewMemoryStore() + // A record saved before the setting existed still loads, with the setting off. + now := time.Now().UTC() + if err := store.Ensure(ctx, Record{Name: AccessName, Revision: 3, Values: map[string]any{"public_read": true}, UpdatedAt: now, UpdatedBy: "sso:admin"}); err != nil { + t.Fatal(err) + } + s, err := New(ctx, store, false) + if err != nil { + t.Fatal(err) + } + if !s.PublicRead() || s.AllowUserRepositories() { + t.Fatalf("legacy access = %+v", s.Snapshot().Access.Values) + } + if _, err = s.Update(ctx, AccessName, 3, []byte(`{"public_read":true,"allow_user_repositories":true}`), "sso:admin"); err != nil { + t.Fatal(err) + } + if !s.AllowUserRepositories() { + t.Fatal("setting was not saved") + } + // Older clients that send only public_read turn the setting off rather than failing. + if _, err = s.Update(ctx, AccessName, 4, []byte(`{"public_read":false}`), "sso:admin"); err != nil || s.AllowUserRepositories() || s.PublicRead() { + t.Fatalf("public_read only: %v %+v", err, s.Snapshot().Access.Values) + } + if _, err = s.Update(ctx, AccessName, 5, []byte(`{"public_read":true,"allow_user_repositories":"yes"}`), "sso:admin"); err == nil { + t.Fatal("non-boolean setting accepted") + } + var nilService *Service + if nilService.AllowUserRepositories() { + t.Fatal("no settings service means the setting is off") + } + fresh, err := New(ctx, NewMemoryStore(), true) + if err != nil || fresh.AllowUserRepositories() { + t.Fatalf("new deployments start with the setting off: %v", err) + } +} diff --git a/internal/sitesettings/model.go b/internal/sitesettings/model.go index 90ca7a4..055bae8 100644 --- a/internal/sitesettings/model.go +++ b/internal/sitesettings/model.go @@ -36,6 +36,9 @@ type Access struct { AllowRegistration bool `json:"allow_registration"` RegistrationNoticeZH string `json:"registration_notice_zh"` RegistrationNoticeEN string `json:"registration_notice_en"` + // AllowUserRepositories lets any signed-in user register repositories and + // manage those they create. Absent in older records, which keeps it off. + AllowUserRepositories bool `json:"allow_user_repositories"` } // CodeSource holds website values for the editable code source; empty fields inherit defaults. @@ -98,10 +101,11 @@ func decodeValues(section string, data []byte) (map[string]any, error) { return map[string]any{"retention_mode": v.RetentionMode, "retention_days": v.RetentionDays, "retention_count": v.RetentionCount}, nil case AccessName: var input struct { - PublicRead *bool `json:"public_read"` - AllowRegistration bool `json:"allow_registration"` - RegistrationNoticeZH string `json:"registration_notice_zh"` - RegistrationNoticeEN string `json:"registration_notice_en"` + PublicRead *bool `json:"public_read"` + AllowRegistration bool `json:"allow_registration"` + RegistrationNoticeZH string `json:"registration_notice_zh"` + RegistrationNoticeEN string `json:"registration_notice_en"` + AllowUserRepositories bool `json:"allow_user_repositories"` } if contract.DecodeObject(data, &input) != nil || input.PublicRead == nil || len(input.RegistrationNoticeZH) > 4096 || len(input.RegistrationNoticeEN) > 4096 { return nil, contract.Fail("INVALID_SETTINGS", "Provide public_read, allow_registration and notices of at most 4096 bytes.", 400) @@ -112,7 +116,7 @@ func decodeValues(section string, data []byte) (map[string]any, error) { if input.RegistrationNoticeEN == "" { input.RegistrationNoticeEN = DefaultNoticeEN } - return map[string]any{"public_read": *input.PublicRead, "allow_registration": input.AllowRegistration, "registration_notice_zh": input.RegistrationNoticeZH, "registration_notice_en": input.RegistrationNoticeEN}, nil + return map[string]any{"public_read": *input.PublicRead, "allow_registration": input.AllowRegistration, "registration_notice_zh": input.RegistrationNoticeZH, "registration_notice_en": input.RegistrationNoticeEN, "allow_user_repositories": input.AllowUserRepositories}, nil case CodeSourceName: var v CodeSource if contract.DecodeObject(data, &v) != nil { @@ -133,7 +137,7 @@ func assign(out *Snapshot, section string, meta Metadata, values map[string]any) case RepositoryDefaultsName: out.RepositoryDefaults = RepositorySection{Metadata: meta, Values: RepositoryDefaults{values["retention_mode"].(string), values["retention_days"].(int), values["retention_count"].(int)}} case AccessName: - out.Access = AccessSection{Metadata: meta, Values: Access{PublicRead: values["public_read"].(bool), AllowRegistration: values["allow_registration"].(bool), RegistrationNoticeZH: values["registration_notice_zh"].(string), RegistrationNoticeEN: values["registration_notice_en"].(string)}} + out.Access = AccessSection{Metadata: meta, Values: Access{PublicRead: values["public_read"].(bool), AllowRegistration: values["allow_registration"].(bool), RegistrationNoticeZH: values["registration_notice_zh"].(string), RegistrationNoticeEN: values["registration_notice_en"].(string), AllowUserRepositories: values["allow_user_repositories"].(bool)}} case CodeSourceName: out.CodeSource = CodeSourceSection{Metadata: meta, Values: CodeSource{values["name"].(string), values["host"].(string), values["username"].(string)}} } diff --git a/internal/sitesettings/service.go b/internal/sitesettings/service.go index abd273c..a1bcc8b 100644 --- a/internal/sitesettings/service.go +++ b/internal/sitesettings/service.go @@ -29,7 +29,7 @@ func New(ctx context.Context, store Store, publicReadSeed bool) (*Service, error now := time.Now().UTC() records := []Record{ {Name: RepositoryDefaultsName, Revision: 1, Values: map[string]any{"retention_mode": "days", "retention_days": 30, "retention_count": 20}, UpdatedAt: now, UpdatedBy: "system:bootstrap"}, - {Name: AccessName, Revision: 1, Values: map[string]any{"public_read": publicReadSeed, "allow_registration": false, "registration_notice_zh": DefaultNoticeZH, "registration_notice_en": DefaultNoticeEN}, UpdatedAt: now, UpdatedBy: "system:bootstrap"}, + {Name: AccessName, Revision: 1, Values: map[string]any{"public_read": publicReadSeed, "allow_registration": false, "registration_notice_zh": DefaultNoticeZH, "registration_notice_en": DefaultNoticeEN, "allow_user_repositories": false}, UpdatedAt: now, UpdatedBy: "system:bootstrap"}, {Name: CodeSourceName, Revision: 1, Values: map[string]any{"name": "", "host": "", "username": ""}, UpdatedAt: now, UpdatedBy: "system:bootstrap"}, } for _, r := range records { @@ -52,6 +52,11 @@ func (s *Service) Snapshot() Snapshot { } func (s *Service) PublicRead() bool { return s.Snapshot().Access.Values.PublicRead } +// AllowUserRepositories reports whether ordinary signed-in users may register repositories. +func (s *Service) AllowUserRepositories() bool { + return s != nil && s.Snapshot().Access.Values.AllowUserRepositories +} + // CodeSourceOverride exposes the website layer of the editable code source. func (s *Service) CodeSourceOverride() codesources.Override { v := s.Snapshot().CodeSource.Values diff --git a/internal/sso/handler.go b/internal/sso/handler.go index 882afb6..0895d74 100644 --- a/internal/sso/handler.go +++ b/internal/sso/handler.go @@ -188,6 +188,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.adminUsers(w, r) return } + if r.URL.Path == Prefix+"users/search" { + h.searchPeople(w, r) + return + } if r.URL.Path == Prefix+"api-keys" || strings.HasPrefix(r.URL.Path, Prefix+"api-keys/") { h.apiKeys(w, r) return @@ -463,7 +467,11 @@ func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request, management b fail(w, 403, "CSRF_REQUIRED") return r, false } - return r.WithContext(identity.WithActor(r.Context(), "sso:"+u.ID)), true + ctx := identity.WithActor(r.Context(), "sso:"+u.ID) + if admin { + ctx = identity.WithAdmin(ctx) + } + return r.WithContext(ctx), true } // HasSession reports a browser credential without treating it as authenticated. diff --git a/internal/sso/people.go b/internal/sso/people.go new file mode 100644 index 0000000..c45b71f --- /dev/null +++ b/internal/sso/people.go @@ -0,0 +1,106 @@ +package sso + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" +) + +// Person is the directory view other signed-in users may see: enough to +// recognize a repository creator or choose a manager, without roles or logins. +type Person struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + Department string `json:"department,omitempty"` +} + +func person(u User) Person { + return Person{ID: u.ID, Name: u.Name, Email: u.Email, Department: u.Department} +} + +// ErrUserUnavailable reports a missing or disabled user. +var ErrUserUnavailable = errors.New("user unavailable") + +// EnabledPerson resolves one enabled user, e.g. before adding a manager. +func (h *Handler) EnabledPerson(ctx context.Context, id string) (Person, error) { + if !userIDPattern.MatchString(id) { + return Person{}, ErrUserUnavailable + } + u, e := h.store.GetUser(ctx, id) + if errors.Is(e, ErrNotFound) || (e == nil && !u.Enabled) { + return Person{}, ErrUserUnavailable + } + if e != nil { + return Person{}, e + } + return person(u), nil +} + +// People resolves user IDs for display. Unknown IDs keep only their ID so a +// removed account still shows up in a manager list rather than vanishing. +func (h *Handler) People(ctx context.Context, ids []string) []Person { + out := make([]Person, 0, len(ids)) + for _, id := range ids { + p := Person{ID: id} + if userIDPattern.MatchString(id) { + if u, e := h.store.GetUser(ctx, id); e == nil { + p = person(u) + } + } + out = append(out, p) + } + return out +} + +// searchPeople lets any signed-in user find enabled users by name or email, +// e.g. to add a repository manager. It returns at most 20 directory entries. +func (h *Handler) searchPeople(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.Header().Set("Allow", "GET") + fail(w, 405, "METHOD_NOT_ALLOWED") + return + } + if _, present := r.Header["Authorization"]; present { + fail(w, 403, "ACCESS_DENIED") + return + } + _, user, e := h.session(r) + if e != nil { + h.authError(w, e) + return + } + if read, _ := h.rights(user); !read { + fail(w, 403, "ACCESS_DENIED") + return + } + values, e := url.ParseQuery(r.URL.RawQuery) + q := strings.TrimSpace(values.Get("q")) + if e != nil || len(values) != 1 || len(values["q"]) != 1 || q == "" || len(q) > 128 || !utf8.ValidString(q) || strings.ContainsAny(q, "\r\n\x00") { + fail(w, 400, "INVALID_USER_FILTER") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + a, e := readAccess(ctx, h.store) + if e != nil { + roleError(w, e) + return + } + users, e := h.store.ListUsers(ctx, UserQuery{Q: q, Limit: 40}, a.AdminIDs) + if e != nil { + roleError(w, e) + return + } + items := make([]Person, 0, 20) + for _, u := range users { + if u.Enabled && len(items) < 20 { + items = append(items, person(u)) + } + } + respond(w, 200, map[string]any{"items": items}) +} diff --git a/web/assets/api.js b/web/assets/api.js index 46c9957..8bb589e 100644 --- a/web/assets/api.js +++ b/web/assets/api.js @@ -8,10 +8,12 @@ LEASE_EXPIRED: ["任务租约过期,原执行者未及时续租或保存完成状态", "Task lease expired before renewal or completion was saved"], PREPARATION_FAILED: ["准备失败,请重试;持续失败时联系维护者查看日志", "Preparation failed; retry or ask the maintainer to inspect logs"], PREPARATION_TIMEOUT: ["准备超时,可稍后重试", "Preparation timed out; retry later"], - GIT_AUTH_FAILED: ["代码服务凭证认证失败,请更新令牌", "Code service authentication failed; update credentials"], - GIT_ACCESS_DENIED: ["代码服务拒绝访问,请检查仓库权限", "Code service access denied; check repository permissions"], - GIT_CREDENTIAL_UNAVAILABLE: ["没有可用的代码服务凭证,请检查令牌和有效期", "No usable code credentials; check tokens and expiry"], - GIT_FETCH_FAILED: ["拉取代码失败,请检查网络、凭证和仓库权限", "Fetch failed; check network, credentials and repository access"], + // Clone credentials: the repository creator's own code source token first, then the site-wide token. + GIT_AUTH_FAILED: ["代码源令牌已失效或被拒绝。请仓库创建者在「用户设置 → 代码源令牌」更新个人令牌,然后重新检查更新", "The code source token was rejected or has expired. The repository creator should update their token in User settings → Code source tokens, then check for updates again"], + GIT_ACCESS_DENIED: ["所用的代码源令牌没有读取这个仓库的权限。拉取时先用仓库创建者的个人令牌,没有时用管理员配置的全局令牌;请仓库创建者在「用户设置 → 代码源令牌」添加能读取该仓库的个人令牌,然后重新检查更新", "The code source token used cannot read this repository. Fetching uses the repository creator's own token first, then the site-wide token; the creator should add a token that can read it in User settings → Code source tokens, then check for updates again"], + GIT_REPOSITORY_UNAVAILABLE: ["找不到这个仓库:仓库名可能有误,或所用的令牌看不到这个非公开仓库。请先核对仓库名;仓库存在时,请仓库创建者在「用户设置 → 代码源令牌」添加能读取它的个人令牌,然后重新检查更新", "Repository not found: the name may be wrong, or the token used cannot see this private repository. Check the name first; if it exists, the repository creator should add a token that can read it in User settings → Code source tokens, then check for updates again"], + GIT_CREDENTIAL_UNAVAILABLE: ["没有可用的代码源令牌:仓库创建者没有配置个人令牌,站点也没有可用的全局令牌。请在「用户设置 → 代码源令牌」添加个人令牌,然后重新检查更新", "No code source token is available: the repository creator has none and the site has no usable global token. Add one in User settings → Code source tokens, then check for updates again"], + GIT_FETCH_FAILED: ["拉取代码失败。新登记的仓库多半是令牌没有读取权限:请仓库创建者在「用户设置 → 代码源令牌」添加能读取它的个人令牌后重新检查更新;其他情况请检查网络后重试", "Fetching failed. For a newly registered repository this is usually a token without read access: the creator should add a token that can read it in User settings → Code source tokens and check for updates again; otherwise check connectivity and retry"], GIT_OPERATION_FAILED: ["Git 操作失败,请检查仓库访问及服务日志", "Git operation failed; check access and service logs"], GIT_OUTPUT_LIMIT: ["Git 输出超过处理上限,请缩小范围", "Git output exceeded the processing limit; narrow the scope"], PARTIAL_CLONE_UNSUPPORTED: ["远端不支持按需克隆,无法使用当前索引模式", "Remote does not support partial clone for this mode"], @@ -39,6 +41,10 @@ DEFAULT_BRANCH_NOT_OBSERVED: ['主干尚未同步,请查看仓库准备进度', 'The default branch has not been synchronized'], INDEX_NOT_READY: ['该提交的索引尚未就绪', 'The index for this commit is not ready'], REPOSITORY_DISABLED: ['仓库已停用或删除', 'Repository disabled or deleted'], + REGISTRATION_DISABLED: ['本站只允许管理员登记仓库', 'Only administrators can register repositories on this site'], + REPOSITORY_MANAGEMENT_DENIED: ['只有这个仓库的管理员或站点管理员可以管理它', 'Only this repository\'s managers or a site administrator can manage it'], + USER_NOT_FOUND: ['找不到该用户,或该用户已停用', 'User not found or disabled'], + TOO_MANY_MANAGERS: ['一个仓库最多 50 位管理员', 'A repository can have at most 50 managers'], RATE_LIMITED: ['请求较多,请稍后重试', 'Too many requests. Try again shortly'] }; function message(code, fallback) { @@ -86,7 +92,9 @@ } throw new Error('Repository catalog exceeds the browser limit'); } - var API = window.API = { request: request, loadSession: loadSession, catalog: catalog, message: message, jobError: function(job) { var code=job.error||""; return message(code, document.documentElement.lang === "en" ? "Task did not complete; retry or inspect service logs" : "任务未完成,请重试或联系维护者查看日志") + (job.recovery_reason ? " · " + message(job.recovery_reason, job.recovery_reason) : "") + " · " + code; }, session: session, policy: policy, + var API = window.API = { request: request, loadSession: loadSession, catalog: catalog, message: message, credentialCodes: ['GIT_AUTH_FAILED', 'GIT_ACCESS_DENIED', 'GIT_REPOSITORY_UNAVAILABLE', 'GIT_CREDENTIAL_UNAVAILABLE', 'GIT_FETCH_FAILED'], + credentialLink: function(code) { if (!API.credentialCodes.includes(code)) return ''; var en = document.documentElement.lang === 'en'; return ' ' + (en ? 'Open code source tokens' : '去配置代码源令牌') + ''; }, + jobError: function(job) { var code=job.error||""; return message(code, document.documentElement.lang === "en" ? "Task did not complete; retry or inspect service logs" : "任务未完成,请重试或联系维护者查看日志") + (job.recovery_reason ? " · " + message(job.recovery_reason, job.recovery_reason) : "") + " · " + code; }, session: session, policy: policy, idempotency: function () { return crypto.randomUUID(); }, repo: function (repo, signal) { return request('/v1/repo/availability?repo=' + encodeURIComponent(repo), { signal: signal }); } }; API.ready = Promise.all([loadSession(), request('/access-policy').then(function (p) { API.policy = p; }).catch(function (e) { API.policyError = e; })]); diff --git a/web/assets/management.js b/web/assets/management.js index 264d076..71ebe50 100644 --- a/web/assets/management.js +++ b/web/assets/management.js @@ -95,9 +95,10 @@ return '' + A.esc(A.t(...label)) + ''; } function error(e) { return [e.code, e.message, e.requestId && 'request: ' + e.requestId].filter(Boolean).join(' · '); } - function manage() { + // allowed overrides the site-wide check, e.g. with a repository's can_manage. + function manage(allowed) { if (!root.App.user()) { root.App.requireLogin(location.pathname + location.search + location.hash); return false; } - if (!root.App.canManage()) { root.App.toast(root.App.t('当前登录身份没有仓库管理权限', 'Your account does not have repository management permission'), 'bad'); return false; } + if (!(allowed === undefined ? root.App.canManage() : allowed)) { root.App.toast(root.App.t('当前登录身份没有仓库管理权限', 'Your account does not have repository management permission'), 'bad'); return false; } return true; } // Retry a result-unknown request with the same key; successful explicit actions get a new one. diff --git a/web/assets/repo-maintenance.js b/web/assets/repo-maintenance.js index f9ff4b3..cc8637e 100644 --- a/web/assets/repo-maintenance.js +++ b/web/assets/repo-maintenance.js @@ -110,7 +110,7 @@ finally{lock(false);} } return {open:kind=>{ - if(working||options.isBusy()||!M.manage())return; + if(working||options.isBusy()||!M.manage(options.canManage()))return; if(kind==='index'){action=kind;indexState={branches:branchNames(),branch:branchNames()[0]||'',commits:[],commit:''};renderIndex();loadCommits(indexState.branch);} else if(kind==='cleanup')openCleanup(); else if(kind==='delete'||kind==='purge')openDelete(); diff --git a/web/assets/repo-page.js b/web/assets/repo-page.js index 16b31ba..ae05922 100644 --- a/web/assets/repo-page.js +++ b/web/assets/repo-page.js @@ -9,7 +9,7 @@ let tab=TABS.some(x=>x[0]===p.get('tab'))?p.get('tab'):'overview',repo=null,draft=null,preview=null,busy=false,loading=false,versionLimit=20,jobLimit=JOB_PAGE; let branchInput=null,pathInput=null; const keys=M.operationKeys(); - const maintenance=RepoMaintenance.mount({repository:()=>repo,busy:value=>{busy=value;},isBusy:()=>busy,reload:()=>load(true),jobs:()=>{tab='jobs';p.set('tab',tab);history.replaceState(null,'',location.pathname+'?'+p);}}); + const maintenance=RepoMaintenance.mount({repository:()=>repo,canManage:()=>canRepo(),busy:value=>{busy=value;},isBusy:()=>busy,reload:()=>load(true),jobs:()=>{tab='jobs';p.set('tab',tab);history.replaceState(null,'',location.pathname+'?'+p);}}); const btn2='rounded-control bg-ink/[0.05] px-3 py-1.5 text-[13.5px] hover:bg-ink/[0.08]'; const act='shrink-0 inline-flex items-center gap-1 rounded-control px-2 py-1 text-[13px] text-muted hover:bg-ink/[0.05] hover:text-ink disabled:opacity-40'; const SEP=' · '; @@ -19,7 +19,8 @@ function setTab(k){tab=k;p.set('tab',k);history.replaceState(null,'',location.pathname+'?'+p);render();} function keep(v){return v.days?[v.days,t('天','days')]:[v.count,t('个提交','commits')];} - function canPrepare(){return !!(API.policy.public_prepare||A.canManage());} + function canRepo(){return !!(repo&&repo.can_manage)||A.canManage();} + function canPrepare(){return !!(API.policy.public_prepare||canRepo());} function prepareLabel(label){if(canPrepare())return label;const why=A.user()?t('需管理权限','management permission required'):t('需登录','sign-in required');return A.ICON.lock+''+label+' · '+why+'';} function prepareTitle(){return canPrepare()?'':A.user()?t('需管理权限','Management permission required'):t('登录后可用','Available after sign-in');} function ago(x){ @@ -62,7 +63,7 @@ $('#manage').innerHTML=(u?'':A.ICON.lock)+t('管理','Manage')+A.ICON.chevron; const items=repo.deleted?[['purge','重试永久删除','Retry permanent deletion']]:[['policy','修改设置','Edit settings'],['index','新增索引','Add index'],['cleanup','清理落后索引','Clean old indexes'],['toggle',repo.enabled?'停用仓库':'启用仓库',repo.enabled?'Disable':'Enable'],['delete','删除仓库','Delete']]; $('#manage-menu').innerHTML=(u?'':'
'+t('登录后可用','Available after sign-in')+'
')+items.map(x=>'').join(''); - $('#manage-menu').querySelectorAll('[data-act]').forEach(b=>b.onclick=()=>{b.closest('details').open=false;if(!M.manage())return;if(b.dataset.act==='policy')setTab('policy');else if(['index','cleanup','delete','purge'].includes(b.dataset.act))maintenance.open(b.dataset.act);else confirm(b.dataset.act);}); + $('#manage-menu').querySelectorAll('[data-act]').forEach(b=>b.onclick=()=>{b.closest('details').open=false;if(!M.manage(canRepo()))return;if(b.dataset.act==='policy')setTab('policy');else if(['index','cleanup','delete','purge'].includes(b.dataset.act))maintenance.open(b.dataset.act);else confirm(b.dataset.act);}); $('#tabs').innerHTML=TABS.map(x=>'').join(''); $('#tabs').querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>setTab(b.dataset.tab)); } @@ -106,7 +107,34 @@ [t('可查询','Searchable'),v?mono(A.short(v.commit))+sub(t('提交于 '+A.clock(v.committer_time),'committed '+A.clock(v.committer_time))):''+t('准备完成后可查询','Available once prepared')+'',v?''+t('阅读代码','Browse code')+'':''], ...(repo.index_mode!=='monorepo'&&M.indexTiming(repo)?[[t('最近索引','Last indexing'),esc(M.indexTiming(repo))+sub(A.validTime(repo.last_indexed_at)?esc(M.time(repo.last_indexed_at)):'')]]:[]), [t('最近任务','Last job'),''+jobText(jobs[0])+'',jobs.length?'':''] - ]))+''; + ]))+''+info(); + } + + // Who created and manages the repository. The server returns people to signed-in users only. + function person(x){return x?esc(x.name||x.id)+(x.email?''+esc(x.email)+'':''):''+t('未记录','Not recorded')+'';} + function info(){ + if(!A.user())return ''; + const can=canRepo()&&!repo.deleted, me=A.user().id; + const list=(repo.managers||[]).map(x=>'
  • '+person(x)+''+(can?'':'')+'
  • ').join('')||'
  • '+t('无,仅站点管理员可管理','None; only site administrators can manage it')+'
  • '; + const add=can?'
    ':''; + const note='

    '+t('仓库管理员和站点管理员可以修改设置、准备和清理索引、删除仓库,以及增减管理员','Repository managers and site administrators can edit settings, prepare and clean indexes, delete the repository, and add or remove managers')+'

    '; + return '
    '+heading(t('仓库信息','Repository info'))+'
    '+rows([[t('创建者','Creator'),person(repo.creator)],[t('管理员','Managers'),'
      '+list+'
    '+add+note]])+'
    '; + } + let managerOptions=new Map(),managerTimer=0; + function bindManagers(){ + document.querySelectorAll('[data-remove-manager]').forEach(b=>b.onclick=()=>changeManager({remove:b.dataset.removeManager})); + const q=$('#manager-q');if(!q)return; + q.oninput=()=>{clearTimeout(managerTimer);const v=q.value.trim();$('#manager-error').textContent='';if(!v||managerOptions.has(v))return; + managerTimer=setTimeout(async()=>{try{const r=await API.request('/sourcegraph/auth/users/search?q='+encodeURIComponent(v));managerOptions=new Map(r.items.map(u=>[u.email||u.name,u]));$('#manager-options').innerHTML=r.items.map(u=>'').join('');}catch(e){$('#manager-error').textContent=M.error(e);}},250);}; + $('#add-manager').onsubmit=e=>{e.preventDefault();const u=managerOptions.get(q.value.trim());if(!u){$('#manager-error').textContent=t('请从搜索结果中选择用户','Choose a user from the search results');return;}changeManager({add:u.id});}; + } + async function changeManager(body){ + if(busy||!M.manage(canRepo()))return; + if(body.remove&&body.remove===A.user().id&&!A.canManage()&&!confirm(t('退出后你将不能再管理这个仓库,确定吗?','After leaving you can no longer manage this repository. Continue?')))return; + busy=true; + try{repo=await API.request('/api/admin/v1/repo/managers?repo='+enc,{method:'POST',headers:{'If-Match':'"'+repo.revision+'"'},body});managerOptions=new Map();A.toast(t('管理员已更新','Managers updated'));} + catch(e){A.toast(M.error(e),'bad');if(e.status===412){try{await load(true);}catch(_){}}} + finally{busy=false;render();} } function versions(){ @@ -130,7 +158,7 @@ // Steps: a numbered rail. Done is ink, the current stage is live or danger, the rest is muted. function jobs(){ const js=R.jobs(repo); - const checks=Object.entries(repo.sync_checks||{}).map(([group,c])=>'

    '+(group?esc(group)+' · ':'')+t('最近检查 ','Last checked ')+esc(A.clock(c.checked_at))+' · '+(c.error?esc(API.jobError({error:c.error})):c.no_changes?t('版本未变化,无需索引','No version changes; indexing skipped'):t('检查完成','Check completed'))+(c.unchanged_count>1?t(' · 连续 '+c.unchanged_count+' 次无变化',' · '+c.unchanged_count+' consecutive unchanged checks'):'')+'

    ').join(''); + const checks=Object.entries(repo.sync_checks||{}).map(([group,c])=>'

    '+(group?esc(group)+' · ':'')+t('最近检查 ','Last checked ')+esc(A.clock(c.checked_at))+' · '+(c.error?esc(API.jobError({error:c.error}))+API.credentialLink(c.error):c.no_changes?t('版本未变化,无需索引','No version changes; indexing skipped'):t('检查完成','Check completed'))+(c.unchanged_count>1?t(' · 连续 '+c.unchanged_count+' 次无变化',' · '+c.unchanged_count+' consecutive unchanged checks'):'')+'

    ').join(''); const failures=(repo.job_failures||[]).filter(f=>Date.now()-Date.parse(f.time)<7*86400000); const history=failures.length?'
    '+t('历史任务失败摘要('+failures.length+' 条)','Historical task failures ('+failures.length+')')+'

    '+t('保留最近 7 天,最多 128 条;独立于任务队列。历史失败不代表当前状态。','Last 7 days, up to 128 records; separate from the task queue. Historical failures do not describe current health.')+'

    '+failures.map(f=>'
    '+esc(M.time(f.time))+' · '+esc(f.kind)+' · '+esc(f.group||'')+'
    '+esc(f.code)+' · '+esc(f.commit||'—')+'
    '+esc(f.job_id)+t(' · 尝试 '+f.attempt+' 次',' · '+f.attempt+' attempts')+'
    ').join('')+'
    ':''; const summary=(checks?'
    '+checks+'
    ':'')+history; @@ -149,10 +177,10 @@ const phase=j.state==='queued'?t('已等待 ','waiting '):j.state==='running'?t('已运行 ','running '):t('用时 ','took '); const when=t('创建 '+A.clock(j.created_at),'created '+A.clock(j.created_at))+(took?' · '+phase+took:'')+(j.attempt>1?t(' · 第 '+j.attempt+' 次尝试',' · attempt '+j.attempt):''); const retry=(j.state==='failed'||j.state==='canceled'||j.state==='cancelled')&&live?' ':''; - return '
    '+esc(j.kind==='sync'?t('检查更新','Check updates'):kindName(j.kind))+''+A.status(j.state)+(j.group?''+esc(j.group)+'':'')+(j.commit?''+esc(A.short(j.commit))+'':'')+''+esc(j.job_id)+''+(A.canManage()&&M.active(j)?'':'')+when+'
    '+ + return '
    '+esc(j.kind==='sync'?t('检查更新','Check updates'):kindName(j.kind))+''+A.status(j.state)+(j.group?''+esc(j.group)+'':'')+(j.commit?''+esc(A.short(j.commit))+'':'')+''+esc(j.job_id)+''+(canRepo()&&M.active(j)?'':'')+when+'
    '+ '
    '+rail+'
    '+(j.no_changes&&j.state==='succeeded'?'

    '+t('版本未变化,无需索引','No version changes; indexing skipped')+'

    ':'')+(j.kind!=='sync'&&M.indexTiming(j)?'

    '+esc(M.indexTiming(j))+'

    ':'')+ (jobRecoveredAt(j)?'

    '+t('后续检查或任务已恢复 · ','Recovered by a later check or task · ')+esc(A.clock(jobRecoveredAt(j)))+t(';保留原失败记录','; original failure retained')+'

    ':'')+ - (j.error?'

    '+esc(API.jobError(j))+''+retry+'

    ':retry?'

    '+retry+'

    ':'')+''; + (j.error?'

    '+esc(API.jobError(j))+''+API.credentialLink(j.error)+retry+'

    ':retry?'

    '+retry+'

    ':'')+''; }).join('')+''+ (rest>0?'':''); } @@ -160,7 +188,7 @@ function ensureDraft(){if(!draft)draft={branches:repo.policy.branches.slice(),defaultBranch:!!repo.policy.default_branch,publicRead:repo.public_read!==false,pending:'',kind:repo.policy.days?'days':'count',value:String(repo.policy.days||repo.policy.count),syncInterval:String(repo.sync_interval_minutes||M.DEFAULT_SYNC_INTERVAL_MINUTES),skipGenerated:!repo.index_generated,indexSymlinks:repo.index_symlinks!==false,pathState:{groups:repo.path_groups||[]}};} function settings(){ if(repo.deleted)return '

    '+t('仓库已标记删除,无法修改设置。请在「管理」里重试永久删除','This repository is marked for deletion and cannot be edited. Retry permanent deletion from Manage')+'

    '; - const can=A.user()&&A.canManage(), k=keep(repo.policy); + const can=A.user()&&canRepo(), k=keep(repo.policy); if(!can)return '
    '+rows([[t('索引模式','Indexing mode'),M.indexModeLabel(repo.index_mode)],[t('监听分支','Branches'),mono(esc((repo.policy.branches||[]).join(', ')))],[t('保留','Retention'),t('最近 '+k[0]+' '+k[1],'Last '+k[0]+' '+k[1])],[t('同步间隔','Sync interval'),M.syncIntervalLabel(repo.sync_interval_minutes||M.DEFAULT_SYNC_INTERVAL_MINUTES)],[t('软链目标','Symlink targets'),repo.index_symlinks!==false?t('新索引展开仓库内软链','New indexes follow repository symlinks'):t('新索引不展开软链','New indexes do not follow symlinks')],[t('压缩文件','Minified files'),repo.index_generated?t('压缩 JS/CSS 与 source map 建立索引','Minified JS/CSS and source maps are indexed'):t('压缩 JS/CSS 与 source map 不建立索引','Minified JS/CSS and source maps are not indexed')]])+ '
    '; ensureDraft(); @@ -211,10 +239,10 @@ } return '
    '+t('保存后','After saving')+'
      '+lines.map(x=>'
    • '+x+'
    • ').join('')+'
    '; } - async function previewPolicy(){if(busy||!M.manage())return;let body;try{body=target();}catch(e){$('#policy-error').textContent=e.message;return;}busy=true;$('#preview').disabled=true;$('#policy-error').textContent='';try{const v=await API.request('/api/admin/v1/repo/preview?repo='+enc,{method:'POST',body});if(v.revision!==repo.revision){await load(true);throw Object.assign(new Error(t('仓库刚被修改过,输入已保留,请重新预览','The repository just changed; your input is kept. Preview again')),{code:'REVISION_CONFLICT',status:412});}if(JSON.stringify(target())!==JSON.stringify(body))throw new Error(t('输入已变化,请重新预览','Inputs changed; preview again'));preview={body,value:v};$('#impact').className='rounded-surface bg-ink/[0.03] p-5 text-[13.5px] text-muted';$('#impact').innerHTML=impact(v,body);$('#save').disabled=false;}catch(e){preview=null;$('#policy-error').textContent=M.error(e);}finally{busy=false;$('#preview').disabled=false;}} - async function savePolicy(e){e.preventDefault();if(busy||!preview||!M.manage())return;capture();let body;try{body=target();}catch(e){$('#policy-error').textContent=e.message;return;}if(JSON.stringify(body)!==JSON.stringify(preview.body)){invalidate();return;}busy=true;$('#save').disabled=true;try{repo=await API.request(endpoint,{method:'PATCH',headers:{'If-Match':'"'+preview.value.revision+'"'},body:{...body,preview:preview.value.preview}});preview=null;draft=null;A.toast(t('设置已保存','Settings saved'));render();}catch(e){preview=null;if(e.status===412){try{await load(true);}catch(_){}}$('#policy-error').textContent=M.error(e)+(e.status===412?t(';输入已保留,请重新预览','; input kept, preview again'):'');}finally{busy=false;if(repo)header();}} + async function previewPolicy(){if(busy||!M.manage(canRepo()))return;let body;try{body=target();}catch(e){$('#policy-error').textContent=e.message;return;}busy=true;$('#preview').disabled=true;$('#policy-error').textContent='';try{const v=await API.request('/api/admin/v1/repo/preview?repo='+enc,{method:'POST',body});if(v.revision!==repo.revision){await load(true);throw Object.assign(new Error(t('仓库刚被修改过,输入已保留,请重新预览','The repository just changed; your input is kept. Preview again')),{code:'REVISION_CONFLICT',status:412});}if(JSON.stringify(target())!==JSON.stringify(body))throw new Error(t('输入已变化,请重新预览','Inputs changed; preview again'));preview={body,value:v};$('#impact').className='rounded-surface bg-ink/[0.03] p-5 text-[13.5px] text-muted';$('#impact').innerHTML=impact(v,body);$('#save').disabled=false;}catch(e){preview=null;$('#policy-error').textContent=M.error(e);}finally{busy=false;$('#preview').disabled=false;}} + async function savePolicy(e){e.preventDefault();if(busy||!preview||!M.manage(canRepo()))return;capture();let body;try{body=target();}catch(e){$('#policy-error').textContent=e.message;return;}if(JSON.stringify(body)!==JSON.stringify(preview.body)){invalidate();return;}busy=true;$('#save').disabled=true;try{repo=await API.request(endpoint,{method:'PATCH',headers:{'If-Match':'"'+preview.value.revision+'"'},body:{...body,preview:preview.value.preview}});preview=null;draft=null;A.toast(t('设置已保存','Settings saved'));render();}catch(e){preview=null;if(e.status===412){try{await load(true);}catch(_){}}$('#policy-error').textContent=M.error(e)+(e.status===412?t(';输入已保留,请重新预览','; input kept, preview again'):'');}finally{busy=false;if(repo)header();}} async function prepare(kind,commit='',group=''){ - if(busy)return;if(!canPrepare()){M.manage();return;}busy=true;render();const body={kind,commit,...(group?{group}:{})}; + if(busy)return;if(!canPrepare()){M.manage(canRepo());return;}busy=true;render();const body={kind,commit,...(group?{group}:{})}; try{ const result=await API.request('/api/admin/v1/repo/prepare?repo='+enc,{method:'POST',body,headers:{'Idempotency-Key':keys.get(body)}}),jobs=M.jobReceipts(result); keys.done(body);A.toast(jobs.length>1?t(jobs.length+' 个分组任务已登记',jobs.length+' group jobs registered'):kind==='sync'?t('已开始检查更新','Checking for updates'):t('已开始准备','Preparation started'),jobs.some(j=>j.state==='failed')?'bad':undefined); @@ -223,7 +251,7 @@ } function confirm(kind){ - if(busy||repo.deleted||kind!=='toggle'||!M.manage())return; + if(busy||repo.deleted||kind!=='toggle'||!M.manage(canRepo()))return; const d=$('#confirm'),enable=!repo.enabled; const title=t(enable?'启用仓库':'停用仓库',enable?'Enable repository':'Disable repository'); const msg=t(enable?'启用后恢复搜索和更新':'停用后不再接受新的搜索和更新,已有数据保留',enable?'Search and updates resume':'Search and updates stop. Existing data is kept'); @@ -242,7 +270,7 @@ }; } async function stopJob(button){ - if(busy||!M.manage())return; + if(busy||!M.manage(canRepo()))return; busy=true;button.disabled=true; try{await API.request('/api/admin/v1/repo/jobs/cancel?repo='+enc,{method:'POST',body:{job_id:button.dataset.stopJob}});A.toast(t('已停止任务','Task stopped'));await load(true);} catch(e){A.toast(M.error(e),'bad');} @@ -252,6 +280,7 @@ if(!repo)return;header();$('#panel').innerHTML=({overview,versions,jobs,plugins:()=>'
    ',policy:settings})[tab]();branchInput=null;pathInput=null; if(tab==='plugins')RepoPlugins.mount($('#repo-plugins'),repo); document.querySelectorAll('[data-stop-job]').forEach(b=>b.onclick=()=>stopJob(b)); + bindManagers(); document.querySelectorAll('[data-prepare]').forEach(b=>b.onclick=()=>prepare('index',b.dataset.prepare,b.dataset.group)); document.querySelectorAll('.retry').forEach(b=>b.onclick=()=>prepare(b.dataset.kind,b.dataset.commit,b.dataset.group)); document.querySelectorAll('[data-group-sync]').forEach(b=>b.onclick=()=>prepare('sync','',b.dataset.groupSync)); @@ -259,11 +288,11 @@ document.querySelectorAll('[data-goto]').forEach(b=>b.onclick=()=>setTab(b.dataset.goto)); if($('#more-versions'))$('#more-versions').onclick=()=>{versionLimit+=50;render();}; if($('#more-jobs'))$('#more-jobs').onclick=()=>{jobLimit+=JOB_PAGE;render();}; - if($('#policy-login'))$('#policy-login').onclick=()=>M.manage(); + if($('#policy-login'))$('#policy-login').onclick=()=>M.manage(canRepo()); if($('#preview'))$('#preview').onclick=previewPolicy; if($('#policy-form')){branchInput=BranchInput.mount($('#pbranches'),{...draft,onChange:invalidate});if($('#ppaths'))pathInput=PathInput.mount($('#ppaths'),{...draft.pathState,onChange:invalidate});$('#policy-form').onsubmit=savePolicy;['#pvalue','#pkind','#psync-interval','#ppublic','#pskip-generated'].forEach(s=>$(s).oninput=()=>{invalidate();$('#pvalue').max=$('#pkind').value==='days'?365:4096;});} } - async function load(force=false){if(loading||!name)return;if(!force&&(busy||tab==='policy'||$('#confirm').open))return;loading=true;try{repo=await API.repo(name);$('#load-error').textContent='';render();}catch(e){$('#load-error').textContent=M.error(e);throw e;}finally{loading=false;}} + async function load(force=false){if(loading||!name)return;if(!force&&(busy||tab==='policy'||$('#confirm').open||(document.activeElement&&document.activeElement.id==='manager-q')))return;loading=true;try{repo=await API.repo(name);$('#load-error').textContent='';render();}catch(e){$('#load-error').textContent=M.error(e);throw e;}finally{loading=false;}} const poller=M.poll(()=>load(),15000,80);$('#sync').disabled=true;$('#search').classList.add('hidden');$('#sync').onclick=()=>prepare('sync'); document.addEventListener('app:auth',()=>{if(repo)render();}); document.addEventListener('langchange',()=>{if(tab==='policy')capture();render();}); diff --git a/web/assets/repos-page.js b/web/assets/repos-page.js index 94f08e2..7d5c618 100644 --- a/web/assets/repos-page.js +++ b/web/assets/repos-page.js @@ -86,14 +86,16 @@ } // Only sources with a configured host can register repositories; the editable source is preselected. async function loadSources(){const select=$('#f-code-provider');try{const list=(await API.request('/v1/code-sources')).filter(s=>s.host);const chosen=select.value||(list.find(s=>s.editable)||list[0])?.id||'';select.innerHTML=list.map(s=>'').join('');select.value=list.some(s=>s.id===chosen)?chosen:(list[0]?.id||'');}catch(e){$('#form-error').textContent=M.error(e);}} - function open(){if(!M.manage())return;loadSources();$('#drawer').classList.remove('hidden');$('#drawer aside').setAttribute('role','dialog');$('#drawer aside').setAttribute('aria-modal','true');setTimeout(()=>$('#f-name').focus(),30);} + // Administrators always register; others only when the site allows user repositories. + function canRegister(){return A.canManage()||!!(A.user()&&API.policy&&API.policy.user_repositories);} + function open(){if(!M.manage(canRegister()))return;loadSources();$('#drawer').classList.remove('hidden');$('#drawer aside').setAttribute('role','dialog');$('#drawer aside').setAttribute('aria-modal','true');setTimeout(()=>$('#f-name').focus(),30);} function close(){if(submitting)return;$('#drawer').classList.add('hidden');const q=new URLSearchParams(location.search);q.delete('new');history.replaceState(null,'',location.pathname+(q.size?'?'+q:''));$('#add').focus();} $('#add').onclick=()=>{const q=new URLSearchParams(location.search);q.set('new','1');history.replaceState(null,'',location.pathname+'?'+q);open();}; document.querySelectorAll('[data-close]').forEach(b=>b.onclick=e=>{e.preventDefault();close();}); document.addEventListener('keydown',e=>{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='';}); - async function submit(e){if(e)e.preventDefault();if(submitting||!M.manage()||!$('#form').reportValidity())return;let body; + 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,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=''; diff --git a/web/assets/settings-page.js b/web/assets/settings-page.js index 8012b17..b6fb76d 100644 --- a/web/assets/settings-page.js +++ b/web/assets/settings-page.js @@ -12,13 +12,13 @@ function status(key,text,isError=false){const el=$('#'+sections[key].id+'-status');el.textContent=text;el.classList.toggle('text-danger',isError);el.classList.toggle('text-muted',!isError);} function enabled(key){const s=sections[key],off=loading||s.busy||!s.data||!permitted();$('#'+s.id+'-fields').disabled=off;$('#save-'+s.id).disabled=off;$('#reload-'+s.id).disabled=Object.values(sections).some(x=>x.busy)||loading||!permitted();} function apply(key,data){if(!data||!Number.isInteger(data.revision)||!data.values)throw new Error(t('系统设置响应无效','Invalid settings response'));sections[key].data=data; - if(key==='repository_defaults'){const v=data.values;$('#default-retention-mode').value=v.retention_mode;$('#default-retention-days').value=v.retention_days;$('#default-retention-count').value=v.retention_count;syncMode();}else {$('#public-read').checked=data.values.public_read===true;$('#allow-registration').checked=data.values.allow_registration===true;$('#registration-notice-zh').value=data.values.registration_notice_zh||'';$('#registration-notice-en').value=data.values.registration_notice_en||'';} + if(key==='repository_defaults'){const v=data.values;$('#default-retention-mode').value=v.retention_mode;$('#default-retention-days').value=v.retention_days;$('#default-retention-count').value=v.retention_count;syncMode();}else {$('#public-read').checked=data.values.public_read===true;$('#allow-registration').checked=data.values.allow_registration===true;$('#registration-notice-zh').value=data.values.registration_notice_zh||'';$('#registration-notice-en').value=data.values.registration_notice_en||'';$('#allow-user-repositories').checked=data.values.allow_user_repositories===true;} status(key,'');enabled(key);} async function load(only){if(loading||Object.values(sections).some(x=>x.busy)||!gate())return;loading=true;$('#settings-retry').disabled=true;$('#settings-load-error').textContent='';Object.keys(sections).forEach(enabled); try{const data=await API.request('/api/admin/settings');if(!permitted())return;for(const key of only?[only]:Object.keys(sections))apply(key,data.sections?.[key]);$('#settings-retry').classList.add('hidden');} catch(e){if(only)status(only,message(e)+t(',输入已保留','; input retained'),true);else{$('#settings-load-error').textContent=message(e);$('#settings-retry').classList.remove('hidden');}if(e.status===401||e.status===403){await API.loadSession();gate();}} finally{loading=false;$('#settings-retry').disabled=false;Object.keys(sections).forEach(enabled);}} - function values(key){if(key==='access')return {public_read:$('#public-read').checked,allow_registration:$('#allow-registration').checked,registration_notice_zh:$('#registration-notice-zh').value,registration_notice_en:$('#registration-notice-en').value};const mode=$('#default-retention-mode').value,days=Number($('#default-retention-days').value),count=Number($('#default-retention-count').value);if(!['days','count'].includes(mode)||!Number.isInteger(days)||days<1||days>365||!Number.isInteger(count)||count<1||count>4096)throw new Error(t('天数为 1 到 365,提交数为 1 到 4096','Use 1 to 365 days or 1 to 4096 commits'));return {retention_mode:mode,retention_days:days,retention_count:count};} + function values(key){if(key==='access')return {public_read:$('#public-read').checked,allow_registration:$('#allow-registration').checked,registration_notice_zh:$('#registration-notice-zh').value,registration_notice_en:$('#registration-notice-en').value,allow_user_repositories:$('#allow-user-repositories').checked};const mode=$('#default-retention-mode').value,days=Number($('#default-retention-days').value),count=Number($('#default-retention-count').value);if(!['days','count'].includes(mode)||!Number.isInteger(days)||days<1||days>365||!Number.isInteger(count)||count<1||count>4096)throw new Error(t('天数为 1 到 365,提交数为 1 到 4096','Use 1 to 365 days or 1 to 4096 commits'));return {retention_mode:mode,retention_days:days,retention_count:count};} async function save(key,e){e.preventDefault();const s=sections[key];if(loading||s.busy||!s.data||!gate())return;let draft;try{draft=values(key);}catch(err){status(key,err.message,true);return;}s.busy=true;Object.keys(sections).forEach(enabled);status(key,t('正在保存…','Saving…')); try{const data=await API.request('/api/admin/settings/'+key,{method:'PATCH',body:{revision:s.data.revision,values:draft}});if(!Number.isInteger(data.revision)||!data.values)throw new Error(t('保存回执无效,请重新加载确认','Invalid save receipt. Reload to confirm.'));s.data=data;status(key,t('已保存','Saved')); if(key==='access'){try{API.policy=await API.request('/access-policy');document.dispatchEvent(new Event('app:policy'));}catch{status(key,t('已保存,刷新页面后生效','Saved; refresh to apply'));}}} diff --git a/web/settings.html b/web/settings.html index d3ffc41..8671960 100644 --- a/web/settings.html +++ b/web/settings.html @@ -62,6 +62,7 @@ +

    diff --git a/web/user-settings.html b/web/user-settings.html index a7798be..ed7aacd 100644 --- a/web/user-settings.html +++ b/web/user-settings.html @@ -25,7 +25,7 @@ -
    +

    代码源令牌

    用于拉取你登记的仓库。未配置、过期或认证失败时,改用管理员配置的全局令牌。

    From d6993fb90699d1442860dd698b1bf559854a008d Mon Sep 17 00:00:00 2001 From: qiansc Date: Tue, 27 Oct 2026 07:20:16 +0000 Subject: [PATCH 2/6] fix: return the repository view after saving settings A repository manager lost edit access and the people list after saving settings or enabling/disabling: PATCH returned the bare repository without can_manage, creator and managers, and the page replaced its state with it. Co-Authored-By: Claude Opus 5.5 --- internal/httpserver/repository_access_test.go | 12 ++++++++++++ internal/httpserver/runtime.go | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/httpserver/repository_access_test.go b/internal/httpserver/repository_access_test.go index aef5ae7..88a785a 100644 --- a/internal/httpserver/repository_access_test.go +++ b/internal/httpserver/repository_access_test.go @@ -257,6 +257,18 @@ func TestOrdinaryUsersRegisterAndManageTheirRepositories(t *testing.T) { t.Fatalf("manager gate refused %s: %v", who, body) } } + // Saving settings (and enabling or disabling) returns the same view, so the + // website keeps the manager's permission and the people after a save. + pv := `{"policy":{"branches":["main","dev"],"count":10},"enabled":true,"deleted":false}` + code, preview := s.call(alice, "POST", repo+"/preview"+q, pv) + if code != 200 { + t.Fatalf("manager preview: %d %v", code, preview) + } + patch := strings.TrimSuffix(pv, "}") + `,"preview":"` + preview["preview"].(string) + `"}` + code, saved := s.call(alice, "PATCH", repo+q, patch, "If-Match", `"`+itoa(int64(preview["revision"].(float64)))+`"`) + if code != 200 || saved["can_manage"] != true || saved["creator"] == nil || len(saved["managers"].([]any)) != 1 { + t.Fatalf("PATCH response must keep the repository view: %d %v", code, saved) + } // Site-wide administration stays admin-only. if code, _ := s.call(alice, "GET", "/sourcegraph/api/admin/v1/instances", ""); code != 403 { t.Fatalf("instances for a repository manager: %d", code) diff --git a/internal/httpserver/runtime.go b/internal/httpserver/runtime.go index d82ba78..b7bb617 100644 --- a/internal/httpserver/runtime.go +++ b/internal/httpserver/runtime.go @@ -357,7 +357,11 @@ func runtime(c config.Config, log *slog.Logger, s *service.Service, token string if e != nil { err = contract.Fail("PRECONDITION_REQUIRED", "If-Match revision is required.", 428) } else { - result, err = s.Update(r.Context(), repo, q, rev) + var row control.Repository + row, err = s.Update(r.Context(), repo, q, rev) + // The website replaces its repository with this response; keep the + // caller's permission and people so a manager can keep editing. + result = viewRepository(r.Context(), row, browserAuth) } } } else { From cbe66963632290586a43c424480e86b1cd91d90c Mon Sep 17 00:00:00 2001 From: qiansc Date: Tue, 27 Oct 2026 07:21:29 +0000 Subject: [PATCH 3/6] ci: pin the MongoDB contract image The floating mongo:8 tag moved to an image whose container exits on start on hosted runners, failing the databases job. Pin the last passing digest and print the container log if MongoDB never becomes primary. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfa2a2c..05ac00c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,8 @@ jobs: go-version-file: go.mod - name: Start MongoDB replica set run: | - docker run -d --name mongo-contract -p 27017:27017 mongo:8 --replSet rs0 --bind_ip_all + # Pinned: the floating mongo:8 tag moved to an image that exits on start on hosted runners (2026-10-27). + docker run -d --name mongo-contract -p 27017:27017 mongo:8@sha256:2d2b8998d31037bf721cfdf764d76ba74171b4fab3431b7f72c27c56ddbdf9e3 --replSet rs0 --bind_ip_all for i in $(seq 1 40); do if docker exec mongo-contract mongosh --quiet --eval 'db.adminCommand({ping:1})'; then break; fi sleep 2 @@ -53,6 +54,7 @@ jobs: if docker exec mongo-contract mongosh --quiet --eval 'if (!db.hello().isWritablePrimary) quit(1)'; then exit 0; fi sleep 2 done + docker logs --tail 50 mongo-contract exit 1 - run: go test -count=1 -v ./internal/storagecontract env: From 8a7c87c3efb6aee6bb923ec5c65dc8106f2ad217 Mon Sep 17 00:00:00 2001 From: qiansc Date: Tue, 27 Oct 2026 07:24:59 +0000 Subject: [PATCH 4/6] ci: pin MongoDB 8.0 for the contract test The digest pinned earlier is no longer pullable; use the 8.0 release line. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05ac00c..95ecd52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,8 +43,8 @@ jobs: go-version-file: go.mod - name: Start MongoDB replica set run: | - # Pinned: the floating mongo:8 tag moved to an image that exits on start on hosted runners (2026-10-27). - docker run -d --name mongo-contract -p 27017:27017 mongo:8@sha256:2d2b8998d31037bf721cfdf764d76ba74171b4fab3431b7f72c27c56ddbdf9e3 --replSet rs0 --bind_ip_all + # Pinned to 8.0: the floating mongo:8 tag moved to 8.3.x, whose container exits on start on hosted runners (2026-10-27). + docker run -d --name mongo-contract -p 27017:27017 mongo:8.0 --replSet rs0 --bind_ip_all for i in $(seq 1 40); do if docker exec mongo-contract mongosh --quiet --eval 'db.adminCommand({ping:1})'; then break; fi sleep 2 From 366155115096a0bb5587a4d6cb84d738771335c7 Mon Sep 17 00:00:00 2001 From: qiansc Date: Tue, 27 Oct 2026 07:28:21 +0000 Subject: [PATCH 5/6] ci: print the MongoDB container log when the database job fails Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95ecd52..5284516 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,8 +54,10 @@ jobs: if docker exec mongo-contract mongosh --quiet --eval 'if (!db.hello().isWritablePrimary) quit(1)'; then exit 0; fi sleep 2 done - docker logs --tail 50 mongo-contract exit 1 + - name: MongoDB container log + if: failure() + run: docker logs --tail 80 mongo-contract || true - run: go test -count=1 -v ./internal/storagecontract env: TEST_POSTGRES_URL: postgres://postgres:contract-test@localhost:5432/sourcegraph_test?sslmode=disable From bd8acba65083af11adbb40a6d371b5f52e7c1387 Mon Sep 17 00:00:00 2001 From: qiansc Date: Tue, 27 Oct 2026 07:32:12 +0000 Subject: [PATCH 6/6] ci: run the database contract test on Ubuntu 24.04 ubuntu-latest moved to Ubuntu 26.04, whose Linux 6.19+ kernel MongoDB refuses to start on (SERVER-121912). Keep mongo:8 and run this job on 24.04. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5284516..bd57391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,8 @@ jobs: - run: python3 scripts/audit-public.py - run: docker build --build-arg BUILD_REVISION=${{ github.sha }} -t sourcegraph:verify . databases: - runs-on: ubuntu-latest + # MongoDB refuses to start on Linux 6.19+ (SERVER-121912), which ubuntu-latest (26.04) now runs. + runs-on: ubuntu-24.04 services: postgres: image: postgres:17 @@ -43,8 +44,7 @@ jobs: go-version-file: go.mod - name: Start MongoDB replica set run: | - # Pinned to 8.0: the floating mongo:8 tag moved to 8.3.x, whose container exits on start on hosted runners (2026-10-27). - docker run -d --name mongo-contract -p 27017:27017 mongo:8.0 --replSet rs0 --bind_ip_all + docker run -d --name mongo-contract -p 27017:27017 mongo:8 --replSet rs0 --bind_ip_all for i in $(seq 1 40); do if docker exec mongo-contract mongosh --quiet --eval 'db.adminCommand({ping:1})'; then break; fi sleep 2