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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +55,9 @@ jobs:
sleep 2
done
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
Expand Down
2 changes: 1 addition & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions docs/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<user id>"}` or `{"remove":"<user id>"}` with `If-Match: "<revision>"`; 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).
Expand Down
21 changes: 21 additions & 0 deletions internal/control/managers_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
37 changes: 35 additions & 2 deletions internal/control/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/gitstore/remote_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
24 changes: 24 additions & 0 deletions internal/gitstore/remote_error_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
84 changes: 84 additions & 0 deletions internal/httpserver/repository_access.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading